mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
* 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>
525 lines
18 KiB
TypeScript
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;
|
|
},
|
|
);
|
|
}
|