mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 22:54:46 +00:00
Add dependency release safety evidence and PR awareness (#81325)
* test: cover dependency pin guard * build: add dependency vulnerability gate * build: add dependency risk report * build: add dependency drift reports * build: include dependency ownership surface evidence * build: rename dependency report commands * build: respect release age exclusions in risk report * build: clarify transitive risk accounting * build: remove transitive risk exception registry * build: clarify transitive risk signal wording * ci: attach dependency evidence to release preflight * ci: extract dependency release evidence generator * build: rename ownership surface dependency report * ci: clarify release evidence naming * build: clarify recently published risk report * build: reorder transitive risk report sections * build: fix ownership surface pluralization * ci: surface dependency changes on PRs * ci: harden dependency change awareness * ci: use dependency changed PR label * build: fix dependency report lint * docs: add dependency safety changelog
This commit is contained in:
179
test/scripts/check-dependency-pins.test.ts
Normal file
179
test/scripts/check-dependency-pins.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { collectDependencyPinViolations } from "../../scripts/check-dependency-pins.mjs";
|
||||
import { cleanupTempDirs, makeTempRepoRoot } from "../helpers/temp-repo.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
const nestedGitEnvKeys = [
|
||||
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
||||
"GIT_DIR",
|
||||
"GIT_INDEX_FILE",
|
||||
"GIT_OBJECT_DIRECTORY",
|
||||
"GIT_QUARANTINE_PATH",
|
||||
"GIT_WORK_TREE",
|
||||
] as const;
|
||||
|
||||
function createNestedGitEnv(): NodeJS.ProcessEnv {
|
||||
const env = {
|
||||
...process.env,
|
||||
GIT_CONFIG_NOSYSTEM: "1",
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
};
|
||||
for (const key of nestedGitEnvKeys) {
|
||||
delete env[key];
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function git(cwd: string, args: string[]) {
|
||||
execFileSync("git", args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
env: createNestedGitEnv(),
|
||||
});
|
||||
}
|
||||
|
||||
function writeJson(filePath: string, value: unknown) {
|
||||
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function makeRepo() {
|
||||
const dir = makeTempRepoRoot(tempDirs, "openclaw-dependency-pins-");
|
||||
git(dir, ["init", "-q", "--initial-branch=main"]);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanupTempDirs(tempDirs);
|
||||
});
|
||||
|
||||
describe("check-dependency-pins", () => {
|
||||
it("accepts exact dependency specs and intentionally ranged peer contracts", () => {
|
||||
const dir = makeRepo();
|
||||
writeJson(path.join(dir, "package.json"), {
|
||||
dependencies: {
|
||||
exact: "1.2.3",
|
||||
prerelease: "1.2.3-beta.1",
|
||||
alias: "npm:@scope/real-package@2.3.4",
|
||||
workspace: "workspace:*",
|
||||
linked: "link:../linked",
|
||||
local: "file:../local",
|
||||
gitPinned: "github:owner/repo#0123456789abcdef0123456789abcdef01234567",
|
||||
},
|
||||
devDependencies: {
|
||||
devExact: "4.5.6",
|
||||
},
|
||||
optionalDependencies: {
|
||||
optionalExact: "7.8.9",
|
||||
},
|
||||
peerDependencies: {
|
||||
peerCanRange: "^1.0.0",
|
||||
},
|
||||
});
|
||||
writeFileSync(
|
||||
path.join(dir, "pnpm-workspace.yaml"),
|
||||
`overrides:
|
||||
exact: 1.2.3
|
||||
alias: "npm:@scope/real-package@2.3.4"
|
||||
packageExtensions:
|
||||
parent@1.0.0:
|
||||
dependencies:
|
||||
child: 3.2.1
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
git(dir, ["add", "package.json", "pnpm-workspace.yaml"]);
|
||||
|
||||
expect(collectDependencyPinViolations(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects floating dependency specs in tracked package manifests", () => {
|
||||
const dir = makeRepo();
|
||||
mkdirSync(path.join(dir, "extensions", "demo"), { recursive: true });
|
||||
writeJson(path.join(dir, "package.json"), {
|
||||
dependencies: {
|
||||
caret: "^1.2.3",
|
||||
tilde: "~1.2.3",
|
||||
wildcard: "*",
|
||||
tag: "latest",
|
||||
broad: ">=1 <2",
|
||||
gitFloating: "github:owner/repo#main",
|
||||
},
|
||||
});
|
||||
writeJson(path.join(dir, "extensions", "demo", "package.json"), {
|
||||
devDependencies: {
|
||||
devCaret: "^4.5.6",
|
||||
},
|
||||
optionalDependencies: {
|
||||
optionalTilde: "~7.8.9",
|
||||
},
|
||||
peerDependencies: {
|
||||
peerCanRange: "^10.0.0",
|
||||
},
|
||||
});
|
||||
git(dir, ["add", "package.json", "extensions/demo/package.json"]);
|
||||
|
||||
expect(collectDependencyPinViolations(dir)).toEqual([
|
||||
{
|
||||
file: "extensions/demo/package.json",
|
||||
section: "devDependencies",
|
||||
name: "devCaret",
|
||||
spec: "^4.5.6",
|
||||
},
|
||||
{
|
||||
file: "extensions/demo/package.json",
|
||||
section: "optionalDependencies",
|
||||
name: "optionalTilde",
|
||||
spec: "~7.8.9",
|
||||
},
|
||||
{ file: "package.json", section: "dependencies", name: "caret", spec: "^1.2.3" },
|
||||
{ file: "package.json", section: "dependencies", name: "tilde", spec: "~1.2.3" },
|
||||
{ file: "package.json", section: "dependencies", name: "wildcard", spec: "*" },
|
||||
{ file: "package.json", section: "dependencies", name: "tag", spec: "latest" },
|
||||
{ file: "package.json", section: "dependencies", name: "broad", spec: ">=1 <2" },
|
||||
{
|
||||
file: "package.json",
|
||||
section: "dependencies",
|
||||
name: "gitFloating",
|
||||
spec: "github:owner/repo#main",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects floating workspace overrides and package extension dependencies", () => {
|
||||
const dir = makeRepo();
|
||||
writeJson(path.join(dir, "package.json"), {});
|
||||
writeFileSync(
|
||||
path.join(dir, "pnpm-workspace.yaml"),
|
||||
`overrides:
|
||||
exact: 1.2.3
|
||||
floating: ^2.0.0
|
||||
packageExtensions:
|
||||
parent@1.0.0:
|
||||
dependencies:
|
||||
exact-child: 3.2.1
|
||||
floating-child: ~4.0.0
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
git(dir, ["add", "package.json", "pnpm-workspace.yaml"]);
|
||||
|
||||
expect(collectDependencyPinViolations(dir)).toEqual([
|
||||
{
|
||||
file: "pnpm-workspace.yaml",
|
||||
section: "overrides",
|
||||
name: "floating",
|
||||
spec: "^2.0.0",
|
||||
},
|
||||
{
|
||||
file: "pnpm-workspace.yaml",
|
||||
section: "packageExtensions.parent@1.0.0.dependencies",
|
||||
name: "floating-child",
|
||||
spec: "~4.0.0",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
105
test/scripts/dependency-change-awareness-workflow.test.ts
Normal file
105
test/scripts/dependency-change-awareness-workflow.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
const WORKFLOW = ".github/workflows/dependency-change-awareness.yml";
|
||||
const CODEOWNERS = ".github/CODEOWNERS";
|
||||
|
||||
type WorkflowStep = {
|
||||
name?: string;
|
||||
run?: string;
|
||||
uses?: string;
|
||||
with?: Record<string, string>;
|
||||
};
|
||||
|
||||
type WorkflowJob = {
|
||||
steps?: WorkflowStep[];
|
||||
};
|
||||
|
||||
type Workflow = {
|
||||
jobs?: Record<string, WorkflowJob>;
|
||||
permissions?: Record<string, string>;
|
||||
};
|
||||
|
||||
function readWorkflow(): Workflow {
|
||||
return parse(readFileSync(WORKFLOW, "utf8")) as Workflow;
|
||||
}
|
||||
|
||||
describe("dependency change awareness workflow", () => {
|
||||
it("uses a metadata-only pull_request_target workflow with minimal write permissions", () => {
|
||||
const workflow = readFileSync(WORKFLOW, "utf8");
|
||||
const parsed = readWorkflow();
|
||||
|
||||
expect(workflow).toContain("pull_request_target:");
|
||||
expect(workflow).toContain("metadata-only workflow; no checkout or untrusted code execution");
|
||||
expect(parsed.permissions).toEqual({
|
||||
"pull-requests": "read",
|
||||
issues: "write",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not checkout or execute PR-controlled code", () => {
|
||||
const workflow = readFileSync(WORKFLOW, "utf8");
|
||||
const forbiddenSnippets = [
|
||||
"actions/checkout",
|
||||
"github.event.pull_request.head",
|
||||
"pullRequest.head",
|
||||
"pnpm install",
|
||||
"npm install",
|
||||
"pnpm dlx",
|
||||
"contents: write",
|
||||
"actions: write",
|
||||
"id-token: write",
|
||||
"secrets.",
|
||||
"github.rest.issues.createLabel",
|
||||
];
|
||||
|
||||
for (const snippet of forbiddenSnippets) {
|
||||
expect(workflow).not.toContain(snippet);
|
||||
}
|
||||
|
||||
const steps = readWorkflow().jobs?.["dependency-change-awareness"]?.steps ?? [];
|
||||
expect(steps).toHaveLength(1);
|
||||
expect(steps[0].run).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses a pinned GitHub Script action and bounded sticky comments", () => {
|
||||
const workflow = readFileSync(WORKFLOW, "utf8");
|
||||
const steps = readWorkflow().jobs?.["dependency-change-awareness"]?.steps ?? [];
|
||||
const step = steps[0];
|
||||
|
||||
expect(step.uses).toBe("actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3");
|
||||
expect(step.with?.script).toContain("<!-- openclaw:dependency-change-awareness -->");
|
||||
expect(step.with?.script).toContain("const maxListedFiles = 25;");
|
||||
expect(step.with?.script).toContain("const sanitizeDisplayValue = (value)");
|
||||
expect(step.with?.script).toContain('.replace(/[\\u0000-\\u001f\\u007f]/gu, "?")');
|
||||
expect(step.with?.script).toContain(".slice(0, 240)");
|
||||
expect(step.with?.script).toContain('comment.user?.login === "github-actions[bot]"');
|
||||
expect(step.with?.script).toContain("github.rest.pulls.listFiles");
|
||||
expect(step.with?.script).toContain("github.rest.issues.createComment");
|
||||
expect(step.with?.script).toContain("github.rest.issues.updateComment");
|
||||
expect(step.with?.script).toContain("github.rest.issues.deleteComment");
|
||||
expect(workflow).toContain('"dependencies-changed"');
|
||||
});
|
||||
|
||||
it("detects the intended dependency-related file surfaces", () => {
|
||||
const script = readWorkflow().jobs?.["dependency-change-awareness"]?.steps?.[0].with?.script;
|
||||
expect(script).toContain('filename === "package.json"');
|
||||
expect(script).toContain('filename === "pnpm-lock.yaml"');
|
||||
expect(script).toContain('filename === "pnpm-workspace.yaml"');
|
||||
expect(script).toContain('filename === "ui/package.json"');
|
||||
expect(script).toContain('filename.startsWith("patches/")');
|
||||
expect(script).toContain("^packages\\/[^/]+\\/package\\.json$");
|
||||
expect(script).toContain("^extensions\\/[^/]+\\/package\\.json$");
|
||||
});
|
||||
|
||||
it("requires secops review for future workflow or guard changes", () => {
|
||||
const codeowners = readFileSync(CODEOWNERS, "utf8");
|
||||
expect(codeowners).toContain(
|
||||
"/.github/workflows/dependency-change-awareness.yml @openclaw/openclaw-secops",
|
||||
);
|
||||
expect(codeowners).toContain(
|
||||
"/test/scripts/dependency-change-awareness-workflow.test.ts @openclaw/openclaw-secops",
|
||||
);
|
||||
});
|
||||
});
|
||||
42
test/scripts/dependency-changes-report.test.ts
Normal file
42
test/scripts/dependency-changes-report.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createDependencyChangesReport } from "../../scripts/dependency-changes-report.mjs";
|
||||
|
||||
describe("dependency-changes-report", () => {
|
||||
it("reports added, removed, and changed packages", () => {
|
||||
const report = createDependencyChangesReport({
|
||||
basePayload: {
|
||||
removed: ["1.0.0"],
|
||||
stable: ["1.0.0"],
|
||||
changed: ["1.0.0"],
|
||||
},
|
||||
headPayload: {
|
||||
added: ["1.0.0"],
|
||||
stable: ["1.0.0"],
|
||||
changed: ["2.0.0"],
|
||||
},
|
||||
dependencyFileChanges: [
|
||||
{ status: "M", path: "pnpm-lock.yaml", oldPath: null },
|
||||
{ status: "M", path: "pnpm-workspace.yaml", oldPath: null },
|
||||
],
|
||||
generatedAt: "2026-05-12T00:00:00Z",
|
||||
});
|
||||
|
||||
expect(report.summary).toEqual({
|
||||
basePackages: 3,
|
||||
headPackages: 3,
|
||||
addedPackages: 1,
|
||||
removedPackages: 1,
|
||||
changedPackages: 1,
|
||||
dependencyFileChanges: 2,
|
||||
});
|
||||
expect(report.dependencyFileChanges).toEqual([
|
||||
{ status: "M", path: "pnpm-lock.yaml", oldPath: null },
|
||||
{ status: "M", path: "pnpm-workspace.yaml", oldPath: null },
|
||||
]);
|
||||
expect(report.addedPackages).toEqual([{ packageName: "added", versions: ["1.0.0"] }]);
|
||||
expect(report.removedPackages).toEqual([{ packageName: "removed", versions: ["1.0.0"] }]);
|
||||
expect(report.changedPackages).toEqual([
|
||||
{ packageName: "changed", addedVersions: ["2.0.0"], removedVersions: ["1.0.0"] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,11 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectSbomRiskCheckErrors,
|
||||
collectSbomRiskReport,
|
||||
collectDependencyOwnershipSurfaceCheckErrors,
|
||||
collectDependencyOwnershipSurfaceReport,
|
||||
packageNameFromLockKey,
|
||||
} from "../../scripts/sbom-risk-report.mjs";
|
||||
renderDependencyOwnershipSurfaceMarkdownReport,
|
||||
} from "../../scripts/dependency-ownership-surface-report.mjs";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
@@ -17,7 +18,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
function makeTempRepo() {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "openclaw-sbom-risk-"));
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "openclaw-ownership-surface-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
@@ -35,8 +36,8 @@ describe("packageNameFromLockKey", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectSbomRiskReport", () => {
|
||||
it("reports root closure sizes, build-risk packages, and ownership gaps", () => {
|
||||
describe("collectDependencyOwnershipSurfaceReport", () => {
|
||||
it("reports root dependency reachability, install-surface packages, and ownership metadata gaps", () => {
|
||||
const repoRoot = makeTempRepo();
|
||||
writeRepoFile(
|
||||
repoRoot,
|
||||
@@ -98,7 +99,7 @@ snapshots:
|
||||
);
|
||||
writeRepoFile(repoRoot, "src/index.ts", 'import "core-lib";\n');
|
||||
|
||||
const report = collectSbomRiskReport({ repoRoot });
|
||||
const report = collectDependencyOwnershipSurfaceReport({ repoRoot });
|
||||
|
||||
expect(report.summary).toEqual({
|
||||
buildRiskPackageCount: 1,
|
||||
@@ -123,9 +124,28 @@ snapshots:
|
||||
sourceSections: [],
|
||||
specifier: "1.0.0",
|
||||
});
|
||||
expect(collectSbomRiskCheckErrors(report)).toEqual([
|
||||
expect(collectDependencyOwnershipSurfaceCheckErrors(report)).toEqual([
|
||||
"root dependency 'missing-owner' is missing from scripts/lib/dependency-ownership.json",
|
||||
]);
|
||||
|
||||
const markdown = renderDependencyOwnershipSurfaceMarkdownReport(report);
|
||||
expect(markdown).toContain("# Dependency Ownership and Install Surface Report");
|
||||
expect(markdown).toContain("## Target");
|
||||
expect(markdown).toContain("## Scope");
|
||||
expect(markdown).toContain("It does not query npm advisories");
|
||||
expect(markdown).toContain("## Root Dependencies Missing Ownership Metadata");
|
||||
expect(markdown).toContain("`missing-owner`");
|
||||
expect(markdown).toContain("## Root Dependencies By Resolved Transitive Package Count");
|
||||
expect(markdown).toContain("`core-lib`: 3 resolved transitive packages");
|
||||
expect(markdown).toContain("## Workspace Packages With The Most Dependencies");
|
||||
expect(markdown).toContain("3 direct dependencies");
|
||||
expect(markdown).not.toContain("dependencys");
|
||||
expect(markdown).toContain("## Packages With Install-Time Or Platform-Specific Behavior");
|
||||
expect(markdown).toContain("`transitive-native@1.0.0`: requires build");
|
||||
expect(markdown).not.toContain("# Dependency Risk Report");
|
||||
expect(markdown).not.toContain("Ownership gaps");
|
||||
expect(markdown).not.toContain("Largest root dependency cones");
|
||||
expect(markdown).not.toContain("## Root Dependencies With The Most Transitive Packages");
|
||||
});
|
||||
|
||||
it("does not mark plugin importer dependencies as stale ownership records", () => {
|
||||
@@ -180,7 +200,7 @@ snapshots:
|
||||
}),
|
||||
);
|
||||
|
||||
const report = collectSbomRiskReport({ repoRoot });
|
||||
const report = collectDependencyOwnershipSurfaceReport({ repoRoot });
|
||||
|
||||
expect(report.ownershipGaps).toStrictEqual([]);
|
||||
expect(report.staleOwnershipRecords).toEqual(["removed-lib"]);
|
||||
171
test/scripts/dependency-vulnerability-gate.test.ts
Normal file
171
test/scripts/dependency-vulnerability-gate.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
classifyVulnerabilityFindings,
|
||||
renderDependencyVulnerabilityGateMarkdownReport,
|
||||
runDependencyVulnerabilityGate,
|
||||
} from "../../scripts/dependency-vulnerability-gate.mjs";
|
||||
|
||||
function advisory({
|
||||
id,
|
||||
severity,
|
||||
title,
|
||||
vulnerableVersions = "<=1.0.0",
|
||||
}: {
|
||||
id: string;
|
||||
severity: string;
|
||||
title: string;
|
||||
vulnerableVersions?: string;
|
||||
}) {
|
||||
return {
|
||||
id,
|
||||
severity,
|
||||
title,
|
||||
vulnerable_versions: vulnerableVersions,
|
||||
url: `https://github.com/advisories/${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeLockfile(rootDir: string) {
|
||||
await writeFile(
|
||||
path.join(rootDir, "pnpm-lock.yaml"),
|
||||
`lockfileVersion: '9.0'
|
||||
|
||||
importers:
|
||||
.:
|
||||
dependencies:
|
||||
runtime-high:
|
||||
version: 1.0.0
|
||||
devDependencies:
|
||||
dev-high:
|
||||
version: 1.0.0
|
||||
|
||||
snapshots:
|
||||
runtime-high@1.0.0: {}
|
||||
dev-high@1.0.0: {}
|
||||
transitive-critical@1.0.0: {}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
describe("dependency-vulnerability-gate", () => {
|
||||
it("blocks critical advisories anywhere and high advisories in the production graph", () => {
|
||||
const result = classifyVulnerabilityFindings({
|
||||
allAdvisories: {
|
||||
"dev-high": [advisory({ id: "GHSA-dev-high", severity: "high", title: "dev high" })],
|
||||
"transitive-critical": [
|
||||
advisory({ id: "GHSA-critical", severity: "critical", title: "critical issue" }),
|
||||
],
|
||||
},
|
||||
productionAdvisories: {
|
||||
"runtime-high": [
|
||||
advisory({ id: "GHSA-runtime-high", severity: "high", title: "runtime high" }),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.blockers.map((finding) => finding.id)).toEqual([
|
||||
"GHSA-critical",
|
||||
"GHSA-runtime-high",
|
||||
]);
|
||||
expect(result.findings.map((finding) => finding.id)).toEqual([
|
||||
"GHSA-critical",
|
||||
"GHSA-dev-high",
|
||||
"GHSA-runtime-high",
|
||||
]);
|
||||
});
|
||||
|
||||
it("blocks malware advisories regardless of severity or graph", () => {
|
||||
const result = classifyVulnerabilityFindings({
|
||||
allAdvisories: {
|
||||
dev: [advisory({ id: "GHSA-malware", severity: "low", title: "Malware in dev" })],
|
||||
},
|
||||
productionAdvisories: {},
|
||||
});
|
||||
|
||||
expect(result.blockers).toMatchObject([
|
||||
{
|
||||
id: "GHSA-malware",
|
||||
malware: true,
|
||||
severity: "low",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("queries full and production lockfile graphs separately", async () => {
|
||||
const rootDir = await mkdtemp(path.join(tmpdir(), "openclaw-vuln-gate-"));
|
||||
await writeLockfile(rootDir);
|
||||
const payloads: Record<string, string[]>[] = [];
|
||||
|
||||
const report = await runDependencyVulnerabilityGate({
|
||||
rootDir,
|
||||
fetchImpl: async (_url, init) => {
|
||||
const payload = JSON.parse(String(init?.body));
|
||||
payloads.push(payload);
|
||||
const packages = Object.keys(payload);
|
||||
const body: Record<string, unknown[]> = {};
|
||||
if (packages.includes("runtime-high")) {
|
||||
body["runtime-high"] = [
|
||||
advisory({ id: "GHSA-runtime-high", severity: "high", title: "runtime high" }),
|
||||
];
|
||||
}
|
||||
if (packages.includes("dev-high")) {
|
||||
body["dev-high"] = [
|
||||
advisory({ id: "GHSA-dev-high", severity: "high", title: "dev high" }),
|
||||
];
|
||||
}
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
expect(payloads).toHaveLength(2);
|
||||
expect(payloads[0]).toEqual({
|
||||
"dev-high": ["1.0.0"],
|
||||
"runtime-high": ["1.0.0"],
|
||||
"transitive-critical": ["1.0.0"],
|
||||
});
|
||||
expect(payloads[1]).toEqual({
|
||||
"runtime-high": ["1.0.0"],
|
||||
});
|
||||
expect(report.blockers.map((finding) => finding.id)).toEqual(["GHSA-runtime-high"]);
|
||||
expect(report.findings.map((finding) => finding.id)).toEqual([
|
||||
"GHSA-dev-high",
|
||||
"GHSA-runtime-high",
|
||||
]);
|
||||
});
|
||||
|
||||
it("documents the resolved transitive dependency graph scope in Markdown", () => {
|
||||
const markdown = renderDependencyVulnerabilityGateMarkdownReport({
|
||||
generatedAt: "2026-05-12T00:00:00.000Z",
|
||||
policy: {
|
||||
blocks: [
|
||||
"known malware advisories anywhere in the installed graph",
|
||||
"critical advisories anywhere in the installed graph",
|
||||
"high advisories in the production/runtime graph",
|
||||
],
|
||||
reports: [
|
||||
"moderate and lower advisories",
|
||||
"high advisories outside production/runtime graph",
|
||||
],
|
||||
vulnerabilityExceptions: false,
|
||||
},
|
||||
graphs: {
|
||||
all: { packages: 2, packageVersions: 2 },
|
||||
production: { packages: 1, packageVersions: 1 },
|
||||
},
|
||||
blockers: [],
|
||||
findings: [],
|
||||
});
|
||||
|
||||
expect(markdown).toContain("# npm Advisory Vulnerability Gate: Resolved Dependency Graph");
|
||||
expect(markdown).toContain("## Scope");
|
||||
expect(markdown).toContain("resolved package versions from pnpm-lock.yaml");
|
||||
expect(markdown).toContain("It includes transitive dependencies.");
|
||||
});
|
||||
});
|
||||
172
test/scripts/generate-dependency-release-evidence.test.ts
Normal file
172
test/scripts/generate-dependency-release-evidence.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEPENDENCY_EVIDENCE_REPORTS,
|
||||
collectDependencyEvidenceSummaryCounts,
|
||||
createDependencyEvidenceManifest,
|
||||
renderDependencyEvidenceStepSummary,
|
||||
renderDependencyEvidenceSummary,
|
||||
resolvePreviousReleaseTag,
|
||||
resolveReleaseTag,
|
||||
} from "../../scripts/generate-dependency-release-evidence.mjs";
|
||||
|
||||
async function writeJson(dir: string, fileName: string, value: unknown) {
|
||||
await writeFile(path.join(dir, fileName), `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
describe("generate-dependency-release-evidence", () => {
|
||||
it("defines the release evidence command list and policy classifications", () => {
|
||||
expect(DEPENDENCY_EVIDENCE_REPORTS.map(({ command, policy }) => ({ command, policy }))).toEqual(
|
||||
[
|
||||
{ command: "pnpm deps:vuln:gate", policy: "hard-blocking" },
|
||||
{ command: "pnpm deps:transitive-risk:report", policy: "report-only" },
|
||||
{ command: "pnpm deps:ownership-surface:report", policy: "report-only" },
|
||||
{ command: "pnpm deps:changes:report", policy: "report-only" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("creates the dependency evidence manifest shape", () => {
|
||||
const manifest = createDependencyEvidenceManifest({
|
||||
generatedAt: "2026-05-13T00:00:00.000Z",
|
||||
releaseTag: "v2026.5.13-beta.1",
|
||||
releaseRef: "v2026.5.13-beta.1",
|
||||
releaseSha: "abc123",
|
||||
npmDistTag: "beta",
|
||||
packageVersion: "2026.5.13-beta.1",
|
||||
workflowRunId: "123",
|
||||
workflowRunAttempt: "2",
|
||||
dependencyChangeBaseRef: "v2026.5.1",
|
||||
});
|
||||
|
||||
expect(manifest).toEqual({
|
||||
schemaVersion: 1,
|
||||
generatedAt: "2026-05-13T00:00:00.000Z",
|
||||
releaseTag: "v2026.5.13-beta.1",
|
||||
releaseRef: "v2026.5.13-beta.1",
|
||||
releaseSha: "abc123",
|
||||
npmDistTag: "beta",
|
||||
packageName: "openclaw",
|
||||
packageVersion: "2026.5.13-beta.1",
|
||||
workflowRunId: "123",
|
||||
workflowRunAttempt: "2",
|
||||
dependencyChangeBaseRef: "v2026.5.1",
|
||||
reports: DEPENDENCY_EVIDENCE_REPORTS,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a synthetic release tag for validation-only SHA preflight input", () => {
|
||||
expect(
|
||||
resolveReleaseTag({
|
||||
releaseRef: "0123456789abcdef0123456789abcdef01234567",
|
||||
packageVersion: "2026.5.13",
|
||||
}),
|
||||
).toBe("v2026.5.13");
|
||||
expect(
|
||||
resolveReleaseTag({
|
||||
releaseRef: "v2026.5.13-beta.1",
|
||||
packageVersion: "2026.5.13-beta.1",
|
||||
}),
|
||||
).toBe("v2026.5.13-beta.1");
|
||||
});
|
||||
|
||||
it("falls back to fetching tags when local previous-release resolution misses", () => {
|
||||
const calls: Array<{ command: string; args: string[] }> = [];
|
||||
let describeCalls = 0;
|
||||
const execFileSyncImpl = (command: string, args: string[] = []) => {
|
||||
calls.push({ command, args });
|
||||
if (command !== "git") {
|
||||
throw new Error(`unexpected command: ${command}`);
|
||||
}
|
||||
if (args[0] === "describe") {
|
||||
describeCalls += 1;
|
||||
if (describeCalls === 1) {
|
||||
throw new Error("tag not found");
|
||||
}
|
||||
return "v2026.5.1\n";
|
||||
}
|
||||
if (args[0] === "fetch") {
|
||||
return "";
|
||||
}
|
||||
throw new Error(`unexpected git args: ${args.join(" ")}`);
|
||||
};
|
||||
|
||||
expect(
|
||||
resolvePreviousReleaseTag({
|
||||
rootDir: "/repo",
|
||||
execFileSyncImpl,
|
||||
}),
|
||||
).toBe("v2026.5.1");
|
||||
expect(calls.map(({ args }) => args[0])).toEqual(["describe", "fetch", "describe"]);
|
||||
expect(calls[1].args).toEqual(["fetch", "--tags", "--force", "origin"]);
|
||||
});
|
||||
|
||||
it("collects report counts and renders human summaries", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-release-dependency-evidence-test-"));
|
||||
await writeJson(dir, "dependency-vulnerability-gate.json", {
|
||||
blockers: [{ id: "GHSA-blocker" }],
|
||||
findings: [{ id: "GHSA-blocker" }, { id: "GHSA-report" }],
|
||||
});
|
||||
await writeJson(dir, "transitive-manifest-risk-report.json", {
|
||||
findingCount: 17,
|
||||
workspaceExcludedFindingCount: 3,
|
||||
metadataFailures: [{ packageName: "missing" }],
|
||||
});
|
||||
await writeJson(dir, "dependency-ownership-surface-report.json", {
|
||||
summary: {
|
||||
lockfilePackageCount: 101,
|
||||
buildRiskPackageCount: 8,
|
||||
},
|
||||
});
|
||||
await writeJson(dir, "dependency-changes-report.json", {
|
||||
summary: {
|
||||
dependencyFileChanges: 4,
|
||||
addedPackages: 5,
|
||||
removedPackages: 6,
|
||||
changedPackages: 7,
|
||||
},
|
||||
});
|
||||
|
||||
const counts = await collectDependencyEvidenceSummaryCounts(dir);
|
||||
expect(counts).toEqual({
|
||||
vulnerabilityBlockers: 1,
|
||||
vulnerabilityFindings: 2,
|
||||
transitiveRiskSignals: 17,
|
||||
workspaceExcludedTransitiveSignals: 3,
|
||||
transitiveMetadataFailures: 1,
|
||||
ownershipLockfilePackages: 101,
|
||||
ownershipBuildRiskPackages: 8,
|
||||
dependencyFileChanges: 4,
|
||||
dependencyAddedPackages: 5,
|
||||
dependencyRemovedPackages: 6,
|
||||
dependencyChangedPackages: 7,
|
||||
});
|
||||
|
||||
const summary = renderDependencyEvidenceSummary({
|
||||
releaseTag: "v2026.5.13",
|
||||
releaseSha: "abc123",
|
||||
baseRef: "v2026.5.1",
|
||||
counts,
|
||||
});
|
||||
expect(summary).toContain("- npm advisory vulnerability hard blockers: 1");
|
||||
expect(summary).toContain("- Transitive manifest reported risk signals: 17");
|
||||
expect(summary).toContain("- Dependency change baseline: `v2026.5.1`");
|
||||
expect(summary).toContain("- Resolved package changes: +5 -6 changed 7");
|
||||
|
||||
const stepSummary = renderDependencyEvidenceStepSummary({
|
||||
evidenceArtifactName: "openclaw-release-dependency-evidence-v2026.5.13",
|
||||
baseRef: "v2026.5.1",
|
||||
counts,
|
||||
});
|
||||
expect(stepSummary).toContain(
|
||||
"- Evidence artifact: `openclaw-release-dependency-evidence-v2026.5.13`",
|
||||
);
|
||||
expect(stepSummary).toContain("- npm advisory vulnerability hard blockers: `1`");
|
||||
|
||||
await expect(
|
||||
readFile(path.join(dir, "dependency-vulnerability-gate.json"), "utf8"),
|
||||
).resolves.toContain("GHSA-blocker");
|
||||
});
|
||||
});
|
||||
177
test/scripts/transitive-manifest-risk-report.test.ts
Normal file
177
test/scripts/transitive-manifest-risk-report.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createTransitiveManifestRiskReport,
|
||||
renderTransitiveManifestRiskMarkdownReport,
|
||||
} from "../../scripts/transitive-manifest-risk-report.mjs";
|
||||
|
||||
describe("transitive-manifest-risk-report", () => {
|
||||
it("reports floating transitive specs, lifecycle scripts, exotic sources, and recently published versions", async () => {
|
||||
const report = await createTransitiveManifestRiskReport({
|
||||
packageVersions: [
|
||||
{ packageName: "parent", version: "1.0.0" },
|
||||
{ packageName: "tarball-package", version: "https://example.test/pkg.tgz" },
|
||||
],
|
||||
now: new Date("2026-05-12T00:00:00Z"),
|
||||
minimumReleaseAgeMinutes: 2_880,
|
||||
manifestLoader: async ({ packageName, version }) => {
|
||||
if (packageName !== "parent" || version !== "1.0.0") {
|
||||
throw new Error("unexpected manifest request");
|
||||
}
|
||||
return {
|
||||
publishedAt: "2026-05-11T23:00:00Z",
|
||||
manifest: {
|
||||
dependencies: {
|
||||
floating: "^1.2.3",
|
||||
exact: "2.0.0",
|
||||
gitdep: "github:owner/repo#main",
|
||||
},
|
||||
optionalDependencies: {
|
||||
optionalFloating: "~3.0.0",
|
||||
},
|
||||
scripts: {
|
||||
install: "node install.js",
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(report.byType).toEqual({
|
||||
"exotic-source": 2,
|
||||
"floating-transitive-spec": 3,
|
||||
"lifecycle-script": 1,
|
||||
"recently-published-version": 1,
|
||||
});
|
||||
expect(report.workspaceExcludedFindings).toEqual([]);
|
||||
expect(report.metadataFailures).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses pnpm minimum release age exclusions for recently published versions", async () => {
|
||||
const report = await createTransitiveManifestRiskReport({
|
||||
packageVersions: [
|
||||
{ packageName: "regular", version: "1.0.0" },
|
||||
{ packageName: "exact-package", version: "2.0.0" },
|
||||
{ packageName: "either-version", version: "5.102.1" },
|
||||
{ packageName: "@scope/native-linux-x64", version: "3.0.0" },
|
||||
],
|
||||
now: new Date("2026-05-12T00:00:00Z"),
|
||||
minimumReleaseAgeMinutes: 2_880,
|
||||
minimumReleaseAgeExclude: [
|
||||
"exact-package@2.0.0",
|
||||
"either-version@4.47.0 || 5.102.1",
|
||||
"@scope/native-*",
|
||||
],
|
||||
manifestLoader: async () => ({
|
||||
publishedAt: "2026-05-11T23:00:00Z",
|
||||
manifest: {},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(report.byType).toEqual({
|
||||
"recently-published-version": 1,
|
||||
});
|
||||
expect(report.workspaceExcludedByType).toEqual({
|
||||
"recently-published-version": 3,
|
||||
});
|
||||
expect(report.findings).toMatchObject([
|
||||
{
|
||||
packageName: "regular",
|
||||
type: "recently-published-version",
|
||||
},
|
||||
]);
|
||||
expect(report.workspaceExcludedFindings).toMatchObject([
|
||||
{
|
||||
packageName: "@scope/native-linux-x64",
|
||||
type: "recently-published-version",
|
||||
workspaceExcluded: true,
|
||||
workspaceExclusion: "@scope/native-*",
|
||||
},
|
||||
{
|
||||
packageName: "either-version",
|
||||
type: "recently-published-version",
|
||||
workspaceExcluded: true,
|
||||
workspaceExclusion: "either-version@4.47.0 || 5.102.1",
|
||||
},
|
||||
{
|
||||
packageName: "exact-package",
|
||||
type: "recently-published-version",
|
||||
workspaceExcluded: true,
|
||||
workspaceExclusion: "exact-package@2.0.0",
|
||||
},
|
||||
]);
|
||||
|
||||
const markdown = renderTransitiveManifestRiskMarkdownReport(report);
|
||||
expect(markdown).toContain(
|
||||
"## Recently Published Versions Not Covered By Workspace Exclusions",
|
||||
);
|
||||
expect(markdown).toContain("## Recently Published Versions Covered By Workspace Exclusions");
|
||||
expect(markdown).toContain("Workspace minimum release age: 2880 minutes.");
|
||||
expect(markdown).toContain("`regular@1.0.0`: published 2026-05-11T23:00:00Z");
|
||||
expect(markdown).toContain(
|
||||
"`exact-package@2.0.0`: published 2026-05-11T23:00:00Z; workspace exclusion `exact-package@2.0.0`",
|
||||
);
|
||||
expect(markdown).not.toContain(
|
||||
"`regular@1.0.0`: published 2026-05-11T23:00:00Z; minimum release age 2880 minutes",
|
||||
);
|
||||
});
|
||||
|
||||
it("documents JSON completeness and renders grouped Markdown summaries", async () => {
|
||||
const report = await createTransitiveManifestRiskReport({
|
||||
packageVersions: [
|
||||
{ packageName: "@earendil-works/pi-ai", version: "0.74.0" },
|
||||
{ packageName: "aaa-package", version: "1.0.0" },
|
||||
{ packageName: "recent-package", version: "1.0.0" },
|
||||
],
|
||||
now: new Date("2026-05-12T00:00:00Z"),
|
||||
minimumReleaseAgeMinutes: 2_880,
|
||||
minimumReleaseAgeExclude: ["recent-package@1.0.0"],
|
||||
manifestLoader: async ({ packageName }) => ({
|
||||
publishedAt:
|
||||
packageName === "recent-package" ? "2026-05-11T23:00:00Z" : "2026-04-01T00:00:00Z",
|
||||
manifest:
|
||||
packageName === "@earendil-works/pi-ai"
|
||||
? {
|
||||
dependencies: {
|
||||
"@mistralai/mistralai": "^2.2.0",
|
||||
},
|
||||
}
|
||||
: packageName === "recent-package"
|
||||
? {
|
||||
dependencies: {
|
||||
"recent-dependency": "^1.0.0",
|
||||
},
|
||||
}
|
||||
: {
|
||||
dependencies: {
|
||||
"aaa-dependency": "^1.0.0",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const markdown = renderTransitiveManifestRiskMarkdownReport(report);
|
||||
|
||||
expect(markdown).toContain("# Transitive Manifest Risk Report");
|
||||
expect(markdown).toContain("## Scope");
|
||||
expect(markdown).toContain("published package manifests for resolved packages");
|
||||
expect(markdown).toContain("It is report-only.");
|
||||
expect(markdown).toContain("Resolved package versions inspected");
|
||||
expect(markdown).toContain("Reported risk signals");
|
||||
expect(markdown).toContain("Signals covered by workspace policy exclusions");
|
||||
expect(markdown).toContain("## Reported Risk Signals By Type");
|
||||
expect(markdown).toContain("## Signals Covered By Workspace Policy Exclusions");
|
||||
expect(markdown).toContain("not included in the reported risk signal totals");
|
||||
expect(markdown).toContain("## Complete Evidence");
|
||||
expect(markdown).toContain("The complete reported signal list is available in the JSON report");
|
||||
expect(markdown).toContain("## Published Package Manifests With Risk Findings");
|
||||
expect(markdown).toContain("`@earendil-works/pi-ai@0.74.0`: 1 manifest finding");
|
||||
expect(markdown).toContain("`aaa-package@1.0.0`: 1 manifest finding");
|
||||
expect(markdown).toContain("## Floating Dependency Targets");
|
||||
expect(markdown).toContain("`@mistralai/mistralai`: 1 declarations");
|
||||
expect(markdown).toContain("`aaa-dependency`: 1 declarations");
|
||||
expect(markdown).not.toContain("## Packages With Findings");
|
||||
expect(markdown).not.toContain("## Finding Details");
|
||||
expect(markdown).not.toContain("## Notable Findings");
|
||||
expect(markdown).not.toContain("## Additional Sample Findings");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user