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

2
.github/CODEOWNERS vendored
View File

@@ -11,6 +11,8 @@
/.github/workflows/codeql.yml @openclaw/openclaw-secops
/.github/workflows/codeql-android-critical-security.yml @openclaw/openclaw-secops
/.github/workflows/codeql-critical-quality.yml @openclaw/openclaw-secops
/.github/workflows/dependency-change-awareness.yml @openclaw/openclaw-secops
/test/scripts/dependency-change-awareness-workflow.test.ts @openclaw/openclaw-secops
/src/security/ @openclaw/openclaw-secops
/src/secrets/ @openclaw/openclaw-secops
/src/config/*secret*.ts @openclaw/openclaw-secops

View File

@@ -0,0 +1,168 @@
name: Dependency Change Awareness
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] metadata-only workflow; no checkout or untrusted code execution
types: [opened, reopened, synchronize, ready_for_review]
permissions:
pull-requests: read
issues: write
concurrency:
group: dependency-change-awareness-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
dependency-change-awareness:
if: ${{ !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Label and comment on dependency changes
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const marker = "<!-- openclaw:dependency-change-awareness -->";
const labelName = "dependencies-changed";
const maxListedFiles = 25;
const pullRequest = context.payload.pull_request;
if (!pullRequest) {
core.info("No pull_request payload found; skipping.");
return;
}
const isDependencyFile = (filename) =>
filename === "package.json" ||
filename === "pnpm-lock.yaml" ||
filename === "pnpm-workspace.yaml" ||
filename === "ui/package.json" ||
filename.startsWith("patches/") ||
/^packages\/[^/]+\/package\.json$/u.test(filename) ||
/^extensions\/[^/]+\/package\.json$/u.test(filename);
const sanitizeDisplayValue = (value) =>
String(value)
.replace(/[\u0000-\u001f\u007f]/gu, "?")
.slice(0, 240);
const markdownCode = (value) =>
`\`${sanitizeDisplayValue(value).replaceAll("`", "\\`")}\``;
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pullRequest.number,
per_page: 100,
});
const dependencyFiles = files
.map((file) => file.filename)
.filter((filename) => typeof filename === "string" && isDependencyFile(filename))
.sort((left, right) => left.localeCompare(right));
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
per_page: 100,
});
const existingComment = comments.find(
(comment) =>
comment.user?.login === "github-actions[bot]" && comment.body?.includes(marker),
);
const labels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
per_page: 100,
});
const hasLabel = labels.some((label) => label.name === labelName);
if (dependencyFiles.length === 0) {
if (hasLabel) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
name: labelName,
}).catch((error) => {
if (error?.status !== 404) {
throw error;
}
});
}
if (existingComment) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
});
}
await core.summary
.addHeading("Dependency Change Awareness")
.addRaw("No dependency-related file changes detected.")
.write();
core.info("No dependency-related file changes detected.");
return;
}
if (!hasLabel) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
labels: [labelName],
}).catch((error) => {
if (error?.status === 404 || error?.status === 422) {
core.warning(`Dependency change label "${labelName}" is missing or unavailable.`);
return;
}
throw error;
});
}
const listedFiles = dependencyFiles.slice(0, maxListedFiles);
const omittedCount = dependencyFiles.length - listedFiles.length;
const fileLines = listedFiles.map((filename) => `- ${markdownCode(filename)}`);
if (omittedCount > 0) {
fileLines.push(`- ${omittedCount} additional dependency-related files not shown`);
}
const body = [
marker,
"",
"### Dependency Changes Detected",
"",
"This PR changes dependency-related files. Maintainers should confirm these changes are intentional.",
"",
"Changed files:",
...fileLines,
"",
"Maintainer follow-up:",
"- Review whether the dependency changes are intentional.",
"- Inspect resolved package deltas when lockfile or workspace dependency policy changes are present.",
"- Run `pnpm deps:changes:report -- --base-ref origin/main --markdown /tmp/dependency-changes.md --json /tmp/dependency-changes.json` locally for detailed release-style evidence.",
].join("\n");
if (existingComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
body,
});
}
await core.summary
.addHeading("Dependency Change Awareness")
.addRaw(`Detected ${dependencyFiles.length} dependency-related file change(s).`)
.addList(dependencyFiles.map((filename) => markdownCode(filename)))
.write();
core.notice(`Detected ${dependencyFiles.length} dependency-related file change(s).`);

View File

@@ -169,12 +169,27 @@ jobs:
- name: Verify release contents
run: pnpm release:check
- name: Generate dependency release evidence
id: dependency_evidence
env:
RELEASE_REF: ${{ inputs.tag }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
run: |
set -euo pipefail
node scripts/generate-dependency-release-evidence.mjs \
--release-ref "$RELEASE_REF" \
--npm-dist-tag "$RELEASE_NPM_DIST_TAG" \
--output-dir "$RUNNER_TEMP/openclaw-release-dependency-evidence" \
--github-output "$GITHUB_OUTPUT" \
--github-step-summary "$GITHUB_STEP_SUMMARY"
- name: Pack prepared npm tarball
id: packed_tarball
env:
OPENCLAW_PREPACK_PREPARED: "1"
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
DEPENDENCY_EVIDENCE_DIR: ${{ steps.dependency_evidence.outputs.dir }}
run: |
set -euo pipefail
PACK_OUTPUT="$RUNNER_TEMP/npm-pack-output.txt"
@@ -246,6 +261,7 @@ jobs:
rm -rf "$ARTIFACT_DIR"
mkdir -p "$ARTIFACT_DIR"
cp "$PACK_PATH" "$ARTIFACT_DIR/"
cp -R "$DEPENDENCY_EVIDENCE_DIR" "$ARTIFACT_DIR/dependency-evidence"
printf '%s\n' "$RELEASE_TAG" > "$ARTIFACT_DIR/release-tag.txt"
printf '%s\n' "$RELEASE_SHA" > "$ARTIFACT_DIR/release-sha.txt"
printf '%s\n' "$RELEASE_NPM_DIST_TAG" > "$ARTIFACT_DIR/release-npm-dist-tag.txt"
@@ -261,6 +277,8 @@ jobs:
packageVersion: process.env.PACKAGE_VERSION,
tarballName: process.env.TARBALL_NAME,
tarballSha256: process.env.TARBALL_SHA256,
dependencyEvidenceDir: "dependency-evidence",
dependencyEvidenceManifest: "dependency-evidence/dependency-evidence-manifest.json",
};
fs.writeFileSync(
path.join(process.env.ARTIFACT_DIR, "preflight-manifest.json"),
@@ -269,6 +287,13 @@ jobs:
NODE
echo "dir=$ARTIFACT_DIR" >> "$GITHUB_OUTPUT"
- name: Upload dependency release evidence
uses: actions/upload-artifact@v7
with:
name: openclaw-release-dependency-evidence-${{ inputs.tag }}
path: ${{ steps.dependency_evidence.outputs.dir }}
if-no-files-found: error
- name: Upload prepared npm publish bundle
uses: actions/upload-artifact@v7
with:

View File

@@ -401,6 +401,33 @@ jobs:
echo "- GitHub release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
}
upload_dependency_evidence_release_asset() {
local release_version download_dir asset_path asset_name
release_version="${RELEASE_TAG#v}"
download_dir="${RUNNER_TEMP}/openclaw-release-dependency-evidence-asset"
asset_name="openclaw-${release_version}-dependency-evidence.zip"
asset_path="${RUNNER_TEMP}/${asset_name}"
rm -rf "${download_dir}" "${asset_path}"
mkdir -p "${download_dir}"
gh run download "${PREFLIGHT_RUN_ID}" \
--repo "${GITHUB_REPOSITORY}" \
--name "openclaw-npm-preflight-${RELEASE_TAG}" \
--dir "${download_dir}"
if [[ ! -d "${download_dir}/dependency-evidence" ]]; then
echo "Dependency evidence is missing from OpenClaw npm preflight artifact." >&2
find "${download_dir}" -maxdepth 2 -type f -print >&2 || true
exit 1
fi
(cd "${download_dir}" && zip -qr "${asset_path}" dependency-evidence)
gh release upload "${RELEASE_TAG}" "${asset_path}#${asset_name}" \
--repo "${GITHUB_REPOSITORY}" \
--clobber
echo "- Dependency evidence asset: \`${asset_name}\`" >> "$GITHUB_STEP_SUMMARY"
}
{
echo "### Publish sequence"
echo
@@ -491,4 +518,5 @@ jobs:
if [[ -n "${openclaw_npm_run_id}" ]]; then
create_or_update_github_release
upload_dependency_evidence_release_asset
fi

View File

@@ -125,6 +125,7 @@ Docs: https://docs.openclaw.ai
- Dependencies: refresh workspace pins and patch targets, including ACPX `@agentclientprotocol/claude-agent-acp` `0.33.1`, Codex ACP `0.14.0`, Baileys `7.0.0-rc10`, Google GenAI `2.0.1`, OpenAI `6.37.0`, AWS SDK `3.1045.0`, Kysely `0.29.0`, Tlon skill `0.3.6`, Aimock `1.19.5`, and tsdown `0.22.0`.
- Dependencies: refresh workspace pins for Anthropic SDK, Smithy shared ini loading, Playwright, YAML, Aimock, TypeScript native preview, Vitest, Oxlint/Oxfmt, Vite, and pnpm 11.1.0.
- Dependencies: hard-pin non-peer direct dependency specs across bundled packages and add a changed-check guard so runtime installs resolve the exact versions tested by maintainers.
- Dependencies: add release dependency evidence reports, npm advisory gating, and PR dependency-change awareness so maintainers can review dependency risk before and during releases. Thanks @joshavant.
- Dependencies: move embedded Pi packages to the `@earendil-works` namespace, refresh Twitch Twurple packages, and move `@openclaw/fs-safe` from the GitHub release pin to the published npm package.
- Build: route Testbox changed-check delegation through Crabbox and remove the OpenClaw-specific Blacksmith Testbox helper scripts.
- Agents/compaction: preserve scoped background exec/process session references across embedded compaction and after-turn runtime contexts without exposing sessions from unrelated scopes. Fixes #79284. (#79307) Thanks @TurboTheTurtle.

View File

@@ -68,7 +68,9 @@ the maintainer-only release runbook.
`pnpm build && pnpm ui:build`, and `pnpm release:check`.
6. Run `OpenClaw NPM Release` with `preflight_only=true`. Before a tag exists,
a full 40-character release-branch SHA is allowed for validation-only
preflight. Save the successful `preflight_run_id`.
preflight. The preflight generates dependency release evidence for the
exact checked-out dependency graph and stores it in the npm preflight
artifact. Save the successful `preflight_run_id`.
7. Kick off all pre-release tests with `Full Release Validation` for the
release branch, tag, or full commit SHA. This is the one manual entrypoint
for the four big release test boxes: Vitest, Docker, QA Lab, and Package.
@@ -85,7 +87,10 @@ the maintainer-only release runbook.
matching GitHub release/prerelease page from the complete matching
`CHANGELOG.md` section. Stable releases published to npm `latest` become the
GitHub latest release; stable maintenance releases kept on npm `beta` are
created with GitHub `latest=false`.
created with GitHub `latest=false`. The workflow also uploads the preflight
dependency evidence to the GitHub release as
`openclaw-<version>-dependency-evidence.zip` for post-release incident
response.
ClawHub publishing may still be running while OpenClaw npm publishes, but the
release publish workflow prints the child run IDs immediately. By default it
does not wait for ClawHub after dispatching it, so OpenClaw npm availability
@@ -189,6 +194,17 @@ the maintainer-only release runbook.
span names, bounded attributes, and content/identifier redaction without
requiring Opik, Langfuse, or another external collector.
- Run `pnpm release:check` before every tagged release
- `OpenClaw NPM Release` preflight generates dependency release evidence before
it packs the npm tarball. The npm advisory vulnerability gate is
release-blocking. The transitive manifest risk, dependency ownership/install
surface, and dependency change reports are release evidence only. The
dependency change report compares the release candidate with the previous
reachable release tag.
- The preflight uploads dependency evidence as
`openclaw-release-dependency-evidence-<tag>` and also embeds it under
`dependency-evidence/` inside the prepared npm preflight artifact. The real
publish path reuses that preflight artifact, then attaches the same evidence
to the GitHub release as `openclaw-<version>-dependency-evidence.zip`.
- Run `OpenClaw Release Publish` for the mutating publish sequence after the
tag exists. Dispatch it from `release/YYYY.M.D` (or `main` when publishing a
main-reachable tag), pass the release tag and successful OpenClaw npm

View File

@@ -1389,9 +1389,12 @@
"deadcode:unused-files": "node scripts/check-deadcode-unused-files.mjs",
"deps:root-ownership": "node scripts/root-dependency-ownership-audit.mjs",
"deps:root-ownership:check": "node scripts/root-dependency-ownership-audit.mjs --check",
"deps:changes:report": "node scripts/dependency-changes-report.mjs",
"deps:pins:check": "node scripts/check-dependency-pins.mjs",
"deps:sbom-risk": "node scripts/sbom-risk-report.mjs",
"deps:sbom-risk:check": "node scripts/sbom-risk-report.mjs --check",
"deps:ownership-surface:check": "node scripts/dependency-ownership-surface-report.mjs --check",
"deps:ownership-surface:report": "node scripts/dependency-ownership-surface-report.mjs",
"deps:transitive-risk:report": "node scripts/transitive-manifest-risk-report.mjs",
"deps:vuln:gate": "node scripts/dependency-vulnerability-gate.mjs",
"dev": "node scripts/run-node.mjs",
"docs:bin": "node scripts/build-docs-list.mjs",
"docs:check-i18n-glossary": "node scripts/check-docs-i18n-glossary.mjs",

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) => {
buildRiskPackages: collectBuildRiskPackages(lockfile),
topRootDependencyCones: rootDependencyRows.toSorted((left, right) => {
if (right.closureSize !== left.closureSize) {
return right.closureSize - left.closureSize;
}
return left.name.localeCompare(right.name);
})
.slice(0, 20),
}),
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;
},
);
}

View File

@@ -0,0 +1,179 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { collectDependencyPinViolations } from "../../scripts/check-dependency-pins.mjs";
import { cleanupTempDirs, makeTempRepoRoot } from "../helpers/temp-repo.js";
const tempDirs: string[] = [];
const nestedGitEnvKeys = [
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_DIR",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_QUARANTINE_PATH",
"GIT_WORK_TREE",
] as const;
function createNestedGitEnv(): NodeJS.ProcessEnv {
const env = {
...process.env,
GIT_CONFIG_NOSYSTEM: "1",
GIT_TERMINAL_PROMPT: "0",
};
for (const key of nestedGitEnvKeys) {
delete env[key];
}
return env;
}
function git(cwd: string, args: string[]) {
execFileSync("git", args, {
cwd,
encoding: "utf8",
env: createNestedGitEnv(),
});
}
function writeJson(filePath: string, value: unknown) {
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function makeRepo() {
const dir = makeTempRepoRoot(tempDirs, "openclaw-dependency-pins-");
git(dir, ["init", "-q", "--initial-branch=main"]);
return dir;
}
afterEach(() => {
cleanupTempDirs(tempDirs);
});
describe("check-dependency-pins", () => {
it("accepts exact dependency specs and intentionally ranged peer contracts", () => {
const dir = makeRepo();
writeJson(path.join(dir, "package.json"), {
dependencies: {
exact: "1.2.3",
prerelease: "1.2.3-beta.1",
alias: "npm:@scope/real-package@2.3.4",
workspace: "workspace:*",
linked: "link:../linked",
local: "file:../local",
gitPinned: "github:owner/repo#0123456789abcdef0123456789abcdef01234567",
},
devDependencies: {
devExact: "4.5.6",
},
optionalDependencies: {
optionalExact: "7.8.9",
},
peerDependencies: {
peerCanRange: "^1.0.0",
},
});
writeFileSync(
path.join(dir, "pnpm-workspace.yaml"),
`overrides:
exact: 1.2.3
alias: "npm:@scope/real-package@2.3.4"
packageExtensions:
parent@1.0.0:
dependencies:
child: 3.2.1
`,
"utf8",
);
git(dir, ["add", "package.json", "pnpm-workspace.yaml"]);
expect(collectDependencyPinViolations(dir)).toEqual([]);
});
it("rejects floating dependency specs in tracked package manifests", () => {
const dir = makeRepo();
mkdirSync(path.join(dir, "extensions", "demo"), { recursive: true });
writeJson(path.join(dir, "package.json"), {
dependencies: {
caret: "^1.2.3",
tilde: "~1.2.3",
wildcard: "*",
tag: "latest",
broad: ">=1 <2",
gitFloating: "github:owner/repo#main",
},
});
writeJson(path.join(dir, "extensions", "demo", "package.json"), {
devDependencies: {
devCaret: "^4.5.6",
},
optionalDependencies: {
optionalTilde: "~7.8.9",
},
peerDependencies: {
peerCanRange: "^10.0.0",
},
});
git(dir, ["add", "package.json", "extensions/demo/package.json"]);
expect(collectDependencyPinViolations(dir)).toEqual([
{
file: "extensions/demo/package.json",
section: "devDependencies",
name: "devCaret",
spec: "^4.5.6",
},
{
file: "extensions/demo/package.json",
section: "optionalDependencies",
name: "optionalTilde",
spec: "~7.8.9",
},
{ file: "package.json", section: "dependencies", name: "caret", spec: "^1.2.3" },
{ file: "package.json", section: "dependencies", name: "tilde", spec: "~1.2.3" },
{ file: "package.json", section: "dependencies", name: "wildcard", spec: "*" },
{ file: "package.json", section: "dependencies", name: "tag", spec: "latest" },
{ file: "package.json", section: "dependencies", name: "broad", spec: ">=1 <2" },
{
file: "package.json",
section: "dependencies",
name: "gitFloating",
spec: "github:owner/repo#main",
},
]);
});
it("rejects floating workspace overrides and package extension dependencies", () => {
const dir = makeRepo();
writeJson(path.join(dir, "package.json"), {});
writeFileSync(
path.join(dir, "pnpm-workspace.yaml"),
`overrides:
exact: 1.2.3
floating: ^2.0.0
packageExtensions:
parent@1.0.0:
dependencies:
exact-child: 3.2.1
floating-child: ~4.0.0
`,
"utf8",
);
git(dir, ["add", "package.json", "pnpm-workspace.yaml"]);
expect(collectDependencyPinViolations(dir)).toEqual([
{
file: "pnpm-workspace.yaml",
section: "overrides",
name: "floating",
spec: "^2.0.0",
},
{
file: "pnpm-workspace.yaml",
section: "packageExtensions.parent@1.0.0.dependencies",
name: "floating-child",
spec: "~4.0.0",
},
]);
});
});

View File

@@ -0,0 +1,105 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
const WORKFLOW = ".github/workflows/dependency-change-awareness.yml";
const CODEOWNERS = ".github/CODEOWNERS";
type WorkflowStep = {
name?: string;
run?: string;
uses?: string;
with?: Record<string, string>;
};
type WorkflowJob = {
steps?: WorkflowStep[];
};
type Workflow = {
jobs?: Record<string, WorkflowJob>;
permissions?: Record<string, string>;
};
function readWorkflow(): Workflow {
return parse(readFileSync(WORKFLOW, "utf8")) as Workflow;
}
describe("dependency change awareness workflow", () => {
it("uses a metadata-only pull_request_target workflow with minimal write permissions", () => {
const workflow = readFileSync(WORKFLOW, "utf8");
const parsed = readWorkflow();
expect(workflow).toContain("pull_request_target:");
expect(workflow).toContain("metadata-only workflow; no checkout or untrusted code execution");
expect(parsed.permissions).toEqual({
"pull-requests": "read",
issues: "write",
});
});
it("does not checkout or execute PR-controlled code", () => {
const workflow = readFileSync(WORKFLOW, "utf8");
const forbiddenSnippets = [
"actions/checkout",
"github.event.pull_request.head",
"pullRequest.head",
"pnpm install",
"npm install",
"pnpm dlx",
"contents: write",
"actions: write",
"id-token: write",
"secrets.",
"github.rest.issues.createLabel",
];
for (const snippet of forbiddenSnippets) {
expect(workflow).not.toContain(snippet);
}
const steps = readWorkflow().jobs?.["dependency-change-awareness"]?.steps ?? [];
expect(steps).toHaveLength(1);
expect(steps[0].run).toBeUndefined();
});
it("uses a pinned GitHub Script action and bounded sticky comments", () => {
const workflow = readFileSync(WORKFLOW, "utf8");
const steps = readWorkflow().jobs?.["dependency-change-awareness"]?.steps ?? [];
const step = steps[0];
expect(step.uses).toBe("actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3");
expect(step.with?.script).toContain("<!-- openclaw:dependency-change-awareness -->");
expect(step.with?.script).toContain("const maxListedFiles = 25;");
expect(step.with?.script).toContain("const sanitizeDisplayValue = (value)");
expect(step.with?.script).toContain('.replace(/[\\u0000-\\u001f\\u007f]/gu, "?")');
expect(step.with?.script).toContain(".slice(0, 240)");
expect(step.with?.script).toContain('comment.user?.login === "github-actions[bot]"');
expect(step.with?.script).toContain("github.rest.pulls.listFiles");
expect(step.with?.script).toContain("github.rest.issues.createComment");
expect(step.with?.script).toContain("github.rest.issues.updateComment");
expect(step.with?.script).toContain("github.rest.issues.deleteComment");
expect(workflow).toContain('"dependencies-changed"');
});
it("detects the intended dependency-related file surfaces", () => {
const script = readWorkflow().jobs?.["dependency-change-awareness"]?.steps?.[0].with?.script;
expect(script).toContain('filename === "package.json"');
expect(script).toContain('filename === "pnpm-lock.yaml"');
expect(script).toContain('filename === "pnpm-workspace.yaml"');
expect(script).toContain('filename === "ui/package.json"');
expect(script).toContain('filename.startsWith("patches/")');
expect(script).toContain("^packages\\/[^/]+\\/package\\.json$");
expect(script).toContain("^extensions\\/[^/]+\\/package\\.json$");
});
it("requires secops review for future workflow or guard changes", () => {
const codeowners = readFileSync(CODEOWNERS, "utf8");
expect(codeowners).toContain(
"/.github/workflows/dependency-change-awareness.yml @openclaw/openclaw-secops",
);
expect(codeowners).toContain(
"/test/scripts/dependency-change-awareness-workflow.test.ts @openclaw/openclaw-secops",
);
});
});

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { createDependencyChangesReport } from "../../scripts/dependency-changes-report.mjs";
describe("dependency-changes-report", () => {
it("reports added, removed, and changed packages", () => {
const report = createDependencyChangesReport({
basePayload: {
removed: ["1.0.0"],
stable: ["1.0.0"],
changed: ["1.0.0"],
},
headPayload: {
added: ["1.0.0"],
stable: ["1.0.0"],
changed: ["2.0.0"],
},
dependencyFileChanges: [
{ status: "M", path: "pnpm-lock.yaml", oldPath: null },
{ status: "M", path: "pnpm-workspace.yaml", oldPath: null },
],
generatedAt: "2026-05-12T00:00:00Z",
});
expect(report.summary).toEqual({
basePackages: 3,
headPackages: 3,
addedPackages: 1,
removedPackages: 1,
changedPackages: 1,
dependencyFileChanges: 2,
});
expect(report.dependencyFileChanges).toEqual([
{ status: "M", path: "pnpm-lock.yaml", oldPath: null },
{ status: "M", path: "pnpm-workspace.yaml", oldPath: null },
]);
expect(report.addedPackages).toEqual([{ packageName: "added", versions: ["1.0.0"] }]);
expect(report.removedPackages).toEqual([{ packageName: "removed", versions: ["1.0.0"] }]);
expect(report.changedPackages).toEqual([
{ packageName: "changed", addedVersions: ["2.0.0"], removedVersions: ["1.0.0"] },
]);
});
});

View File

@@ -3,10 +3,11 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
collectSbomRiskCheckErrors,
collectSbomRiskReport,
collectDependencyOwnershipSurfaceCheckErrors,
collectDependencyOwnershipSurfaceReport,
packageNameFromLockKey,
} from "../../scripts/sbom-risk-report.mjs";
renderDependencyOwnershipSurfaceMarkdownReport,
} from "../../scripts/dependency-ownership-surface-report.mjs";
const tempDirs: string[] = [];
@@ -17,7 +18,7 @@ afterEach(() => {
});
function makeTempRepo() {
const dir = mkdtempSync(path.join(tmpdir(), "openclaw-sbom-risk-"));
const dir = mkdtempSync(path.join(tmpdir(), "openclaw-ownership-surface-"));
tempDirs.push(dir);
return dir;
}
@@ -35,8 +36,8 @@ describe("packageNameFromLockKey", () => {
});
});
describe("collectSbomRiskReport", () => {
it("reports root closure sizes, build-risk packages, and ownership gaps", () => {
describe("collectDependencyOwnershipSurfaceReport", () => {
it("reports root dependency reachability, install-surface packages, and ownership metadata gaps", () => {
const repoRoot = makeTempRepo();
writeRepoFile(
repoRoot,
@@ -98,7 +99,7 @@ snapshots:
);
writeRepoFile(repoRoot, "src/index.ts", 'import "core-lib";\n');
const report = collectSbomRiskReport({ repoRoot });
const report = collectDependencyOwnershipSurfaceReport({ repoRoot });
expect(report.summary).toEqual({
buildRiskPackageCount: 1,
@@ -123,9 +124,28 @@ snapshots:
sourceSections: [],
specifier: "1.0.0",
});
expect(collectSbomRiskCheckErrors(report)).toEqual([
expect(collectDependencyOwnershipSurfaceCheckErrors(report)).toEqual([
"root dependency 'missing-owner' is missing from scripts/lib/dependency-ownership.json",
]);
const markdown = renderDependencyOwnershipSurfaceMarkdownReport(report);
expect(markdown).toContain("# Dependency Ownership and Install Surface Report");
expect(markdown).toContain("## Target");
expect(markdown).toContain("## Scope");
expect(markdown).toContain("It does not query npm advisories");
expect(markdown).toContain("## Root Dependencies Missing Ownership Metadata");
expect(markdown).toContain("`missing-owner`");
expect(markdown).toContain("## Root Dependencies By Resolved Transitive Package Count");
expect(markdown).toContain("`core-lib`: 3 resolved transitive packages");
expect(markdown).toContain("## Workspace Packages With The Most Dependencies");
expect(markdown).toContain("3 direct dependencies");
expect(markdown).not.toContain("dependencys");
expect(markdown).toContain("## Packages With Install-Time Or Platform-Specific Behavior");
expect(markdown).toContain("`transitive-native@1.0.0`: requires build");
expect(markdown).not.toContain("# Dependency Risk Report");
expect(markdown).not.toContain("Ownership gaps");
expect(markdown).not.toContain("Largest root dependency cones");
expect(markdown).not.toContain("## Root Dependencies With The Most Transitive Packages");
});
it("does not mark plugin importer dependencies as stale ownership records", () => {
@@ -180,7 +200,7 @@ snapshots:
}),
);
const report = collectSbomRiskReport({ repoRoot });
const report = collectDependencyOwnershipSurfaceReport({ repoRoot });
expect(report.ownershipGaps).toStrictEqual([]);
expect(report.staleOwnershipRecords).toEqual(["removed-lib"]);

View File

@@ -0,0 +1,171 @@
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
classifyVulnerabilityFindings,
renderDependencyVulnerabilityGateMarkdownReport,
runDependencyVulnerabilityGate,
} from "../../scripts/dependency-vulnerability-gate.mjs";
function advisory({
id,
severity,
title,
vulnerableVersions = "<=1.0.0",
}: {
id: string;
severity: string;
title: string;
vulnerableVersions?: string;
}) {
return {
id,
severity,
title,
vulnerable_versions: vulnerableVersions,
url: `https://github.com/advisories/${id}`,
};
}
async function writeLockfile(rootDir: string) {
await writeFile(
path.join(rootDir, "pnpm-lock.yaml"),
`lockfileVersion: '9.0'
importers:
.:
dependencies:
runtime-high:
version: 1.0.0
devDependencies:
dev-high:
version: 1.0.0
snapshots:
runtime-high@1.0.0: {}
dev-high@1.0.0: {}
transitive-critical@1.0.0: {}
`,
"utf8",
);
}
describe("dependency-vulnerability-gate", () => {
it("blocks critical advisories anywhere and high advisories in the production graph", () => {
const result = classifyVulnerabilityFindings({
allAdvisories: {
"dev-high": [advisory({ id: "GHSA-dev-high", severity: "high", title: "dev high" })],
"transitive-critical": [
advisory({ id: "GHSA-critical", severity: "critical", title: "critical issue" }),
],
},
productionAdvisories: {
"runtime-high": [
advisory({ id: "GHSA-runtime-high", severity: "high", title: "runtime high" }),
],
},
});
expect(result.blockers.map((finding) => finding.id)).toEqual([
"GHSA-critical",
"GHSA-runtime-high",
]);
expect(result.findings.map((finding) => finding.id)).toEqual([
"GHSA-critical",
"GHSA-dev-high",
"GHSA-runtime-high",
]);
});
it("blocks malware advisories regardless of severity or graph", () => {
const result = classifyVulnerabilityFindings({
allAdvisories: {
dev: [advisory({ id: "GHSA-malware", severity: "low", title: "Malware in dev" })],
},
productionAdvisories: {},
});
expect(result.blockers).toMatchObject([
{
id: "GHSA-malware",
malware: true,
severity: "low",
},
]);
});
it("queries full and production lockfile graphs separately", async () => {
const rootDir = await mkdtemp(path.join(tmpdir(), "openclaw-vuln-gate-"));
await writeLockfile(rootDir);
const payloads: Record<string, string[]>[] = [];
const report = await runDependencyVulnerabilityGate({
rootDir,
fetchImpl: async (_url, init) => {
const payload = JSON.parse(String(init?.body));
payloads.push(payload);
const packages = Object.keys(payload);
const body: Record<string, unknown[]> = {};
if (packages.includes("runtime-high")) {
body["runtime-high"] = [
advisory({ id: "GHSA-runtime-high", severity: "high", title: "runtime high" }),
];
}
if (packages.includes("dev-high")) {
body["dev-high"] = [
advisory({ id: "GHSA-dev-high", severity: "high", title: "dev high" }),
];
}
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
},
});
expect(payloads).toHaveLength(2);
expect(payloads[0]).toEqual({
"dev-high": ["1.0.0"],
"runtime-high": ["1.0.0"],
"transitive-critical": ["1.0.0"],
});
expect(payloads[1]).toEqual({
"runtime-high": ["1.0.0"],
});
expect(report.blockers.map((finding) => finding.id)).toEqual(["GHSA-runtime-high"]);
expect(report.findings.map((finding) => finding.id)).toEqual([
"GHSA-dev-high",
"GHSA-runtime-high",
]);
});
it("documents the resolved transitive dependency graph scope in Markdown", () => {
const markdown = renderDependencyVulnerabilityGateMarkdownReport({
generatedAt: "2026-05-12T00:00:00.000Z",
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: 2, packageVersions: 2 },
production: { packages: 1, packageVersions: 1 },
},
blockers: [],
findings: [],
});
expect(markdown).toContain("# npm Advisory Vulnerability Gate: Resolved Dependency Graph");
expect(markdown).toContain("## Scope");
expect(markdown).toContain("resolved package versions from pnpm-lock.yaml");
expect(markdown).toContain("It includes transitive dependencies.");
});
});

View File

@@ -0,0 +1,172 @@
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
DEPENDENCY_EVIDENCE_REPORTS,
collectDependencyEvidenceSummaryCounts,
createDependencyEvidenceManifest,
renderDependencyEvidenceStepSummary,
renderDependencyEvidenceSummary,
resolvePreviousReleaseTag,
resolveReleaseTag,
} from "../../scripts/generate-dependency-release-evidence.mjs";
async function writeJson(dir: string, fileName: string, value: unknown) {
await writeFile(path.join(dir, fileName), `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
describe("generate-dependency-release-evidence", () => {
it("defines the release evidence command list and policy classifications", () => {
expect(DEPENDENCY_EVIDENCE_REPORTS.map(({ command, policy }) => ({ command, policy }))).toEqual(
[
{ command: "pnpm deps:vuln:gate", policy: "hard-blocking" },
{ command: "pnpm deps:transitive-risk:report", policy: "report-only" },
{ command: "pnpm deps:ownership-surface:report", policy: "report-only" },
{ command: "pnpm deps:changes:report", policy: "report-only" },
],
);
});
it("creates the dependency evidence manifest shape", () => {
const manifest = createDependencyEvidenceManifest({
generatedAt: "2026-05-13T00:00:00.000Z",
releaseTag: "v2026.5.13-beta.1",
releaseRef: "v2026.5.13-beta.1",
releaseSha: "abc123",
npmDistTag: "beta",
packageVersion: "2026.5.13-beta.1",
workflowRunId: "123",
workflowRunAttempt: "2",
dependencyChangeBaseRef: "v2026.5.1",
});
expect(manifest).toEqual({
schemaVersion: 1,
generatedAt: "2026-05-13T00:00:00.000Z",
releaseTag: "v2026.5.13-beta.1",
releaseRef: "v2026.5.13-beta.1",
releaseSha: "abc123",
npmDistTag: "beta",
packageName: "openclaw",
packageVersion: "2026.5.13-beta.1",
workflowRunId: "123",
workflowRunAttempt: "2",
dependencyChangeBaseRef: "v2026.5.1",
reports: DEPENDENCY_EVIDENCE_REPORTS,
});
});
it("uses a synthetic release tag for validation-only SHA preflight input", () => {
expect(
resolveReleaseTag({
releaseRef: "0123456789abcdef0123456789abcdef01234567",
packageVersion: "2026.5.13",
}),
).toBe("v2026.5.13");
expect(
resolveReleaseTag({
releaseRef: "v2026.5.13-beta.1",
packageVersion: "2026.5.13-beta.1",
}),
).toBe("v2026.5.13-beta.1");
});
it("falls back to fetching tags when local previous-release resolution misses", () => {
const calls: Array<{ command: string; args: string[] }> = [];
let describeCalls = 0;
const execFileSyncImpl = (command: string, args: string[] = []) => {
calls.push({ command, args });
if (command !== "git") {
throw new Error(`unexpected command: ${command}`);
}
if (args[0] === "describe") {
describeCalls += 1;
if (describeCalls === 1) {
throw new Error("tag not found");
}
return "v2026.5.1\n";
}
if (args[0] === "fetch") {
return "";
}
throw new Error(`unexpected git args: ${args.join(" ")}`);
};
expect(
resolvePreviousReleaseTag({
rootDir: "/repo",
execFileSyncImpl,
}),
).toBe("v2026.5.1");
expect(calls.map(({ args }) => args[0])).toEqual(["describe", "fetch", "describe"]);
expect(calls[1].args).toEqual(["fetch", "--tags", "--force", "origin"]);
});
it("collects report counts and renders human summaries", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-release-dependency-evidence-test-"));
await writeJson(dir, "dependency-vulnerability-gate.json", {
blockers: [{ id: "GHSA-blocker" }],
findings: [{ id: "GHSA-blocker" }, { id: "GHSA-report" }],
});
await writeJson(dir, "transitive-manifest-risk-report.json", {
findingCount: 17,
workspaceExcludedFindingCount: 3,
metadataFailures: [{ packageName: "missing" }],
});
await writeJson(dir, "dependency-ownership-surface-report.json", {
summary: {
lockfilePackageCount: 101,
buildRiskPackageCount: 8,
},
});
await writeJson(dir, "dependency-changes-report.json", {
summary: {
dependencyFileChanges: 4,
addedPackages: 5,
removedPackages: 6,
changedPackages: 7,
},
});
const counts = await collectDependencyEvidenceSummaryCounts(dir);
expect(counts).toEqual({
vulnerabilityBlockers: 1,
vulnerabilityFindings: 2,
transitiveRiskSignals: 17,
workspaceExcludedTransitiveSignals: 3,
transitiveMetadataFailures: 1,
ownershipLockfilePackages: 101,
ownershipBuildRiskPackages: 8,
dependencyFileChanges: 4,
dependencyAddedPackages: 5,
dependencyRemovedPackages: 6,
dependencyChangedPackages: 7,
});
const summary = renderDependencyEvidenceSummary({
releaseTag: "v2026.5.13",
releaseSha: "abc123",
baseRef: "v2026.5.1",
counts,
});
expect(summary).toContain("- npm advisory vulnerability hard blockers: 1");
expect(summary).toContain("- Transitive manifest reported risk signals: 17");
expect(summary).toContain("- Dependency change baseline: `v2026.5.1`");
expect(summary).toContain("- Resolved package changes: +5 -6 changed 7");
const stepSummary = renderDependencyEvidenceStepSummary({
evidenceArtifactName: "openclaw-release-dependency-evidence-v2026.5.13",
baseRef: "v2026.5.1",
counts,
});
expect(stepSummary).toContain(
"- Evidence artifact: `openclaw-release-dependency-evidence-v2026.5.13`",
);
expect(stepSummary).toContain("- npm advisory vulnerability hard blockers: `1`");
await expect(
readFile(path.join(dir, "dependency-vulnerability-gate.json"), "utf8"),
).resolves.toContain("GHSA-blocker");
});
});

View File

@@ -0,0 +1,177 @@
import { describe, expect, it } from "vitest";
import {
createTransitiveManifestRiskReport,
renderTransitiveManifestRiskMarkdownReport,
} from "../../scripts/transitive-manifest-risk-report.mjs";
describe("transitive-manifest-risk-report", () => {
it("reports floating transitive specs, lifecycle scripts, exotic sources, and recently published versions", async () => {
const report = await createTransitiveManifestRiskReport({
packageVersions: [
{ packageName: "parent", version: "1.0.0" },
{ packageName: "tarball-package", version: "https://example.test/pkg.tgz" },
],
now: new Date("2026-05-12T00:00:00Z"),
minimumReleaseAgeMinutes: 2_880,
manifestLoader: async ({ packageName, version }) => {
if (packageName !== "parent" || version !== "1.0.0") {
throw new Error("unexpected manifest request");
}
return {
publishedAt: "2026-05-11T23:00:00Z",
manifest: {
dependencies: {
floating: "^1.2.3",
exact: "2.0.0",
gitdep: "github:owner/repo#main",
},
optionalDependencies: {
optionalFloating: "~3.0.0",
},
scripts: {
install: "node install.js",
},
},
};
},
});
expect(report.byType).toEqual({
"exotic-source": 2,
"floating-transitive-spec": 3,
"lifecycle-script": 1,
"recently-published-version": 1,
});
expect(report.workspaceExcludedFindings).toEqual([]);
expect(report.metadataFailures).toEqual([]);
});
it("uses pnpm minimum release age exclusions for recently published versions", async () => {
const report = await createTransitiveManifestRiskReport({
packageVersions: [
{ packageName: "regular", version: "1.0.0" },
{ packageName: "exact-package", version: "2.0.0" },
{ packageName: "either-version", version: "5.102.1" },
{ packageName: "@scope/native-linux-x64", version: "3.0.0" },
],
now: new Date("2026-05-12T00:00:00Z"),
minimumReleaseAgeMinutes: 2_880,
minimumReleaseAgeExclude: [
"exact-package@2.0.0",
"either-version@4.47.0 || 5.102.1",
"@scope/native-*",
],
manifestLoader: async () => ({
publishedAt: "2026-05-11T23:00:00Z",
manifest: {},
}),
});
expect(report.byType).toEqual({
"recently-published-version": 1,
});
expect(report.workspaceExcludedByType).toEqual({
"recently-published-version": 3,
});
expect(report.findings).toMatchObject([
{
packageName: "regular",
type: "recently-published-version",
},
]);
expect(report.workspaceExcludedFindings).toMatchObject([
{
packageName: "@scope/native-linux-x64",
type: "recently-published-version",
workspaceExcluded: true,
workspaceExclusion: "@scope/native-*",
},
{
packageName: "either-version",
type: "recently-published-version",
workspaceExcluded: true,
workspaceExclusion: "either-version@4.47.0 || 5.102.1",
},
{
packageName: "exact-package",
type: "recently-published-version",
workspaceExcluded: true,
workspaceExclusion: "exact-package@2.0.0",
},
]);
const markdown = renderTransitiveManifestRiskMarkdownReport(report);
expect(markdown).toContain(
"## Recently Published Versions Not Covered By Workspace Exclusions",
);
expect(markdown).toContain("## Recently Published Versions Covered By Workspace Exclusions");
expect(markdown).toContain("Workspace minimum release age: 2880 minutes.");
expect(markdown).toContain("`regular@1.0.0`: published 2026-05-11T23:00:00Z");
expect(markdown).toContain(
"`exact-package@2.0.0`: published 2026-05-11T23:00:00Z; workspace exclusion `exact-package@2.0.0`",
);
expect(markdown).not.toContain(
"`regular@1.0.0`: published 2026-05-11T23:00:00Z; minimum release age 2880 minutes",
);
});
it("documents JSON completeness and renders grouped Markdown summaries", async () => {
const report = await createTransitiveManifestRiskReport({
packageVersions: [
{ packageName: "@earendil-works/pi-ai", version: "0.74.0" },
{ packageName: "aaa-package", version: "1.0.0" },
{ packageName: "recent-package", version: "1.0.0" },
],
now: new Date("2026-05-12T00:00:00Z"),
minimumReleaseAgeMinutes: 2_880,
minimumReleaseAgeExclude: ["recent-package@1.0.0"],
manifestLoader: async ({ packageName }) => ({
publishedAt:
packageName === "recent-package" ? "2026-05-11T23:00:00Z" : "2026-04-01T00:00:00Z",
manifest:
packageName === "@earendil-works/pi-ai"
? {
dependencies: {
"@mistralai/mistralai": "^2.2.0",
},
}
: packageName === "recent-package"
? {
dependencies: {
"recent-dependency": "^1.0.0",
},
}
: {
dependencies: {
"aaa-dependency": "^1.0.0",
},
},
}),
});
const markdown = renderTransitiveManifestRiskMarkdownReport(report);
expect(markdown).toContain("# Transitive Manifest Risk Report");
expect(markdown).toContain("## Scope");
expect(markdown).toContain("published package manifests for resolved packages");
expect(markdown).toContain("It is report-only.");
expect(markdown).toContain("Resolved package versions inspected");
expect(markdown).toContain("Reported risk signals");
expect(markdown).toContain("Signals covered by workspace policy exclusions");
expect(markdown).toContain("## Reported Risk Signals By Type");
expect(markdown).toContain("## Signals Covered By Workspace Policy Exclusions");
expect(markdown).toContain("not included in the reported risk signal totals");
expect(markdown).toContain("## Complete Evidence");
expect(markdown).toContain("The complete reported signal list is available in the JSON report");
expect(markdown).toContain("## Published Package Manifests With Risk Findings");
expect(markdown).toContain("`@earendil-works/pi-ai@0.74.0`: 1 manifest finding");
expect(markdown).toContain("`aaa-package@1.0.0`: 1 manifest finding");
expect(markdown).toContain("## Floating Dependency Targets");
expect(markdown).toContain("`@mistralai/mistralai`: 1 declarations");
expect(markdown).toContain("`aaa-dependency`: 1 declarations");
expect(markdown).not.toContain("## Packages With Findings");
expect(markdown).not.toContain("## Finding Details");
expect(markdown).not.toContain("## Notable Findings");
expect(markdown).not.toContain("## Additional Sample Findings");
});
});