openclaw/scripts/dependency-vulnerability-gate.mts
Dallin Romney 06021b42b9
fix(release): qualify frozen dependency evidence locks (#136884)
* fix(release): qualify frozen dependency evidence locks

* test(release): satisfy dependency gate lint

* fix(release): describe target-owned dependency graphs

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-09-06 23:13:21 -07:00

525 lines
18 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) {
const releaseToolLockfiles = await Promise.all(
RELEASE_TOOL_LOCKFILES.map(async (lockfile) => {
try {
// A frozen target only owns this graph once it owns the tool package.
// Its lock must still be present once that package exists.
await readFile(path.join(rootDir, path.dirname(lockfile), "package.json"), "utf8");
return lockfile;
} catch (error) {
if (isRecord(error) && error.code === "ENOENT") {
return null;
}
throw error;
}
}),
);
return ["pnpm-lock.yaml", ...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 into report-only findings and hard blockers.
*/
function classifyVulnerabilityFindings({
lockfile,
allAdvisories,
productionAdvisories,
}: AdvisorySets) {
const findings = [
...flattenAdvisories(allAdvisories, "all", lockfile),
...flattenAdvisories(productionAdvisories, "production", lockfile),
];
const blockers = findings.filter(
(finding) =>
finding.malware ||
finding.severity === "critical" ||
(finding.graph === "production" && finding.severity === "high"),
);
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 target-owned product and release-tool locks without merging their runtime graphs.
*/
export async function runDependencyVulnerabilityGate({
rootDir = process.cwd(),
fetchImpl = fetch,
}: { rootDir?: string; fetchImpl?: typeof fetch } = {}) {
const lockfiles = await Promise.all(
(await resolveLockfiles(rootDir)).map(async (lockfile) => {
try {
const text = await readFile(path.join(rootDir, 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",
"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: 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 declared by that target against npm bulk data and published advisories from verified public, registry-declared GitHub repositories. It includes transitive dependencies. Each listed lockfile is audited independently: known malware and critical advisories block anywhere; high advisories block in that source's production/runtime graph. Release-tool findings do not imply product runtime exposure.",
"",
"## Summary",
"",
`- Hard blockers: ${report.blockers.length}`,
`- Total findings: ${report.findings.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}`),
`- Vulnerability exceptions: ${report.policy.vulnerabilityExceptions ? "allowed" : "not allowed"}`,
"",
];
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 [
["Hard Blockers", 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`;
}
export async function main(argv = process.argv.slice(2)) {
const options = parseReportCliArgs(argv);
const report = await runDependencyVulnerabilityGate({ rootDir: options.rootDir });
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`,
);
}
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 hard blockers, ` +
`${report.findings.length} total advisories; ${coverage}. ` +
"This is not comprehensive vulnerability clearance.\n",
);
return 0;
}
process.stderr.write(
`FAIL dependency vulnerability gate: ${report.blockers.length} hard blockers in resolved ` +
`dependency graph; ${report.findings.length} total advisories; ${coverage}.\n`,
);
for (const blocker of report.blockers.slice(0, 25)) {
process.stderr.write(
`- ${blocker.severity.toUpperCase()} ${blocker.packageName} (${blocker.lockfile}; ${blocker.graph}) ` +
`id=${blocker.id} source=${blocker.source} title=${blocker.title}\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;
},
);
}