mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 17:04:46 +00:00
Add dependency release safety evidence and PR awareness (#81325)
* test: cover dependency pin guard * build: add dependency vulnerability gate * build: add dependency risk report * build: add dependency drift reports * build: include dependency ownership surface evidence * build: rename dependency report commands * build: respect release age exclusions in risk report * build: clarify transitive risk accounting * build: remove transitive risk exception registry * build: clarify transitive risk signal wording * ci: attach dependency evidence to release preflight * ci: extract dependency release evidence generator * build: rename ownership surface dependency report * ci: clarify release evidence naming * build: clarify recently published risk report * build: reorder transitive risk report sections * build: fix ownership surface pluralization * ci: surface dependency changes on PRs * ci: harden dependency change awareness * ci: use dependency changed PR label * build: fix dependency report lint * docs: add dependency safety changelog
This commit is contained in:
@@ -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)}`,
|
||||
|
||||
312
scripts/dependency-changes-report.mjs
Normal file
312
scripts/dependency-changes-report.mjs
Normal 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;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
334
scripts/dependency-vulnerability-gate.mjs
Normal file
334
scripts/dependency-vulnerability-gate.mjs
Normal 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;
|
||||
},
|
||||
);
|
||||
}
|
||||
420
scripts/generate-dependency-release-evidence.mjs
Normal file
420
scripts/generate-dependency-release-evidence.mjs
Normal 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;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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()]
|
||||
|
||||
660
scripts/transitive-manifest-risk-report.mjs
Normal file
660
scripts/transitive-manifest-risk-report.mjs
Normal 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;
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user