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:
Josh Avant
2026-05-13 03:05:09 -05:00
committed by GitHub
parent b9b7ffc8cd
commit bd4db5ee62
21 changed files with 3096 additions and 60 deletions

View File

@@ -92,13 +92,41 @@ export function collectDependencyPinViolations(cwd = process.cwd()) {
return [...collectPackageJsonViolations(cwd), ...collectWorkspaceViolations(cwd)];
}
export function collectDependencyPinAudit(cwd = process.cwd()) {
const packageJsonFiles = listTrackedPackageJsonFiles(cwd);
let packageSpecCount = 0;
for (const relativePath of packageJsonFiles) {
const packageJson = readJson(path.join(cwd, relativePath));
for (const section of PACKAGE_DEPENDENCY_SECTIONS) {
packageSpecCount += Object.keys(packageJson[section] ?? {}).length;
}
}
const workspaceViolations = collectWorkspaceViolations(cwd);
const violations = [...collectPackageJsonViolations(cwd), ...workspaceViolations];
return {
packageManifestCount: packageJsonFiles.length,
packageSpecCount,
violations,
};
}
export async function main() {
const violations = collectDependencyPinViolations();
const audit = collectDependencyPinAudit();
const { violations } = audit;
if (violations.length === 0) {
process.stdout.write(
`PASS direct dependency pin guard: checked ${audit.packageSpecCount} directly declared ` +
`dependency specs across ${audit.packageManifestCount} tracked package manifests; ` +
"0 violations.\n",
);
return;
}
console.error("Dependency specs must be pinned exactly outside peer dependency contracts:");
console.error(
`FAIL direct dependency pin guard: ${violations.length} unpinned directly declared ` +
"dependency specs found. Direct dependency specs must be pinned exactly outside peer " +
"dependency contracts:",
);
for (const violation of violations) {
console.error(
`- ${violation.file}:${violation.section}:${violation.name} -> ${JSON.stringify(violation.spec)}`,

View File

@@ -0,0 +1,312 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import {
collectAllResolvedPackagesFromLockfile,
createBulkAdvisoryPayload,
} from "./pre-commit/pnpm-audit-prod.mjs";
const DEPENDENCY_FILE_PATTERNS = [
/^package\.json$/u,
/^pnpm-lock\.yaml$/u,
/^pnpm-workspace\.yaml$/u,
/^patches\//u,
/\/package\.json$/u,
];
function payloadFromLockfile(lockfileText) {
return createBulkAdvisoryPayload(collectAllResolvedPackagesFromLockfile(lockfileText));
}
function versionsFor(payload, packageName) {
return new Set(payload[packageName] ?? []);
}
export function createDependencyChangesReport({
basePayload,
headPayload,
dependencyFileChanges = [],
baseLabel = "base",
headLabel = "head",
generatedAt = new Date().toISOString(),
}) {
const packageNames = [
...new Set([...Object.keys(basePayload), ...Object.keys(headPayload)]),
].toSorted((left, right) => left.localeCompare(right));
const addedPackages = [];
const removedPackages = [];
const changedPackages = [];
for (const packageName of packageNames) {
const baseVersions = versionsFor(basePayload, packageName);
const headVersions = versionsFor(headPayload, packageName);
if (baseVersions.size === 0) {
addedPackages.push({
packageName,
versions: [...headVersions].toSorted((left, right) => left.localeCompare(right)),
});
continue;
}
if (headVersions.size === 0) {
removedPackages.push({
packageName,
versions: [...baseVersions].toSorted((left, right) => left.localeCompare(right)),
});
continue;
}
const addedVersions = [...headVersions]
.filter((version) => !baseVersions.has(version))
.toSorted((left, right) => left.localeCompare(right));
const removedVersions = [...baseVersions]
.filter((version) => !headVersions.has(version))
.toSorted((left, right) => left.localeCompare(right));
if (addedVersions.length > 0 || removedVersions.length > 0) {
changedPackages.push({ packageName, addedVersions, removedVersions });
}
}
return {
generatedAt,
baseLabel,
headLabel,
summary: {
basePackages: Object.keys(basePayload).length,
headPackages: Object.keys(headPayload).length,
addedPackages: addedPackages.length,
removedPackages: removedPackages.length,
changedPackages: changedPackages.length,
dependencyFileChanges: dependencyFileChanges.length,
},
dependencyFileChanges,
addedPackages,
removedPackages,
changedPackages,
};
}
function markdownCode(value) {
return `\`${String(value).replaceAll("`", "\\`")}\``;
}
function renderMarkdownReport(report) {
const lines = [
"# Dependency Change Report",
"",
`Generated: ${report.generatedAt}`,
"",
"## Target",
"",
`- Base: ${report.baseLabel}`,
`- Head lockfile: ${report.headLabel}`,
"",
"## Scope",
"",
"This report compares dependency-related files and resolved lockfile package versions between the selected base and the current checkout.",
"",
"It reports two related but different things:",
"",
"- Dependency file changes: package manifests, pnpm workspace config, lockfile, and patches.",
"- Resolved package changes: package versions added, removed, or changed in pnpm-lock.yaml.",
"",
"## Summary",
"",
"**Dependency files**",
`- Changed files: ${report.summary.dependencyFileChanges}`,
"",
"**Resolved packages**",
`- Base: ${report.summary.basePackages}`,
`- Head: ${report.summary.headPackages}`,
`- Added: ${report.summary.addedPackages}`,
`- Removed: ${report.summary.removedPackages}`,
`- Changed versions: ${report.summary.changedPackages}`,
"",
];
if (report.dependencyFileChanges.length > 0) {
lines.push("## Dependency File Changes", "");
for (const item of report.dependencyFileChanges) {
lines.push(`- ${markdownCode(item.path)}: ${item.status}`);
}
lines.push("");
}
if (report.addedPackages.length > 0) {
lines.push("## Added Resolved Packages", "");
for (const item of report.addedPackages) {
lines.push(`- ${markdownCode(item.packageName)}: ${item.versions.join(", ")}`);
}
lines.push("");
}
if (report.removedPackages.length > 0) {
lines.push("## Removed Resolved Packages", "");
for (const item of report.removedPackages) {
lines.push(`- ${markdownCode(item.packageName)}: ${item.versions.join(", ")}`);
}
lines.push("");
}
if (report.changedPackages.length > 0) {
lines.push("## Changed Resolved Package Versions", "");
for (const item of report.changedPackages) {
lines.push(
`- ${markdownCode(item.packageName)}: +${item.addedVersions.join(", ") || "none"} ` +
`-${item.removedVersions.join(", ") || "none"}`,
);
}
lines.push("");
}
return `${lines.join("\n")}\n`;
}
function readGitFile(ref, filePath, cwd) {
return execFileSync("git", ["show", `${ref}:${filePath}`], {
cwd,
encoding: "utf8",
maxBuffer: 100 * 1024 * 1024,
});
}
function isDependencyFile(filePath) {
return DEPENDENCY_FILE_PATTERNS.some((pattern) => pattern.test(filePath));
}
function gitDiffDependencyFiles(baseRef, cwd) {
const output = execFileSync(
"git",
[
"diff",
"--name-status",
baseRef,
"--",
"package.json",
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
"*package.json",
"patches",
],
{
cwd,
encoding: "utf8",
maxBuffer: 20 * 1024 * 1024,
},
);
return output
.split("\n")
.filter(Boolean)
.map((line) => {
const [status, ...paths] = line.split("\t");
return {
status,
path: paths.at(-1),
oldPath: paths.length > 1 ? paths[0] : null,
};
})
.filter((item) => item.path && isDependencyFile(item.path))
.toSorted((left, right) => {
if (left.path !== right.path) {
return left.path.localeCompare(right.path);
}
return left.status.localeCompare(right.status);
});
}
function parseArgs(argv) {
const options = {
rootDir: process.cwd(),
baseRef: null,
baseLockfile: null,
headLockfile: "pnpm-lock.yaml",
jsonPath: null,
markdownPath: null,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--") {
continue;
}
if (arg === "--root") {
options.rootDir = argv[++index];
continue;
}
if (arg === "--base-ref") {
options.baseRef = argv[++index];
continue;
}
if (arg === "--base-lockfile") {
options.baseLockfile = argv[++index];
continue;
}
if (arg === "--head-lockfile") {
options.headLockfile = argv[++index];
continue;
}
if (arg === "--json") {
options.jsonPath = argv[++index];
continue;
}
if (arg === "--markdown") {
options.markdownPath = argv[++index];
continue;
}
throw new Error(`Unsupported argument: ${arg}`);
}
if (!options.baseRef && !options.baseLockfile) {
throw new Error("Expected --base-ref <git-ref> or --base-lockfile <path>.");
}
return options;
}
async function writeArtifact(filePath, content) {
if (!filePath) {
return;
}
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, content, "utf8");
}
export async function runDependencyChangesReport(options) {
const headLockfileText = await readFile(path.join(options.rootDir, options.headLockfile), "utf8");
const baseLockfileText = options.baseRef
? readGitFile(options.baseRef, "pnpm-lock.yaml", options.rootDir)
: await readFile(path.join(options.rootDir, options.baseLockfile), "utf8");
const dependencyFileChanges = options.baseRef
? gitDiffDependencyFiles(options.baseRef, options.rootDir)
: [];
return createDependencyChangesReport({
basePayload: payloadFromLockfile(baseLockfileText),
headPayload: payloadFromLockfile(headLockfileText),
dependencyFileChanges,
baseLabel: options.baseRef ?? options.baseLockfile,
headLabel: options.headLockfile,
});
}
export async function main(argv = process.argv.slice(2)) {
const options = parseArgs(argv);
const report = await runDependencyChangesReport(options);
await writeArtifact(options.jsonPath, `${JSON.stringify(report, null, 2)}\n`);
await writeArtifact(options.markdownPath, renderMarkdownReport(report));
const artifactHint =
typeof options.markdownPath === "string" ? " See " + options.markdownPath + "." : "";
process.stdout.write(
`INFO dependency change report: ${report.summary.addedPackages} added, ` +
`${report.summary.removedPackages} removed, ${report.summary.changedPackages} changed ` +
`resolved packages and ${report.summary.dependencyFileChanges} dependency file changes ` +
`relative to ${report.baseLabel}.${artifactHint}\n`,
);
return 0;
}
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
main().then(
(exitCode) => {
process.exitCode = exitCode;
},
(error) => {
process.stderr.write(`${error.stack ?? error.message ?? String(error)}\n`);
process.exitCode = 1;
},
);
}

View File

@@ -1,5 +1,6 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
@@ -119,7 +120,30 @@ function ownershipFor(dependencyOwnership, name) {
return dependencyOwnership.dependencies?.[name];
}
export function collectSbomRiskReport(params = {}) {
function gitValue(repoRoot, args) {
try {
return execFileSync("git", args, {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return null;
}
}
function collectReportTarget({ repoRoot, packageJson, ownershipPath }) {
return {
packageName: packageJson.name ?? null,
packageVersion: packageJson.version ?? null,
gitBranch: gitValue(repoRoot, ["branch", "--show-current"]),
gitCommit: gitValue(repoRoot, ["rev-parse", "HEAD"]),
lockfile: "pnpm-lock.yaml",
ownershipMetadata: path.relative(repoRoot, ownershipPath),
};
}
export function collectDependencyOwnershipSurfaceReport(params = {}) {
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
const packageJson = readJson(path.join(repoRoot, "package.json"));
const lockfile = readLockfile(path.join(repoRoot, "pnpm-lock.yaml"));
@@ -213,6 +237,8 @@ export function collectSbomRiskReport(params = {}) {
return {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
target: collectReportTarget({ repoRoot, packageJson, ownershipPath }),
summary: {
importerCount: Object.keys(lockfile.importers ?? {}).length,
lockfilePackageCount: Object.keys(lockfile.packages ?? {}).length,
@@ -224,88 +250,215 @@ export function collectSbomRiskReport(params = {}) {
ownershipGaps,
staleOwnershipRecords,
ownershipWarnings,
buildRiskPackages: collectBuildRiskPackages(lockfile).slice(0, 50),
topRootDependencyCones: rootDependencyRows
.toSorted((left, right) => {
if (right.closureSize !== left.closureSize) {
return right.closureSize - left.closureSize;
}
return left.name.localeCompare(right.name);
})
.slice(0, 20),
buildRiskPackages: collectBuildRiskPackages(lockfile),
topRootDependencyCones: rootDependencyRows.toSorted((left, right) => {
if (right.closureSize !== left.closureSize) {
return right.closureSize - left.closureSize;
}
return left.name.localeCompare(right.name);
}),
rootDependencies: rootDependencyRows,
importerClosures: importerClosures.slice(0, 30),
importerClosures,
};
}
export function collectSbomRiskCheckErrors(report) {
export function collectDependencyOwnershipSurfaceCheckErrors(report) {
return report.ownershipGaps.map(
(name) => `root dependency '${name}' is missing from ${DEFAULT_OWNERSHIP_PATH}`,
);
}
function printTextReport(report) {
console.log("# SBOM dependency risk report");
console.log("");
console.log(`importers: ${report.summary.importerCount}`);
console.log(`lockfile packages: ${report.summary.lockfilePackageCount}`);
console.log(`root direct dependencies: ${report.summary.rootDirectDependencyCount}`);
console.log(`root closure packages: ${report.summary.rootClosurePackageCount}`);
console.log(`build/native/bin risk packages: ${report.summary.buildRiskPackageCount}`);
console.log(`ownership records: ${report.summary.rootOwnershipRecordCount}`);
function renderTargetPackage(target) {
if (!target?.packageName && !target?.packageVersion) {
return "unknown";
}
if (!target.packageName) {
return target.packageVersion;
}
if (!target.packageVersion) {
return target.packageName;
}
return `${target.packageName}@${target.packageVersion}`;
}
function markdownCode(value) {
return `\`${String(value).replaceAll("`", "\\`")}\``;
}
function pluralize(count, singular, plural = `${singular}s`) {
return `${count} ${count === 1 ? singular : plural}`;
}
export function renderDependencyOwnershipSurfaceMarkdownReport(report) {
const lines = [
"# Dependency Ownership and Install Surface Report",
"",
`Generated: ${report.generatedAt}`,
"",
"## Target",
"",
`- Package: ${renderTargetPackage(report.target)}`,
`- Git branch: ${report.target?.gitBranch ?? "unknown"}`,
`- Git commit: ${report.target?.gitCommit ?? "unknown"}`,
`- Lockfile: ${report.target?.lockfile ?? "pnpm-lock.yaml"}`,
`- Ownership metadata: ${report.target?.ownershipMetadata ?? DEFAULT_OWNERSHIP_PATH}`,
"",
"## Scope",
"",
"This report summarizes the dependency ownership and install-time surface represented by the current workspace lockfile. It uses the root package dependencies, workspace package entries from pnpm-lock.yaml, dependency ownership metadata, and lockfile package metadata such as build requirements, binaries, and platform restrictions.",
"",
"It is report-only. It does not query npm advisories and does not inspect published package manifests.",
"",
"## Summary",
"",
`- Workspace package entries in lockfile: ${report.summary.importerCount}`,
`- Packages in lockfile: ${report.summary.lockfilePackageCount}`,
`- Root direct dependencies: ${report.summary.rootDirectDependencyCount}`,
`- Packages reachable from root dependencies: ${report.summary.rootClosurePackageCount}`,
`- Packages with install-time or platform-specific behavior: ${report.summary.buildRiskPackageCount}`,
`- Root dependency ownership records: ${report.summary.rootOwnershipRecordCount}`,
];
if (report.ownershipGaps.length > 0) {
console.log("");
console.log("## Ownership gaps");
lines.push("", "## Root Dependencies Missing Ownership Metadata", "");
for (const name of report.ownershipGaps) {
console.log(`- ${name}`);
lines.push(`- ${markdownCode(name)}`);
}
}
if (report.ownershipWarnings.length > 0) {
console.log("");
console.log("## Ownership warnings");
lines.push("", "## Dependency Ownership Mismatches", "");
for (const warning of report.ownershipWarnings) {
console.log(`- ${warning.name}: ${warning.message} (${warning.sourceSections.join(",")})`);
lines.push(
`- ${markdownCode(warning.name)}: ${warning.message}; source sections: ` +
`${warning.sourceSections.join(", ")}`,
);
}
}
console.log("");
console.log("## Largest root dependency cones");
if (report.staleOwnershipRecords.length > 0) {
lines.push("", "## Stale Ownership Metadata", "");
for (const name of report.staleOwnershipRecords) {
lines.push(`- ${markdownCode(name)}`);
}
}
lines.push("", "## Root Dependencies By Resolved Transitive Package Count", "");
for (const dependency of report.topRootDependencyCones) {
const owner = dependency.owner ?? "unowned";
console.log(
`- ${dependency.name}: closure=${dependency.closureSize} owner=${owner} class=${dependency.class ?? "-"}`,
lines.push(
`- ${markdownCode(dependency.name)}: ` +
`${pluralize(dependency.closureSize, "resolved transitive package")}; ` +
`owner=${owner}; class=${dependency.class ?? "-"}`,
);
}
console.log("");
console.log("## Largest importer closures");
for (const importer of report.importerClosures.slice(0, 15)) {
console.log(
`- ${importer.importer}: closure=${importer.closureSize} direct=${importer.directDependencyCount}`,
lines.push("", "## Workspace Packages With The Most Dependencies", "");
for (const importer of report.importerClosures) {
lines.push(
`- ${markdownCode(importer.importer)}: ${pluralize(importer.closureSize, "package")}; ` +
pluralize(importer.directDependencyCount, "direct dependency", "direct dependencies"),
);
}
if (report.buildRiskPackages.length > 0) {
lines.push("", "## Packages With Install-Time Or Platform-Specific Behavior", "");
}
for (const dependency of report.buildRiskPackages) {
const traits = [];
if (dependency.requiresBuild) {
traits.push("requires build");
}
if (dependency.hasBin) {
traits.push("has binary");
}
if (dependency.platformRestricted) {
traits.push("platform-specific");
}
lines.push(`- ${markdownCode(dependency.lockKey)}: ${traits.join(", ") || "metadata present"}`);
}
return `${lines.join("\n")}\n`;
}
const renderTextReport = renderDependencyOwnershipSurfaceMarkdownReport;
function printTextReport(report) {
process.stdout.write(renderTextReport(report));
}
function parseArgs(argv) {
const options = {
asJson: false,
check: false,
jsonPath: null,
markdownPath: null,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--") {
continue;
}
if (arg === "--check") {
options.check = true;
continue;
}
if (arg === "--json") {
options.asJson = true;
if (argv[index + 1] && !argv[index + 1].startsWith("--")) {
options.jsonPath = argv[++index];
}
continue;
}
if (arg === "--markdown") {
options.markdownPath = argv[++index];
continue;
}
throw new Error(`Unsupported argument: ${arg}`);
}
return options;
}
function writeArtifact(filePath, content) {
if (!filePath) {
return;
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, "utf8");
}
function main(argv = process.argv.slice(2)) {
const asJson = argv.includes("--json");
const check = argv.includes("--check");
const report = collectSbomRiskReport();
if (check) {
const errors = collectSbomRiskCheckErrors(report);
const options = parseArgs(argv);
const report = collectDependencyOwnershipSurfaceReport();
writeArtifact(options.jsonPath, `${JSON.stringify(report, null, 2)}\n`);
writeArtifact(options.markdownPath, renderTextReport(report));
if (options.check) {
const errors = collectDependencyOwnershipSurfaceCheckErrors(report);
if (errors.length > 0) {
for (const error of errors) {
console.error(`[sbom-risk] ${error}`);
console.error(`[ownership-surface] ${error}`);
}
process.exitCode = 1;
return;
}
if (!asJson) {
console.error("[sbom-risk] ok");
if (!options.asJson) {
console.error("[ownership-surface] ok");
return;
}
}
if (asJson) {
if (options.asJson && !options.jsonPath) {
console.log(JSON.stringify(report, null, 2));
return;
}
if (options.asJson) {
const artifactHint =
typeof options.markdownPath === "string" ? " See " + options.markdownPath + "." : "";
process.stdout.write(
`INFO dependency ownership/install surface report: ` +
`${report.summary.importerCount} workspace package entries, ` +
`${report.summary.lockfilePackageCount} lockfile packages, ` +
`${report.ownershipGaps.length} root dependencies missing ownership metadata; ` +
`report-only.${artifactHint}\n`,
);
return;
}
printTextReport(report);
}

View File

@@ -0,0 +1,334 @@
#!/usr/bin/env node
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import {
collectAllResolvedPackagesFromLockfile,
collectProdResolvedPackagesFromLockfile,
createBulkAdvisoryPayload,
fetchBulkAdvisories,
} from "./pre-commit/pnpm-audit-prod.mjs";
const SEVERITY_RANK = {
info: 0,
low: 1,
moderate: 2,
high: 3,
critical: 4,
};
function normalizeSeverity(severity) {
if (typeof severity !== "string") {
return "info";
}
return severity.toLowerCase();
}
function isMalwareAdvisory(advisory) {
const fields = [advisory?.title, advisory?.overview, advisory?.url].filter(
(field) => typeof field === "string",
);
return fields.some((field) => /\bmalware\b/iu.test(field));
}
function chunkEntries(entries, size) {
const chunks = [];
for (let index = 0; index < entries.length; index += size) {
chunks.push(entries.slice(index, index + size));
}
return chunks;
}
async function fetchBulkAdvisoriesForPayload(payload, fetchImpl) {
const advisoryResults = {};
for (const payloadChunk of chunkEntries(Object.entries(payload), 400)) {
const chunkPayload = Object.fromEntries(payloadChunk);
Object.assign(
advisoryResults,
await fetchBulkAdvisories({
payload: chunkPayload,
fetchImpl,
}),
);
}
return advisoryResults;
}
function flattenAdvisories(advisoriesByPackage, graphName) {
const findings = [];
for (const [packageName, advisories] of Object.entries(advisoriesByPackage ?? {})) {
if (!Array.isArray(advisories)) {
continue;
}
for (const advisory of advisories) {
if (!advisory || typeof advisory !== "object") {
continue;
}
const severity = normalizeSeverity(advisory.severity);
findings.push({
graph: graphName,
packageName,
id: advisory.id ?? "unknown",
severity,
title: advisory.title ?? "Untitled advisory",
url: advisory.url ?? null,
vulnerableVersions: advisory.vulnerable_versions ?? null,
malware: isMalwareAdvisory(advisory),
});
}
}
return findings;
}
function findingKey(finding) {
return [
finding.packageName,
String(finding.id),
finding.severity,
finding.vulnerableVersions ?? "",
].join("\0");
}
function dedupeFindings(findings) {
const byKey = new Map();
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) {
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));
});
}
export function classifyVulnerabilityFindings({ allAdvisories, productionAdvisories }) {
const allFindings = flattenAdvisories(allAdvisories, "all");
const productionFindings = flattenAdvisories(productionAdvisories, "production");
const blockers = [];
for (const finding of allFindings) {
if (finding.malware || finding.severity === "critical") {
blockers.push(finding);
}
}
for (const finding of productionFindings) {
if (finding.severity === "high" || finding.severity === "critical" || finding.malware) {
blockers.push(finding);
}
}
return {
blockers: sortFindings(dedupeFindings(blockers)),
findings: sortFindings(dedupeFindings([...allFindings, ...productionFindings])),
};
}
function countPayloadVersions(payload) {
return Object.values(payload).reduce((sum, versions) => sum + versions.length, 0);
}
export async function runDependencyVulnerabilityGate({
rootDir = process.cwd(),
fetchImpl = fetch,
} = {}) {
const lockfileText = await readFile(path.join(rootDir, "pnpm-lock.yaml"), "utf8");
const allPayload = createBulkAdvisoryPayload(
collectAllResolvedPackagesFromLockfile(lockfileText),
);
const productionPayload = createBulkAdvisoryPayload(
collectProdResolvedPackagesFromLockfile(lockfileText),
);
const [allAdvisories, productionAdvisories] = await Promise.all([
fetchBulkAdvisoriesForPayload(allPayload, fetchImpl),
fetchBulkAdvisoriesForPayload(productionPayload, fetchImpl),
]);
const classified = classifyVulnerabilityFindings({ allAdvisories, productionAdvisories });
return {
generatedAt: new Date().toISOString(),
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: Object.keys(allPayload).length,
packageVersions: countPayloadVersions(allPayload),
},
production: {
packages: Object.keys(productionPayload).length,
packageVersions: countPayloadVersions(productionPayload),
},
},
...classified,
};
}
export function renderDependencyVulnerabilityGateMarkdownReport(report) {
const lines = [
"# npm Advisory Vulnerability Gate: Resolved Dependency Graph",
"",
`Generated: ${report.generatedAt}`,
"",
"## Scope",
"",
"This gate checks resolved package versions from pnpm-lock.yaml against npm advisory data. It includes transitive dependencies. It blocks known malware anywhere, critical advisories anywhere, and high advisories in the production/runtime graph.",
"",
"## Summary",
"",
`- All graph packages: ${report.graphs.all.packages}`,
`- All graph package versions: ${report.graphs.all.packageVersions}`,
`- Production graph packages: ${report.graphs.production.packages}`,
`- Production graph package versions: ${report.graphs.production.packageVersions}`,
`- Hard blockers: ${report.blockers.length}`,
`- Total findings: ${report.findings.length}`,
"",
"## 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.blockers.length > 0) {
lines.push("## Hard Blockers", "");
for (const finding of report.blockers) {
lines.push(
`- ${finding.severity.toUpperCase()} ${finding.packageName} (${finding.graph}) ` +
`id=${finding.id} range=${finding.vulnerableVersions ?? "unknown"} ` +
`${finding.malware ? "[malware] " : ""}${finding.url ?? ""}`,
);
lines.push(` - ${finding.title}`);
}
lines.push("");
}
if (report.findings.length > 0) {
lines.push("## Findings", "");
for (const finding of report.findings) {
lines.push(
`- ${finding.severity.toUpperCase()} ${finding.packageName} (${finding.graph}) ` +
`id=${finding.id} range=${finding.vulnerableVersions ?? "unknown"} ` +
`${finding.malware ? "[malware] " : ""}${finding.url ?? ""}`,
);
lines.push(` - ${finding.title}`);
}
lines.push("");
}
if (report.findings.length === 0) {
lines.push("No advisories found.", "");
}
return `${lines.join("\n")}\n`;
}
const renderMarkdownReport = renderDependencyVulnerabilityGateMarkdownReport;
function parseArgs(argv) {
const options = {
rootDir: process.cwd(),
jsonPath: null,
markdownPath: null,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--") {
continue;
}
if (arg === "--root") {
options.rootDir = argv[++index];
continue;
}
if (arg === "--json") {
options.jsonPath = argv[++index];
continue;
}
if (arg === "--markdown") {
options.markdownPath = argv[++index];
continue;
}
throw new Error(`Unsupported argument: ${arg}`);
}
return options;
}
async function writeArtifact(filePath, content) {
if (!filePath) {
return;
}
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, content, "utf8");
}
export async function main(argv = process.argv.slice(2)) {
const options = parseArgs(argv);
const report = await runDependencyVulnerabilityGate({ rootDir: options.rootDir });
await writeArtifact(options.jsonPath, `${JSON.stringify(report, null, 2)}\n`);
await writeArtifact(options.markdownPath, renderMarkdownReport(report));
if (report.blockers.length === 0) {
const packageVersions = Number(report.graphs.all.packageVersions);
process.stdout.write(
`PASS npm advisory vulnerability gate: checked ${packageVersions} resolved ` +
`package versions across the lockfile graph; 0 hard blockers, ` +
`${report.findings.length} total advisories.\n`,
);
return 0;
}
process.stderr.write(
`FAIL npm advisory vulnerability gate: ${report.blockers.length} hard blockers in resolved ` +
`dependency graph; ${report.findings.length} total advisories.\n`,
);
for (const blocker of report.blockers.slice(0, 25)) {
process.stderr.write(
`- ${blocker.severity.toUpperCase()} ${blocker.packageName} (${blocker.graph}) ` +
`id=${blocker.id} 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;
},
(error) => {
process.stderr.write(`${error.stack ?? error.message ?? String(error)}\n`);
process.exitCode = 1;
},
);
}

View File

@@ -0,0 +1,420 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
export const DEPENDENCY_EVIDENCE_REPORTS = [
{
name: "npm advisory vulnerability gate",
command: "pnpm deps:vuln:gate",
policy: "hard-blocking",
json: "dependency-vulnerability-gate.json",
markdown: "dependency-vulnerability-gate.md",
},
{
name: "Transitive manifest risk report",
command: "pnpm deps:transitive-risk:report",
policy: "report-only",
json: "transitive-manifest-risk-report.json",
markdown: "transitive-manifest-risk-report.md",
},
{
name: "Dependency ownership and install surface report",
command: "pnpm deps:ownership-surface:report",
policy: "report-only",
json: "dependency-ownership-surface-report.json",
markdown: "dependency-ownership-surface-report.md",
},
{
name: "Dependency change report",
command: "pnpm deps:changes:report",
policy: "report-only",
json: "dependency-changes-report.json",
markdown: "dependency-changes-report.md",
},
];
const RELEASE_TAG_PATTERN = "v[0-9]*.[0-9]*.[0-9]*";
function trimOutput(output) {
return String(output).trim();
}
function commandOutput(
command,
args,
{ rootDir, execFileSyncImpl = execFileSync, allowFailure = false },
) {
try {
return trimOutput(
execFileSyncImpl(command, args, {
cwd: rootDir,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}),
);
} catch (error) {
if (allowFailure) {
return null;
}
throw error;
}
}
function runCommand(command, args, { rootDir, execFileSyncImpl = execFileSync }) {
execFileSyncImpl(command, args, {
cwd: rootDir,
stdio: "inherit",
});
}
export function resolveReleaseTag({ releaseRef, packageVersion }) {
if (/^[0-9a-fA-F]{40}$/u.test(releaseRef)) {
return `v${packageVersion}`;
}
return releaseRef;
}
export function resolvePreviousReleaseTag({
rootDir = process.cwd(),
execFileSyncImpl = execFileSync,
fetchOnMiss = true,
} = {}) {
const describeArgs = [
"describe",
"--tags",
"--match",
RELEASE_TAG_PATTERN,
"--abbrev=0",
"HEAD^",
];
const localTag = commandOutput("git", describeArgs, {
rootDir,
execFileSyncImpl,
allowFailure: true,
});
if (localTag) {
return localTag;
}
if (fetchOnMiss) {
runCommand("git", ["fetch", "--tags", "--force", "origin"], { rootDir, execFileSyncImpl });
}
const fetchedTag = commandOutput("git", describeArgs, {
rootDir,
execFileSyncImpl,
allowFailure: true,
});
if (fetchedTag) {
return fetchedTag;
}
throw new Error(
"Could not resolve a previous reachable release tag for dependency change evidence.",
);
}
export function createDependencyEvidenceManifest({
generatedAt = new Date().toISOString(),
releaseTag,
releaseRef,
releaseSha,
npmDistTag,
packageVersion,
workflowRunId = "",
workflowRunAttempt = "",
dependencyChangeBaseRef,
} = {}) {
return {
schemaVersion: 1,
generatedAt,
releaseTag,
releaseRef,
releaseSha,
npmDistTag,
packageName: "openclaw",
packageVersion,
workflowRunId,
workflowRunAttempt,
dependencyChangeBaseRef,
reports: DEPENDENCY_EVIDENCE_REPORTS,
};
}
function reportPath(evidenceDir, fileName) {
return path.join(evidenceDir, fileName);
}
async function readJson(filePath) {
return JSON.parse(await readFile(filePath, "utf8"));
}
export async function collectDependencyEvidenceSummaryCounts(evidenceDir) {
const [vulnerability, transitiveRisk, ownershipSurface, dependencyChanges] = await Promise.all([
readJson(reportPath(evidenceDir, "dependency-vulnerability-gate.json")),
readJson(reportPath(evidenceDir, "transitive-manifest-risk-report.json")),
readJson(reportPath(evidenceDir, "dependency-ownership-surface-report.json")),
readJson(reportPath(evidenceDir, "dependency-changes-report.json")),
]);
return {
vulnerabilityBlockers: vulnerability.blockers.length,
vulnerabilityFindings: vulnerability.findings.length,
transitiveRiskSignals: transitiveRisk.findingCount,
workspaceExcludedTransitiveSignals: transitiveRisk.workspaceExcludedFindingCount,
transitiveMetadataFailures: transitiveRisk.metadataFailures.length,
ownershipLockfilePackages: ownershipSurface.summary.lockfilePackageCount,
ownershipBuildRiskPackages: ownershipSurface.summary.buildRiskPackageCount,
dependencyFileChanges: dependencyChanges.summary.dependencyFileChanges,
dependencyAddedPackages: dependencyChanges.summary.addedPackages,
dependencyRemovedPackages: dependencyChanges.summary.removedPackages,
dependencyChangedPackages: dependencyChanges.summary.changedPackages,
};
}
export function renderDependencyEvidenceSummary({ releaseTag, releaseSha, baseRef, counts }) {
return `${[
"# Dependency release evidence",
"",
`Generated for \`${releaseTag}\` at \`${releaseSha}\`.`,
"",
"## Summary",
"",
`- npm advisory vulnerability hard blockers: ${counts.vulnerabilityBlockers}`,
`- npm advisory vulnerability total findings: ${counts.vulnerabilityFindings}`,
`- Transitive manifest reported risk signals: ${counts.transitiveRiskSignals}`,
`- Workspace-policy excluded transitive signals: ${counts.workspaceExcludedTransitiveSignals}`,
`- Transitive manifest metadata failures: ${counts.transitiveMetadataFailures}`,
`- Lockfile packages inspected for ownership/install surface: ${counts.ownershipLockfilePackages}`,
`- Packages with install-time or platform-specific behavior: ${counts.ownershipBuildRiskPackages}`,
`- Dependency change baseline: \`${baseRef}\``,
`- Dependency file changes: ${counts.dependencyFileChanges}`,
`- Resolved package changes: +${counts.dependencyAddedPackages} -${counts.dependencyRemovedPackages} changed ${counts.dependencyChangedPackages}`,
"",
"## Reports",
"",
"- `dependency-vulnerability-gate.md`",
"- `transitive-manifest-risk-report.md`",
"- `dependency-ownership-surface-report.md`",
"- `dependency-changes-report.md`",
].join("\n")}\n`;
}
export function renderDependencyEvidenceStepSummary({ evidenceArtifactName, baseRef, counts }) {
return `${[
"### Dependency release evidence",
"",
`- Evidence artifact: \`${evidenceArtifactName}\``,
`- Dependency change baseline: \`${baseRef}\``,
`- npm advisory vulnerability hard blockers: \`${counts.vulnerabilityBlockers}\``,
`- Transitive manifest reported risk signals: \`${counts.transitiveRiskSignals}\``,
`- Workspace-policy excluded transitive signals: \`${counts.workspaceExcludedTransitiveSignals}\``,
`- Ownership/install surface lockfile packages: \`${counts.ownershipLockfilePackages}\``,
`- Dependency file changes: \`${counts.dependencyFileChanges}\``,
`- Resolved package changes: \`+${counts.dependencyAddedPackages} -${counts.dependencyRemovedPackages} changed ${counts.dependencyChangedPackages}\``,
].join("\n")}\n`;
}
function runEvidenceReports({ rootDir, outputDir, baseRef, execFileSyncImpl }) {
runCommand(
"pnpm",
[
"deps:vuln:gate",
"--",
"--json",
reportPath(outputDir, "dependency-vulnerability-gate.json"),
"--markdown",
reportPath(outputDir, "dependency-vulnerability-gate.md"),
],
{ rootDir, execFileSyncImpl },
);
runCommand(
"pnpm",
[
"deps:transitive-risk:report",
"--",
"--json",
reportPath(outputDir, "transitive-manifest-risk-report.json"),
"--markdown",
reportPath(outputDir, "transitive-manifest-risk-report.md"),
],
{ rootDir, execFileSyncImpl },
);
runCommand(
"pnpm",
[
"deps:ownership-surface:report",
"--",
"--json",
reportPath(outputDir, "dependency-ownership-surface-report.json"),
"--markdown",
reportPath(outputDir, "dependency-ownership-surface-report.md"),
],
{ rootDir, execFileSyncImpl },
);
runCommand(
"pnpm",
[
"deps:changes:report",
"--",
"--base-ref",
baseRef,
"--json",
reportPath(outputDir, "dependency-changes-report.json"),
"--markdown",
reportPath(outputDir, "dependency-changes-report.md"),
],
{ rootDir, execFileSyncImpl },
);
}
export async function generateDependencyReleaseEvidence({
rootDir = process.cwd(),
outputDir,
releaseRef,
npmDistTag,
baseRef = null,
githubOutput = process.env.GITHUB_OUTPUT,
githubStepSummary = process.env.GITHUB_STEP_SUMMARY,
workflowRunId = process.env.GITHUB_RUN_ID ?? "",
workflowRunAttempt = process.env.GITHUB_RUN_ATTEMPT ?? "",
execFileSyncImpl = execFileSync,
now = new Date(),
} = {}) {
if (!outputDir) {
throw new Error("Expected --output-dir <path>.");
}
if (!releaseRef) {
throw new Error("Expected --release-ref <tag-or-sha>.");
}
if (!npmDistTag) {
throw new Error("Expected --npm-dist-tag <tag>.");
}
await rm(outputDir, { recursive: true, force: true });
await mkdir(outputDir, { recursive: true });
const releaseSha = commandOutput("git", ["rev-parse", "HEAD"], { rootDir, execFileSyncImpl });
const packageJson = await readJson(path.join(rootDir, "package.json"));
const packageVersion = packageJson.version;
const releaseTag = resolveReleaseTag({ releaseRef, packageVersion });
const dependencyChangeBaseRef =
baseRef ?? resolvePreviousReleaseTag({ rootDir, execFileSyncImpl });
runEvidenceReports({
rootDir,
outputDir,
baseRef: dependencyChangeBaseRef,
execFileSyncImpl,
});
const manifest = createDependencyEvidenceManifest({
generatedAt: now.toISOString(),
releaseTag,
releaseRef,
releaseSha,
npmDistTag,
packageVersion,
workflowRunId,
workflowRunAttempt,
dependencyChangeBaseRef,
});
await writeFile(
reportPath(outputDir, "dependency-evidence-manifest.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
"utf8",
);
const counts = await collectDependencyEvidenceSummaryCounts(outputDir);
await writeFile(
reportPath(outputDir, "dependency-evidence-summary.md"),
renderDependencyEvidenceSummary({
releaseTag,
releaseSha,
baseRef: dependencyChangeBaseRef,
counts,
}),
"utf8",
);
if (githubStepSummary) {
await appendFile(
githubStepSummary,
renderDependencyEvidenceStepSummary({
evidenceArtifactName: `openclaw-release-dependency-evidence-${releaseRef}`,
baseRef: dependencyChangeBaseRef,
counts,
}),
"utf8",
);
}
if (githubOutput) {
await appendFile(githubOutput, `dir=${outputDir}\n`, "utf8");
}
return { manifest, counts, outputDir };
}
function parseArgs(argv) {
const options = {
rootDir: process.cwd(),
outputDir: null,
releaseRef: null,
npmDistTag: null,
baseRef: null,
githubOutput: process.env.GITHUB_OUTPUT,
githubStepSummary: process.env.GITHUB_STEP_SUMMARY,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--") {
continue;
}
if (arg === "--root") {
options.rootDir = argv[++index];
continue;
}
if (arg === "--output-dir") {
options.outputDir = argv[++index];
continue;
}
if (arg === "--release-ref") {
options.releaseRef = argv[++index];
continue;
}
if (arg === "--npm-dist-tag") {
options.npmDistTag = argv[++index];
continue;
}
if (arg === "--base-ref") {
options.baseRef = argv[++index];
continue;
}
if (arg === "--github-output") {
options.githubOutput = argv[++index];
continue;
}
if (arg === "--github-step-summary") {
options.githubStepSummary = argv[++index];
continue;
}
throw new Error(`Unsupported argument: ${arg}`);
}
return options;
}
export async function main(argv = process.argv.slice(2)) {
await generateDependencyReleaseEvidence(parseArgs(argv));
return 0;
}
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
main().then(
(exitCode) => {
process.exitCode = exitCode;
},
(error) => {
process.stderr.write(`${error.stack ?? error.message ?? String(error)}\n`);
process.exitCode = 1;
},
);
}

View File

@@ -552,6 +552,26 @@ export function collectProdResolvedPackagesFromLockfile(lockfileText) {
return versionsByPackage;
}
export function collectAllResolvedPackagesFromLockfile(lockfileText) {
const lockfile = parsePnpmLockfileSections(lockfileText);
if (!lockfile.hasSnapshotsSection) {
throw new Error("pnpm-lock.yaml is missing the snapshots section.");
}
const versionsByPackage = new Map();
for (const snapshotKey of Object.keys(lockfile.snapshots)) {
const resolved = parseSnapshotKey(snapshotKey);
let versions = versionsByPackage.get(resolved.packageName);
if (!versions) {
versions = new Set();
versionsByPackage.set(resolved.packageName, versions);
}
versions.add(resolved.version);
}
return versionsByPackage;
}
export function createBulkAdvisoryPayload(versionsByPackage) {
return Object.fromEntries(
[...versionsByPackage.entries()]

View File

@@ -0,0 +1,660 @@
#!/usr/bin/env node
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import YAML from "yaml";
import {
collectAllResolvedPackagesFromLockfile,
createBulkAdvisoryPayload,
} from "./pre-commit/pnpm-audit-prod.mjs";
const INSTALL_LIFECYCLE_SCRIPTS = ["preinstall", "install", "postinstall", "prepare"];
const EXACT_SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u;
const EXACT_NPM_ALIAS_PATTERN =
/^npm:(?:@[^/\s]+\/)?[^@\s]+@\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u;
const PINNED_GIT_PATTERN = /(?:#|\/commit\/)[0-9a-f]{40}$/iu;
const EXOTIC_SPEC_PATTERN = /^(?:git\+|github:|gitlab:|bitbucket:|https?:)/iu;
const RECENTLY_PUBLISHED_VERSION_TYPE = "recently-published-version";
function isAllowedPinnedSpec(spec) {
if (typeof spec !== "string") {
return false;
}
if (EXACT_SEMVER_PATTERN.test(spec) || EXACT_NPM_ALIAS_PATTERN.test(spec)) {
return true;
}
if (spec === "workspace:*" || spec.startsWith("file:") || spec.startsWith("link:")) {
return true;
}
if (/^(?:git\+|github:|gitlab:|bitbucket:)/u.test(spec)) {
return PINNED_GIT_PATTERN.test(spec);
}
return false;
}
function encodePackageName(name) {
return name.startsWith("@") ? name.replace("/", "%2f") : name;
}
function resolveRegistryBaseUrl() {
const configured =
process.env.npm_config_registry ??
process.env.NPM_CONFIG_REGISTRY ??
process.env.npm_config_userconfig_registry ??
"https://registry.npmjs.org";
return configured.replace(/\/+$/u, "");
}
function isExoticResolvedVersion(version) {
return EXOTIC_SPEC_PATTERN.test(version);
}
function packageVersionsFromPayload(payload) {
return Object.entries(payload).flatMap(([packageName, versions]) =>
versions.map((version) => ({ packageName, version })),
);
}
async function loadWorkspaceRiskSettings(rootDir) {
const workspacePath = path.join(rootDir, "pnpm-workspace.yaml");
try {
const workspace = YAML.parse(await readFile(workspacePath, "utf8"));
return {
minimumReleaseAgeMinutes:
typeof workspace?.minimumReleaseAge === "number" ? workspace.minimumReleaseAge : null,
minimumReleaseAgeExclude: Array.isArray(workspace?.minimumReleaseAgeExclude)
? workspace.minimumReleaseAgeExclude.filter((entry) => typeof entry === "string")
: [],
};
} catch {
return { minimumReleaseAgeMinutes: null, minimumReleaseAgeExclude: [] };
}
}
function splitMinimumReleaseAgeExcludeSelector(selector) {
const trimmed = selector.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith("@")) {
const scopeSeparatorIndex = trimmed.indexOf("/");
const versionSeparatorIndex =
scopeSeparatorIndex === -1 ? -1 : trimmed.indexOf("@", scopeSeparatorIndex + 1);
if (versionSeparatorIndex === -1) {
return { packagePattern: trimmed, versionSelectors: [] };
}
return {
packagePattern: trimmed.slice(0, versionSeparatorIndex),
versionSelectors: trimmed
.slice(versionSeparatorIndex + 1)
.split("||")
.map((entry) => entry.trim())
.filter(Boolean),
};
}
const versionSeparatorIndex = trimmed.indexOf("@");
if (versionSeparatorIndex === -1) {
return { packagePattern: trimmed, versionSelectors: [] };
}
return {
packagePattern: trimmed.slice(0, versionSeparatorIndex),
versionSelectors: trimmed
.slice(versionSeparatorIndex + 1)
.split("||")
.map((entry) => entry.trim())
.filter(Boolean),
};
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}
function packagePatternMatches(pattern, packageName) {
const regex = new RegExp(`^${pattern.split("*").map(escapeRegExp).join(".*")}$`, "u");
return regex.test(packageName);
}
function matchesMinimumReleaseAgeExclude(selector, packageName, version) {
const parsed = splitMinimumReleaseAgeExcludeSelector(selector);
if (!parsed || !packagePatternMatches(parsed.packagePattern, packageName)) {
return false;
}
return parsed.versionSelectors.length === 0 || parsed.versionSelectors.includes(version);
}
function findMinimumReleaseAgeExcludeSelector(selectors, packageName, version) {
return selectors.find((selector) =>
matchesMinimumReleaseAgeExclude(selector, packageName, version),
);
}
function collectManifestFindings({
packageName,
version,
manifest,
publishedAt,
now,
minimumReleaseAgeMinutes,
minimumReleaseAgeExclude = [],
}) {
const findings = [];
const workspaceExcludedFindings = [];
for (const section of ["dependencies", "optionalDependencies"]) {
for (const [dependencyName, spec] of Object.entries(manifest[section] ?? {})) {
if (!isAllowedPinnedSpec(spec)) {
findings.push({
type: "floating-transitive-spec",
packageName,
version,
dependency: { name: dependencyName, spec, section },
});
}
if (typeof spec === "string" && EXOTIC_SPEC_PATTERN.test(spec)) {
findings.push({
type: "exotic-source",
packageName,
version,
source: spec,
dependency: { name: dependencyName, spec, section },
});
}
}
}
const scripts = manifest.scripts ?? {};
for (const script of INSTALL_LIFECYCLE_SCRIPTS) {
if (typeof scripts[script] === "string") {
findings.push({ type: "lifecycle-script", packageName, version, script });
}
}
if (!publishedAt) {
findings.push({ type: "missing-publish-time", packageName, version });
} else if (typeof minimumReleaseAgeMinutes === "number") {
const ageMs = now.getTime() - Date.parse(publishedAt);
if (Number.isFinite(ageMs) && ageMs < minimumReleaseAgeMinutes * 60_000) {
const finding = {
type: RECENTLY_PUBLISHED_VERSION_TYPE,
packageName,
version,
publishedAt,
minimumReleaseAgeMinutes,
};
const exclusion = findMinimumReleaseAgeExcludeSelector(
minimumReleaseAgeExclude,
packageName,
version,
);
if (exclusion) {
workspaceExcludedFindings.push({
...finding,
workspaceExcluded: true,
workspaceExclusion: exclusion,
});
} else {
findings.push(finding);
}
}
}
return { findings, workspaceExcludedFindings };
}
async function fetchNpmManifest({ packageName, version, fetchImpl, registryBaseUrl }) {
const response = await fetchImpl(`${registryBaseUrl}/${encodePackageName(packageName)}`);
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
const packument = await response.json();
const manifest = packument.versions?.[version];
if (!manifest) {
throw new Error(`version ${version} not found`);
}
return {
manifest,
publishedAt: typeof packument.time?.[version] === "string" ? packument.time[version] : null,
};
}
export async function createTransitiveManifestRiskReport({
packageVersions,
manifestLoader,
now = new Date(),
minimumReleaseAgeMinutes = null,
minimumReleaseAgeExclude = [],
}) {
const findings = [];
const workspaceExcludedFindings = [];
const metadataFailures = [];
for (const { packageName, version } of packageVersions) {
if (isExoticResolvedVersion(version)) {
findings.push({
type: "exotic-source",
packageName,
version,
source: version,
});
continue;
}
try {
const { manifest, publishedAt } = await manifestLoader({ packageName, version });
const manifestFindings = collectManifestFindings({
packageName,
version,
manifest,
publishedAt,
now,
minimumReleaseAgeMinutes,
minimumReleaseAgeExclude,
});
findings.push(...manifestFindings.findings);
workspaceExcludedFindings.push(...manifestFindings.workspaceExcludedFindings);
} catch (error) {
metadataFailures.push({
packageName,
version,
error: String(error?.message ?? error),
});
}
}
const sortedFindings = findings.toSorted((left, right) => {
if (left.type !== right.type) {
return left.type.localeCompare(right.type);
}
if (left.packageName !== right.packageName) {
return left.packageName.localeCompare(right.packageName);
}
return left.version.localeCompare(right.version);
});
const byType = sortedFindings.reduce((counts, finding) => {
counts[finding.type] = (counts[finding.type] ?? 0) + 1;
return counts;
}, {});
return {
generatedAt: now.toISOString(),
packageVersions: packageVersions.length,
findingCount: sortedFindings.length,
byType,
workspacePolicy: {
minimumReleaseAgeMinutes,
minimumReleaseAgeExclude,
},
workspaceExcludedFindingCount: workspaceExcludedFindings.length,
workspaceExcludedByType: workspaceExcludedFindings.reduce((counts, finding) => {
counts[finding.type] = (counts[finding.type] ?? 0) + 1;
return counts;
}, {}),
workspaceExcludedFindings: workspaceExcludedFindings.toSorted((left, right) => {
if (left.type !== right.type) {
return left.type.localeCompare(right.type);
}
if (left.packageName !== right.packageName) {
return left.packageName.localeCompare(right.packageName);
}
return left.version.localeCompare(right.version);
}),
metadataFailures,
findings: sortedFindings,
};
}
function markdownCode(value) {
return `\`${String(value).replaceAll("`", "\\`")}\``;
}
function pluralize(count, singular, plural = `${singular}s`) {
return `${count} ${count === 1 ? singular : plural}`;
}
function findingPackageKey(finding) {
return `${finding.packageName}@${finding.version}`;
}
function incrementMapCount(map, key, amount = 1) {
map.set(key, (map.get(key) ?? 0) + amount);
}
function sortedCountEntries(map) {
return [...map.entries()].toSorted((left, right) => {
if (right[1] !== left[1]) {
return right[1] - left[1];
}
return left[0].localeCompare(right[0]);
});
}
function typeBreakdown(findings) {
const counts = new Map();
for (const finding of findings) {
incrementMapCount(counts, finding.type);
}
return [...counts.entries()]
.toSorted(([left], [right]) => left.localeCompare(right))
.map(([type, count]) => `${type}: ${count}`)
.join(", ");
}
function collectMarkdownRollups(findings) {
const packageFindings = new Map();
const floatingTargets = new Map();
const lifecyclePackages = new Map();
const recentlyPublishedVersions = [];
const exoticSources = [];
for (const finding of findings) {
const packageKey = findingPackageKey(finding);
const packageList = packageFindings.get(packageKey) ?? [];
packageList.push(finding);
packageFindings.set(packageKey, packageList);
if (finding.type === "floating-transitive-spec" && finding.dependency?.name) {
const target = floatingTargets.get(finding.dependency.name) ?? {
declarations: 0,
sourcePackages: new Set(),
specifiers: new Map(),
};
target.declarations += 1;
target.sourcePackages.add(packageKey);
incrementMapCount(target.specifiers, finding.dependency.spec ?? "unknown");
floatingTargets.set(finding.dependency.name, target);
}
if (finding.type === "lifecycle-script") {
const scripts = lifecyclePackages.get(packageKey) ?? new Set();
scripts.add(finding.script ?? "unknown");
lifecyclePackages.set(packageKey, scripts);
}
if (finding.type === RECENTLY_PUBLISHED_VERSION_TYPE) {
recentlyPublishedVersions.push(finding);
}
if (finding.type === "exotic-source") {
exoticSources.push(finding);
}
}
return {
packageFindings,
floatingTargets,
lifecyclePackages,
recentlyPublishedVersions,
exoticSources,
};
}
function renderCompleteEvidence(lines) {
lines.push("## Complete Evidence", "");
lines.push(
"The complete reported signal list is available in the JSON report, including every package, version, dependency, and specifier. Recently published versions covered by pnpm workspace release-age exclusions are listed separately under workspaceExcludedFindings. The sections below summarize the same data by package, dependency target, and finding class for human review.",
);
lines.push("");
}
function renderPackageFindingSummary(lines, packageFindings) {
lines.push("## Published Package Manifests With Risk Findings", "");
for (const [packageKey, findings] of [...packageFindings.entries()].toSorted((left, right) => {
if (right[1].length !== left[1].length) {
return right[1].length - left[1].length;
}
return left[0].localeCompare(right[0]);
})) {
lines.push(
`- ${markdownCode(packageKey)}: ${pluralize(findings.length, "manifest finding")} ` +
`(${typeBreakdown(findings)})`,
);
}
lines.push("");
}
function renderFloatingDependencyTargets(lines, floatingTargets) {
if (floatingTargets.size === 0) {
return;
}
lines.push("## Floating Dependency Targets", "");
for (const [dependencyName, detail] of [...floatingTargets.entries()].toSorted((left, right) => {
if (right[1].declarations !== left[1].declarations) {
return right[1].declarations - left[1].declarations;
}
return left[0].localeCompare(right[0]);
})) {
const specifiers = sortedCountEntries(detail.specifiers)
.map(([specifier, count]) => `${specifier}: ${count}`)
.join(", ");
lines.push(
`- ${markdownCode(dependencyName)}: ${detail.declarations} declarations from ` +
`${detail.sourcePackages.size} resolved packages; specifiers: ${specifiers}`,
);
}
lines.push("");
}
function renderLifecycleScriptPackages(lines, lifecyclePackages) {
if (lifecyclePackages.size === 0) {
return;
}
lines.push("## Lifecycle Script Packages", "");
for (const [packageKey, scripts] of [...lifecyclePackages.entries()].toSorted(([left], [right]) =>
left.localeCompare(right),
)) {
lines.push(
`- ${markdownCode(packageKey)}: ${[...scripts]
.toSorted((left, right) => left.localeCompare(right))
.join(", ")}`,
);
}
lines.push("");
}
function renderRecentlyPublishedVersions(lines, findings, heading) {
if (findings.length === 0) {
return;
}
lines.push(`## ${heading}`, "");
const minimumReleaseAgeMinutes = findings.find(
(finding) => typeof finding.minimumReleaseAgeMinutes === "number",
)?.minimumReleaseAgeMinutes;
if (typeof minimumReleaseAgeMinutes === "number") {
lines.push(`Workspace minimum release age: ${minimumReleaseAgeMinutes} minutes.`, "");
}
for (const finding of findings.toSorted((left, right) => {
const dateDelta = Date.parse(right.publishedAt ?? "") - Date.parse(left.publishedAt ?? "");
if (Number.isFinite(dateDelta) && dateDelta !== 0) {
return dateDelta;
}
return findingPackageKey(left).localeCompare(findingPackageKey(right));
})) {
const suffix = finding.workspaceExclusion
? `; workspace exclusion ${markdownCode(finding.workspaceExclusion)}`
: "";
lines.push(
`- ${markdownCode(findingPackageKey(finding))}: published ${finding.publishedAt}${suffix}`,
);
}
lines.push("");
}
function renderExoticSources(lines, exoticSources) {
if (exoticSources.length === 0) {
return;
}
lines.push("## Exotic Sources", "");
for (const finding of exoticSources.toSorted((left, right) =>
findingPackageKey(left).localeCompare(findingPackageKey(right)),
)) {
lines.push(`- ${markdownCode(findingPackageKey(finding))}: source ${finding.source}`);
}
lines.push("");
}
export function renderTransitiveManifestRiskMarkdownReport(report) {
const lines = [
"# Transitive Manifest Risk Report",
"",
`Generated: ${report.generatedAt}`,
"",
"## Scope",
"",
"This report inspects published package manifests for resolved packages in the lockfile. It looks for supply-chain risk signals such as floating dependency specs, lifecycle scripts, exotic sources, recently published versions, and missing publish time metadata. It is report-only.",
"",
"## Summary",
"",
`- Resolved package versions inspected: ${report.packageVersions}`,
`- Reported risk signals: ${report.findingCount}`,
`- Signals covered by workspace policy exclusions: ${report.workspaceExcludedFindingCount ?? 0}`,
`- Metadata failures: ${report.metadataFailures.length}`,
"",
"## Reported Risk Signals By Type",
"",
];
for (const [type, count] of Object.entries(report.byType).toSorted(([left], [right]) =>
left.localeCompare(right),
)) {
lines.push(`- ${type}: ${count}`);
}
lines.push("");
if (Object.keys(report.workspaceExcludedByType ?? {}).length > 0) {
lines.push("## Signals Covered By Workspace Policy Exclusions", "");
lines.push(
"These are not included in the reported risk signal totals above. They are tracked separately because the workspace package-manager policy already excludes them.",
);
lines.push("");
for (const [type, count] of Object.entries(report.workspaceExcludedByType ?? {}).toSorted(
([left], [right]) => left.localeCompare(right),
)) {
lines.push(`- ${type}: ${count}`);
}
lines.push("");
}
renderCompleteEvidence(lines);
if (report.findings.length > 0) {
const rollups = collectMarkdownRollups(report.findings);
renderPackageFindingSummary(lines, rollups.packageFindings);
renderFloatingDependencyTargets(lines, rollups.floatingTargets);
renderLifecycleScriptPackages(lines, rollups.lifecyclePackages);
renderExoticSources(lines, rollups.exoticSources);
renderRecentlyPublishedVersions(
lines,
rollups.recentlyPublishedVersions,
"Recently Published Versions Not Covered By Workspace Exclusions",
);
}
renderRecentlyPublishedVersions(
lines,
report.workspaceExcludedFindings ?? [],
"Recently Published Versions Covered By Workspace Exclusions",
);
if (report.metadataFailures.length > 0) {
lines.push("## Metadata Failures", "");
for (const failure of report.metadataFailures) {
lines.push(
`- ${markdownCode(`${failure.packageName}@${failure.version}`)}: ${failure.error}`,
);
}
lines.push("");
}
return `${lines.join("\n")}\n`;
}
const renderMarkdownReport = renderTransitiveManifestRiskMarkdownReport;
function parseArgs(argv) {
const options = {
rootDir: process.cwd(),
jsonPath: null,
markdownPath: null,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--") {
continue;
}
if (arg === "--root") {
options.rootDir = argv[++index];
continue;
}
if (arg === "--json") {
options.jsonPath = argv[++index];
continue;
}
if (arg === "--markdown") {
options.markdownPath = argv[++index];
continue;
}
throw new Error(`Unsupported argument: ${arg}`);
}
return options;
}
async function writeArtifact(filePath, content) {
if (!filePath) {
return;
}
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, content, "utf8");
}
export async function runTransitiveManifestRiskReport({
rootDir = process.cwd(),
fetchImpl = fetch,
now = new Date(),
} = {}) {
const lockfileText = await readFile(path.join(rootDir, "pnpm-lock.yaml"), "utf8");
const payload = createBulkAdvisoryPayload(collectAllResolvedPackagesFromLockfile(lockfileText));
const packageVersions = packageVersionsFromPayload(payload);
const settings = await loadWorkspaceRiskSettings(rootDir);
return createTransitiveManifestRiskReport({
packageVersions,
now,
minimumReleaseAgeMinutes: settings.minimumReleaseAgeMinutes,
minimumReleaseAgeExclude: settings.minimumReleaseAgeExclude,
manifestLoader: ({ packageName, version }) =>
fetchNpmManifest({
packageName,
version,
fetchImpl,
registryBaseUrl: resolveRegistryBaseUrl(),
}),
});
}
export async function main(argv = process.argv.slice(2)) {
const options = parseArgs(argv);
const report = await runTransitiveManifestRiskReport({
rootDir: options.rootDir,
});
await writeArtifact(options.jsonPath, `${JSON.stringify(report, null, 2)}\n`);
await writeArtifact(options.markdownPath, renderMarkdownReport(report));
const artifactHint =
typeof options.markdownPath === "string" ? " See " + options.markdownPath + "." : "";
process.stdout.write(
`INFO transitive manifest risk report: inspected ${report.packageVersions} resolved ` +
`package manifests; ${report.findingCount} reported risk signals, ` +
`${report.metadataFailures.length} metadata failures; release not blocked.${artifactHint}\n`,
);
return 0;
}
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
main().then(
(exitCode) => {
process.exitCode = exitCode;
},
(error) => {
process.stderr.write(`${error.stack ?? error.message ?? String(error)}\n`);
process.exitCode = 1;
},
);
}