mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
fix(release): dependency advisories no longer fail or delay a release (#161463)
* fix(release): record dependency advisories without blocking releases Release dependency evidence now blocks only on known malware. Vulnerability advisories of every severity are recorded in the evidence summary and surfaced as GitHub warning annotations, and CI dispatched by Full Release Validation or release publication reports a failing production audit as a warning. The per-release risk-acceptance table existed only to accept advisory blockers and is removed. The release skills also record that main CI health never gates a release and that every failed test gets an explicit real-blocker-or-flake decision. * fix(ci): keep release audit relaxation within the workflow size budget ci.yml sits at the 480000-byte guard, so the release-dispatch check moves into a trusted harness script that security-fast already checks out.
This commit is contained in:
parent
0e33bb3d26
commit
56f616e437
19 changed files with 409 additions and 522 deletions
|
|
@ -28,7 +28,9 @@ Use this with `$release-openclaw-maintainer` and `$openclaw-testing` when a rele
|
|||
- Apply a release firebreak after the Code SHA is frozen. Admit only confirmed
|
||||
product defects, wrong or unverifiable package bytes, security
|
||||
defects, or failures that make publication impossible. Queue other findings
|
||||
for postpublish confidence or the next beta.
|
||||
for postpublish confidence or the next beta. Dependency advisories are never
|
||||
firebreak admissions: record them as release evidence and queue the bump on
|
||||
`main` after publication; only known malware stops publication.
|
||||
- Frozen CI children use the pinned Tooling SHA's Node shard planner and measured
|
||||
costs, while discovering and executing tests from the candidate checkout.
|
||||
Hosted full-release plans split measured rows above 12 minutes; preserve file
|
||||
|
|
@ -46,9 +48,12 @@ Use this with `$release-openclaw-maintainer` and `$openclaw-testing` when a rele
|
|||
mint or reuse that tag from `--workflow-sha <tooling-sha>`.
|
||||
- Touch `main` only for an operator-requested change or the smallest critical
|
||||
main-owned blocker that prevents this release and cannot be handled from the
|
||||
release branch. If the required main landing policy is blocked by unrelated
|
||||
main failures, report that blocker and keep independent release work moving
|
||||
instead of healing broader main.
|
||||
release branch. Main's CI health never gates a release. If a release-tooling
|
||||
landing is blocked by `main` failures it does not cause, prove on a clean
|
||||
`main` checkout that the failure already exists there, record it in the PR
|
||||
body, and merge; during an active release an admin merge is allowed for that
|
||||
exact case. A separate lane fixes red `main` in parallel, off the release's
|
||||
critical path.
|
||||
- Land tooling-only fixes on `main` with the `release-fast-lane` label added
|
||||
before the push (see [Release tooling fast lane](#release-tooling-fast-lane)): `openclaw/ci-gate`
|
||||
then runs lint, types, guards, dependencies, docs, and the changed Node rows
|
||||
|
|
@ -270,9 +275,11 @@ until their dependent enforcement changes land.
|
|||
- Recover one failed surface with one diagnosis, one fix when needed, and one
|
||||
narrow retry. Then reassess the release decision. Do not automatically
|
||||
dispatch `rerun_group=all`.
|
||||
- Never automatically rerun a failed or timed out test job. New dispatches reject
|
||||
`known_flaky_jobs_json`; diagnose the original failure and fix its owner before
|
||||
explicit operator recovery.
|
||||
- Rerun a failed or timed out test job only after the lead records its
|
||||
real-blocker-or-flake decision (see the maintainer skill's shared release
|
||||
boundaries). A flake gets at most two recorded reruns on the same Release SHA
|
||||
plus a fix-in-parallel issue or PR on `main`; never a re-cut, tooling change,
|
||||
or new FRV. New dispatches reject `known_flaky_jobs_json`.
|
||||
- For a supported parent, `pnpm frv rerun --run <parent-run-id> --job
|
||||
"<child-key>:<exact job name>"` reruns one executed terminal job using its accepted
|
||||
Actions job ID. Get the child key and exact name from `frv status --json`.
|
||||
|
|
|
|||
|
|
@ -37,14 +37,44 @@ Every other selected validation lane must succeed: macOS Node and other normal
|
|||
CI jobs, install smoke, survivor lanes, `update-first-hop-compat*`, pack/npm
|
||||
qualification, package integrity, and Linux/Windows/macOS Gateway checks,
|
||||
including Windows packaged install/upgrade checks in Release Checks. A cancelled
|
||||
run still blocks. Preserve first failures and fix the owning defect before
|
||||
explicit recovery. Stable publication requires stable/full
|
||||
run still blocks. Preserve first failures and classify each one as below
|
||||
before recovery. Stable publication requires stable/full
|
||||
evidence, soak, and blocking performance. Beta-profile evidence cannot authorize
|
||||
stable publication. No lane or soak waiver can bypass these requirements.
|
||||
All nine Gateway install/upgrade combinations across Linux, Windows, and macOS
|
||||
are required for all-group qualification. Preserve identity, provenance,
|
||||
complete evidence, and existing publication approvals.
|
||||
|
||||
Every failed test gets an explicit lead decision, real release blocker or
|
||||
flake, recorded in the handoff with its evidence: the same SHA passing
|
||||
elsewhere or on rerun, no relation to the release delta, a runner or infra
|
||||
signature, a history of the same case flaking, or a pre-existing product bug
|
||||
that is not a regression (for example the 2026.9.6 "Assign to…" bug). A real
|
||||
blocker is a regression in shipped bytes or behavior, or an update/install/
|
||||
publish defect; fix it on the release branch. A flake never blocks: rerun it on
|
||||
the same Release SHA with at most two recorded reruns by default, and file a
|
||||
fix-in-parallel issue or PR on `main` with the evidence. Never re-cut, change
|
||||
tooling, or start a new FRV for a flake. Main-only failures and infrastructure
|
||||
failures (runner outages, GitHub ghost jobs, hosted-runner offload) count as
|
||||
flakes for the release. The publish gate does not yet accept a recorded flake
|
||||
classification, so a flake still red after its reruns goes to the operator.
|
||||
|
||||
Dependency advisories never delay a release. A newly published advisory is
|
||||
never a reason to re-cut, change tooling, or rerun validation. Record it in the
|
||||
release evidence and handoff, then file or queue the dependency bump on `main`
|
||||
as a normal follow-up after publication. Only a known-malware finding stops
|
||||
publication. Release dependency evidence and release-dispatched CI enforce this.
|
||||
|
||||
Main's CI health never gates a release. Validation and publication run from the
|
||||
release branch plus pinned tooling, so a red `main` is not a reason to wait,
|
||||
re-cut, or pause. When the release needs a release-tooling fix on `main`
|
||||
(tooling SHAs must be trusted `main` commits), `main` failures the fix does not
|
||||
cause must not hold that landing: prove on a clean `main` checkout that the
|
||||
failure already exists there, record it in the PR body, and merge. During an
|
||||
active release, an admin merge is allowed for a release-tooling PR in exactly
|
||||
that situation. Red `main` still gets fixed, in parallel by a separate lane,
|
||||
never on the release's critical path.
|
||||
|
||||
The operating objectives are approximately 20 minutes to seal validation and
|
||||
publication within an hour, not measured guarantees. Source-only children start
|
||||
alongside artifact producers; candidate consumers start as soon as the candidate
|
||||
|
|
|
|||
|
|
@ -109,8 +109,8 @@ class must pass. See [shared release boundaries](../SKILL.md#shared-release-boun
|
|||
failures and use the controller's bounded retry for affected required proof.
|
||||
Continue eligible parents to seal; a parent that produced its own sealed
|
||||
candidate artifacts requires a new parent with verified successful evidence
|
||||
reuse. Diagnose selected test failures before rerunning; an untouched test or
|
||||
passing replay alone does not prove a flake or a fix. Only a confirmed product
|
||||
reuse. Classify each selected test failure as a real blocker or a flake before
|
||||
rerunning, per the shared release boundaries. Only a confirmed product
|
||||
defect that a required lane blocks on creates a new Code SHA: the
|
||||
update/install path (previous stable updates to the candidate, install smoke,
|
||||
pack budget, worker bundle), the bytes to publish, or another required gate
|
||||
|
|
|
|||
|
|
@ -94,9 +94,10 @@ reference for commands rather than redispatching the release parent.
|
|||
path, publish bytes, or another required gate proven by diagnosis): fix the
|
||||
release branch, freeze a new Code SHA, and invalidate downstream product
|
||||
evidence; any other failure keeps the Code SHA
|
||||
- selected test failure or diagnosed flaky lane: block publication, record actual
|
||||
results, and investigate the owner; rerun after the failure is resolved.
|
||||
An untouched test or passing replay alone establishes neither a flake nor a fix
|
||||
- selected test failure: record the lead's real-blocker-or-flake decision and
|
||||
its evidence. A flake gets at most two recorded reruns on the same Release
|
||||
SHA and a fix-in-parallel issue or PR on `main`; never re-cut, change
|
||||
tooling, or start a new FRV for it
|
||||
- regular changelog-only failure before tagging: change the selected release entry and only
|
||||
its permitted record/index paths, freeze a new Release SHA, and reuse green
|
||||
Code SHA evidence after `split-changelog-release-v1` delta proof
|
||||
|
|
|
|||
|
|
@ -57,6 +57,11 @@ latest drift/unavailable lookups are advisory: retain the tested Codex pin and
|
|||
record warnings. Malformed runtime metadata, package/install failures and
|
||||
required validation failures still block.
|
||||
|
||||
Dependency advisory findings in release dependency evidence and
|
||||
release-dispatched CI audits are warnings at every severity; only known malware
|
||||
blocks. Record them in the handoff and queue the bump on `main` after
|
||||
publication; never re-cut, change tooling, or rerun validation for them.
|
||||
|
||||
Install smoke also checks pack budget and direct npm global fresh/update paths;
|
||||
keep those enabled. `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1` is the existing
|
||||
non-root-skip mode, not permission to skip install proof. Published correction
|
||||
|
|
@ -148,9 +153,12 @@ diagnosis but cannot substitute for required stable evidence.
|
|||
Preserve the validation parent and successful children when continuation is
|
||||
eligible; parents that produced sealed candidate artifacts need a new parent
|
||||
with verified evidence reuse. Diagnose failures and retry only the affected
|
||||
surface within the controller's budget. Selected test failures block publication; an untouched test or passing replay
|
||||
alone proves neither a flake nor a fix. Change Code SHA for a confirmed
|
||||
product defect and validate the repaired source. Aim to seal within approximately 20 minutes
|
||||
surface within the controller's budget. Classify every selected test failure as
|
||||
a real blocker or a flake under the
|
||||
[shared release boundaries](../SKILL.md#shared-release-boundaries): flakes get
|
||||
bounded recorded reruns on the same Release SHA and a fix-in-parallel issue or
|
||||
PR on `main`. Change Code SHA only for a real blocker and validate the repaired
|
||||
source. Aim to seal within approximately 20 minutes
|
||||
and publish within an hour; report observed blockers and timing rather than
|
||||
claiming those objectives as measured guarantees.
|
||||
|
||||
|
|
|
|||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -2804,7 +2804,7 @@ jobs:
|
|||
with:
|
||||
ref: ${{ github.workflow_sha }}
|
||||
path: .ci-harness
|
||||
sparse-checkout: ".github/actions\n.github/zizmor.yml\nscripts/detect-private-keys.mts"
|
||||
sparse-checkout: ".github/actions\n.github/zizmor.yml\nscripts/detect-private-keys.mts\nscripts/ci-production-audit.mjs"
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve security diff base
|
||||
|
|
@ -2935,7 +2935,7 @@ jobs:
|
|||
pre-commit run --config "$PRE_COMMIT_CONFIG_PATH" zizmor --files "${workflow_files[@]}"
|
||||
|
||||
- name: Audit production dependencies
|
||||
run: node scripts/pre-commit/pnpm-audit-prod.mjs --audit-level=high
|
||||
run: node .ci-harness/scripts/ci-production-audit.mjs
|
||||
|
||||
- name: Setup differential guard dependencies
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.OPENCLAW_CI_ON_PUSH != 'true'
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ const repositoryScriptEntries = [
|
|||
"apps/linux/scripts/test-native-control-auth.mjs!",
|
||||
"scripts/render-proof-video.mts!",
|
||||
"scripts/ci-shard-timings-refresh.mts!",
|
||||
// CI security-fast runs this from its trusted harness checkout.
|
||||
"scripts/ci-production-audit.mjs!",
|
||||
// tsdown builds this private macOS app worker protocol entry by path.
|
||||
"src/node-host/mac-worker-entry.ts!",
|
||||
// CI imports this selector from its trusted harness inside an inline Node script.
|
||||
|
|
|
|||
|
|
@ -704,7 +704,7 @@ automation account, and SecOps-owned-path cases before declaring enforcement act
|
|||
## Fail-fast order
|
||||
|
||||
1. `preflight` decides which lanes exist at all. The `docs-scope` and `changed-scope` logic are steps inside this job, not standalone jobs. Canonical `main` starts immediately in one of two parity slots; each slot admits one complete run and coalesces later pushes into its newest pending tip. Downstream jobs wait for the manifest, then eligible Blacksmith jobs restore exact dependencies from the trusted warmer or fall back to the ordinary pnpm-store cache on a miss. Pushes, pull requests, and manual runs targeting the workflow revision run preflight with native Node and skip dependency setup. Manual runs targeting a different revision install dependencies and retain that target's `tsx` tooling.
|
||||
2. `security-fast`, `check-*`, `check-additional-*`, `check-docs`, and `skills-python` fail quickly without waiting on the heavier artifact and platform matrix jobs. Additional checks start directly after preflight. Compiler narrowing retains complete discovery for eligible source changes; known full selections skip discovery and retain boundary proof in an already selected additional boundary row or the required planner. No extra boundary row is admitted for that optimization. The production dependency audit sends one complete graph with up to four attempts and a four-minute total request budget, including retries and response reading. Timeouts, native fetch failures, HTTP 429, and 5xx responses retry with exponential backoff; retryable HTTP responses honor `Retry-After`. Attempts and recovery are logged. Persistent unavailability, vulnerability findings, invalid inputs, malformed advisory data, oversized responses, and permanent HTTP failures block CI. An unavailable audit is incomplete coverage, not a clean result. Local pre-commit and release dependency audits use the same bounded request owner and fail on unavailability.
|
||||
2. `security-fast`, `check-*`, `check-additional-*`, `check-docs`, and `skills-python` fail quickly without waiting on the heavier artifact and platform matrix jobs. Additional checks start directly after preflight. Compiler narrowing retains complete discovery for eligible source changes; known full selections skip discovery and retain boundary proof in an already selected additional boundary row or the required planner. No extra boundary row is admitted for that optimization. The production dependency audit sends one complete graph with up to four attempts and a four-minute total request budget, including retries and response reading. Timeouts, native fetch failures, HTTP 429, and 5xx responses retry with exponential backoff; retryable HTTP responses honor `Retry-After`. Attempts and recovery are logged. Persistent unavailability, vulnerability findings, invalid inputs, malformed advisory data, oversized responses, and permanent HTTP failures block CI. An unavailable audit is incomplete coverage, not a clean result. CI dispatched by Full Release Validation or release publication records a failing audit as a warning instead, because advisories never block a release. Local pre-commit and release dependency audits use the same bounded request owner and fail on unavailability; release dependency evidence blocks only on known malware.
|
||||
3. `build-artifacts` and the locale checks overlap with the fast Linux lanes. Control UI and native app source PRs exclude generated locale snapshots/resources; their serialized refresh workflows repair and auto-merge isolated generated PRs in the background. Source CI still blocks stale source inventories and unsafe localization calls. Generated PRs, manual CI, and release prep enforce full translated/platform-generated parity. Canonical `release/YYYY.M.PATCH` branches may include release-prep locale repairs with the other generated release output.
|
||||
4. Baseline ratchets and selected Node test shards start independently after preflight. Node rows consume the manifest, not ratchet outputs. `ci-gate` still requires every selected ratchet to pass, and the PR failure monitor still cancels remaining work after a ratchet failure. Frozen targets retain their existing ratchet selection.
|
||||
5. Current plans with guards run `check:coercion-helpers` there once; fast-only plans retain its standalone row. Other platform and runtime lanes fan out independently: `checks-fast-core` (including startup corpus), `checks-fast-contracts-plugins`, `checks-fast-contracts-channels`, `checks-windows`, `macos-node`, `macos-swift`, `ios-build`, the screenshot shards, and `android`.
|
||||
|
|
|
|||
|
|
@ -542,6 +542,8 @@ For local reproduction, run
|
|||
selects a shorter 30-second diagnostic budget but preserves exit codes: 0 means
|
||||
no matching findings, 1 means findings or an error, and 2 means incomplete coverage.
|
||||
Ordinary CI, scheduled audits, and local hooks propagate every non-zero exit.
|
||||
CI dispatched by Full Release Validation or release publication reports a
|
||||
non-zero exit as a warning, because advisories never block a release.
|
||||
|
||||
### Docs Sync Publish Repo
|
||||
|
||||
|
|
|
|||
|
|
@ -19,11 +19,11 @@ These projects do not inherit root pnpm overrides. The Vercel project uses appro
|
|||
|
||||
## Check dependency advisories
|
||||
|
||||
The production audit pre-commit hook and ordinary CI's `security-fast` job remain zero-install, npm-only checks of the product production graph. They query npm bulk advisory data, not upstream repository advisories. A passing result is limited to that source and graph; it does not establish that dependencies are unaffected by all known vulnerabilities.
|
||||
The production audit pre-commit hook and ordinary CI's `security-fast` job remain zero-install, npm-only checks of the product production graph. They query npm bulk advisory data, not upstream repository advisories. A passing result is limited to that source and graph; it does not establish that dependencies are unaffected by all known vulnerabilities. CI runs dispatched by Full Release Validation or release publication record a failing audit as a warning instead of failing: advisories never block a release.
|
||||
|
||||
`pnpm deps:vuln:gate`, used by release dependency evidence, audits the target's product pnpm lock plus each release-tool lock whose package is present in that target. It checks npm advisory data and adds published security advisories from verified public GitHub repositories. Repository mappings come from the manifests for exact locked npm package versions, not a package's latest manifest. The gate verifies that each repository is public before requesting advisories with the explicit `state=published` filter. It does not scan private repositories or unpublished advisories.
|
||||
|
||||
Within each lockfile, known malware and critical advisories block anywhere, and high advisories block in the production/runtime graph. Dev-only high advisories and moderate or lower non-malware advisories are reported without blocking. GitHub's `medium` severity maps to `moderate` in this policy. Upstream findings match the npm package identity and affected-version range against exact locked versions; only matches absent from the npm result for the corresponding lockfile and graph are added. Reports retain the source lockfile, so a release-tool finding does not imply product runtime exposure. Missing or invalid locks for declared dependency graphs fail the gate.
|
||||
Known malware is the only blocking class: a malware advisory anywhere in any audited lockfile fails the gate and stops publication, because it marks a compromised package rather than a vulnerability report. Vulnerability advisories of every severity, in every graph, are recorded without blocking: the gate prints each one as a GitHub Actions warning annotation, and the release dependency evidence summary lists them under "Non-blocking advisory findings". A newly published advisory never delays a release; fix it with a normal dependency bump on `main` after publication. GitHub's `medium` severity maps to `moderate` in these reports. Upstream findings match the npm package identity and affected-version range against exact locked versions; only matches absent from the npm result for the corresponding lockfile and graph are added. Reports retain the source lockfile, so a release-tool finding does not imply product runtime exposure. Missing or invalid locks for declared dependency graphs fail the gate.
|
||||
|
||||
Release automation reuses its existing standard `GH_TOKEN` only for GitHub API requests, never for npm registry requests. Local runs without that token use anonymous GitHub requests and their rate limits. No new OpenClaw configuration or operator credential setup is required.
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ The upstream scan has fixed bounds: 2,500 exact package versions, 4,000 HTTP req
|
|||
|
||||
Matching findings from each repository page reserve request capacity across all workers and are checked against GitHub's reviewed advisory ranges before that task continues discovery. This prevents later repository scans from consuming the budget needed to reconcile already-discovered findings. Missing reviewed evidence preserves the publisher's finding and records partial coverage.
|
||||
|
||||
Missing or unsupported repository metadata, malformed affected-version ranges, exhausted request or pagination budgets, and request or rate-limit failures produce `partial` upstream coverage, not an unaffected result. Confirmed findings remain in the report and enter the same severity policy. Inspect the coverage issue subjects and reasons before interpreting a zero-finding result.
|
||||
Missing or unsupported repository metadata, malformed affected-version ranges, exhausted request or pagination budgets, and request or rate-limit failures produce `partial` upstream coverage, not an unaffected result. Confirmed findings remain in the report under the same policy. Inspect the coverage issue subjects and reasons before interpreting a zero-finding result.
|
||||
|
||||
## Published package behavior
|
||||
|
||||
|
|
|
|||
|
|
@ -81,6 +81,16 @@ install/upgrade combinations across Linux, Windows, and macOS. Coverage otherwis
|
|||
varies by profile and selected operating systems. Check the release's recorded
|
||||
coverage: skipped or deferred checks are not passes.
|
||||
|
||||
Dependency advisories never block or delay a release. Release dependency
|
||||
evidence records every advisory finding, at any severity, and CI dispatched by
|
||||
release validation or publication reports a failing dependency audit as a
|
||||
warning. The dependency fix ships through `main` after publication. Only a
|
||||
known-malware finding stops publication.
|
||||
|
||||
The health of `main` CI does not gate a release. Validation and publication run
|
||||
from the release branch with pinned release tooling, so a red `main` is not a
|
||||
reason to wait, re-cut, or pause.
|
||||
|
||||
See [Full release validation](/reference/full-release-validation) for coverage
|
||||
by profile and how to interpret the results.
|
||||
|
||||
|
|
|
|||
40
scripts/ci-production-audit.mjs
Normal file
40
scripts/ci-production-audit.mjs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
// Runs the target's production dependency audit for CI's security-fast job.
|
||||
// Trusted harness code: CI dispatched by release validation or publication records a
|
||||
// failing audit as a warning, because dependency advisories never block a release.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import process from "node:process";
|
||||
|
||||
const RELEASE_DISPATCH_PREFIXES = ["full-release-validation-", "release-native-android-"];
|
||||
|
||||
function isReleaseDispatch() {
|
||||
if (process.env.GITHUB_EVENT_NAME !== "workflow_dispatch" || !process.env.GITHUB_EVENT_PATH) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const dispatchId = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")).inputs
|
||||
?.dispatch_id;
|
||||
return (
|
||||
typeof dispatchId === "string" &&
|
||||
RELEASE_DISPATCH_PREFIXES.some((prefix) => dispatchId.startsWith(prefix))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const audit = spawnSync(
|
||||
process.execPath,
|
||||
["scripts/pre-commit/pnpm-audit-prod.mjs", "--audit-level=high"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
const status = audit.status ?? 1;
|
||||
const nonBlocking = status !== 0 && isReleaseDispatch();
|
||||
if (nonBlocking) {
|
||||
process.stdout.write(
|
||||
`::warning title=Dependency advisories do not block releases::Production dependency audit exited ${status}. Release CI records this without failing; queue the dependency bump on main after publication.\n`,
|
||||
);
|
||||
}
|
||||
process.exitCode = nonBlocking ? 0 : status;
|
||||
|
|
@ -200,7 +200,8 @@ function sortFindings(findings: Finding[]) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Classifies source-attributed advisory findings into report-only findings and hard blockers.
|
||||
* Classifies source-attributed advisory findings. Only known malware blocks: it is a
|
||||
* compromised package, while vulnerability advisories never delay a release.
|
||||
*/
|
||||
function classifyVulnerabilityFindings({
|
||||
lockfile,
|
||||
|
|
@ -211,12 +212,7 @@ function classifyVulnerabilityFindings({
|
|||
...flattenAdvisories(allAdvisories, "all", lockfile),
|
||||
...flattenAdvisories(productionAdvisories, "production", lockfile),
|
||||
];
|
||||
const blockers = findings.filter(
|
||||
(finding) =>
|
||||
finding.malware ||
|
||||
finding.severity === "critical" ||
|
||||
(finding.graph === "production" && finding.severity === "high"),
|
||||
);
|
||||
const blockers = findings.filter((finding) => finding.malware);
|
||||
return {
|
||||
blockers: sortFindings(dedupeFindings(blockers)),
|
||||
findings: sortFindings(dedupeFindings(findings)),
|
||||
|
|
@ -366,16 +362,8 @@ export async function runDependencyVulnerabilityGate({
|
|||
generatedAt: new Date().toISOString(),
|
||||
coverage: { npm: "checked" as const, upstream: upstream.coverage },
|
||||
policy: {
|
||||
blocks: [
|
||||
"known malware advisories anywhere in the installed graph",
|
||||
"critical advisories anywhere in the installed graph",
|
||||
"high advisories in the production/runtime graph",
|
||||
],
|
||||
reports: [
|
||||
"moderate and lower advisories",
|
||||
"high advisories outside production/runtime graph",
|
||||
],
|
||||
vulnerabilityExceptions: false,
|
||||
blocks: ["known malware advisories anywhere in the installed graph"],
|
||||
reports: ["vulnerability advisories of every severity in every graph; they never block"],
|
||||
},
|
||||
graphs: sources.map((source) => source.graphs),
|
||||
blockers: sortFindings(sources.flatMap((source) => source.blockers)),
|
||||
|
|
@ -396,12 +384,13 @@ export function renderDependencyVulnerabilityGateMarkdownReport(
|
|||
"",
|
||||
"## Scope",
|
||||
"",
|
||||
"This gate checks resolved package versions from the target's pnpm lock and each release-tool npm lock in the executing trusted tooling checkout against npm bulk data and published advisories from verified public, registry-declared GitHub repositories. It includes transitive dependencies. Each listed lockfile is audited independently: known malware and critical advisories block anywhere; high advisories block in that source's production/runtime graph. Release-tool findings do not imply product runtime exposure.",
|
||||
"This gate checks resolved package versions from the target's pnpm lock and each release-tool npm lock in the executing trusted tooling checkout against npm bulk data and published advisories from verified public, registry-declared GitHub repositories. It includes transitive dependencies. Each listed lockfile is audited independently. Only known malware blocks; vulnerability advisories of every severity are recorded as non-blocking release evidence. Release-tool findings do not imply product runtime exposure.",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
`- Hard blockers: ${report.blockers.length}`,
|
||||
`- Known malware (blocking): ${report.blockers.length}`,
|
||||
`- Total findings: ${report.findings.length}`,
|
||||
`- Non-blocking advisories: ${report.findings.length - report.blockers.length}`,
|
||||
`- Upstream-only findings missing from npm results: ${report.findings.filter((finding) => finding.source === "github-repository").length}`,
|
||||
`- npm bulk: ${report.coverage.npm}`,
|
||||
`- Public upstream coverage: ${report.coverage.upstream.status}`,
|
||||
|
|
@ -425,7 +414,6 @@ export function renderDependencyVulnerabilityGateMarkdownReport(
|
|||
"",
|
||||
...report.policy.blocks.map((block) => `- Block: ${block}`),
|
||||
...report.policy.reports.map((item) => `- Report: ${item}`),
|
||||
`- Vulnerability exceptions: ${report.policy.vulnerabilityExceptions ? "allowed" : "not allowed"}`,
|
||||
"",
|
||||
];
|
||||
|
||||
|
|
@ -443,7 +431,7 @@ export function renderDependencyVulnerabilityGateMarkdownReport(
|
|||
}
|
||||
|
||||
for (const [title, findings] of [
|
||||
["Hard Blockers", report.blockers],
|
||||
["Known Malware (blocking)", report.blockers],
|
||||
["Findings", report.findings],
|
||||
] as const) {
|
||||
if (findings.length === 0) {
|
||||
|
|
@ -471,6 +459,17 @@ export function renderDependencyVulnerabilityGateMarkdownReport(
|
|||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function formatFinding(finding: Finding) {
|
||||
return (
|
||||
`${finding.severity.toUpperCase()} ${finding.packageName} (${finding.lockfile}; ${finding.graph}) ` +
|
||||
`id=${finding.id} source=${finding.source} title=${finding.title}`
|
||||
);
|
||||
}
|
||||
|
||||
function escapeAnnotation(message: string) {
|
||||
return message.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
|
||||
}
|
||||
|
||||
export async function main(
|
||||
argv = process.argv.slice(2),
|
||||
{ releaseToolRoot = path.resolve(import.meta.dirname, "..") } = {},
|
||||
|
|
@ -495,6 +494,18 @@ export async function main(
|
|||
`WARN incomplete upstream advisory coverage: ${upstream.issues.length} issues. Unchecked packages are not cleared; inspect the coverage section of the JSON/Markdown report.\n`,
|
||||
);
|
||||
}
|
||||
const advisories = report.findings.filter((finding) => !finding.malware);
|
||||
// Workflow commands turn each advisory into a visible, non-failing run annotation.
|
||||
for (const finding of advisories.slice(0, 25)) {
|
||||
process.stdout.write(
|
||||
`::warning title=Dependency advisory (non-blocking)::${escapeAnnotation(formatFinding(finding))}\n`,
|
||||
);
|
||||
}
|
||||
if (advisories.length > 25) {
|
||||
process.stdout.write(
|
||||
`::warning title=Dependency advisory (non-blocking)::${advisories.length - 25} more advisories; see the JSON/Markdown report.\n`,
|
||||
);
|
||||
}
|
||||
if (report.blockers.length === 0) {
|
||||
const packageVersions = report.graphs.reduce(
|
||||
(sum, graph) => sum + graph.all.packageVersions,
|
||||
|
|
@ -502,21 +513,20 @@ export async function main(
|
|||
);
|
||||
process.stdout.write(
|
||||
`PASS dependency vulnerability gate: checked ${packageVersions} resolved ` +
|
||||
`package versions across ${report.graphs.length} separate lockfile graphs; 0 hard blockers, ` +
|
||||
`${report.findings.length} total advisories; ${coverage}. ` +
|
||||
`package versions across ${report.graphs.length} separate lockfile graphs; 0 known malware, ` +
|
||||
`${advisories.length} non-blocking advisories recorded as release evidence; ${coverage}. ` +
|
||||
"This is not comprehensive vulnerability clearance.\n",
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
process.stderr.write(
|
||||
`FAIL dependency vulnerability gate: ${report.blockers.length} hard blockers in resolved ` +
|
||||
`FAIL dependency vulnerability gate: ${report.blockers.length} known malware findings in resolved ` +
|
||||
`dependency graph; ${report.findings.length} total advisories; ${coverage}.\n`,
|
||||
);
|
||||
for (const blocker of report.blockers.slice(0, 25)) {
|
||||
process.stderr.write(
|
||||
`- ${blocker.severity.toUpperCase()} ${blocker.packageName} (${blocker.lockfile}; ${blocker.graph}) ` +
|
||||
`id=${blocker.id} source=${blocker.source} title=${blocker.title}\n`,
|
||||
`::error title=Known malware dependency::${escapeAnnotation(formatFinding(blocker))}\n`,
|
||||
);
|
||||
}
|
||||
return 1;
|
||||
|
|
|
|||
|
|
@ -2,16 +2,11 @@
|
|||
|
||||
// Generates release dependency evidence artifacts and summaries.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import type { runDependencyVulnerabilityGate } from "./dependency-vulnerability-gate.mts";
|
||||
import { parseFlagArgs, stringFlag } from "./lib/arg-utils.mts";
|
||||
import {
|
||||
getReleaseDependencyRiskLockfiles,
|
||||
resolveReleaseDependencyRiskAcceptance,
|
||||
} from "./lib/release-dependency-risk-acceptance.mts";
|
||||
import { REPORT_CLI_PARSE_OPTIONS } from "./lib/report-cli-helpers.mts";
|
||||
import type { generateNpmPackageLocksReport } from "./npm-package-locks-report.mts";
|
||||
|
||||
|
|
@ -22,7 +17,7 @@ export const DEPENDENCY_EVIDENCE_REPORTS = [
|
|||
{
|
||||
name: "Dependency advisory vulnerability gate",
|
||||
command: "pnpm deps:vuln:gate",
|
||||
policy: "hard-blocking",
|
||||
policy: "malware-blocking",
|
||||
json: "dependency-vulnerability-gate.json",
|
||||
markdown: "dependency-vulnerability-gate.md",
|
||||
},
|
||||
|
|
@ -260,8 +255,9 @@ export async function collectDependencyEvidenceSummaryCounts(evidenceDir: string
|
|||
),
|
||||
]);
|
||||
return {
|
||||
vulnerabilityBlockers: vulnerability.blockers.length,
|
||||
malwareBlockers: vulnerability.blockers.length,
|
||||
vulnerabilityFindings: vulnerability.findings.length,
|
||||
advisories: vulnerability.findings.filter((finding) => !finding.malware),
|
||||
vulnerabilityCoverage: vulnerability.coverage,
|
||||
upstreamOnlyVulnerabilityFindings: vulnerability.findings.filter(
|
||||
(finding) => finding.source === "github-repository",
|
||||
|
|
@ -293,7 +289,8 @@ function renderVulnerabilityEvidenceSummary(counts: EvidenceSummaryCounts) {
|
|||
`- Upstream source: \`${upstream.source}\``,
|
||||
`- Upstream package versions mapped: ${upstream.mappedPackageVersions}/${upstream.packageVersions}`,
|
||||
`- Upstream repositories checked: ${upstream.checkedRepositories}/${upstream.repositories}`,
|
||||
`- Advisory vulnerability hard blockers: ${counts.vulnerabilityBlockers}`,
|
||||
`- Known malware findings (release-blocking): ${counts.malwareBlockers}`,
|
||||
`- Non-blocking advisory findings: ${counts.advisories.length}`,
|
||||
`- Advisory vulnerability total findings: ${counts.vulnerabilityFindings}`,
|
||||
`- Upstream-only vulnerability findings: ${counts.upstreamOnlyVulnerabilityFindings}`,
|
||||
`- Upstream coverage issues: ${upstream.issues.length}`,
|
||||
|
|
@ -309,6 +306,29 @@ function renderVulnerabilityEvidenceSummary(counts: EvidenceSummaryCounts) {
|
|||
];
|
||||
}
|
||||
|
||||
function renderNonBlockingAdvisories(heading: string, { advisories }: EvidenceSummaryCounts) {
|
||||
if (advisories.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
"",
|
||||
heading,
|
||||
"",
|
||||
"Advisories never block or delay a release. Record them in the release handoff and queue the dependency bump on `main` after publication. Only known malware blocks.",
|
||||
"",
|
||||
...advisories
|
||||
.slice(0, 25)
|
||||
.map(
|
||||
(finding) =>
|
||||
`- ${finding.severity.toUpperCase()} \`${finding.packageName}\` (${finding.lockfile}; ${finding.graph}) ` +
|
||||
`id=${finding.id} source=${finding.source}${finding.url ? ` ${finding.url}` : ""}`,
|
||||
),
|
||||
...(advisories.length > 25
|
||||
? [`- ${advisories.length - 25} more; see dependency-vulnerability-gate.md.`]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the dependency evidence Markdown summary.
|
||||
*/
|
||||
|
|
@ -345,6 +365,7 @@ export function renderDependencyEvidenceSummary({
|
|||
"- `dependency-ownership-surface-report.md`",
|
||||
"- `dependency-changes-report.md`",
|
||||
"- `npm-package-locks.md`",
|
||||
...renderNonBlockingAdvisories("## Non-blocking advisory findings", counts),
|
||||
].join("\n")}\n`;
|
||||
}
|
||||
|
||||
|
|
@ -370,78 +391,37 @@ export function renderDependencyEvidenceStepSummary({
|
|||
`- npm package-lock mirrors: ${counts.npmLockPackages}`,
|
||||
`- Lockless packages (bundleRuntimeDependencies=false): ${counts.npmLocklessPackages}`,
|
||||
`- Partial npm package-lock mirrors (workspace omissions): ${counts.npmPartialLockPackages}`,
|
||||
...renderNonBlockingAdvisories("#### Non-blocking advisory findings", counts),
|
||||
].join("\n")}\n`;
|
||||
}
|
||||
|
||||
async function runEvidenceReports(
|
||||
function runEvidenceReports(
|
||||
rootDir: string,
|
||||
outputDir: string,
|
||||
baseRef: string,
|
||||
execFileSyncImpl: ExecFileSyncLike,
|
||||
packageVersion: string,
|
||||
) {
|
||||
let riskAcceptance: ReturnType<typeof resolveReleaseDependencyRiskAcceptance> = null;
|
||||
const riskLockfiles = getReleaseDependencyRiskLockfiles(packageVersion);
|
||||
const toolingRoot = path.resolve(import.meta.dirname, "..");
|
||||
// Report implementations belong to this tooling checkout; --root selects only the source data.
|
||||
// Release branches can keep frozen product bytes while trusted release tooling is repaired.
|
||||
for (const report of DEPENDENCY_EVIDENCE_REPORTS) {
|
||||
try {
|
||||
runCommand(
|
||||
"pnpm",
|
||||
[
|
||||
report.command.slice("pnpm ".length),
|
||||
"--",
|
||||
"--root",
|
||||
rootDir,
|
||||
...(report.json === "dependency-changes-report.json" ? ["--base-ref", baseRef] : []),
|
||||
"--json",
|
||||
reportPath(outputDir, report.json),
|
||||
"--markdown",
|
||||
reportPath(outputDir, report.markdown),
|
||||
],
|
||||
toolingRoot,
|
||||
execFileSyncImpl,
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
report.json !== "dependency-vulnerability-gate.json" ||
|
||||
!(error instanceof Error) ||
|
||||
!("status" in error) ||
|
||||
error.status !== 1 ||
|
||||
!riskLockfiles
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
const vulnerability = await readJson<
|
||||
Awaited<ReturnType<typeof runDependencyVulnerabilityGate>>
|
||||
>(reportPath(outputDir, report.json));
|
||||
const lockfileSha256 = Object.fromEntries(
|
||||
await Promise.all(
|
||||
riskLockfiles.map(async (file) => [
|
||||
file,
|
||||
createHash("sha256")
|
||||
.update(
|
||||
await readFile(path.join(file === "pnpm-lock.yaml" ? rootDir : toolingRoot, file)),
|
||||
)
|
||||
.digest("hex"),
|
||||
]),
|
||||
),
|
||||
);
|
||||
riskAcceptance = resolveReleaseDependencyRiskAcceptance({
|
||||
packageVersion,
|
||||
lockfileSha256,
|
||||
blockers: vulnerability.blockers,
|
||||
});
|
||||
if (!riskAcceptance) {
|
||||
throw error;
|
||||
}
|
||||
console.warn(
|
||||
`WARNING: ${packageVersion} dependency risks accepted by maintainer; scan findings remain unresolved.`,
|
||||
);
|
||||
}
|
||||
runCommand(
|
||||
"pnpm",
|
||||
[
|
||||
report.command.slice("pnpm ".length),
|
||||
"--",
|
||||
"--root",
|
||||
rootDir,
|
||||
...(report.json === "dependency-changes-report.json" ? ["--base-ref", baseRef] : []),
|
||||
"--json",
|
||||
reportPath(outputDir, report.json),
|
||||
"--markdown",
|
||||
reportPath(outputDir, report.markdown),
|
||||
],
|
||||
toolingRoot,
|
||||
execFileSyncImpl,
|
||||
);
|
||||
}
|
||||
return riskAcceptance;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -474,7 +454,7 @@ export async function generateDependencyReleaseEvidence({
|
|||
const outputDir = path.resolve(requestedOutputDir);
|
||||
await rm(outputDir, { recursive: true, force: true });
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
// Publish the artifact location before a blocking report exits so CI can retain its evidence.
|
||||
// Publish the artifact location before a malware finding fails the gate so CI retains its evidence.
|
||||
if (githubOutput) {
|
||||
await appendFile(githubOutput, `dir=${outputDir}\n`, "utf8");
|
||||
}
|
||||
|
|
@ -489,28 +469,19 @@ export async function generateDependencyReleaseEvidence({
|
|||
const dependencyChangeBaseRef =
|
||||
baseRef ?? resolvePreviousReleaseTag({ rootDir, execFileSyncImpl });
|
||||
|
||||
const riskAcceptance = await runEvidenceReports(
|
||||
rootDir,
|
||||
outputDir,
|
||||
dependencyChangeBaseRef,
|
||||
execFileSyncImpl,
|
||||
packageVersion,
|
||||
);
|
||||
runEvidenceReports(rootDir, outputDir, dependencyChangeBaseRef, execFileSyncImpl);
|
||||
|
||||
const manifest = {
|
||||
...createDependencyEvidenceManifest({
|
||||
generatedAt: now.toISOString(),
|
||||
releaseTag,
|
||||
releaseRef,
|
||||
releaseSha,
|
||||
npmDistTag,
|
||||
packageVersion,
|
||||
workflowRunId,
|
||||
workflowRunAttempt,
|
||||
dependencyChangeBaseRef,
|
||||
}),
|
||||
...(riskAcceptance ? { riskAcceptance } : {}),
|
||||
};
|
||||
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`,
|
||||
|
|
@ -525,10 +496,7 @@ export async function generateDependencyReleaseEvidence({
|
|||
releaseSha,
|
||||
baseRef: dependencyChangeBaseRef,
|
||||
counts,
|
||||
}) +
|
||||
(riskAcceptance
|
||||
? `\n## Operator-accepted dependency risk\n\nThe maintainer accepted ${riskAcceptance.blockers.length} recorded advisory finding(s) for ${packageVersion} with unchanged dependencies. They remain unresolved, not a clean security scan. Exact graph hashes and findings are retained in dependency-evidence-manifest.json.\n`
|
||||
: ""),
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
|
|
@ -539,10 +507,7 @@ export async function generateDependencyReleaseEvidence({
|
|||
evidenceArtifactName: `openclaw-release-dependency-evidence-${releaseRef}`,
|
||||
baseRef: dependencyChangeBaseRef,
|
||||
counts,
|
||||
}) +
|
||||
(riskAcceptance
|
||||
? `\nWARNING: ${riskAcceptance.blockers.length} dependency advisory finding(s) remain unresolved and were explicitly accepted for ${packageVersion}. See the dependency evidence manifest.\n`
|
||||
: ""),
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,88 +0,0 @@
|
|||
import type { runDependencyVulnerabilityGate } from "../dependency-vulnerability-gate.mts";
|
||||
|
||||
type Blocker = Awaited<ReturnType<typeof runDependencyVulnerabilityGate>>["blockers"][number];
|
||||
|
||||
// Release-specific maintainer decisions retain unresolved findings. Exact graph
|
||||
// bytes and finding sets prevent acceptance from carrying to another release,
|
||||
// a changed graph, or an additional advisory.
|
||||
export const RELEASE_DEPENDENCY_RISK_LOCKFILES = {
|
||||
"pnpm-lock.yaml": "60ec3478d55f958efd41f314b32e0975a5becb4e0a90216c3cf9f9c6365e1443",
|
||||
".github/release/vercel-cli/package-lock.json":
|
||||
"b06b20bca67a863ad99cb479d51319b3483fb131b57c6855c26a35a92c2c89b5",
|
||||
".github/release/clawhub-cli/package-lock.json":
|
||||
"adc9d3613a752dfe00597a8826f45fab82e7651478d16ba1bf5354369157fee9",
|
||||
};
|
||||
|
||||
const acceptedFindings = new Set([
|
||||
"pnpm-lock.yaml|fast-uri|GHSA-58mr-gqgx-xq4g|4.1.3",
|
||||
"pnpm-lock.yaml|fast-uri|GHSA-qw65-cvwx-89v3|4.1.3",
|
||||
".github/release/vercel-cli/package-lock.json|fast-uri|GHSA-58mr-gqgx-xq4g|3.1.6",
|
||||
".github/release/vercel-cli/package-lock.json|fast-uri|GHSA-qw65-cvwx-89v3|3.1.6",
|
||||
"pnpm-lock.yaml|nodemailer|GHSA-2x7j-588g-ccc2|9.0.4,9.0.5",
|
||||
]);
|
||||
|
||||
const acceptedReleaseRisks = new Map([
|
||||
[
|
||||
"2026.9.1",
|
||||
{
|
||||
lockfileSha256: RELEASE_DEPENDENCY_RISK_LOCKFILES,
|
||||
acceptedFindings,
|
||||
acceptedOn: "2026-09-02",
|
||||
},
|
||||
],
|
||||
[
|
||||
"2026.9.5",
|
||||
{
|
||||
lockfileSha256: {
|
||||
"pnpm-lock.yaml": "ab6c244a27c09488e51e9d5879d84514fd0ac57b1db4e927d6a0464e598d543f",
|
||||
".github/release/vercel-cli/package-lock.json":
|
||||
"a094a59287570aa124a65eb208739a5f4b89b7e8ffe768a3ac38842dd2e2dc85",
|
||||
".github/release/clawhub-cli/package-lock.json":
|
||||
"30142b07c1167d030926f9dd3320a8b158aa59cbca5a56e05d949a50e6e2b3c6",
|
||||
},
|
||||
acceptedFindings: new Set(["pnpm-lock.yaml|axios|GHSA-3pq3-5fj3-cg6v|1.20.0"]),
|
||||
acceptedOn: "2026-09-17",
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
export function getReleaseDependencyRiskLockfiles(packageVersion: string): string[] | null {
|
||||
const acceptance = acceptedReleaseRisks.get(packageVersion);
|
||||
return acceptance ? Object.keys(acceptance.lockfileSha256) : null;
|
||||
}
|
||||
|
||||
export function resolveReleaseDependencyRiskAcceptance(params: {
|
||||
packageVersion: string;
|
||||
lockfileSha256: Record<string, string>;
|
||||
blockers: Blocker[];
|
||||
}) {
|
||||
const { packageVersion, lockfileSha256, blockers } = params;
|
||||
const acceptance = acceptedReleaseRisks.get(packageVersion);
|
||||
const keys = blockers.map(
|
||||
(finding) =>
|
||||
`${finding.lockfile}|${finding.packageName}|${finding.id}|${(finding.matchedVersions ?? []).toSorted().join(",")}`,
|
||||
);
|
||||
if (
|
||||
!acceptance ||
|
||||
Object.entries(acceptance.lockfileSha256).some(
|
||||
([file, digest]) => lockfileSha256[file] !== digest,
|
||||
) ||
|
||||
keys.length !== acceptance.acceptedFindings.size ||
|
||||
new Set(keys).size !== acceptance.acceptedFindings.size ||
|
||||
keys.some((key) => !acceptance.acceptedFindings.has(key)) ||
|
||||
blockers.some(
|
||||
(finding) => finding.severity !== "high" || finding.malware || finding.graph !== "production",
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: "operator-accepted-dependency-risk" as const,
|
||||
packageVersion,
|
||||
acceptedOn: acceptance.acceptedOn,
|
||||
decision:
|
||||
"Release with unchanged dependencies; retain known advisory findings as accepted risk.",
|
||||
lockfileSha256,
|
||||
blockers,
|
||||
};
|
||||
}
|
||||
|
|
@ -246,30 +246,62 @@ describe("security-fast workflow", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it.each([0, 1, 130])("propagates audit exit %s in ordinary and scheduled CI", (auditExit) => {
|
||||
const repo = tempDirs.make("openclaw-audit-ci-");
|
||||
mkdirSync(join(repo, "scripts", "pre-commit"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(repo, "scripts", "pre-commit", "pnpm-audit-prod.mjs"),
|
||||
`process.exit(${auditExit});\n`,
|
||||
);
|
||||
const result = runStep(securityStep("Audit production dependencies"), repo, {});
|
||||
expect(result.status).toBe(auditExit);
|
||||
expect(result.stdout).toBe("");
|
||||
const scheduled = parse(readFileSync(".github/workflows/dependency-audit.yml", "utf8")) as {
|
||||
jobs: { audit: { steps: WorkflowStep[] } };
|
||||
};
|
||||
const strictStep = scheduled.jobs.audit.steps.find(
|
||||
(step) => step.name === "Audit production dependencies",
|
||||
);
|
||||
if (!strictStep) {
|
||||
throw new Error("scheduled production audit step is missing");
|
||||
}
|
||||
const summary = join(repo, "summary.md");
|
||||
const strict = runStep(strictStep, repo, { GITHUB_STEP_SUMMARY: summary });
|
||||
expect(strict.status).toBe(auditExit);
|
||||
expect(readFileSync(summary, "utf8")).toContain("Triage owner: @steipete");
|
||||
});
|
||||
it.each([0, 1, 130])(
|
||||
"propagates audit exit %s in ordinary and scheduled CI but not release CI",
|
||||
(auditExit) => {
|
||||
const repo = tempDirs.make("openclaw-audit-ci-");
|
||||
mkdirSync(join(repo, "scripts", "pre-commit"), { recursive: true });
|
||||
mkdirSync(join(repo, ".ci-harness", "scripts"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(repo, "scripts", "pre-commit", "pnpm-audit-prod.mjs"),
|
||||
`process.exit(${auditExit});\n`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(repo, ".ci-harness", "scripts", "ci-production-audit.mjs"),
|
||||
readFileSync("scripts/ci-production-audit.mjs"),
|
||||
);
|
||||
expect(securityStep("Checkout trusted CI harness").with?.["sparse-checkout"]).toContain(
|
||||
"scripts/ci-production-audit.mjs",
|
||||
);
|
||||
const audit = securityStep("Audit production dependencies");
|
||||
const runAudit = (eventName: string, dispatchId: string) => {
|
||||
const eventPath = join(repo, "event.json");
|
||||
writeFileSync(eventPath, JSON.stringify({ inputs: { dispatch_id: dispatchId } }));
|
||||
return runStep(audit, repo, { GITHUB_EVENT_NAME: eventName, GITHUB_EVENT_PATH: eventPath });
|
||||
};
|
||||
for (const [eventName, dispatchId] of [
|
||||
["pull_request", "full-release-validation-1-1-ci"],
|
||||
["workflow_dispatch", ""],
|
||||
["workflow_dispatch", "manual-ci"],
|
||||
]) {
|
||||
const result = runAudit(eventName!, dispatchId!);
|
||||
expect(result.status).toBe(auditExit);
|
||||
expect(result.stdout).toBe("");
|
||||
}
|
||||
for (const dispatchId of ["full-release-validation-1-1-ci", "release-native-android-1-1-a"]) {
|
||||
const release = runAudit("workflow_dispatch", dispatchId);
|
||||
expect(release.status).toBe(0);
|
||||
expect(release.stdout).toBe(
|
||||
auditExit === 0
|
||||
? ""
|
||||
: `::warning title=Dependency advisories do not block releases::Production dependency audit exited ${auditExit}. Release CI records this without failing; queue the dependency bump on main after publication.\n`,
|
||||
);
|
||||
}
|
||||
const scheduled = parse(readFileSync(".github/workflows/dependency-audit.yml", "utf8")) as {
|
||||
jobs: { audit: { steps: WorkflowStep[] } };
|
||||
};
|
||||
const strictStep = scheduled.jobs.audit.steps.find(
|
||||
(step) => step.name === "Audit production dependencies",
|
||||
);
|
||||
if (!strictStep) {
|
||||
throw new Error("scheduled production audit step is missing");
|
||||
}
|
||||
const summary = join(repo, "summary.md");
|
||||
const strict = runStep(strictStep, repo, { GITHUB_STEP_SUMMARY: summary });
|
||||
expect(strict.status).toBe(auditExit);
|
||||
expect(readFileSync(summary, "utf8")).toContain("Triage owner: @steipete");
|
||||
},
|
||||
);
|
||||
|
||||
it("generates the exact local-only scanner contract from trusted policy", () => {
|
||||
const job = securityJob();
|
||||
|
|
|
|||
|
|
@ -239,11 +239,15 @@ describe("dependency-vulnerability-gate", () => {
|
|||
},
|
||||
{ "runtime-high": ["1.0.0"] },
|
||||
]);
|
||||
expect(report.blockers.map(({ packageName }) => packageName)).toEqual([
|
||||
expect(report.findings.map(({ packageName }) => packageName)).toEqual([
|
||||
"transitive-critical",
|
||||
"dev-high",
|
||||
"runtime-high",
|
||||
...(withToolchain ? ["toolchain"] : []),
|
||||
]);
|
||||
expect(report.blockers.map(({ packageName }) => packageName)).toEqual(
|
||||
withToolchain ? ["toolchain"] : [],
|
||||
);
|
||||
expect(report.findings.every(({ lockfile }) => lockfile === "pnpm-lock.yaml")).toBe(true);
|
||||
expect(report.graphs).toContainEqual({
|
||||
lockfile: "pnpm-lock.yaml",
|
||||
|
|
@ -261,13 +265,12 @@ describe("dependency-vulnerability-gate", () => {
|
|||
location: "node_modules/@scope/parent/node_modules/@scope/alias",
|
||||
metadata: { name: "runtime-high" },
|
||||
},
|
||||
{ name: "dev-only high", metadata: { dev: true }, graph: "all", blocks: false },
|
||||
{ name: "dev-only high", metadata: { dev: true }, graph: "all" },
|
||||
{ name: "optional runtime high", metadata: { optional: true } },
|
||||
{
|
||||
name: "dev and optional high",
|
||||
metadata: { dev: true, optional: true },
|
||||
graph: "all",
|
||||
blocks: false,
|
||||
},
|
||||
{ name: "shared devOptional high", metadata: { devOptional: true } },
|
||||
{ name: "dev-only critical", metadata: { dev: true }, severity: "critical", graph: "all" },
|
||||
|
|
@ -277,8 +280,9 @@ describe("dependency-vulnerability-gate", () => {
|
|||
severity: "low",
|
||||
title: "Malware in dependency",
|
||||
graph: "all",
|
||||
blocks: true,
|
||||
},
|
||||
{ name: "runtime moderate", severity: "moderate", blocks: false },
|
||||
{ name: "runtime moderate", severity: "moderate" },
|
||||
]
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
|
|
@ -286,7 +290,7 @@ describe("dependency-vulnerability-gate", () => {
|
|||
metadata: entry.metadata ?? {},
|
||||
severity: entry.severity ?? "high",
|
||||
graph: entry.graph ?? "production",
|
||||
blocks: entry.blocks ?? true,
|
||||
blocks: entry.blocks ?? false,
|
||||
title: entry.title,
|
||||
}))
|
||||
.flatMap((entry) => {
|
||||
|
|
@ -326,7 +330,7 @@ describe("dependency-vulnerability-gate", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it("blocks a published upstream advisory missing from npm without tainting the patched product", async () => {
|
||||
it("records a published upstream advisory missing from npm without tainting the patched product", async () => {
|
||||
await withLockfiles(async (rootDir) => {
|
||||
await writeNpmLock(rootDir, releaseLocks[1], {
|
||||
"node_modules/runtime-high": { version: "0.9.0" },
|
||||
|
|
@ -336,7 +340,8 @@ describe("dependency-vulnerability-gate", () => {
|
|||
fetchImpl: withPublicUpstream(async () => Response.json({}), [publishedAdvisory()]),
|
||||
});
|
||||
|
||||
expect(report.blockers).toMatchObject([
|
||||
expect(report.blockers).toEqual([]);
|
||||
expect(report.findings).toMatchObject([
|
||||
{
|
||||
lockfile: releaseLocks[1],
|
||||
packageName: "runtime-high",
|
||||
|
|
@ -378,8 +383,9 @@ describe("dependency-vulnerability-gate", () => {
|
|||
[publishedAdvisory(">= 0.8.0, <= 1.0.0")],
|
||||
),
|
||||
});
|
||||
expect(report.blockers).toHaveLength(1);
|
||||
expect(report.blockers[0]).toMatchObject({
|
||||
const production = report.findings.filter(({ graph }) => graph === "production");
|
||||
expect(production).toHaveLength(1);
|
||||
expect(production[0]).toMatchObject({
|
||||
lockfile: "pnpm-lock.yaml",
|
||||
graph: "production",
|
||||
source: runtimeReported ? "npm-bulk" : "github-repository",
|
||||
|
|
@ -457,8 +463,8 @@ describe("dependency-vulnerability-gate", () => {
|
|||
expect(
|
||||
payloads.every((payload) => !("root-only" in payload) && !("link-only" in payload)),
|
||||
).toBe(true);
|
||||
expect(report.blockers.map(({ lockfile }) => lockfile)).toEqual(releaseLocks);
|
||||
expect(report.findings).toHaveLength(2);
|
||||
expect(report.findings.map(({ lockfile }) => lockfile)).toEqual(releaseLocks);
|
||||
expect(report.blockers).toEqual([]);
|
||||
for (const lockfile of releaseLocks) {
|
||||
expect(report.graphs).toContainEqual({
|
||||
lockfile,
|
||||
|
|
@ -604,68 +610,81 @@ describe("dependency-vulnerability-gate", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"writes attributed CLI artifacts and the gate exit code (tool blocker %s)",
|
||||
async (blocked) => {
|
||||
await withLockfiles(async (rootDir) => {
|
||||
const lockfile = ".github/release/vercel-cli/package-lock.json";
|
||||
await writeNpmLock(rootDir, lockfile, {
|
||||
"node_modules/runtime-high": { version: "0.9.0" },
|
||||
});
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockImplementation(
|
||||
withPublicUpstream(
|
||||
async (_url, init) =>
|
||||
new Response(
|
||||
JSON.stringify(
|
||||
blocked && requestPayload(init)["runtime-high"]?.includes("0.9.0")
|
||||
? { "runtime-high": [advisory("critical")] }
|
||||
: {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
|
||||
const stdout = vi.spyOn(process.stdout, "write").mockReturnValue(true);
|
||||
try {
|
||||
const jsonPath = path.join(rootDir, "report.json");
|
||||
const markdownPath = path.join(rootDir, "report.md");
|
||||
expect(
|
||||
await main(["--root", rootDir, "--json", jsonPath, "--markdown", markdownPath], {
|
||||
releaseToolRoot: rootDir,
|
||||
}),
|
||||
).toBe(blocked ? 1 : 0);
|
||||
const report = JSON.parse(await readFile(jsonPath, "utf8"));
|
||||
expect(report.blockers).toHaveLength(blocked ? 1 : 0);
|
||||
const markdown = await readFile(markdownPath, "utf8");
|
||||
expect(markdown).toContain("### pnpm-lock.yaml");
|
||||
expect(markdown).toContain("### .github/release/clawhub-cli/package-lock.json");
|
||||
if (blocked) {
|
||||
expect(report.blockers).toMatchObject([{ lockfile }]);
|
||||
expect(markdown).toContain(
|
||||
"runtime-high (.github/release/vercel-cli/package-lock.json; production)",
|
||||
);
|
||||
expect(stderr.mock.calls.flat().join("")).toContain(lockfile);
|
||||
expect(stdout).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(markdown).toContain("No matching advisories returned by the checked sources.");
|
||||
expect(markdown).toContain("not comprehensive vulnerability clearance");
|
||||
expect(report.coverage).toMatchObject({
|
||||
npm: "checked",
|
||||
upstream: { status: "checked" },
|
||||
});
|
||||
expect(stdout.mock.calls.flat().join("")).toContain(
|
||||
"checked 5 resolved package versions across 3 separate lockfile graphs; 0 hard blockers",
|
||||
);
|
||||
expect(stderr).not.toHaveBeenCalled();
|
||||
}
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
stderr.mockRestore();
|
||||
stdout.mockRestore();
|
||||
}
|
||||
it.each([
|
||||
{ name: "clean", finding: null },
|
||||
{ name: "critical production advisory", finding: advisory("critical") },
|
||||
{ name: "known malware", finding: advisory("low", "Malware in release tool") },
|
||||
])("writes attributed CLI artifacts and the gate exit code ($name)", async ({ finding }) => {
|
||||
await withLockfiles(async (rootDir) => {
|
||||
const lockfile = ".github/release/vercel-cli/package-lock.json";
|
||||
await writeNpmLock(rootDir, lockfile, {
|
||||
"node_modules/runtime-high": { version: "0.9.0" },
|
||||
});
|
||||
},
|
||||
);
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockImplementation(
|
||||
withPublicUpstream(
|
||||
async (_url, init) =>
|
||||
new Response(
|
||||
JSON.stringify(
|
||||
finding && requestPayload(init)["runtime-high"]?.includes("0.9.0")
|
||||
? { "runtime-high": [finding] }
|
||||
: {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
|
||||
const stdout = vi.spyOn(process.stdout, "write").mockReturnValue(true);
|
||||
const malware = finding?.title.startsWith("Malware") ?? false;
|
||||
try {
|
||||
const jsonPath = path.join(rootDir, "report.json");
|
||||
const markdownPath = path.join(rootDir, "report.md");
|
||||
expect(
|
||||
await main(["--root", rootDir, "--json", jsonPath, "--markdown", markdownPath], {
|
||||
releaseToolRoot: rootDir,
|
||||
}),
|
||||
).toBe(malware ? 1 : 0);
|
||||
const report = JSON.parse(await readFile(jsonPath, "utf8"));
|
||||
expect(report.findings).toHaveLength(finding ? 1 : 0);
|
||||
expect(report.blockers).toEqual(malware ? report.findings : []);
|
||||
const markdown = await readFile(markdownPath, "utf8");
|
||||
expect(markdown).toContain("### pnpm-lock.yaml");
|
||||
expect(markdown).toContain("### .github/release/clawhub-cli/package-lock.json");
|
||||
const out = stdout.mock.calls.flat().join("");
|
||||
const err = stderr.mock.calls.flat().join("");
|
||||
const line = `runtime-high (${lockfile}; production) id=GHSA-fixture`;
|
||||
if (!finding) {
|
||||
expect(markdown).toContain("No matching advisories returned by the checked sources.");
|
||||
expect(markdown).toContain("not comprehensive vulnerability clearance");
|
||||
expect(report.coverage).toMatchObject({
|
||||
npm: "checked",
|
||||
upstream: { status: "checked" },
|
||||
});
|
||||
expect(out).toContain(
|
||||
"checked 5 resolved package versions across 3 separate lockfile graphs; 0 known malware, 0 non-blocking advisories",
|
||||
);
|
||||
expect(stderr).not.toHaveBeenCalled();
|
||||
} else if (malware) {
|
||||
expect(markdown).toContain("## Known Malware (blocking)");
|
||||
expect(err).toContain(`::error title=Known malware dependency::LOW ${line}`);
|
||||
expect(out).not.toContain("::warning");
|
||||
} else {
|
||||
expect(markdown).toContain(`- CRITICAL ${line} range=<1.0.0`);
|
||||
expect(markdown).not.toContain("## Known Malware (blocking)");
|
||||
expect(out).toContain(
|
||||
`::warning title=Dependency advisory (non-blocking)::CRITICAL ${line}`,
|
||||
);
|
||||
expect(out).toContain(
|
||||
"0 known malware, 1 non-blocking advisories recorded as release evidence",
|
||||
);
|
||||
expect(stderr).not.toHaveBeenCalled();
|
||||
}
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
stderr.mockRestore();
|
||||
stdout.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,10 +16,6 @@ import {
|
|||
resolvePreviousReleaseTag,
|
||||
resolveReleaseTag,
|
||||
} from "../../scripts/generate-dependency-release-evidence.mts";
|
||||
import {
|
||||
RELEASE_DEPENDENCY_RISK_LOCKFILES,
|
||||
resolveReleaseDependencyRiskAcceptance,
|
||||
} from "../../scripts/lib/release-dependency-risk-acceptance.mts";
|
||||
|
||||
async function writeJson(dir: string, fileName: string, value: unknown) {
|
||||
await writeFile(path.join(dir, fileName), `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
|
|
@ -43,100 +39,10 @@ function expectNoNodeStack(stderr: string) {
|
|||
}
|
||||
|
||||
describe("generate-dependency-release-evidence", () => {
|
||||
function acceptedRiskInput(): Parameters<typeof resolveReleaseDependencyRiskAcceptance>[0] {
|
||||
return {
|
||||
packageVersion: "2026.9.1",
|
||||
lockfileSha256: { ...RELEASE_DEPENDENCY_RISK_LOCKFILES },
|
||||
blockers: [
|
||||
...["GHSA-58mr-gqgx-xq4g", "GHSA-qw65-cvwx-89v3"].flatMap((id) =>
|
||||
[
|
||||
{ lockfile: "pnpm-lock.yaml", matchedVersions: ["4.1.3"] },
|
||||
{
|
||||
lockfile: ".github/release/vercel-cli/package-lock.json",
|
||||
matchedVersions: ["3.1.6"],
|
||||
},
|
||||
].map(({ lockfile, matchedVersions }) => ({
|
||||
lockfile,
|
||||
matchedVersions,
|
||||
packageName: "fast-uri",
|
||||
id,
|
||||
severity: "high" as const,
|
||||
graph: "production" as const,
|
||||
malware: false,
|
||||
source: "github-repository" as const,
|
||||
title: "URI authority validation",
|
||||
url: `https://github.com/fastify/fast-uri/security/advisories/${id}`,
|
||||
vulnerableVersions: "<4.1.4",
|
||||
})),
|
||||
),
|
||||
{
|
||||
lockfile: "pnpm-lock.yaml",
|
||||
packageName: "nodemailer",
|
||||
matchedVersions: ["9.0.4", "9.0.5"],
|
||||
id: "GHSA-2x7j-588g-ccc2",
|
||||
severity: "high",
|
||||
graph: "production",
|
||||
malware: false,
|
||||
source: "github-repository",
|
||||
title: "Address list denial of service",
|
||||
url: "https://github.com/nodemailer/nodemailer/security/advisories/GHSA-2x7j-588g-ccc2",
|
||||
vulnerableVersions: "<9.1.0",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
it("retains every accepted advisory and exact graph binding without declaring the scan clean", () => {
|
||||
const input = acceptedRiskInput();
|
||||
const original = structuredClone(input);
|
||||
const acceptance = resolveReleaseDependencyRiskAcceptance(input);
|
||||
expect(acceptance).toMatchObject({
|
||||
kind: "operator-accepted-dependency-risk",
|
||||
packageVersion: "2026.9.1",
|
||||
blockers: original.blockers,
|
||||
lockfileSha256: original.lockfileSha256,
|
||||
});
|
||||
expect(input).toEqual(original);
|
||||
});
|
||||
|
||||
it("never carries acceptance to another release, graph, or unaccepted finding", () => {
|
||||
const mutations = [
|
||||
(input: ReturnType<typeof acceptedRiskInput>) => {
|
||||
input.packageVersion = "2026.9.2";
|
||||
},
|
||||
(input: ReturnType<typeof acceptedRiskInput>) => {
|
||||
input.lockfileSha256["pnpm-lock.yaml"] = "changed";
|
||||
},
|
||||
(input: ReturnType<typeof acceptedRiskInput>) => {
|
||||
input.blockers[0]!.severity = "critical";
|
||||
},
|
||||
(input: ReturnType<typeof acceptedRiskInput>) => {
|
||||
input.blockers[0]!.malware = true;
|
||||
},
|
||||
(input: ReturnType<typeof acceptedRiskInput>) => {
|
||||
input.blockers[0]!.id = "GHSA-unaccepted";
|
||||
},
|
||||
(input: ReturnType<typeof acceptedRiskInput>) => {
|
||||
input.blockers[0]!.matchedVersions = ["4.1.2"];
|
||||
},
|
||||
(input: ReturnType<typeof acceptedRiskInput>) => {
|
||||
input.blockers.push({ ...input.blockers[0]! });
|
||||
},
|
||||
(input: ReturnType<typeof acceptedRiskInput>) => {
|
||||
input.blockers[0] = { ...input.blockers[1]! };
|
||||
},
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
const input = acceptedRiskInput();
|
||||
mutate(input);
|
||||
expect(resolveReleaseDependencyRiskAcceptance(input)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
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:vuln:gate", policy: "malware-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" },
|
||||
|
|
@ -174,7 +80,7 @@ describe("generate-dependency-release-evidence", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("runs the npm lock report from tooling and retains it in the manifest and summaries", async () => {
|
||||
it("records production advisories as non-blocking evidence alongside the npm lock report", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-release-lock-evidence-test-"));
|
||||
try {
|
||||
const source = path.join(dir, "source");
|
||||
|
|
@ -185,7 +91,18 @@ describe("generate-dependency-release-evidence", () => {
|
|||
const reportData: Record<string, unknown> = {
|
||||
"dependency-vulnerability-gate.json": {
|
||||
blockers: [],
|
||||
findings: [],
|
||||
findings: [
|
||||
{
|
||||
id: "GHSA-rfgv-xxqx-mfg5",
|
||||
packageName: "undici",
|
||||
severity: "high",
|
||||
graph: "production",
|
||||
lockfile: ".github/release/vercel-cli/package-lock.json",
|
||||
source: "github-repository",
|
||||
malware: false,
|
||||
url: "https://github.com/advisories/GHSA-rfgv-xxqx-mfg5",
|
||||
},
|
||||
],
|
||||
coverage: {
|
||||
npm: "checked",
|
||||
upstream: {
|
||||
|
|
@ -272,6 +189,11 @@ describe("generate-dependency-release-evidence", () => {
|
|||
expect(rendered).toContain("- npm package-lock mirrors: 2");
|
||||
expect(rendered).toContain("- Lockless packages (bundleRuntimeDependencies=false): 1");
|
||||
expect(rendered).toContain("- Partial npm package-lock mirrors (workspace omissions): 1");
|
||||
expect(rendered).toContain("- Known malware findings (release-blocking): 0");
|
||||
expect(rendered).toMatch(/#+ Non-blocking advisory findings\n\nAdvisories never block/u);
|
||||
expect(rendered).toContain(
|
||||
"- HIGH `undici` (.github/release/vercel-cli/package-lock.json; production) id=GHSA-rfgv-xxqx-mfg5 source=github-repository https://github.com/advisories/GHSA-rfgv-xxqx-mfg5",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await rm(dir, { force: true, recursive: true });
|
||||
|
|
@ -352,7 +274,7 @@ describe("generate-dependency-release-evidence", () => {
|
|||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"uses trusted report tooling for a separate target and retains blocking evidence",
|
||||
"uses trusted report tooling for a separate target and retains known-malware evidence",
|
||||
async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-release-dependency-failure-test-"));
|
||||
try {
|
||||
|
|
@ -378,8 +300,8 @@ describe("generate-dependency-release-evidence", () => {
|
|||
"writeFileSync(process.env.RELEASE_TEST_MARKER, process.cwd());",
|
||||
'if (args[0] !== "deps:vuln:gate") throw new Error("Wrong report command");',
|
||||
'if (args[args.indexOf("--root") + 1] !== process.env.RELEASE_TEST_SOURCE_ROOT) throw new Error("Wrong report target");',
|
||||
'writeFileSync(args[args.indexOf("--json") + 1], JSON.stringify({ blockers: [{ id: "GHSA-fixture" }] }));',
|
||||
'writeFileSync(args[args.indexOf("--markdown") + 1], "# Blocking advisory evidence\\n");',
|
||||
'writeFileSync(args[args.indexOf("--json") + 1], JSON.stringify({ blockers: [{ id: "GHSA-fixture", malware: true }] }));',
|
||||
'writeFileSync(args[args.indexOf("--markdown") + 1], "# Known malware evidence\\n");',
|
||||
"process.exitCode = 1;",
|
||||
].join("\n"),
|
||||
{ mode: 0o755 },
|
||||
|
|
@ -416,10 +338,10 @@ describe("generate-dependency-release-evidence", () => {
|
|||
await expect(readFile(githubOutput, "utf8")).resolves.toBe(`dir=${outputDir}\n`);
|
||||
await expect(
|
||||
readFile(path.join(outputDir, "dependency-vulnerability-gate.json"), "utf8"),
|
||||
).resolves.toBe(JSON.stringify({ blockers: [{ id: "GHSA-fixture" }] }));
|
||||
).resolves.toBe(JSON.stringify({ blockers: [{ id: "GHSA-fixture", malware: true }] }));
|
||||
await expect(
|
||||
readFile(path.join(outputDir, "dependency-vulnerability-gate.md"), "utf8"),
|
||||
).resolves.toBe("# Blocking advisory evidence\n");
|
||||
).resolves.toBe("# Known malware evidence\n");
|
||||
} finally {
|
||||
await rm(dir, { force: true, recursive: true });
|
||||
}
|
||||
|
|
@ -514,18 +436,24 @@ describe("generate-dependency-release-evidence", () => {
|
|||
},
|
||||
};
|
||||
const findings = [
|
||||
{ id: "GHSA-blocker", lockfile: "pnpm-lock.yaml", source: "npm-bulk" },
|
||||
{ id: "GHSA-malware", lockfile: "pnpm-lock.yaml", source: "npm-bulk", malware: true },
|
||||
{
|
||||
id: "GHSA-blocker",
|
||||
id: "GHSA-malware",
|
||||
lockfile: ".github/release/vercel-cli/package-lock.json",
|
||||
source: "github-repository",
|
||||
matchedVersions: ["1.0.0", "1.1.0"],
|
||||
malware: true,
|
||||
},
|
||||
{
|
||||
id: "GHSA-report",
|
||||
packageName: "report-pkg",
|
||||
severity: "high",
|
||||
graph: "production",
|
||||
lockfile: ".github/release/clawhub-cli/package-lock.json",
|
||||
source: "github-repository",
|
||||
matchedVersions: ["2.0.0"],
|
||||
malware: false,
|
||||
url: null,
|
||||
},
|
||||
];
|
||||
await writeJson(dir, "dependency-vulnerability-gate.json", {
|
||||
|
|
@ -566,8 +494,9 @@ describe("generate-dependency-release-evidence", () => {
|
|||
});
|
||||
const counts = await collectDependencyEvidenceSummaryCounts(dir);
|
||||
expect(counts).toEqual({
|
||||
vulnerabilityBlockers: 2,
|
||||
malwareBlockers: 2,
|
||||
vulnerabilityFindings: 3,
|
||||
advisories: [findings[2]],
|
||||
vulnerabilityCoverage: coverage,
|
||||
upstreamOnlyVulnerabilityFindings: 2,
|
||||
transitiveRiskSignals: 17,
|
||||
|
|
@ -618,7 +547,11 @@ describe("generate-dependency-release-evidence", () => {
|
|||
expect(rendered).toContain(
|
||||
`- Upstream repositories checked: ${upstream.checkedRepositories}/2`,
|
||||
);
|
||||
expect(rendered).toContain("- Advisory vulnerability hard blockers: 2");
|
||||
expect(rendered).toContain("- Known malware findings (release-blocking): 2");
|
||||
expect(rendered).toContain("- Non-blocking advisory findings: 1");
|
||||
expect(rendered).toContain(
|
||||
"- HIGH `report-pkg` (.github/release/clawhub-cli/package-lock.json; production) id=GHSA-report source=github-repository\n",
|
||||
);
|
||||
expect(rendered).toContain("- Advisory vulnerability total findings: 3");
|
||||
expect(rendered).toContain("- Upstream-only vulnerability findings: 2");
|
||||
expect(rendered).toContain(`- Upstream coverage issues: ${upstream.issues.length}`);
|
||||
|
|
|
|||
|
|
@ -1,84 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { resolveReleaseDependencyRiskAcceptance } from "../../scripts/lib/release-dependency-risk-acceptance.mts";
|
||||
|
||||
describe("2026.9.5 operator dependency risk acceptance", () => {
|
||||
function acceptedAxiosRiskInput(): Parameters<typeof resolveReleaseDependencyRiskAcceptance>[0] {
|
||||
return {
|
||||
packageVersion: "2026.9.5",
|
||||
lockfileSha256: {
|
||||
"pnpm-lock.yaml": "ab6c244a27c09488e51e9d5879d84514fd0ac57b1db4e927d6a0464e598d543f",
|
||||
".github/release/vercel-cli/package-lock.json":
|
||||
"a094a59287570aa124a65eb208739a5f4b89b7e8ffe768a3ac38842dd2e2dc85",
|
||||
".github/release/clawhub-cli/package-lock.json":
|
||||
"30142b07c1167d030926f9dd3320a8b158aa59cbca5a56e05d949a50e6e2b3c6",
|
||||
},
|
||||
blockers: [
|
||||
{
|
||||
lockfile: "pnpm-lock.yaml",
|
||||
packageName: "axios",
|
||||
matchedVersions: ["1.20.0"],
|
||||
id: "GHSA-3pq3-5fj3-cg6v",
|
||||
severity: "high",
|
||||
graph: "production",
|
||||
malware: false,
|
||||
source: "github-repository",
|
||||
title: "HTTP/2 DNS and proxy policy",
|
||||
url: "https://github.com/axios/axios/security/advisories/GHSA-3pq3-5fj3-cg6v",
|
||||
vulnerableVersions: ">=1.13.0",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
it("accepts only the approved 2026.9.5 Axios finding and unchanged graph", () => {
|
||||
const input = acceptedAxiosRiskInput();
|
||||
const original = structuredClone(input);
|
||||
expect(resolveReleaseDependencyRiskAcceptance(input)).toMatchObject({
|
||||
kind: "operator-accepted-dependency-risk",
|
||||
packageVersion: "2026.9.5",
|
||||
acceptedOn: "2026-09-17",
|
||||
lockfileSha256: original.lockfileSha256,
|
||||
blockers: original.blockers,
|
||||
});
|
||||
expect(input).toEqual(original);
|
||||
});
|
||||
|
||||
it("does not extend the 2026.9.5 Axios exception to other risks or graph bytes", () => {
|
||||
const input = acceptedAxiosRiskInput();
|
||||
const [finding] = input.blockers;
|
||||
if (!finding) {
|
||||
throw new Error("Expected the single accepted Axios finding");
|
||||
}
|
||||
for (const packageVersion of ["2026.9.1", "2026.9.5-beta.1", "2026.9.6"]) {
|
||||
expect(resolveReleaseDependencyRiskAcceptance({ ...input, packageVersion })).toBeNull();
|
||||
}
|
||||
for (const file of Object.keys(input.lockfileSha256)) {
|
||||
expect(
|
||||
resolveReleaseDependencyRiskAcceptance({
|
||||
...input,
|
||||
lockfileSha256: { ...input.lockfileSha256, [file]: "changed" },
|
||||
}),
|
||||
).toBeNull();
|
||||
}
|
||||
const rejectedFindings: typeof input.blockers = [
|
||||
{ ...finding, id: "GHSA-unaccepted" },
|
||||
{ ...finding, packageName: "another-package" },
|
||||
{ ...finding, matchedVersions: ["1.20.1"] },
|
||||
{ ...finding, severity: "critical" },
|
||||
{ ...finding, malware: true },
|
||||
];
|
||||
for (const rejected of rejectedFindings) {
|
||||
expect(resolveReleaseDependencyRiskAcceptance({ ...input, blockers: [rejected] })).toBeNull();
|
||||
}
|
||||
expect(resolveReleaseDependencyRiskAcceptance({ ...input, blockers: [] })).toBeNull();
|
||||
expect(
|
||||
resolveReleaseDependencyRiskAcceptance({ ...input, blockers: [finding, finding] }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveReleaseDependencyRiskAcceptance({
|
||||
...input,
|
||||
blockers: [finding, { ...finding, id: "GHSA-additional" }],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue