openclaw/scripts/dependency-vulnerability-gate.mts
Peter Steinberger 56f616e437
fix(release): dependency advisories no longer fail or delay a release (#161463)
* fix(release): record dependency advisories without blocking releases

Release dependency evidence now blocks only on known malware. Vulnerability
advisories of every severity are recorded in the evidence summary and surfaced
as GitHub warning annotations, and CI dispatched by Full Release Validation or
release publication reports a failing production audit as a warning. The
per-release risk-acceptance table existed only to accept advisory blockers and
is removed.

The release skills also record that main CI health never gates a release and
that every failed test gets an explicit real-blocker-or-flake decision.

* fix(ci): keep release audit relaxation within the workflow size budget

ci.yml sits at the 480000-byte guard, so the release-dispatch check moves
into a trusted harness script that security-fast already checks out.
2026-09-30 01:11:12 +00:00

550 lines
19 KiB
TypeScript

#!/usr/bin/env node
// Checks resolved dependencies against npm and public upstream advisories.
import { readFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { isRecord } from "../packages/normalization-core/src/record-coerce.ts";
import { parseReportCliArgs, writeReportArtifact } from "./lib/report-cli-helpers.mts";
import {
fetchPublishedRepositoryAdvisories,
type PublishedRepositoryAdvisory,
} from "./lib/upstream-repository-advisories.mts";
import {
collectAllResolvedPackagesFromLockfile,
collectProdResolvedPackagesFromLockfile,
createBulkAdvisoryPayload,
fetchBulkAdvisories,
resolveRegistryBaseUrl,
} from "./pre-commit/pnpm-audit-prod.mjs";
const SEVERITY_RANK = {
info: 0,
low: 1,
moderate: 2,
high: 3,
critical: 4,
};
type Advisory = Partial<
Record<"overview" | "severity" | "title" | "url" | "vulnerable_versions", string>
> & { id?: string | number; source?: "npm-bulk" | "github-repository"; matchedVersions?: string[] };
type Graph = "all" | "production";
type AdvisoryMap = Record<string, Advisory[]>;
type AdvisoryPayload = Record<string, string[]>;
type AdvisorySets = {
lockfile: string;
allAdvisories: AdvisoryMap;
productionAdvisories: AdvisoryMap;
};
const RELEASE_TOOL_LOCKFILES = [
".github/release/clawhub-cli/package-lock.json",
".github/release/vercel-cli/package-lock.json",
];
async function resolveLockfiles(rootDir: string, releaseToolRoot: string) {
const releaseToolLockfiles = await Promise.all(
RELEASE_TOOL_LOCKFILES.map(async (lockfile) => {
try {
// Publishing executes the current trusted tooling checkout even when
// product bytes stay frozen at an older release candidate.
await readFile(path.join(releaseToolRoot, path.dirname(lockfile), "package.json"), "utf8");
return { lockfile, rootDir: releaseToolRoot };
} catch (error) {
if (isRecord(error) && error.code === "ENOENT") {
return null;
}
throw error;
}
}),
);
return [
{ lockfile: "pnpm-lock.yaml", rootDir },
...releaseToolLockfiles.filter((lockfile) => lockfile !== null),
];
}
function isSeverity(severity: string): severity is keyof typeof SEVERITY_RANK {
return Object.hasOwn(SEVERITY_RANK, severity);
}
function normalizeSeverity(severity: unknown) {
if (typeof severity !== "string" || !isSeverity(severity)) {
throw new Error("Invalid advisory severity");
}
return severity;
}
function isMalwareAdvisory(advisory: Advisory) {
const fields = [advisory.title, advisory.overview, advisory.url].filter(
(field) => typeof field === "string",
);
return fields.some((field) => /\bmalware\b/iu.test(field));
}
function findingFromAdvisory(
packageName: string,
advisory: Advisory,
graph: Graph,
lockfile: string,
) {
return {
lockfile,
graph,
packageName,
id: advisory.id ?? "unknown",
severity: normalizeSeverity(advisory.severity),
title: advisory.title ?? "Untitled advisory",
url: advisory.url ?? null,
vulnerableVersions: advisory.vulnerable_versions ?? null,
malware: isMalwareAdvisory(advisory),
source: advisory.source ?? "npm-bulk",
...(advisory.matchedVersions ? { matchedVersions: advisory.matchedVersions } : {}),
};
}
type Finding = ReturnType<typeof findingFromAdvisory>;
async function fetchBulkAdvisoriesForPayload(payload: AdvisoryPayload, fetchImpl: typeof fetch) {
if (Object.keys(payload).length === 0) {
return {};
}
return await fetchBulkAdvisories({ payload, fetchImpl });
}
function includeRepositoryAdvisories(
npmAdvisories: AdvisoryMap,
payload: AdvisoryPayload,
upstream: PublishedRepositoryAdvisory[],
) {
const combined = new Map(Object.entries(npmAdvisories));
for (const { packageName, ...advisory } of upstream) {
const matchedVersions = advisory.matchedVersions.filter((version) =>
payload[packageName]?.includes(version),
);
if (matchedVersions.length === 0) {
continue;
}
const existing = combined.get(packageName) ?? [];
// npm uses numeric IDs; the GHSA URL is the cross-source identity. Compare within
// this exact lock/graph so dev-only or release-tool evidence cannot clear runtime.
if (
existing.some(
(entry) => String(entry.id) === advisory.id || entry.url?.endsWith(`/${advisory.id}`),
)
) {
continue;
}
combined.set(packageName, [
...existing,
{ ...advisory, matchedVersions, source: "github-repository" },
]);
}
return Object.fromEntries(combined);
}
function flattenAdvisories(advisoriesByPackage: AdvisoryMap, graphName: Graph, lockfile: string) {
const findings: Finding[] = [];
for (const [packageName, advisories] of Object.entries(advisoriesByPackage)) {
for (const advisory of advisories) {
findings.push(findingFromAdvisory(packageName, advisory, graphName, lockfile));
}
}
return findings;
}
function findingKey(finding: Finding) {
return [
finding.lockfile,
finding.packageName,
String(finding.id),
finding.severity,
finding.vulnerableVersions ?? "",
].join("\0");
}
function dedupeFindings(findings: Finding[]) {
const byKey = new Map<string, Finding>();
for (const finding of findings) {
const key = findingKey(finding);
const existing = byKey.get(key);
if (!existing) {
byKey.set(key, finding);
continue;
}
if (existing.graph !== "production" && finding.graph === "production") {
byKey.set(key, finding);
}
}
return [...byKey.values()];
}
function sortFindings(findings: Finding[]) {
return findings.toSorted((left, right) => {
const severityDelta =
(SEVERITY_RANK[right.severity] ?? -1) - (SEVERITY_RANK[left.severity] ?? -1);
if (severityDelta !== 0) {
return severityDelta;
}
if (left.graph !== right.graph) {
return left.graph.localeCompare(right.graph);
}
if (left.packageName !== right.packageName) {
return left.packageName.localeCompare(right.packageName);
}
return (
String(left.id).localeCompare(String(right.id)) || left.lockfile.localeCompare(right.lockfile)
);
});
}
/**
* Classifies source-attributed advisory findings. Only known malware blocks: it is a
* compromised package, while vulnerability advisories never delay a release.
*/
function classifyVulnerabilityFindings({
lockfile,
allAdvisories,
productionAdvisories,
}: AdvisorySets) {
const findings = [
...flattenAdvisories(allAdvisories, "all", lockfile),
...flattenAdvisories(productionAdvisories, "production", lockfile),
];
const blockers = findings.filter((finding) => finding.malware);
return {
blockers: sortFindings(dedupeFindings(blockers)),
findings: sortFindings(dedupeFindings(findings)),
};
}
function countPayloadVersions(payload: AdvisoryPayload) {
return Object.values(payload).reduce((sum, versions) => sum + versions.length, 0);
}
function collectNpmLockPackages(text: string) {
const lock: unknown = JSON.parse(text);
if (
!isRecord(lock) ||
lock.lockfileVersion !== 3 ||
!isRecord(lock.packages) ||
!isRecord(lock.packages[""])
) {
throw new Error("Expected an npm v3 lockfile with a packages map and root entry.");
}
const all = new Map<string, Set<string>>();
const production = new Map<string, Set<string>>();
for (const [location, metadata] of Object.entries(lock.packages)) {
if (location === "") {
continue;
}
if (!isRecord(metadata)) {
throw new Error(`Invalid package entry: ${location}`);
}
if (metadata.link === true) {
continue;
}
// npm records alias targets in name; nested paths retain scoped package names.
const name =
metadata.name ?? location.match(/(?:^|\/)node_modules\/((?:@[^/]+\/)?[^/]+)$/u)?.[1];
if (
typeof name !== "string" ||
!name ||
typeof metadata.version !== "string" ||
!metadata.version
) {
throw new Error(`Missing package name or version: ${location}`);
}
// Only dev-only entries are excluded. Optional and devOptional can run in production.
for (const graph of metadata.dev === true ? [all] : [all, production]) {
const versions = graph.get(name) ?? new Set<string>();
versions.add(metadata.version);
graph.set(name, versions);
}
}
if (all.size === 0) {
throw new Error("Expected resolved dependencies in the release-tool lockfile.");
}
return { all, production };
}
/**
* Audits frozen product and trusted release-tool locks without merging their runtime graphs.
*/
export async function runDependencyVulnerabilityGate({
rootDir = process.cwd(),
releaseToolRoot = rootDir,
fetchImpl = fetch,
}: { rootDir?: string; releaseToolRoot?: string; fetchImpl?: typeof fetch } = {}) {
const lockfiles = await Promise.all(
(await resolveLockfiles(rootDir, releaseToolRoot)).map(
async ({ lockfile, rootDir: lockfileRoot }) => {
try {
const text = await readFile(path.join(lockfileRoot, lockfile), "utf8");
const packages =
lockfile === "pnpm-lock.yaml"
? {
all: collectAllResolvedPackagesFromLockfile(text),
production: collectProdResolvedPackagesFromLockfile(text),
}
: collectNpmLockPackages(text);
return {
lockfile,
allPayload: createBulkAdvisoryPayload(packages.all),
productionPayload: createBulkAdvisoryPayload(packages.production),
};
} catch (error) {
throw new Error(
`${lockfile}: ${error instanceof Error ? error.message : String(error)}`,
{
cause: error,
},
);
}
},
),
);
// Query each source independently: a vulnerable tool version must not taint a safe product version.
const npmSources = await Promise.all(
lockfiles.map(async ({ lockfile, allPayload, productionPayload }) => {
const [allAdvisories, productionAdvisories] = await Promise.all([
fetchBulkAdvisoriesForPayload(allPayload, fetchImpl),
fetchBulkAdvisoriesForPayload(productionPayload, fetchImpl),
]);
return { lockfile, allPayload, productionPayload, allAdvisories, productionAdvisories };
}),
);
const upstreamPackages = new Map<string, Set<string>>();
for (const { allPayload } of lockfiles) {
for (const [packageName, versions] of Object.entries(allPayload)) {
const merged = upstreamPackages.get(packageName) ?? new Set<string>();
for (const version of versions) {
merged.add(version);
}
upstreamPackages.set(packageName, merged);
}
}
const upstream = await fetchPublishedRepositoryAdvisories({
payload: createBulkAdvisoryPayload(upstreamPackages),
registryBaseUrl: resolveRegistryBaseUrl(),
fetchImpl,
});
const sources = npmSources.map(
({ lockfile, allPayload, productionPayload, allAdvisories, productionAdvisories }) => {
const classified = classifyVulnerabilityFindings({
lockfile,
allAdvisories: includeRepositoryAdvisories(allAdvisories, allPayload, upstream.advisories),
productionAdvisories: includeRepositoryAdvisories(
productionAdvisories,
productionPayload,
upstream.advisories,
),
});
return {
graphs: {
lockfile,
all: {
packages: Object.keys(allPayload).length,
packageVersions: countPayloadVersions(allPayload),
},
production: {
packages: Object.keys(productionPayload).length,
packageVersions: countPayloadVersions(productionPayload),
},
},
blockers: classified.blockers,
findings: classified.findings,
};
},
);
return {
generatedAt: new Date().toISOString(),
coverage: { npm: "checked" as const, upstream: upstream.coverage },
policy: {
blocks: ["known malware advisories anywhere in the installed graph"],
reports: ["vulnerability advisories of every severity in every graph; they never block"],
},
graphs: sources.map((source) => source.graphs),
blockers: sortFindings(sources.flatMap((source) => source.blockers)),
findings: sortFindings(sources.flatMap((source) => source.findings)),
};
}
/**
* Renders the dependency vulnerability gate report as Markdown.
*/
export function renderDependencyVulnerabilityGateMarkdownReport(
report: Awaited<ReturnType<typeof runDependencyVulnerabilityGate>>,
) {
const lines = [
"# Dependency Vulnerability Gate: Resolved Dependency Graph",
"",
`Generated: ${report.generatedAt}`,
"",
"## Scope",
"",
"This gate checks resolved package versions from the target's pnpm lock and each release-tool npm lock in the executing trusted tooling checkout against npm bulk data and published advisories from verified public, registry-declared GitHub repositories. It includes transitive dependencies. Each listed lockfile is audited independently. Only known malware blocks; vulnerability advisories of every severity are recorded as non-blocking release evidence. Release-tool findings do not imply product runtime exposure.",
"",
"## Summary",
"",
`- Known malware (blocking): ${report.blockers.length}`,
`- Total findings: ${report.findings.length}`,
`- Non-blocking advisories: ${report.findings.length - report.blockers.length}`,
`- Upstream-only findings missing from npm results: ${report.findings.filter((finding) => finding.source === "github-repository").length}`,
`- npm bulk: ${report.coverage.npm}`,
`- Public upstream coverage: ${report.coverage.upstream.status}`,
`- Package versions mapped to upstream repositories: ${report.coverage.upstream.mappedPackageVersions}/${report.coverage.upstream.packageVersions}`,
`- Repositories fully read: ${report.coverage.upstream.checkedRepositories}/${report.coverage.upstream.repositories}`,
`- Coverage issues: ${report.coverage.upstream.issues.length}`,
`- GitHub-reviewed range reconciliations: ${report.coverage.upstream.reconciliations.length} (raw ranges retained in JSON coverage evidence)`,
"",
"An empty source response is not comprehensive vulnerability clearance. Unsupported or incomplete upstream checks are not evidence that a package is unaffected.",
"",
...report.graphs.flatMap((graph) => [
`### ${graph.lockfile}`,
"",
`- All graph packages: ${graph.all.packages}`,
`- All graph package versions: ${graph.all.packageVersions}`,
`- Production graph packages: ${graph.production.packages}`,
`- Production graph package versions: ${graph.production.packageVersions}`,
"",
]),
"## Policy",
"",
...report.policy.blocks.map((block) => `- Block: ${block}`),
...report.policy.reports.map((item) => `- Report: ${item}`),
"",
];
if (report.coverage.upstream.issues.length > 0) {
lines.push("## Incomplete Upstream Coverage", "");
for (const issue of report.coverage.upstream.issues.slice(0, 25)) {
lines.push(`- ${issue.subject}: ${issue.reason}`);
}
if (report.coverage.upstream.issues.length > 25) {
lines.push(
`- ${report.coverage.upstream.issues.length - 25} more coverage issues; see the JSON report.`,
);
}
lines.push("");
}
for (const [title, findings] of [
["Known Malware (blocking)", report.blockers],
["Findings", report.findings],
] as const) {
if (findings.length === 0) {
continue;
}
lines.push(`## ${title}`, "");
for (const finding of findings) {
lines.push(
`- ${finding.severity.toUpperCase()} ${finding.packageName} (${finding.lockfile}; ${finding.graph}) ` +
`id=${finding.id} range=${finding.vulnerableVersions ?? "unknown"} ` +
`${finding.malware ? "[malware] " : ""}${finding.url ?? ""}`,
);
lines.push(` - ${finding.title} [source: ${finding.source}]`);
if (finding.matchedVersions) {
lines.push(` - Matched locked versions: ${finding.matchedVersions.join(", ")}`);
}
}
lines.push("");
}
if (report.findings.length === 0) {
lines.push("No matching advisories returned by the checked sources.", "");
}
return `${lines.join("\n")}\n`;
}
function formatFinding(finding: Finding) {
return (
`${finding.severity.toUpperCase()} ${finding.packageName} (${finding.lockfile}; ${finding.graph}) ` +
`id=${finding.id} source=${finding.source} title=${finding.title}`
);
}
function escapeAnnotation(message: string) {
return message.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
}
export async function main(
argv = process.argv.slice(2),
{ releaseToolRoot = path.resolve(import.meta.dirname, "..") } = {},
) {
const options = parseReportCliArgs(argv);
const report = await runDependencyVulnerabilityGate({
rootDir: options.rootDir,
releaseToolRoot,
});
await writeReportArtifact(options.jsonPath, `${JSON.stringify(report, null, 2)}\n`);
await writeReportArtifact(
options.markdownPath,
renderDependencyVulnerabilityGateMarkdownReport(report),
);
const upstream = report.coverage.upstream;
const coverage =
`npm bulk checked; public upstream ${upstream.status} ` +
`(${upstream.checkedRepositories}/${upstream.repositories} repositories fully read)`;
if (upstream.status === "partial") {
process.stderr.write(
`WARN incomplete upstream advisory coverage: ${upstream.issues.length} issues. Unchecked packages are not cleared; inspect the coverage section of the JSON/Markdown report.\n`,
);
}
const advisories = report.findings.filter((finding) => !finding.malware);
// Workflow commands turn each advisory into a visible, non-failing run annotation.
for (const finding of advisories.slice(0, 25)) {
process.stdout.write(
`::warning title=Dependency advisory (non-blocking)::${escapeAnnotation(formatFinding(finding))}\n`,
);
}
if (advisories.length > 25) {
process.stdout.write(
`::warning title=Dependency advisory (non-blocking)::${advisories.length - 25} more advisories; see the JSON/Markdown report.\n`,
);
}
if (report.blockers.length === 0) {
const packageVersions = report.graphs.reduce(
(sum, graph) => sum + graph.all.packageVersions,
0,
);
process.stdout.write(
`PASS dependency vulnerability gate: checked ${packageVersions} resolved ` +
`package versions across ${report.graphs.length} separate lockfile graphs; 0 known malware, ` +
`${advisories.length} non-blocking advisories recorded as release evidence; ${coverage}. ` +
"This is not comprehensive vulnerability clearance.\n",
);
return 0;
}
process.stderr.write(
`FAIL dependency vulnerability gate: ${report.blockers.length} known malware findings in resolved ` +
`dependency graph; ${report.findings.length} total advisories; ${coverage}.\n`,
);
for (const blocker of report.blockers.slice(0, 25)) {
process.stderr.write(
`::error title=Known malware dependency::${escapeAnnotation(formatFinding(blocker))}\n`,
);
}
return 1;
}
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
main().then(
(exitCode) => {
process.exitCode = exitCode;
if (exitCode !== 0) {
process.stderr.write(`[dependency-vulnerability-gate] FAILED (exit ${exitCode})\n`);
}
},
(error: unknown) => {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n[dependency-vulnerability-gate] FAILED (exit 1)\n`,
);
process.exitCode = 1;
},
);
}