mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 15:47:28 +00:00
Add dependency release safety evidence and PR awareness (#81325)
* test: cover dependency pin guard * build: add dependency vulnerability gate * build: add dependency risk report * build: add dependency drift reports * build: include dependency ownership surface evidence * build: rename dependency report commands * build: respect release age exclusions in risk report * build: clarify transitive risk accounting * build: remove transitive risk exception registry * build: clarify transitive risk signal wording * ci: attach dependency evidence to release preflight * ci: extract dependency release evidence generator * build: rename ownership surface dependency report * ci: clarify release evidence naming * build: clarify recently published risk report * build: reorder transitive risk report sections * build: fix ownership surface pluralization * ci: surface dependency changes on PRs * ci: harden dependency change awareness * ci: use dependency changed PR label * build: fix dependency report lint * docs: add dependency safety changelog
This commit is contained in:
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -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
|
||||
|
||||
168
.github/workflows/dependency-change-awareness.yml
vendored
Normal file
168
.github/workflows/dependency-change-awareness.yml
vendored
Normal 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).`);
|
||||
25
.github/workflows/openclaw-npm-release.yml
vendored
25
.github/workflows/openclaw-npm-release.yml
vendored
@@ -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:
|
||||
|
||||
28
.github/workflows/openclaw-release-publish.yml
vendored
28
.github/workflows/openclaw-release-publish.yml
vendored
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -92,13 +92,41 @@ export function collectDependencyPinViolations(cwd = process.cwd()) {
|
||||
return [...collectPackageJsonViolations(cwd), ...collectWorkspaceViolations(cwd)];
|
||||
}
|
||||
|
||||
export function collectDependencyPinAudit(cwd = process.cwd()) {
|
||||
const packageJsonFiles = listTrackedPackageJsonFiles(cwd);
|
||||
let packageSpecCount = 0;
|
||||
for (const relativePath of packageJsonFiles) {
|
||||
const packageJson = readJson(path.join(cwd, relativePath));
|
||||
for (const section of PACKAGE_DEPENDENCY_SECTIONS) {
|
||||
packageSpecCount += Object.keys(packageJson[section] ?? {}).length;
|
||||
}
|
||||
}
|
||||
const workspaceViolations = collectWorkspaceViolations(cwd);
|
||||
const violations = [...collectPackageJsonViolations(cwd), ...workspaceViolations];
|
||||
return {
|
||||
packageManifestCount: packageJsonFiles.length,
|
||||
packageSpecCount,
|
||||
violations,
|
||||
};
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
const violations = collectDependencyPinViolations();
|
||||
const audit = collectDependencyPinAudit();
|
||||
const { violations } = audit;
|
||||
if (violations.length === 0) {
|
||||
process.stdout.write(
|
||||
`PASS direct dependency pin guard: checked ${audit.packageSpecCount} directly declared ` +
|
||||
`dependency specs across ${audit.packageManifestCount} tracked package manifests; ` +
|
||||
"0 violations.\n",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Dependency specs must be pinned exactly outside peer dependency contracts:");
|
||||
console.error(
|
||||
`FAIL direct dependency pin guard: ${violations.length} unpinned directly declared ` +
|
||||
"dependency specs found. Direct dependency specs must be pinned exactly outside peer " +
|
||||
"dependency contracts:",
|
||||
);
|
||||
for (const violation of violations) {
|
||||
console.error(
|
||||
`- ${violation.file}:${violation.section}:${violation.name} -> ${JSON.stringify(violation.spec)}`,
|
||||
|
||||
312
scripts/dependency-changes-report.mjs
Normal file
312
scripts/dependency-changes-report.mjs
Normal file
@@ -0,0 +1,312 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import {
|
||||
collectAllResolvedPackagesFromLockfile,
|
||||
createBulkAdvisoryPayload,
|
||||
} from "./pre-commit/pnpm-audit-prod.mjs";
|
||||
|
||||
const DEPENDENCY_FILE_PATTERNS = [
|
||||
/^package\.json$/u,
|
||||
/^pnpm-lock\.yaml$/u,
|
||||
/^pnpm-workspace\.yaml$/u,
|
||||
/^patches\//u,
|
||||
/\/package\.json$/u,
|
||||
];
|
||||
|
||||
function payloadFromLockfile(lockfileText) {
|
||||
return createBulkAdvisoryPayload(collectAllResolvedPackagesFromLockfile(lockfileText));
|
||||
}
|
||||
|
||||
function versionsFor(payload, packageName) {
|
||||
return new Set(payload[packageName] ?? []);
|
||||
}
|
||||
|
||||
export function createDependencyChangesReport({
|
||||
basePayload,
|
||||
headPayload,
|
||||
dependencyFileChanges = [],
|
||||
baseLabel = "base",
|
||||
headLabel = "head",
|
||||
generatedAt = new Date().toISOString(),
|
||||
}) {
|
||||
const packageNames = [
|
||||
...new Set([...Object.keys(basePayload), ...Object.keys(headPayload)]),
|
||||
].toSorted((left, right) => left.localeCompare(right));
|
||||
const addedPackages = [];
|
||||
const removedPackages = [];
|
||||
const changedPackages = [];
|
||||
|
||||
for (const packageName of packageNames) {
|
||||
const baseVersions = versionsFor(basePayload, packageName);
|
||||
const headVersions = versionsFor(headPayload, packageName);
|
||||
if (baseVersions.size === 0) {
|
||||
addedPackages.push({
|
||||
packageName,
|
||||
versions: [...headVersions].toSorted((left, right) => left.localeCompare(right)),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (headVersions.size === 0) {
|
||||
removedPackages.push({
|
||||
packageName,
|
||||
versions: [...baseVersions].toSorted((left, right) => left.localeCompare(right)),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const addedVersions = [...headVersions]
|
||||
.filter((version) => !baseVersions.has(version))
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
const removedVersions = [...baseVersions]
|
||||
.filter((version) => !headVersions.has(version))
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
if (addedVersions.length > 0 || removedVersions.length > 0) {
|
||||
changedPackages.push({ packageName, addedVersions, removedVersions });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
generatedAt,
|
||||
baseLabel,
|
||||
headLabel,
|
||||
summary: {
|
||||
basePackages: Object.keys(basePayload).length,
|
||||
headPackages: Object.keys(headPayload).length,
|
||||
addedPackages: addedPackages.length,
|
||||
removedPackages: removedPackages.length,
|
||||
changedPackages: changedPackages.length,
|
||||
dependencyFileChanges: dependencyFileChanges.length,
|
||||
},
|
||||
dependencyFileChanges,
|
||||
addedPackages,
|
||||
removedPackages,
|
||||
changedPackages,
|
||||
};
|
||||
}
|
||||
|
||||
function markdownCode(value) {
|
||||
return `\`${String(value).replaceAll("`", "\\`")}\``;
|
||||
}
|
||||
|
||||
function renderMarkdownReport(report) {
|
||||
const lines = [
|
||||
"# Dependency Change Report",
|
||||
"",
|
||||
`Generated: ${report.generatedAt}`,
|
||||
"",
|
||||
"## Target",
|
||||
"",
|
||||
`- Base: ${report.baseLabel}`,
|
||||
`- Head lockfile: ${report.headLabel}`,
|
||||
"",
|
||||
"## Scope",
|
||||
"",
|
||||
"This report compares dependency-related files and resolved lockfile package versions between the selected base and the current checkout.",
|
||||
"",
|
||||
"It reports two related but different things:",
|
||||
"",
|
||||
"- Dependency file changes: package manifests, pnpm workspace config, lockfile, and patches.",
|
||||
"- Resolved package changes: package versions added, removed, or changed in pnpm-lock.yaml.",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
"**Dependency files**",
|
||||
`- Changed files: ${report.summary.dependencyFileChanges}`,
|
||||
"",
|
||||
"**Resolved packages**",
|
||||
`- Base: ${report.summary.basePackages}`,
|
||||
`- Head: ${report.summary.headPackages}`,
|
||||
`- Added: ${report.summary.addedPackages}`,
|
||||
`- Removed: ${report.summary.removedPackages}`,
|
||||
`- Changed versions: ${report.summary.changedPackages}`,
|
||||
"",
|
||||
];
|
||||
|
||||
if (report.dependencyFileChanges.length > 0) {
|
||||
lines.push("## Dependency File Changes", "");
|
||||
for (const item of report.dependencyFileChanges) {
|
||||
lines.push(`- ${markdownCode(item.path)}: ${item.status}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (report.addedPackages.length > 0) {
|
||||
lines.push("## Added Resolved Packages", "");
|
||||
for (const item of report.addedPackages) {
|
||||
lines.push(`- ${markdownCode(item.packageName)}: ${item.versions.join(", ")}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
if (report.removedPackages.length > 0) {
|
||||
lines.push("## Removed Resolved Packages", "");
|
||||
for (const item of report.removedPackages) {
|
||||
lines.push(`- ${markdownCode(item.packageName)}: ${item.versions.join(", ")}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
if (report.changedPackages.length > 0) {
|
||||
lines.push("## Changed Resolved Package Versions", "");
|
||||
for (const item of report.changedPackages) {
|
||||
lines.push(
|
||||
`- ${markdownCode(item.packageName)}: +${item.addedVersions.join(", ") || "none"} ` +
|
||||
`-${item.removedVersions.join(", ") || "none"}`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function readGitFile(ref, filePath, cwd) {
|
||||
return execFileSync("git", ["show", `${ref}:${filePath}`], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 100 * 1024 * 1024,
|
||||
});
|
||||
}
|
||||
|
||||
function isDependencyFile(filePath) {
|
||||
return DEPENDENCY_FILE_PATTERNS.some((pattern) => pattern.test(filePath));
|
||||
}
|
||||
|
||||
function gitDiffDependencyFiles(baseRef, cwd) {
|
||||
const output = execFileSync(
|
||||
"git",
|
||||
[
|
||||
"diff",
|
||||
"--name-status",
|
||||
baseRef,
|
||||
"--",
|
||||
"package.json",
|
||||
"pnpm-lock.yaml",
|
||||
"pnpm-workspace.yaml",
|
||||
"*package.json",
|
||||
"patches",
|
||||
],
|
||||
{
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
return output
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [status, ...paths] = line.split("\t");
|
||||
return {
|
||||
status,
|
||||
path: paths.at(-1),
|
||||
oldPath: paths.length > 1 ? paths[0] : null,
|
||||
};
|
||||
})
|
||||
.filter((item) => item.path && isDependencyFile(item.path))
|
||||
.toSorted((left, right) => {
|
||||
if (left.path !== right.path) {
|
||||
return left.path.localeCompare(right.path);
|
||||
}
|
||||
return left.status.localeCompare(right.status);
|
||||
});
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
rootDir: process.cwd(),
|
||||
baseRef: null,
|
||||
baseLockfile: null,
|
||||
headLockfile: "pnpm-lock.yaml",
|
||||
jsonPath: null,
|
||||
markdownPath: null,
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--") {
|
||||
continue;
|
||||
}
|
||||
if (arg === "--root") {
|
||||
options.rootDir = argv[++index];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--base-ref") {
|
||||
options.baseRef = argv[++index];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--base-lockfile") {
|
||||
options.baseLockfile = argv[++index];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--head-lockfile") {
|
||||
options.headLockfile = argv[++index];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--json") {
|
||||
options.jsonPath = argv[++index];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--markdown") {
|
||||
options.markdownPath = argv[++index];
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unsupported argument: ${arg}`);
|
||||
}
|
||||
if (!options.baseRef && !options.baseLockfile) {
|
||||
throw new Error("Expected --base-ref <git-ref> or --base-lockfile <path>.");
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
async function writeArtifact(filePath, content) {
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, content, "utf8");
|
||||
}
|
||||
|
||||
export async function runDependencyChangesReport(options) {
|
||||
const headLockfileText = await readFile(path.join(options.rootDir, options.headLockfile), "utf8");
|
||||
const baseLockfileText = options.baseRef
|
||||
? readGitFile(options.baseRef, "pnpm-lock.yaml", options.rootDir)
|
||||
: await readFile(path.join(options.rootDir, options.baseLockfile), "utf8");
|
||||
const dependencyFileChanges = options.baseRef
|
||||
? gitDiffDependencyFiles(options.baseRef, options.rootDir)
|
||||
: [];
|
||||
return createDependencyChangesReport({
|
||||
basePayload: payloadFromLockfile(baseLockfileText),
|
||||
headPayload: payloadFromLockfile(headLockfileText),
|
||||
dependencyFileChanges,
|
||||
baseLabel: options.baseRef ?? options.baseLockfile,
|
||||
headLabel: options.headLockfile,
|
||||
});
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
const options = parseArgs(argv);
|
||||
const report = await runDependencyChangesReport(options);
|
||||
await writeArtifact(options.jsonPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
await writeArtifact(options.markdownPath, renderMarkdownReport(report));
|
||||
const artifactHint =
|
||||
typeof options.markdownPath === "string" ? " See " + options.markdownPath + "." : "";
|
||||
process.stdout.write(
|
||||
`INFO dependency change report: ${report.summary.addedPackages} added, ` +
|
||||
`${report.summary.removedPackages} removed, ${report.summary.changedPackages} changed ` +
|
||||
`resolved packages and ${report.summary.dependencyFileChanges} dependency file changes ` +
|
||||
`relative to ${report.baseLabel}.${artifactHint}\n`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
|
||||
main().then(
|
||||
(exitCode) => {
|
||||
process.exitCode = exitCode;
|
||||
},
|
||||
(error) => {
|
||||
process.stderr.write(`${error.stack ?? error.message ?? String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -119,7 +120,30 @@ function ownershipFor(dependencyOwnership, name) {
|
||||
return dependencyOwnership.dependencies?.[name];
|
||||
}
|
||||
|
||||
export function collectSbomRiskReport(params = {}) {
|
||||
function gitValue(repoRoot, args) {
|
||||
try {
|
||||
return execFileSync("git", args, {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function collectReportTarget({ repoRoot, packageJson, ownershipPath }) {
|
||||
return {
|
||||
packageName: packageJson.name ?? null,
|
||||
packageVersion: packageJson.version ?? null,
|
||||
gitBranch: gitValue(repoRoot, ["branch", "--show-current"]),
|
||||
gitCommit: gitValue(repoRoot, ["rev-parse", "HEAD"]),
|
||||
lockfile: "pnpm-lock.yaml",
|
||||
ownershipMetadata: path.relative(repoRoot, ownershipPath),
|
||||
};
|
||||
}
|
||||
|
||||
export function collectDependencyOwnershipSurfaceReport(params = {}) {
|
||||
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
|
||||
const packageJson = readJson(path.join(repoRoot, "package.json"));
|
||||
const lockfile = readLockfile(path.join(repoRoot, "pnpm-lock.yaml"));
|
||||
@@ -213,6 +237,8 @@ export function collectSbomRiskReport(params = {}) {
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
target: collectReportTarget({ repoRoot, packageJson, ownershipPath }),
|
||||
summary: {
|
||||
importerCount: Object.keys(lockfile.importers ?? {}).length,
|
||||
lockfilePackageCount: Object.keys(lockfile.packages ?? {}).length,
|
||||
@@ -224,88 +250,215 @@ export function collectSbomRiskReport(params = {}) {
|
||||
ownershipGaps,
|
||||
staleOwnershipRecords,
|
||||
ownershipWarnings,
|
||||
buildRiskPackages: collectBuildRiskPackages(lockfile).slice(0, 50),
|
||||
topRootDependencyCones: rootDependencyRows
|
||||
.toSorted((left, right) => {
|
||||
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);
|
||||
}
|
||||
|
||||
334
scripts/dependency-vulnerability-gate.mjs
Normal file
334
scripts/dependency-vulnerability-gate.mjs
Normal file
@@ -0,0 +1,334 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import {
|
||||
collectAllResolvedPackagesFromLockfile,
|
||||
collectProdResolvedPackagesFromLockfile,
|
||||
createBulkAdvisoryPayload,
|
||||
fetchBulkAdvisories,
|
||||
} from "./pre-commit/pnpm-audit-prod.mjs";
|
||||
|
||||
const SEVERITY_RANK = {
|
||||
info: 0,
|
||||
low: 1,
|
||||
moderate: 2,
|
||||
high: 3,
|
||||
critical: 4,
|
||||
};
|
||||
|
||||
function normalizeSeverity(severity) {
|
||||
if (typeof severity !== "string") {
|
||||
return "info";
|
||||
}
|
||||
return severity.toLowerCase();
|
||||
}
|
||||
|
||||
function isMalwareAdvisory(advisory) {
|
||||
const fields = [advisory?.title, advisory?.overview, advisory?.url].filter(
|
||||
(field) => typeof field === "string",
|
||||
);
|
||||
return fields.some((field) => /\bmalware\b/iu.test(field));
|
||||
}
|
||||
|
||||
function chunkEntries(entries, size) {
|
||||
const chunks = [];
|
||||
for (let index = 0; index < entries.length; index += size) {
|
||||
chunks.push(entries.slice(index, index + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
async function fetchBulkAdvisoriesForPayload(payload, fetchImpl) {
|
||||
const advisoryResults = {};
|
||||
for (const payloadChunk of chunkEntries(Object.entries(payload), 400)) {
|
||||
const chunkPayload = Object.fromEntries(payloadChunk);
|
||||
Object.assign(
|
||||
advisoryResults,
|
||||
await fetchBulkAdvisories({
|
||||
payload: chunkPayload,
|
||||
fetchImpl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return advisoryResults;
|
||||
}
|
||||
|
||||
function flattenAdvisories(advisoriesByPackage, graphName) {
|
||||
const findings = [];
|
||||
for (const [packageName, advisories] of Object.entries(advisoriesByPackage ?? {})) {
|
||||
if (!Array.isArray(advisories)) {
|
||||
continue;
|
||||
}
|
||||
for (const advisory of advisories) {
|
||||
if (!advisory || typeof advisory !== "object") {
|
||||
continue;
|
||||
}
|
||||
const severity = normalizeSeverity(advisory.severity);
|
||||
findings.push({
|
||||
graph: graphName,
|
||||
packageName,
|
||||
id: advisory.id ?? "unknown",
|
||||
severity,
|
||||
title: advisory.title ?? "Untitled advisory",
|
||||
url: advisory.url ?? null,
|
||||
vulnerableVersions: advisory.vulnerable_versions ?? null,
|
||||
malware: isMalwareAdvisory(advisory),
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function findingKey(finding) {
|
||||
return [
|
||||
finding.packageName,
|
||||
String(finding.id),
|
||||
finding.severity,
|
||||
finding.vulnerableVersions ?? "",
|
||||
].join("\0");
|
||||
}
|
||||
|
||||
function dedupeFindings(findings) {
|
||||
const byKey = new Map();
|
||||
for (const finding of findings) {
|
||||
const key = findingKey(finding);
|
||||
const existing = byKey.get(key);
|
||||
if (!existing) {
|
||||
byKey.set(key, finding);
|
||||
continue;
|
||||
}
|
||||
if (existing.graph !== "production" && finding.graph === "production") {
|
||||
byKey.set(key, finding);
|
||||
}
|
||||
}
|
||||
return [...byKey.values()];
|
||||
}
|
||||
|
||||
function sortFindings(findings) {
|
||||
return findings.toSorted((left, right) => {
|
||||
const severityDelta =
|
||||
(SEVERITY_RANK[right.severity] ?? -1) - (SEVERITY_RANK[left.severity] ?? -1);
|
||||
if (severityDelta !== 0) {
|
||||
return severityDelta;
|
||||
}
|
||||
if (left.graph !== right.graph) {
|
||||
return left.graph.localeCompare(right.graph);
|
||||
}
|
||||
if (left.packageName !== right.packageName) {
|
||||
return left.packageName.localeCompare(right.packageName);
|
||||
}
|
||||
return String(left.id).localeCompare(String(right.id));
|
||||
});
|
||||
}
|
||||
|
||||
export function classifyVulnerabilityFindings({ allAdvisories, productionAdvisories }) {
|
||||
const allFindings = flattenAdvisories(allAdvisories, "all");
|
||||
const productionFindings = flattenAdvisories(productionAdvisories, "production");
|
||||
const blockers = [];
|
||||
|
||||
for (const finding of allFindings) {
|
||||
if (finding.malware || finding.severity === "critical") {
|
||||
blockers.push(finding);
|
||||
}
|
||||
}
|
||||
for (const finding of productionFindings) {
|
||||
if (finding.severity === "high" || finding.severity === "critical" || finding.malware) {
|
||||
blockers.push(finding);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
blockers: sortFindings(dedupeFindings(blockers)),
|
||||
findings: sortFindings(dedupeFindings([...allFindings, ...productionFindings])),
|
||||
};
|
||||
}
|
||||
|
||||
function countPayloadVersions(payload) {
|
||||
return Object.values(payload).reduce((sum, versions) => sum + versions.length, 0);
|
||||
}
|
||||
|
||||
export async function runDependencyVulnerabilityGate({
|
||||
rootDir = process.cwd(),
|
||||
fetchImpl = fetch,
|
||||
} = {}) {
|
||||
const lockfileText = await readFile(path.join(rootDir, "pnpm-lock.yaml"), "utf8");
|
||||
const allPayload = createBulkAdvisoryPayload(
|
||||
collectAllResolvedPackagesFromLockfile(lockfileText),
|
||||
);
|
||||
const productionPayload = createBulkAdvisoryPayload(
|
||||
collectProdResolvedPackagesFromLockfile(lockfileText),
|
||||
);
|
||||
|
||||
const [allAdvisories, productionAdvisories] = await Promise.all([
|
||||
fetchBulkAdvisoriesForPayload(allPayload, fetchImpl),
|
||||
fetchBulkAdvisoriesForPayload(productionPayload, fetchImpl),
|
||||
]);
|
||||
const classified = classifyVulnerabilityFindings({ allAdvisories, productionAdvisories });
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
policy: {
|
||||
blocks: [
|
||||
"known malware advisories anywhere in the installed graph",
|
||||
"critical advisories anywhere in the installed graph",
|
||||
"high advisories in the production/runtime graph",
|
||||
],
|
||||
reports: [
|
||||
"moderate and lower advisories",
|
||||
"high advisories outside production/runtime graph",
|
||||
],
|
||||
vulnerabilityExceptions: false,
|
||||
},
|
||||
graphs: {
|
||||
all: {
|
||||
packages: Object.keys(allPayload).length,
|
||||
packageVersions: countPayloadVersions(allPayload),
|
||||
},
|
||||
production: {
|
||||
packages: Object.keys(productionPayload).length,
|
||||
packageVersions: countPayloadVersions(productionPayload),
|
||||
},
|
||||
},
|
||||
...classified,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderDependencyVulnerabilityGateMarkdownReport(report) {
|
||||
const lines = [
|
||||
"# npm Advisory Vulnerability Gate: Resolved Dependency Graph",
|
||||
"",
|
||||
`Generated: ${report.generatedAt}`,
|
||||
"",
|
||||
"## Scope",
|
||||
"",
|
||||
"This gate checks resolved package versions from pnpm-lock.yaml against npm advisory data. It includes transitive dependencies. It blocks known malware anywhere, critical advisories anywhere, and high advisories in the production/runtime graph.",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
`- All graph packages: ${report.graphs.all.packages}`,
|
||||
`- All graph package versions: ${report.graphs.all.packageVersions}`,
|
||||
`- Production graph packages: ${report.graphs.production.packages}`,
|
||||
`- Production graph package versions: ${report.graphs.production.packageVersions}`,
|
||||
`- Hard blockers: ${report.blockers.length}`,
|
||||
`- Total findings: ${report.findings.length}`,
|
||||
"",
|
||||
"## Policy",
|
||||
"",
|
||||
...report.policy.blocks.map((block) => `- Block: ${block}`),
|
||||
...report.policy.reports.map((item) => `- Report: ${item}`),
|
||||
`- Vulnerability exceptions: ${report.policy.vulnerabilityExceptions ? "allowed" : "not allowed"}`,
|
||||
"",
|
||||
];
|
||||
|
||||
if (report.blockers.length > 0) {
|
||||
lines.push("## Hard Blockers", "");
|
||||
for (const finding of report.blockers) {
|
||||
lines.push(
|
||||
`- ${finding.severity.toUpperCase()} ${finding.packageName} (${finding.graph}) ` +
|
||||
`id=${finding.id} range=${finding.vulnerableVersions ?? "unknown"} ` +
|
||||
`${finding.malware ? "[malware] " : ""}${finding.url ?? ""}`,
|
||||
);
|
||||
lines.push(` - ${finding.title}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (report.findings.length > 0) {
|
||||
lines.push("## Findings", "");
|
||||
for (const finding of report.findings) {
|
||||
lines.push(
|
||||
`- ${finding.severity.toUpperCase()} ${finding.packageName} (${finding.graph}) ` +
|
||||
`id=${finding.id} range=${finding.vulnerableVersions ?? "unknown"} ` +
|
||||
`${finding.malware ? "[malware] " : ""}${finding.url ?? ""}`,
|
||||
);
|
||||
lines.push(` - ${finding.title}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (report.findings.length === 0) {
|
||||
lines.push("No advisories found.", "");
|
||||
}
|
||||
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
const renderMarkdownReport = renderDependencyVulnerabilityGateMarkdownReport;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
rootDir: process.cwd(),
|
||||
jsonPath: null,
|
||||
markdownPath: null,
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--") {
|
||||
continue;
|
||||
}
|
||||
if (arg === "--root") {
|
||||
options.rootDir = argv[++index];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--json") {
|
||||
options.jsonPath = argv[++index];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--markdown") {
|
||||
options.markdownPath = argv[++index];
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unsupported argument: ${arg}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
async function writeArtifact(filePath, content) {
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, content, "utf8");
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
const options = parseArgs(argv);
|
||||
const report = await runDependencyVulnerabilityGate({ rootDir: options.rootDir });
|
||||
await writeArtifact(options.jsonPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
await writeArtifact(options.markdownPath, renderMarkdownReport(report));
|
||||
|
||||
if (report.blockers.length === 0) {
|
||||
const packageVersions = Number(report.graphs.all.packageVersions);
|
||||
process.stdout.write(
|
||||
`PASS npm advisory vulnerability gate: checked ${packageVersions} resolved ` +
|
||||
`package versions across the lockfile graph; 0 hard blockers, ` +
|
||||
`${report.findings.length} total advisories.\n`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
process.stderr.write(
|
||||
`FAIL npm advisory vulnerability gate: ${report.blockers.length} hard blockers in resolved ` +
|
||||
`dependency graph; ${report.findings.length} total advisories.\n`,
|
||||
);
|
||||
for (const blocker of report.blockers.slice(0, 25)) {
|
||||
process.stderr.write(
|
||||
`- ${blocker.severity.toUpperCase()} ${blocker.packageName} (${blocker.graph}) ` +
|
||||
`id=${blocker.id} title=${blocker.title}\n`,
|
||||
);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
|
||||
main().then(
|
||||
(exitCode) => {
|
||||
process.exitCode = exitCode;
|
||||
},
|
||||
(error) => {
|
||||
process.stderr.write(`${error.stack ?? error.message ?? String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
},
|
||||
);
|
||||
}
|
||||
420
scripts/generate-dependency-release-evidence.mjs
Normal file
420
scripts/generate-dependency-release-evidence.mjs
Normal file
@@ -0,0 +1,420 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
export const DEPENDENCY_EVIDENCE_REPORTS = [
|
||||
{
|
||||
name: "npm advisory vulnerability gate",
|
||||
command: "pnpm deps:vuln:gate",
|
||||
policy: "hard-blocking",
|
||||
json: "dependency-vulnerability-gate.json",
|
||||
markdown: "dependency-vulnerability-gate.md",
|
||||
},
|
||||
{
|
||||
name: "Transitive manifest risk report",
|
||||
command: "pnpm deps:transitive-risk:report",
|
||||
policy: "report-only",
|
||||
json: "transitive-manifest-risk-report.json",
|
||||
markdown: "transitive-manifest-risk-report.md",
|
||||
},
|
||||
{
|
||||
name: "Dependency ownership and install surface report",
|
||||
command: "pnpm deps:ownership-surface:report",
|
||||
policy: "report-only",
|
||||
json: "dependency-ownership-surface-report.json",
|
||||
markdown: "dependency-ownership-surface-report.md",
|
||||
},
|
||||
{
|
||||
name: "Dependency change report",
|
||||
command: "pnpm deps:changes:report",
|
||||
policy: "report-only",
|
||||
json: "dependency-changes-report.json",
|
||||
markdown: "dependency-changes-report.md",
|
||||
},
|
||||
];
|
||||
|
||||
const RELEASE_TAG_PATTERN = "v[0-9]*.[0-9]*.[0-9]*";
|
||||
|
||||
function trimOutput(output) {
|
||||
return String(output).trim();
|
||||
}
|
||||
|
||||
function commandOutput(
|
||||
command,
|
||||
args,
|
||||
{ rootDir, execFileSyncImpl = execFileSync, allowFailure = false },
|
||||
) {
|
||||
try {
|
||||
return trimOutput(
|
||||
execFileSyncImpl(command, args, {
|
||||
cwd: rootDir,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
if (allowFailure) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function runCommand(command, args, { rootDir, execFileSyncImpl = execFileSync }) {
|
||||
execFileSyncImpl(command, args, {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveReleaseTag({ releaseRef, packageVersion }) {
|
||||
if (/^[0-9a-fA-F]{40}$/u.test(releaseRef)) {
|
||||
return `v${packageVersion}`;
|
||||
}
|
||||
return releaseRef;
|
||||
}
|
||||
|
||||
export function resolvePreviousReleaseTag({
|
||||
rootDir = process.cwd(),
|
||||
execFileSyncImpl = execFileSync,
|
||||
fetchOnMiss = true,
|
||||
} = {}) {
|
||||
const describeArgs = [
|
||||
"describe",
|
||||
"--tags",
|
||||
"--match",
|
||||
RELEASE_TAG_PATTERN,
|
||||
"--abbrev=0",
|
||||
"HEAD^",
|
||||
];
|
||||
const localTag = commandOutput("git", describeArgs, {
|
||||
rootDir,
|
||||
execFileSyncImpl,
|
||||
allowFailure: true,
|
||||
});
|
||||
if (localTag) {
|
||||
return localTag;
|
||||
}
|
||||
if (fetchOnMiss) {
|
||||
runCommand("git", ["fetch", "--tags", "--force", "origin"], { rootDir, execFileSyncImpl });
|
||||
}
|
||||
const fetchedTag = commandOutput("git", describeArgs, {
|
||||
rootDir,
|
||||
execFileSyncImpl,
|
||||
allowFailure: true,
|
||||
});
|
||||
if (fetchedTag) {
|
||||
return fetchedTag;
|
||||
}
|
||||
throw new Error(
|
||||
"Could not resolve a previous reachable release tag for dependency change evidence.",
|
||||
);
|
||||
}
|
||||
|
||||
export function createDependencyEvidenceManifest({
|
||||
generatedAt = new Date().toISOString(),
|
||||
releaseTag,
|
||||
releaseRef,
|
||||
releaseSha,
|
||||
npmDistTag,
|
||||
packageVersion,
|
||||
workflowRunId = "",
|
||||
workflowRunAttempt = "",
|
||||
dependencyChangeBaseRef,
|
||||
} = {}) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generatedAt,
|
||||
releaseTag,
|
||||
releaseRef,
|
||||
releaseSha,
|
||||
npmDistTag,
|
||||
packageName: "openclaw",
|
||||
packageVersion,
|
||||
workflowRunId,
|
||||
workflowRunAttempt,
|
||||
dependencyChangeBaseRef,
|
||||
reports: DEPENDENCY_EVIDENCE_REPORTS,
|
||||
};
|
||||
}
|
||||
|
||||
function reportPath(evidenceDir, fileName) {
|
||||
return path.join(evidenceDir, fileName);
|
||||
}
|
||||
|
||||
async function readJson(filePath) {
|
||||
return JSON.parse(await readFile(filePath, "utf8"));
|
||||
}
|
||||
|
||||
export async function collectDependencyEvidenceSummaryCounts(evidenceDir) {
|
||||
const [vulnerability, transitiveRisk, ownershipSurface, dependencyChanges] = await Promise.all([
|
||||
readJson(reportPath(evidenceDir, "dependency-vulnerability-gate.json")),
|
||||
readJson(reportPath(evidenceDir, "transitive-manifest-risk-report.json")),
|
||||
readJson(reportPath(evidenceDir, "dependency-ownership-surface-report.json")),
|
||||
readJson(reportPath(evidenceDir, "dependency-changes-report.json")),
|
||||
]);
|
||||
return {
|
||||
vulnerabilityBlockers: vulnerability.blockers.length,
|
||||
vulnerabilityFindings: vulnerability.findings.length,
|
||||
transitiveRiskSignals: transitiveRisk.findingCount,
|
||||
workspaceExcludedTransitiveSignals: transitiveRisk.workspaceExcludedFindingCount,
|
||||
transitiveMetadataFailures: transitiveRisk.metadataFailures.length,
|
||||
ownershipLockfilePackages: ownershipSurface.summary.lockfilePackageCount,
|
||||
ownershipBuildRiskPackages: ownershipSurface.summary.buildRiskPackageCount,
|
||||
dependencyFileChanges: dependencyChanges.summary.dependencyFileChanges,
|
||||
dependencyAddedPackages: dependencyChanges.summary.addedPackages,
|
||||
dependencyRemovedPackages: dependencyChanges.summary.removedPackages,
|
||||
dependencyChangedPackages: dependencyChanges.summary.changedPackages,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderDependencyEvidenceSummary({ releaseTag, releaseSha, baseRef, counts }) {
|
||||
return `${[
|
||||
"# Dependency release evidence",
|
||||
"",
|
||||
`Generated for \`${releaseTag}\` at \`${releaseSha}\`.`,
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
`- npm advisory vulnerability hard blockers: ${counts.vulnerabilityBlockers}`,
|
||||
`- npm advisory vulnerability total findings: ${counts.vulnerabilityFindings}`,
|
||||
`- Transitive manifest reported risk signals: ${counts.transitiveRiskSignals}`,
|
||||
`- Workspace-policy excluded transitive signals: ${counts.workspaceExcludedTransitiveSignals}`,
|
||||
`- Transitive manifest metadata failures: ${counts.transitiveMetadataFailures}`,
|
||||
`- Lockfile packages inspected for ownership/install surface: ${counts.ownershipLockfilePackages}`,
|
||||
`- Packages with install-time or platform-specific behavior: ${counts.ownershipBuildRiskPackages}`,
|
||||
`- Dependency change baseline: \`${baseRef}\``,
|
||||
`- Dependency file changes: ${counts.dependencyFileChanges}`,
|
||||
`- Resolved package changes: +${counts.dependencyAddedPackages} -${counts.dependencyRemovedPackages} changed ${counts.dependencyChangedPackages}`,
|
||||
"",
|
||||
"## Reports",
|
||||
"",
|
||||
"- `dependency-vulnerability-gate.md`",
|
||||
"- `transitive-manifest-risk-report.md`",
|
||||
"- `dependency-ownership-surface-report.md`",
|
||||
"- `dependency-changes-report.md`",
|
||||
].join("\n")}\n`;
|
||||
}
|
||||
|
||||
export function renderDependencyEvidenceStepSummary({ evidenceArtifactName, baseRef, counts }) {
|
||||
return `${[
|
||||
"### Dependency release evidence",
|
||||
"",
|
||||
`- Evidence artifact: \`${evidenceArtifactName}\``,
|
||||
`- Dependency change baseline: \`${baseRef}\``,
|
||||
`- npm advisory vulnerability hard blockers: \`${counts.vulnerabilityBlockers}\``,
|
||||
`- Transitive manifest reported risk signals: \`${counts.transitiveRiskSignals}\``,
|
||||
`- Workspace-policy excluded transitive signals: \`${counts.workspaceExcludedTransitiveSignals}\``,
|
||||
`- Ownership/install surface lockfile packages: \`${counts.ownershipLockfilePackages}\``,
|
||||
`- Dependency file changes: \`${counts.dependencyFileChanges}\``,
|
||||
`- Resolved package changes: \`+${counts.dependencyAddedPackages} -${counts.dependencyRemovedPackages} changed ${counts.dependencyChangedPackages}\``,
|
||||
].join("\n")}\n`;
|
||||
}
|
||||
|
||||
function runEvidenceReports({ rootDir, outputDir, baseRef, execFileSyncImpl }) {
|
||||
runCommand(
|
||||
"pnpm",
|
||||
[
|
||||
"deps:vuln:gate",
|
||||
"--",
|
||||
"--json",
|
||||
reportPath(outputDir, "dependency-vulnerability-gate.json"),
|
||||
"--markdown",
|
||||
reportPath(outputDir, "dependency-vulnerability-gate.md"),
|
||||
],
|
||||
{ rootDir, execFileSyncImpl },
|
||||
);
|
||||
runCommand(
|
||||
"pnpm",
|
||||
[
|
||||
"deps:transitive-risk:report",
|
||||
"--",
|
||||
"--json",
|
||||
reportPath(outputDir, "transitive-manifest-risk-report.json"),
|
||||
"--markdown",
|
||||
reportPath(outputDir, "transitive-manifest-risk-report.md"),
|
||||
],
|
||||
{ rootDir, execFileSyncImpl },
|
||||
);
|
||||
runCommand(
|
||||
"pnpm",
|
||||
[
|
||||
"deps:ownership-surface:report",
|
||||
"--",
|
||||
"--json",
|
||||
reportPath(outputDir, "dependency-ownership-surface-report.json"),
|
||||
"--markdown",
|
||||
reportPath(outputDir, "dependency-ownership-surface-report.md"),
|
||||
],
|
||||
{ rootDir, execFileSyncImpl },
|
||||
);
|
||||
runCommand(
|
||||
"pnpm",
|
||||
[
|
||||
"deps:changes:report",
|
||||
"--",
|
||||
"--base-ref",
|
||||
baseRef,
|
||||
"--json",
|
||||
reportPath(outputDir, "dependency-changes-report.json"),
|
||||
"--markdown",
|
||||
reportPath(outputDir, "dependency-changes-report.md"),
|
||||
],
|
||||
{ rootDir, execFileSyncImpl },
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateDependencyReleaseEvidence({
|
||||
rootDir = process.cwd(),
|
||||
outputDir,
|
||||
releaseRef,
|
||||
npmDistTag,
|
||||
baseRef = null,
|
||||
githubOutput = process.env.GITHUB_OUTPUT,
|
||||
githubStepSummary = process.env.GITHUB_STEP_SUMMARY,
|
||||
workflowRunId = process.env.GITHUB_RUN_ID ?? "",
|
||||
workflowRunAttempt = process.env.GITHUB_RUN_ATTEMPT ?? "",
|
||||
execFileSyncImpl = execFileSync,
|
||||
now = new Date(),
|
||||
} = {}) {
|
||||
if (!outputDir) {
|
||||
throw new Error("Expected --output-dir <path>.");
|
||||
}
|
||||
if (!releaseRef) {
|
||||
throw new Error("Expected --release-ref <tag-or-sha>.");
|
||||
}
|
||||
if (!npmDistTag) {
|
||||
throw new Error("Expected --npm-dist-tag <tag>.");
|
||||
}
|
||||
|
||||
await rm(outputDir, { recursive: true, force: true });
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const releaseSha = commandOutput("git", ["rev-parse", "HEAD"], { rootDir, execFileSyncImpl });
|
||||
const packageJson = await readJson(path.join(rootDir, "package.json"));
|
||||
const packageVersion = packageJson.version;
|
||||
const releaseTag = resolveReleaseTag({ releaseRef, packageVersion });
|
||||
const dependencyChangeBaseRef =
|
||||
baseRef ?? resolvePreviousReleaseTag({ rootDir, execFileSyncImpl });
|
||||
|
||||
runEvidenceReports({
|
||||
rootDir,
|
||||
outputDir,
|
||||
baseRef: dependencyChangeBaseRef,
|
||||
execFileSyncImpl,
|
||||
});
|
||||
|
||||
const manifest = createDependencyEvidenceManifest({
|
||||
generatedAt: now.toISOString(),
|
||||
releaseTag,
|
||||
releaseRef,
|
||||
releaseSha,
|
||||
npmDistTag,
|
||||
packageVersion,
|
||||
workflowRunId,
|
||||
workflowRunAttempt,
|
||||
dependencyChangeBaseRef,
|
||||
});
|
||||
await writeFile(
|
||||
reportPath(outputDir, "dependency-evidence-manifest.json"),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const counts = await collectDependencyEvidenceSummaryCounts(outputDir);
|
||||
await writeFile(
|
||||
reportPath(outputDir, "dependency-evidence-summary.md"),
|
||||
renderDependencyEvidenceSummary({
|
||||
releaseTag,
|
||||
releaseSha,
|
||||
baseRef: dependencyChangeBaseRef,
|
||||
counts,
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
if (githubStepSummary) {
|
||||
await appendFile(
|
||||
githubStepSummary,
|
||||
renderDependencyEvidenceStepSummary({
|
||||
evidenceArtifactName: `openclaw-release-dependency-evidence-${releaseRef}`,
|
||||
baseRef: dependencyChangeBaseRef,
|
||||
counts,
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
if (githubOutput) {
|
||||
await appendFile(githubOutput, `dir=${outputDir}\n`, "utf8");
|
||||
}
|
||||
|
||||
return { manifest, counts, outputDir };
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
rootDir: process.cwd(),
|
||||
outputDir: null,
|
||||
releaseRef: null,
|
||||
npmDistTag: null,
|
||||
baseRef: null,
|
||||
githubOutput: process.env.GITHUB_OUTPUT,
|
||||
githubStepSummary: process.env.GITHUB_STEP_SUMMARY,
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--") {
|
||||
continue;
|
||||
}
|
||||
if (arg === "--root") {
|
||||
options.rootDir = argv[++index];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--output-dir") {
|
||||
options.outputDir = argv[++index];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--release-ref") {
|
||||
options.releaseRef = argv[++index];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--npm-dist-tag") {
|
||||
options.npmDistTag = argv[++index];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--base-ref") {
|
||||
options.baseRef = argv[++index];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--github-output") {
|
||||
options.githubOutput = argv[++index];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--github-step-summary") {
|
||||
options.githubStepSummary = argv[++index];
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unsupported argument: ${arg}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
await generateDependencyReleaseEvidence(parseArgs(argv));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
|
||||
main().then(
|
||||
(exitCode) => {
|
||||
process.exitCode = exitCode;
|
||||
},
|
||||
(error) => {
|
||||
process.stderr.write(`${error.stack ?? error.message ?? String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -552,6 +552,26 @@ export function collectProdResolvedPackagesFromLockfile(lockfileText) {
|
||||
return versionsByPackage;
|
||||
}
|
||||
|
||||
export function collectAllResolvedPackagesFromLockfile(lockfileText) {
|
||||
const lockfile = parsePnpmLockfileSections(lockfileText);
|
||||
if (!lockfile.hasSnapshotsSection) {
|
||||
throw new Error("pnpm-lock.yaml is missing the snapshots section.");
|
||||
}
|
||||
|
||||
const versionsByPackage = new Map();
|
||||
for (const snapshotKey of Object.keys(lockfile.snapshots)) {
|
||||
const resolved = parseSnapshotKey(snapshotKey);
|
||||
let versions = versionsByPackage.get(resolved.packageName);
|
||||
if (!versions) {
|
||||
versions = new Set();
|
||||
versionsByPackage.set(resolved.packageName, versions);
|
||||
}
|
||||
versions.add(resolved.version);
|
||||
}
|
||||
|
||||
return versionsByPackage;
|
||||
}
|
||||
|
||||
export function createBulkAdvisoryPayload(versionsByPackage) {
|
||||
return Object.fromEntries(
|
||||
[...versionsByPackage.entries()]
|
||||
|
||||
660
scripts/transitive-manifest-risk-report.mjs
Normal file
660
scripts/transitive-manifest-risk-report.mjs
Normal file
@@ -0,0 +1,660 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import YAML from "yaml";
|
||||
import {
|
||||
collectAllResolvedPackagesFromLockfile,
|
||||
createBulkAdvisoryPayload,
|
||||
} from "./pre-commit/pnpm-audit-prod.mjs";
|
||||
|
||||
const INSTALL_LIFECYCLE_SCRIPTS = ["preinstall", "install", "postinstall", "prepare"];
|
||||
const EXACT_SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u;
|
||||
const EXACT_NPM_ALIAS_PATTERN =
|
||||
/^npm:(?:@[^/\s]+\/)?[^@\s]+@\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u;
|
||||
const PINNED_GIT_PATTERN = /(?:#|\/commit\/)[0-9a-f]{40}$/iu;
|
||||
const EXOTIC_SPEC_PATTERN = /^(?:git\+|github:|gitlab:|bitbucket:|https?:)/iu;
|
||||
const RECENTLY_PUBLISHED_VERSION_TYPE = "recently-published-version";
|
||||
|
||||
function isAllowedPinnedSpec(spec) {
|
||||
if (typeof spec !== "string") {
|
||||
return false;
|
||||
}
|
||||
if (EXACT_SEMVER_PATTERN.test(spec) || EXACT_NPM_ALIAS_PATTERN.test(spec)) {
|
||||
return true;
|
||||
}
|
||||
if (spec === "workspace:*" || spec.startsWith("file:") || spec.startsWith("link:")) {
|
||||
return true;
|
||||
}
|
||||
if (/^(?:git\+|github:|gitlab:|bitbucket:)/u.test(spec)) {
|
||||
return PINNED_GIT_PATTERN.test(spec);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function encodePackageName(name) {
|
||||
return name.startsWith("@") ? name.replace("/", "%2f") : name;
|
||||
}
|
||||
|
||||
function resolveRegistryBaseUrl() {
|
||||
const configured =
|
||||
process.env.npm_config_registry ??
|
||||
process.env.NPM_CONFIG_REGISTRY ??
|
||||
process.env.npm_config_userconfig_registry ??
|
||||
"https://registry.npmjs.org";
|
||||
return configured.replace(/\/+$/u, "");
|
||||
}
|
||||
|
||||
function isExoticResolvedVersion(version) {
|
||||
return EXOTIC_SPEC_PATTERN.test(version);
|
||||
}
|
||||
|
||||
function packageVersionsFromPayload(payload) {
|
||||
return Object.entries(payload).flatMap(([packageName, versions]) =>
|
||||
versions.map((version) => ({ packageName, version })),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadWorkspaceRiskSettings(rootDir) {
|
||||
const workspacePath = path.join(rootDir, "pnpm-workspace.yaml");
|
||||
try {
|
||||
const workspace = YAML.parse(await readFile(workspacePath, "utf8"));
|
||||
return {
|
||||
minimumReleaseAgeMinutes:
|
||||
typeof workspace?.minimumReleaseAge === "number" ? workspace.minimumReleaseAge : null,
|
||||
minimumReleaseAgeExclude: Array.isArray(workspace?.minimumReleaseAgeExclude)
|
||||
? workspace.minimumReleaseAgeExclude.filter((entry) => typeof entry === "string")
|
||||
: [],
|
||||
};
|
||||
} catch {
|
||||
return { minimumReleaseAgeMinutes: null, minimumReleaseAgeExclude: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function splitMinimumReleaseAgeExcludeSelector(selector) {
|
||||
const trimmed = selector.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (trimmed.startsWith("@")) {
|
||||
const scopeSeparatorIndex = trimmed.indexOf("/");
|
||||
const versionSeparatorIndex =
|
||||
scopeSeparatorIndex === -1 ? -1 : trimmed.indexOf("@", scopeSeparatorIndex + 1);
|
||||
if (versionSeparatorIndex === -1) {
|
||||
return { packagePattern: trimmed, versionSelectors: [] };
|
||||
}
|
||||
return {
|
||||
packagePattern: trimmed.slice(0, versionSeparatorIndex),
|
||||
versionSelectors: trimmed
|
||||
.slice(versionSeparatorIndex + 1)
|
||||
.split("||")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
const versionSeparatorIndex = trimmed.indexOf("@");
|
||||
if (versionSeparatorIndex === -1) {
|
||||
return { packagePattern: trimmed, versionSelectors: [] };
|
||||
}
|
||||
return {
|
||||
packagePattern: trimmed.slice(0, versionSeparatorIndex),
|
||||
versionSelectors: trimmed
|
||||
.slice(versionSeparatorIndex + 1)
|
||||
.split("||")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||
}
|
||||
|
||||
function packagePatternMatches(pattern, packageName) {
|
||||
const regex = new RegExp(`^${pattern.split("*").map(escapeRegExp).join(".*")}$`, "u");
|
||||
return regex.test(packageName);
|
||||
}
|
||||
|
||||
function matchesMinimumReleaseAgeExclude(selector, packageName, version) {
|
||||
const parsed = splitMinimumReleaseAgeExcludeSelector(selector);
|
||||
if (!parsed || !packagePatternMatches(parsed.packagePattern, packageName)) {
|
||||
return false;
|
||||
}
|
||||
return parsed.versionSelectors.length === 0 || parsed.versionSelectors.includes(version);
|
||||
}
|
||||
|
||||
function findMinimumReleaseAgeExcludeSelector(selectors, packageName, version) {
|
||||
return selectors.find((selector) =>
|
||||
matchesMinimumReleaseAgeExclude(selector, packageName, version),
|
||||
);
|
||||
}
|
||||
|
||||
function collectManifestFindings({
|
||||
packageName,
|
||||
version,
|
||||
manifest,
|
||||
publishedAt,
|
||||
now,
|
||||
minimumReleaseAgeMinutes,
|
||||
minimumReleaseAgeExclude = [],
|
||||
}) {
|
||||
const findings = [];
|
||||
const workspaceExcludedFindings = [];
|
||||
for (const section of ["dependencies", "optionalDependencies"]) {
|
||||
for (const [dependencyName, spec] of Object.entries(manifest[section] ?? {})) {
|
||||
if (!isAllowedPinnedSpec(spec)) {
|
||||
findings.push({
|
||||
type: "floating-transitive-spec",
|
||||
packageName,
|
||||
version,
|
||||
dependency: { name: dependencyName, spec, section },
|
||||
});
|
||||
}
|
||||
if (typeof spec === "string" && EXOTIC_SPEC_PATTERN.test(spec)) {
|
||||
findings.push({
|
||||
type: "exotic-source",
|
||||
packageName,
|
||||
version,
|
||||
source: spec,
|
||||
dependency: { name: dependencyName, spec, section },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scripts = manifest.scripts ?? {};
|
||||
for (const script of INSTALL_LIFECYCLE_SCRIPTS) {
|
||||
if (typeof scripts[script] === "string") {
|
||||
findings.push({ type: "lifecycle-script", packageName, version, script });
|
||||
}
|
||||
}
|
||||
|
||||
if (!publishedAt) {
|
||||
findings.push({ type: "missing-publish-time", packageName, version });
|
||||
} else if (typeof minimumReleaseAgeMinutes === "number") {
|
||||
const ageMs = now.getTime() - Date.parse(publishedAt);
|
||||
if (Number.isFinite(ageMs) && ageMs < minimumReleaseAgeMinutes * 60_000) {
|
||||
const finding = {
|
||||
type: RECENTLY_PUBLISHED_VERSION_TYPE,
|
||||
packageName,
|
||||
version,
|
||||
publishedAt,
|
||||
minimumReleaseAgeMinutes,
|
||||
};
|
||||
const exclusion = findMinimumReleaseAgeExcludeSelector(
|
||||
minimumReleaseAgeExclude,
|
||||
packageName,
|
||||
version,
|
||||
);
|
||||
if (exclusion) {
|
||||
workspaceExcludedFindings.push({
|
||||
...finding,
|
||||
workspaceExcluded: true,
|
||||
workspaceExclusion: exclusion,
|
||||
});
|
||||
} else {
|
||||
findings.push(finding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { findings, workspaceExcludedFindings };
|
||||
}
|
||||
|
||||
async function fetchNpmManifest({ packageName, version, fetchImpl, registryBaseUrl }) {
|
||||
const response = await fetchImpl(`${registryBaseUrl}/${encodePackageName(packageName)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}`);
|
||||
}
|
||||
const packument = await response.json();
|
||||
const manifest = packument.versions?.[version];
|
||||
if (!manifest) {
|
||||
throw new Error(`version ${version} not found`);
|
||||
}
|
||||
return {
|
||||
manifest,
|
||||
publishedAt: typeof packument.time?.[version] === "string" ? packument.time[version] : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createTransitiveManifestRiskReport({
|
||||
packageVersions,
|
||||
manifestLoader,
|
||||
now = new Date(),
|
||||
minimumReleaseAgeMinutes = null,
|
||||
minimumReleaseAgeExclude = [],
|
||||
}) {
|
||||
const findings = [];
|
||||
const workspaceExcludedFindings = [];
|
||||
const metadataFailures = [];
|
||||
for (const { packageName, version } of packageVersions) {
|
||||
if (isExoticResolvedVersion(version)) {
|
||||
findings.push({
|
||||
type: "exotic-source",
|
||||
packageName,
|
||||
version,
|
||||
source: version,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const { manifest, publishedAt } = await manifestLoader({ packageName, version });
|
||||
const manifestFindings = collectManifestFindings({
|
||||
packageName,
|
||||
version,
|
||||
manifest,
|
||||
publishedAt,
|
||||
now,
|
||||
minimumReleaseAgeMinutes,
|
||||
minimumReleaseAgeExclude,
|
||||
});
|
||||
findings.push(...manifestFindings.findings);
|
||||
workspaceExcludedFindings.push(...manifestFindings.workspaceExcludedFindings);
|
||||
} catch (error) {
|
||||
metadataFailures.push({
|
||||
packageName,
|
||||
version,
|
||||
error: String(error?.message ?? error),
|
||||
});
|
||||
}
|
||||
}
|
||||
const sortedFindings = findings.toSorted((left, right) => {
|
||||
if (left.type !== right.type) {
|
||||
return left.type.localeCompare(right.type);
|
||||
}
|
||||
if (left.packageName !== right.packageName) {
|
||||
return left.packageName.localeCompare(right.packageName);
|
||||
}
|
||||
return left.version.localeCompare(right.version);
|
||||
});
|
||||
const byType = sortedFindings.reduce((counts, finding) => {
|
||||
counts[finding.type] = (counts[finding.type] ?? 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
return {
|
||||
generatedAt: now.toISOString(),
|
||||
packageVersions: packageVersions.length,
|
||||
findingCount: sortedFindings.length,
|
||||
byType,
|
||||
workspacePolicy: {
|
||||
minimumReleaseAgeMinutes,
|
||||
minimumReleaseAgeExclude,
|
||||
},
|
||||
workspaceExcludedFindingCount: workspaceExcludedFindings.length,
|
||||
workspaceExcludedByType: workspaceExcludedFindings.reduce((counts, finding) => {
|
||||
counts[finding.type] = (counts[finding.type] ?? 0) + 1;
|
||||
return counts;
|
||||
}, {}),
|
||||
workspaceExcludedFindings: workspaceExcludedFindings.toSorted((left, right) => {
|
||||
if (left.type !== right.type) {
|
||||
return left.type.localeCompare(right.type);
|
||||
}
|
||||
if (left.packageName !== right.packageName) {
|
||||
return left.packageName.localeCompare(right.packageName);
|
||||
}
|
||||
return left.version.localeCompare(right.version);
|
||||
}),
|
||||
metadataFailures,
|
||||
findings: sortedFindings,
|
||||
};
|
||||
}
|
||||
|
||||
function markdownCode(value) {
|
||||
return `\`${String(value).replaceAll("`", "\\`")}\``;
|
||||
}
|
||||
|
||||
function pluralize(count, singular, plural = `${singular}s`) {
|
||||
return `${count} ${count === 1 ? singular : plural}`;
|
||||
}
|
||||
|
||||
function findingPackageKey(finding) {
|
||||
return `${finding.packageName}@${finding.version}`;
|
||||
}
|
||||
|
||||
function incrementMapCount(map, key, amount = 1) {
|
||||
map.set(key, (map.get(key) ?? 0) + amount);
|
||||
}
|
||||
|
||||
function sortedCountEntries(map) {
|
||||
return [...map.entries()].toSorted((left, right) => {
|
||||
if (right[1] !== left[1]) {
|
||||
return right[1] - left[1];
|
||||
}
|
||||
return left[0].localeCompare(right[0]);
|
||||
});
|
||||
}
|
||||
|
||||
function typeBreakdown(findings) {
|
||||
const counts = new Map();
|
||||
for (const finding of findings) {
|
||||
incrementMapCount(counts, finding.type);
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.toSorted(([left], [right]) => left.localeCompare(right))
|
||||
.map(([type, count]) => `${type}: ${count}`)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function collectMarkdownRollups(findings) {
|
||||
const packageFindings = new Map();
|
||||
const floatingTargets = new Map();
|
||||
const lifecyclePackages = new Map();
|
||||
const recentlyPublishedVersions = [];
|
||||
const exoticSources = [];
|
||||
|
||||
for (const finding of findings) {
|
||||
const packageKey = findingPackageKey(finding);
|
||||
const packageList = packageFindings.get(packageKey) ?? [];
|
||||
packageList.push(finding);
|
||||
packageFindings.set(packageKey, packageList);
|
||||
|
||||
if (finding.type === "floating-transitive-spec" && finding.dependency?.name) {
|
||||
const target = floatingTargets.get(finding.dependency.name) ?? {
|
||||
declarations: 0,
|
||||
sourcePackages: new Set(),
|
||||
specifiers: new Map(),
|
||||
};
|
||||
target.declarations += 1;
|
||||
target.sourcePackages.add(packageKey);
|
||||
incrementMapCount(target.specifiers, finding.dependency.spec ?? "unknown");
|
||||
floatingTargets.set(finding.dependency.name, target);
|
||||
}
|
||||
|
||||
if (finding.type === "lifecycle-script") {
|
||||
const scripts = lifecyclePackages.get(packageKey) ?? new Set();
|
||||
scripts.add(finding.script ?? "unknown");
|
||||
lifecyclePackages.set(packageKey, scripts);
|
||||
}
|
||||
|
||||
if (finding.type === RECENTLY_PUBLISHED_VERSION_TYPE) {
|
||||
recentlyPublishedVersions.push(finding);
|
||||
}
|
||||
|
||||
if (finding.type === "exotic-source") {
|
||||
exoticSources.push(finding);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
packageFindings,
|
||||
floatingTargets,
|
||||
lifecyclePackages,
|
||||
recentlyPublishedVersions,
|
||||
exoticSources,
|
||||
};
|
||||
}
|
||||
|
||||
function renderCompleteEvidence(lines) {
|
||||
lines.push("## Complete Evidence", "");
|
||||
lines.push(
|
||||
"The complete reported signal list is available in the JSON report, including every package, version, dependency, and specifier. Recently published versions covered by pnpm workspace release-age exclusions are listed separately under workspaceExcludedFindings. The sections below summarize the same data by package, dependency target, and finding class for human review.",
|
||||
);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
function renderPackageFindingSummary(lines, packageFindings) {
|
||||
lines.push("## Published Package Manifests With Risk Findings", "");
|
||||
for (const [packageKey, findings] of [...packageFindings.entries()].toSorted((left, right) => {
|
||||
if (right[1].length !== left[1].length) {
|
||||
return right[1].length - left[1].length;
|
||||
}
|
||||
return left[0].localeCompare(right[0]);
|
||||
})) {
|
||||
lines.push(
|
||||
`- ${markdownCode(packageKey)}: ${pluralize(findings.length, "manifest finding")} ` +
|
||||
`(${typeBreakdown(findings)})`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
function renderFloatingDependencyTargets(lines, floatingTargets) {
|
||||
if (floatingTargets.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
lines.push("## Floating Dependency Targets", "");
|
||||
for (const [dependencyName, detail] of [...floatingTargets.entries()].toSorted((left, right) => {
|
||||
if (right[1].declarations !== left[1].declarations) {
|
||||
return right[1].declarations - left[1].declarations;
|
||||
}
|
||||
return left[0].localeCompare(right[0]);
|
||||
})) {
|
||||
const specifiers = sortedCountEntries(detail.specifiers)
|
||||
.map(([specifier, count]) => `${specifier}: ${count}`)
|
||||
.join(", ");
|
||||
lines.push(
|
||||
`- ${markdownCode(dependencyName)}: ${detail.declarations} declarations from ` +
|
||||
`${detail.sourcePackages.size} resolved packages; specifiers: ${specifiers}`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
function renderLifecycleScriptPackages(lines, lifecyclePackages) {
|
||||
if (lifecyclePackages.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
lines.push("## Lifecycle Script Packages", "");
|
||||
for (const [packageKey, scripts] of [...lifecyclePackages.entries()].toSorted(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
)) {
|
||||
lines.push(
|
||||
`- ${markdownCode(packageKey)}: ${[...scripts]
|
||||
.toSorted((left, right) => left.localeCompare(right))
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
function renderRecentlyPublishedVersions(lines, findings, heading) {
|
||||
if (findings.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
lines.push(`## ${heading}`, "");
|
||||
const minimumReleaseAgeMinutes = findings.find(
|
||||
(finding) => typeof finding.minimumReleaseAgeMinutes === "number",
|
||||
)?.minimumReleaseAgeMinutes;
|
||||
if (typeof minimumReleaseAgeMinutes === "number") {
|
||||
lines.push(`Workspace minimum release age: ${minimumReleaseAgeMinutes} minutes.`, "");
|
||||
}
|
||||
for (const finding of findings.toSorted((left, right) => {
|
||||
const dateDelta = Date.parse(right.publishedAt ?? "") - Date.parse(left.publishedAt ?? "");
|
||||
if (Number.isFinite(dateDelta) && dateDelta !== 0) {
|
||||
return dateDelta;
|
||||
}
|
||||
return findingPackageKey(left).localeCompare(findingPackageKey(right));
|
||||
})) {
|
||||
const suffix = finding.workspaceExclusion
|
||||
? `; workspace exclusion ${markdownCode(finding.workspaceExclusion)}`
|
||||
: "";
|
||||
lines.push(
|
||||
`- ${markdownCode(findingPackageKey(finding))}: published ${finding.publishedAt}${suffix}`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
function renderExoticSources(lines, exoticSources) {
|
||||
if (exoticSources.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
lines.push("## Exotic Sources", "");
|
||||
for (const finding of exoticSources.toSorted((left, right) =>
|
||||
findingPackageKey(left).localeCompare(findingPackageKey(right)),
|
||||
)) {
|
||||
lines.push(`- ${markdownCode(findingPackageKey(finding))}: source ${finding.source}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
export function renderTransitiveManifestRiskMarkdownReport(report) {
|
||||
const lines = [
|
||||
"# Transitive Manifest Risk Report",
|
||||
"",
|
||||
`Generated: ${report.generatedAt}`,
|
||||
"",
|
||||
"## Scope",
|
||||
"",
|
||||
"This report inspects published package manifests for resolved packages in the lockfile. It looks for supply-chain risk signals such as floating dependency specs, lifecycle scripts, exotic sources, recently published versions, and missing publish time metadata. It is report-only.",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
`- Resolved package versions inspected: ${report.packageVersions}`,
|
||||
`- Reported risk signals: ${report.findingCount}`,
|
||||
`- Signals covered by workspace policy exclusions: ${report.workspaceExcludedFindingCount ?? 0}`,
|
||||
`- Metadata failures: ${report.metadataFailures.length}`,
|
||||
"",
|
||||
"## Reported Risk Signals By Type",
|
||||
"",
|
||||
];
|
||||
for (const [type, count] of Object.entries(report.byType).toSorted(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
)) {
|
||||
lines.push(`- ${type}: ${count}`);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
if (Object.keys(report.workspaceExcludedByType ?? {}).length > 0) {
|
||||
lines.push("## Signals Covered By Workspace Policy Exclusions", "");
|
||||
lines.push(
|
||||
"These are not included in the reported risk signal totals above. They are tracked separately because the workspace package-manager policy already excludes them.",
|
||||
);
|
||||
lines.push("");
|
||||
for (const [type, count] of Object.entries(report.workspaceExcludedByType ?? {}).toSorted(
|
||||
([left], [right]) => left.localeCompare(right),
|
||||
)) {
|
||||
lines.push(`- ${type}: ${count}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
renderCompleteEvidence(lines);
|
||||
|
||||
if (report.findings.length > 0) {
|
||||
const rollups = collectMarkdownRollups(report.findings);
|
||||
renderPackageFindingSummary(lines, rollups.packageFindings);
|
||||
renderFloatingDependencyTargets(lines, rollups.floatingTargets);
|
||||
renderLifecycleScriptPackages(lines, rollups.lifecyclePackages);
|
||||
renderExoticSources(lines, rollups.exoticSources);
|
||||
renderRecentlyPublishedVersions(
|
||||
lines,
|
||||
rollups.recentlyPublishedVersions,
|
||||
"Recently Published Versions Not Covered By Workspace Exclusions",
|
||||
);
|
||||
}
|
||||
|
||||
renderRecentlyPublishedVersions(
|
||||
lines,
|
||||
report.workspaceExcludedFindings ?? [],
|
||||
"Recently Published Versions Covered By Workspace Exclusions",
|
||||
);
|
||||
|
||||
if (report.metadataFailures.length > 0) {
|
||||
lines.push("## Metadata Failures", "");
|
||||
for (const failure of report.metadataFailures) {
|
||||
lines.push(
|
||||
`- ${markdownCode(`${failure.packageName}@${failure.version}`)}: ${failure.error}`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
const renderMarkdownReport = renderTransitiveManifestRiskMarkdownReport;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
rootDir: process.cwd(),
|
||||
jsonPath: null,
|
||||
markdownPath: null,
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--") {
|
||||
continue;
|
||||
}
|
||||
if (arg === "--root") {
|
||||
options.rootDir = argv[++index];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--json") {
|
||||
options.jsonPath = argv[++index];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--markdown") {
|
||||
options.markdownPath = argv[++index];
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unsupported argument: ${arg}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
async function writeArtifact(filePath, content) {
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, content, "utf8");
|
||||
}
|
||||
|
||||
export async function runTransitiveManifestRiskReport({
|
||||
rootDir = process.cwd(),
|
||||
fetchImpl = fetch,
|
||||
now = new Date(),
|
||||
} = {}) {
|
||||
const lockfileText = await readFile(path.join(rootDir, "pnpm-lock.yaml"), "utf8");
|
||||
const payload = createBulkAdvisoryPayload(collectAllResolvedPackagesFromLockfile(lockfileText));
|
||||
const packageVersions = packageVersionsFromPayload(payload);
|
||||
const settings = await loadWorkspaceRiskSettings(rootDir);
|
||||
return createTransitiveManifestRiskReport({
|
||||
packageVersions,
|
||||
now,
|
||||
minimumReleaseAgeMinutes: settings.minimumReleaseAgeMinutes,
|
||||
minimumReleaseAgeExclude: settings.minimumReleaseAgeExclude,
|
||||
manifestLoader: ({ packageName, version }) =>
|
||||
fetchNpmManifest({
|
||||
packageName,
|
||||
version,
|
||||
fetchImpl,
|
||||
registryBaseUrl: resolveRegistryBaseUrl(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
const options = parseArgs(argv);
|
||||
const report = await runTransitiveManifestRiskReport({
|
||||
rootDir: options.rootDir,
|
||||
});
|
||||
await writeArtifact(options.jsonPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
await writeArtifact(options.markdownPath, renderMarkdownReport(report));
|
||||
const artifactHint =
|
||||
typeof options.markdownPath === "string" ? " See " + options.markdownPath + "." : "";
|
||||
process.stdout.write(
|
||||
`INFO transitive manifest risk report: inspected ${report.packageVersions} resolved ` +
|
||||
`package manifests; ${report.findingCount} reported risk signals, ` +
|
||||
`${report.metadataFailures.length} metadata failures; release not blocked.${artifactHint}\n`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
|
||||
main().then(
|
||||
(exitCode) => {
|
||||
process.exitCode = exitCode;
|
||||
},
|
||||
(error) => {
|
||||
process.stderr.write(`${error.stack ?? error.message ?? String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
},
|
||||
);
|
||||
}
|
||||
179
test/scripts/check-dependency-pins.test.ts
Normal file
179
test/scripts/check-dependency-pins.test.ts
Normal 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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
105
test/scripts/dependency-change-awareness-workflow.test.ts
Normal file
105
test/scripts/dependency-change-awareness-workflow.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
42
test/scripts/dependency-changes-report.test.ts
Normal file
42
test/scripts/dependency-changes-report.test.ts
Normal 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"] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
171
test/scripts/dependency-vulnerability-gate.test.ts
Normal file
171
test/scripts/dependency-vulnerability-gate.test.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
172
test/scripts/generate-dependency-release-evidence.test.ts
Normal file
172
test/scripts/generate-dependency-release-evidence.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
177
test/scripts/transitive-manifest-risk-report.test.ts
Normal file
177
test/scripts/transitive-manifest-risk-report.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user