From b7bc523002553720b9f2188e9f03caedfed85e1e Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Tue, 21 Jul 2026 18:47:09 -0700 Subject: [PATCH 1/5] ci(security): add reviewed npm audit exceptions Signed-off-by: Senthil Ravichandran --- .github/CODEOWNERS | 2 + .github/workflows/base-image.yaml | 2 + Dockerfile | 17 +- Dockerfile.base | 19 +- agents/openclaw/dependency-review.md | 7 +- ci/npm-audit-exceptions.json | 4 + ci/reviewed-npm-audit.json | 5 +- .../openclaw-2026.6.10-dependency-review.md | 10 +- scripts/audit-reviewed-npm-graph.mts | 159 +++-- scripts/lib/reviewed-npm-audit.mts | 549 ++++++++++++++++++ test/mcporter-supply-chain.test.ts | 12 +- test/openclaw-dependency-review.test.ts | 9 +- test/openclaw-integrity-pin-suite.ts | 67 ++- test/reviewed-npm-audit.test.ts | 203 ++++++- 14 files changed, 949 insertions(+), 116 deletions(-) create mode 100644 ci/npm-audit-exceptions.json create mode 100755 scripts/lib/reviewed-npm-audit.mts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8009d65f210..379404052cd 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -45,3 +45,5 @@ # ── CI / GitHub config ── /.github/ @NVIDIA/nemoclaw-maintainer /ci/ @NVIDIA/nemoclaw-maintainer +/ci/npm-audit-exceptions.json @NVIDIA/nemoclaw-security @NVIDIA/nemoclaw-maintainer +/scripts/lib/reviewed-npm-audit.mts @NVIDIA/nemoclaw-security @NVIDIA/nemoclaw-maintainer diff --git a/.github/workflows/base-image.yaml b/.github/workflows/base-image.yaml index 0ee7a767082..268e4887cc7 100644 --- a/.github/workflows/base-image.yaml +++ b/.github/workflows/base-image.yaml @@ -29,9 +29,11 @@ on: - "agents/langchain-deepagents-code/requirements.lock" - "agents/openclaw/mcporter-runtime/package.json" - "agents/openclaw/mcporter-runtime/package-lock.json" + - "ci/npm-audit-exceptions.json" # Dockerfile.base validates min_openclaw_version from this file at build time. - "nemoclaw-blueprint/blueprint.yaml" - "scripts/lib/openclaw-npm-remediation.mts" + - "scripts/lib/reviewed-npm-audit.mts" - "scripts/lib/reviewed-npm-archive.mts" - "scripts/lib/sandbox-rlimits.sh" workflow_dispatch: diff --git a/Dockerfile b/Dockerfile index c4e743d1c91..d79dc027214 100644 --- a/Dockerfile +++ b/Dockerfile @@ -69,7 +69,9 @@ COPY agents/openclaw/mcporter-runtime/package.json /usr/local/lib/nemoclaw/mcpor COPY agents/openclaw/mcporter-runtime/package-lock.json /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json COPY agents/openclaw/wechat-runtime/package.json /usr/local/lib/nemoclaw/wechat-runtime/package.json COPY agents/openclaw/wechat-runtime/package-lock.json /usr/local/lib/nemoclaw/wechat-runtime/package-lock.json +COPY ci/npm-audit-exceptions.json /scripts/npm-audit-exceptions.json COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts +COPY scripts/lib/reviewed-npm-audit.mts /scripts/lib/reviewed-npm-audit.mts COPY scripts/lib/openclaw-npm-remediation.mts /scripts/lib/openclaw-npm-remediation.mts # OpenShell blocks the link-local EC2 Instance Metadata Service. Keep AWS SDK @@ -299,6 +301,10 @@ RUN set -eu; \ MCPORTER_LOCK_SHA256="$(sha256sum /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json | awk '{print $1}')"; \ [ -n "$MCPORTER_LOCK_SHA256" ] \ || { echo "ERROR: Could not hash the committed mcporter lockfile" >&2; exit 1; }; \ + MCPORTER_AUDIT_POLICY_SHA256="$(sha256sum /scripts/npm-audit-exceptions.json | awk '{print $1}')"; \ + MCPORTER_EXPECTED_AUDIT_EXCEPTIONS="$(node -e "const policy=require('/scripts/npm-audit-exceptions.json'); const ids=policy.exceptions.filter((entry)=>entry.graph==='mcporter-runtime').map((entry)=>entry.advisory).sort(); process.stdout.write(ids.join(',') || 'none');")"; \ + MCPORTER_EXPECTED_AUDIT_STATUS=clean; \ + if [ "$MCPORTER_EXPECTED_AUDIT_EXCEPTIONS" != "none" ]; then MCPORTER_EXPECTED_AUDIT_STATUS=accepted-exceptions; fi; \ CUR_VER=$(openclaw --version 2>/dev/null | awk '{print $2}' || true); \ CUR_VER="${CUR_VER:-0.0.0}"; \ CUR_MCPORTER_VER=$(mcporter --version 2>/dev/null || true); \ @@ -306,7 +312,7 @@ RUN set -eu; \ OPENCLAW_PROVENANCE_PATH=/usr/local/share/nemoclaw/openclaw-base-provenance-v1; \ OPENCLAW_EXPECTED_PROVENANCE="$(mktemp)"; \ printf '%s\n' \ - 'schema=2' \ + 'schema=3' \ "package=openclaw@${OPENCLAW_VERSION}" \ "integrity=${EXPECTED_INTEGRITY}" \ "tarball=${EXPECTED_TARBALL}" \ @@ -315,7 +321,10 @@ RUN set -eu; \ "mcporter-integrity=${MCPORTER_EXPECTED_INTEGRITY}" \ "mcporter-tarball=${MCPORTER_EXPECTED_TARBALL}" \ "mcporter-lock-sha256=${MCPORTER_LOCK_SHA256}" \ - 'mcporter-recipe=locked-ci+audit-signatures-v1' \ + "mcporter-audit-policy-sha256=${MCPORTER_AUDIT_POLICY_SHA256}" \ + "mcporter-audit-status=${MCPORTER_EXPECTED_AUDIT_STATUS}" \ + "mcporter-audit-exceptions=${MCPORTER_EXPECTED_AUDIT_EXCEPTIONS}" \ + 'mcporter-recipe=locked-ci+reviewed-audit+signatures-v2' \ > "$OPENCLAW_EXPECTED_PROVENANCE"; \ TRUSTED_BASE_IMAGE=0; \ case "$BASE_IMAGE" in \ @@ -379,7 +388,9 @@ RUN set -eu; \ --ignore-scripts --omit=dev --no-audit --no-fund --no-progress; \ ln -s /usr/local/lib/nemoclaw/mcporter-runtime/node_modules/.bin/mcporter /usr/local/bin/mcporter; \ test "$(mcporter --version)" = "$MCPORTER_VERSION"; \ - npm --prefix /usr/local/lib/nemoclaw/mcporter-runtime audit --omit=dev --audit-level=low; \ + node --experimental-strip-types /scripts/lib/reviewed-npm-audit.mts \ + --directory /usr/local/lib/nemoclaw/mcporter-runtime \ + --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high; \ npm --prefix /usr/local/lib/nemoclaw/mcporter-runtime audit signatures; \ fi diff --git a/Dockerfile.base b/Dockerfile.base index c0a4cf1a1e1..d68f1f7d105 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -224,7 +224,9 @@ ARG MCPORTER_0_7_3_INTEGRITY=sha512-egoPVYqTnWb3NjRIxo+xc8OrAI0dlPrJm9pAiZx0pImu ARG MCPORTER_0_7_3_TARBALL=https://registry.npmjs.org/mcporter/-/mcporter-0.7.3.tgz COPY agents/openclaw/mcporter-runtime/package.json /usr/local/lib/nemoclaw/mcporter-runtime/package.json COPY agents/openclaw/mcporter-runtime/package-lock.json /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json +COPY ci/npm-audit-exceptions.json /scripts/npm-audit-exceptions.json COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts +COPY scripts/lib/reviewed-npm-audit.mts /scripts/lib/reviewed-npm-audit.mts COPY scripts/lib/openclaw-npm-remediation.mts /scripts/lib/openclaw-npm-remediation.mts # Keep OpenClaw's jiti-generated source cache out of /tmp so provider marker @@ -309,8 +311,15 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep --ignore-scripts --omit=dev --no-audit --no-fund --no-progress \ && ln -s /usr/local/lib/nemoclaw/mcporter-runtime/node_modules/.bin/mcporter /usr/local/bin/mcporter \ && test "$(mcporter --version)" = "$MCPORTER_VERSION" \ - && npm --prefix /usr/local/lib/nemoclaw/mcporter-runtime audit --omit=dev --audit-level=low \ + && node --experimental-strip-types /scripts/lib/reviewed-npm-audit.mts \ + --directory /usr/local/lib/nemoclaw/mcporter-runtime \ + --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high \ + --report /tmp/mcporter-npm-audit.json --result /tmp/mcporter-npm-audit-policy.json \ && npm --prefix /usr/local/lib/nemoclaw/mcporter-runtime audit signatures \ + && MCPORTER_AUDIT_STATUS="$(node -p "require('/tmp/mcporter-npm-audit-policy.json').status")" \ + && MCPORTER_AUDIT_EXCEPTIONS="$(node -p "require('/tmp/mcporter-npm-audit-policy.json').acceptedAdvisories.join(',') || 'none'")" \ + && MCPORTER_AUDIT_POLICY_SHA256="$(node -p "require('/tmp/mcporter-npm-audit-policy.json').exceptionPolicySha256")" \ + && test -n "$MCPORTER_AUDIT_STATUS" -a -n "$MCPORTER_AUDIT_EXCEPTIONS" -a -n "$MCPORTER_AUDIT_POLICY_SHA256" \ && MCPORTER_LOCK_SHA256="$(sha256sum /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json | awk '{print $1}')" \ && test -n "$MCPORTER_LOCK_SHA256" \ && OPENCLAW_PROVENANCE_PATH=/usr/local/share/nemoclaw/openclaw-base-provenance-v1 \ @@ -318,7 +327,7 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep && mkdir -p "$OPENCLAW_PROVENANCE_DIR" \ && OPENCLAW_PROVENANCE_TMP="$(mktemp "${OPENCLAW_PROVENANCE_PATH}.tmp.XXXXXX")" \ && printf '%s\n' \ - 'schema=2' \ + 'schema=3' \ "package=openclaw@${OPENCLAW_VERSION}" \ "integrity=${EXPECTED_INTEGRITY}" \ "tarball=${EXPECTED_TARBALL}" \ @@ -327,10 +336,14 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep "mcporter-integrity=${MCPORTER_EXPECTED_INTEGRITY}" \ "mcporter-tarball=${MCPORTER_EXPECTED_TARBALL}" \ "mcporter-lock-sha256=${MCPORTER_LOCK_SHA256}" \ - 'mcporter-recipe=locked-ci+audit-signatures-v1' \ + "mcporter-audit-policy-sha256=${MCPORTER_AUDIT_POLICY_SHA256}" \ + "mcporter-audit-status=${MCPORTER_AUDIT_STATUS}" \ + "mcporter-audit-exceptions=${MCPORTER_AUDIT_EXCEPTIONS}" \ + 'mcporter-recipe=locked-ci+reviewed-audit+signatures-v2' \ > "$OPENCLAW_PROVENANCE_TMP" \ && chmod 0444 "$OPENCLAW_PROVENANCE_TMP" \ && mv -f "$OPENCLAW_PROVENANCE_TMP" "$OPENCLAW_PROVENANCE_PATH" \ + && rm -f /tmp/mcporter-npm-audit.json /tmp/mcporter-npm-audit-policy.json \ && pip3 install --no-cache-dir --break-system-packages "pyyaml==6.0.3" diff --git a/agents/openclaw/dependency-review.md b/agents/openclaw/dependency-review.md index a7ff15c252f..9c0ca1d94b6 100644 --- a/agents/openclaw/dependency-review.md +++ b/agents/openclaw/dependency-review.md @@ -15,11 +15,12 @@ Update it and `agents/openclaw/mcporter-runtime/package*.json` together whenever - Registry metadata independently queried from npm: 2026-06-30. - Locked graph: `agents/openclaw/mcporter-runtime/package-lock.json` (npm lockfile version 3). - Lock regeneration command: `npm --prefix agents/openclaw/mcporter-runtime install --package-lock-only --ignore-scripts --omit=dev` -- Advisory command: `npm --prefix agents/openclaw/mcporter-runtime ci --ignore-scripts --omit=dev && npm --prefix agents/openclaw/mcporter-runtime audit --omit=dev && npm --prefix agents/openclaw/mcporter-runtime audit signatures` +- Advisory command: `npm --prefix agents/openclaw/mcporter-runtime ci --ignore-scripts --omit=dev && node --experimental-strip-types scripts/lib/reviewed-npm-audit.mts --directory agents/openclaw/mcporter-runtime --exceptions ci/npm-audit-exceptions.json --graph mcporter-runtime --threshold high && npm --prefix agents/openclaw/mcporter-runtime audit signatures` - Advisory review date: 2026-06-30. - Advisory result: `0` known vulnerabilities across the resolved production dependency graph; npm verified registry signatures for all `120` resolved packages and attestations for `12` packages. Both image paths install the committed graph with `npm ci --ignore-scripts --omit=dev` because the published package declares no install-time lifecycle script and NemoClaw needs only its already-built CLI. +The reviewed audit wrapper reports lower-severity production findings and blocks unaccepted high or critical advisories. The default `ci/npm-audit-exceptions.json` registry is empty. Any future exception must match one advisory, graph, package, installed version, and severity; identify an owner and NemoClaw tracking issue; state a decision, rationale, and expiry no more than 30 days away; and include compensating controls for temporary risk acceptance. Missing, malformed, expired, overlong, mismatched, or unused exceptions fail closed. The repository-wide audit also rejects exceptions for unknown graph IDs. Registry signature verification remains a separate control. ## WeChat plugin runtime graph @@ -39,7 +40,7 @@ The lock records the exact version, registry URL, and integrity for every transi ## Source-of-Truth Boundary - `invalidState`: the image installs a package graph, tarball, license, or advisory state that differs from the independently queried npm registry records for `mcporter@0.7.3`. -- `sourceBoundary`: npm owns registry metadata, tarball integrity, provenance signatures, and advisory responses; NemoClaw owns the exact lock, script-disabled install, Docker integrity assertion, and review record. +- `sourceBoundary`: npm owns registry metadata, tarball integrity, provenance signatures, and advisory responses; NemoClaw owns the exact lock, script-disabled install, Docker integrity assertion, empty-by-default audit exception registry, and review record. - `whyNotSourceFix`: a repository note cannot make external registry state trustworthy, so image builds execute `npm audit` and `npm audit signatures` against the locked production graph and reviewers compare the lock with the registry response. -- `regressionTest`: `test/mcporter-supply-chain.test.ts` keeps the version, integrity, lock metadata, Docker install flags, audit commands, and this review synchronized. +- `regressionTest`: `test/mcporter-supply-chain.test.ts` keeps the version, integrity, lock metadata, Docker install flags, audit commands, and this review synchronized; `test/reviewed-npm-audit.test.ts` proves exact matching and fail-closed exception validation. - `removalCondition`: remove this runtime dependency and review when OpenClaw provides the required authenticated Streamable HTTP client lifecycle without mcporter, or repeat the independent review for a newly pinned version. diff --git a/ci/npm-audit-exceptions.json b/ci/npm-audit-exceptions.json new file mode 100644 index 00000000000..f226765ed30 --- /dev/null +++ b/ci/npm-audit-exceptions.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, + "exceptions": [] +} diff --git a/ci/reviewed-npm-audit.json b/ci/reviewed-npm-audit.json index 3737c93c26d..f4fcc200e09 100644 --- a/ci/reviewed-npm-audit.json +++ b/ci/reviewed-npm-audit.json @@ -1,7 +1,9 @@ { - "schemaVersion": 1, + "schemaVersion": 2, "nodeVersion": "22.22.2", "severityThreshold": "high", + "exceptionFile": "ci/npm-audit-exceptions.json", + "archiveGraphId": "reviewed-archive-graph", "artifactDirectory": "coverage/reviewed-npm-audit", "archivePackages": [ { @@ -61,6 +63,7 @@ ], "lockedGraphs": [ { + "id": "mcporter-runtime", "label": "mcporter 0.7.3 locked runtime graph", "packageSpec": "mcporter@0.7.3", "integrity": "sha512-egoPVYqTnWb3NjRIxo+xc8OrAI0dlPrJm9pAiZx0pImuNIV5rKhGtTnIfH/Y1ldGPVu74ibj3KR5c9U/QSdQFA==", diff --git a/docs/security/openclaw-2026.6.10-dependency-review.md b/docs/security/openclaw-2026.6.10-dependency-review.md index bdd563234f0..e508dfb95f6 100644 --- a/docs/security/openclaw-2026.6.10-dependency-review.md +++ b/docs/security/openclaw-2026.6.10-dependency-review.md @@ -66,7 +66,9 @@ It remains in the reviewed diagnostics OTEL and WhatsApp plugin graphs. Both findings have upstream fixes, but applying them would change additional reviewed plugin shrinkwraps. The current remediation does not silently extend its authority to those graphs. -This review is an advisory snapshot for the direct OpenClaw runtime package, Codex ACP runtime helper, optional plugins, messaging plugins, and their npm dependency graphs at review time. Default PR and main CI now rematerialize those exact direct packages from SRI-verified reviewed local archives under Node `22.22.2`, install with lifecycle scripts disabled, run `npm audit --omit=dev --json`, and upload the raw reports from `coverage/reviewed-npm-audit`. The configured threshold in `ci/reviewed-npm-audit.json` is `high`. The same job independently installs and audits the committed mcporter production lock. This gate complements, but does not replace, the committed npm integrity pins and install-time archive checks. +This review is an advisory snapshot for the direct OpenClaw runtime package, Codex ACP runtime helper, optional plugins, messaging plugins, and their npm dependency graphs at review time. Default PR and main CI now rematerialize those exact direct packages from SRI-verified reviewed local archives under Node `22.22.2`, install with lifecycle scripts disabled, run `npm audit --omit=dev --json` through `scripts/lib/reviewed-npm-audit.mts`, and upload both the raw reports and normalized policy results from `coverage/reviewed-npm-audit`. The configured threshold in `ci/reviewed-npm-audit.json` is `high`. Lower-severity findings remain visible without blocking. The same job independently installs and audits the committed mcporter production lock. This gate complements, but does not replace, the committed npm integrity pins, registry signature verification, and install-time archive checks. + +The exception registry at `ci/npm-audit-exceptions.json` is empty by default. It is not a global npm-audit bypass: each entry must exactly match one advisory, audited graph, package, installed version, and reported severity. It must also record an expiring `not-affected` or `temporary-risk-acceptance` decision, rationale, owner, and NemoClaw issue or PR. Expiry is limited to 30 days. Temporary risk acceptance additionally requires compensating controls. Missing, malformed, expired, overlong, duplicate, mismatched, and unused entries fail the audit. The repository-wide audit also rejects exceptions for unknown graph IDs. The policy file hash, evaluation status, and accepted advisory IDs are bound into OpenClaw base-image provenance, so a child image cannot silently reuse a base built under a different exception set. The registry contains no exception for `GHSA-v2hh-gcrm-f6hx` or any other current advisory. ## Transitive Dependency Graph Rationale @@ -157,7 +159,7 @@ The SRI-verified `openclaw@2026.6.10` artifact's `package/skills/weather/SKILL.m `Dockerfile`, `Dockerfile.base`, optional OpenClaw plugin installs, and `src/lib/messaging/applier/build/messaging-build-applier.mts` bind reviewed npm installs to verified local archives through `scripts/lib/reviewed-npm-archive.mts`. The thin callers provide the exact package spec, committed SRI, reviewed tarball URL, and caller label. The helper verifies both `npm view` fields, packs the reviewed URL, validates the reported SRI and contained regular-file basename in a fresh directory, and returns the local archive before `npm install -g ` or `openclaw plugins install npm-pack:` runs. Runtime mcporter uses the helper's metadata-only path before retaining its committed-lock `npm ci` transaction. -After `Dockerfile.base` completes the OpenClaw archive transaction and reviewed lifecycle, installs mcporter from the committed lock, checks both installed versions, and passes mcporter advisory and signature audits, it atomically publishes a root-owned, read-only provenance marker. The marker binds the OpenClaw package, SRI, tarball, and lifecycle recipe plus the mcporter package, SRI, tarball URL, lockfile SHA-256, and audited-install recipe. The production Dockerfile may reuse both installs only for an official NemoClaw base reference (or the resolver's local base name) when the marker is a non-symlink regular file with exact `root:root` ownership, mode `0444`, byte-for-byte content, and both installed versions match. It removes the marker before applying NemoClaw patches so a derived image cannot claim pristine-base provenance. Missing, malformed, writable, symlinked, mismatched, custom-base, stale, or incomplete provenance takes the complete reviewed install fallback; a base newer than the reviewed OpenClaw target remains a hard failure. +After `Dockerfile.base` completes the OpenClaw archive transaction and reviewed lifecycle, installs mcporter from the committed lock, checks both installed versions, and passes mcporter advisory and signature audits, it atomically publishes a root-owned, read-only provenance marker. The marker binds the OpenClaw package, SRI, tarball, and lifecycle recipe plus the mcporter package, SRI, tarball URL, lockfile SHA-256, audit exception-policy SHA-256, audit status, accepted advisory IDs, and audited-install recipe. The production Dockerfile may reuse both installs only for an official NemoClaw base reference (or the resolver's local base name) when the marker is a non-symlink regular file with exact `root:root` ownership, mode `0444`, byte-for-byte content, and both installed versions match. It removes the marker before applying NemoClaw patches so a derived image cannot claim pristine-base provenance. Missing, malformed, writable, symlinked, mismatched, custom-base, stale, or incomplete provenance takes the complete reviewed install fallback; a base newer than the reviewed OpenClaw target remains a hard failure. Invalid state: `npm view` returns the reviewed SRI but the downloaded artifact used for install has different bytes; `npm pack --json` reports a filename such as `../package.tgz`, `/tmp/package.tgz`, or a name containing path separators so the later install consumes a path outside the fresh pack directory; or the production image reuses OpenClaw or mcporter without every provenance, metadata, trusted-base, lock-hash, and installed-version check above. Source boundary: Dockerfile npm install and provenance blocks, `Dockerfile.base`, the committed mcporter lock, optional plugin install blocks, and `src/lib/messaging/applier/build/messaging-build-applier.mts`. Source-fix constraint: npm package installation must stay artifact-bound for reviewed pins rather than reverting to a later floating package-spec transaction, and local archive path validation must be enforced at NemoClaw's install boundary because npm's JSON filename is untrusted input. Regression tests: the integrity-pin plugin-install suite exercises registry drift, reviewed tarball URL drift, downloaded archive verification, and reviewed local-archive installation; the integrity-pin base suite exercises unsafe reported archive filenames, exact OpenClaw/mcporter provenance reuse, fifteen fallback states, marker consumption, and newer-base rejection. `test/messaging-build-applier.test.ts` verifies messaging plugins run through `npm pack --json` and install the verified archive path; `test/messaging-build-applier-integrity.test.ts` verifies the messaging plugin install fails closed when packed archive integrity drifts or the reported archive filename escapes the pack directory. Removal condition: keep this archive verification and delegated-base provenance until the repo moves the OpenClaw/plugin dependency set to a lockfile path where npm enforces the committed SRI directly and no installer code consumes raw `npm pack --json` filenames. @@ -202,7 +204,7 @@ Removal condition: retain these provenance checks in the shared installer and up The Codex ACP, runtime OpenClaw, base-image OpenClaw, optional-plugin, and messaging-plugin boundaries consume one reviewed implementation with thin shell or TypeScript callers. Every archive boundary retains exact reviewed package identity, registry SRI, reviewed registry tarball URL, packed-byte SRI, a nonempty regular-file basename contained in a fresh pack directory, install from the resolved local archive only, cleanup, and failure before install on any mismatch. Runtime OpenClaw either executes that full transaction or consumes the exact protected result of the base-image transaction under the bounded provenance checks above; it never substitutes a floating package-spec install. Runtime mcporter verifies the same exact registry metadata and then either installs and audits the committed lock or consumes the marker-bound result of that exact locked and audited base-image transaction. -Invalid state: a caller bypasses the helper, the audit inventory diverges from a production pin, CI audits a graph other than the verified local archives and committed mcporter lock, or the raw report is lost when the threshold fails. Source boundary: `scripts/lib/reviewed-npm-archive.mts`, the thin Docker and messaging callers, `ci/reviewed-npm-audit.json`, `scripts/audit-reviewed-npm-graph.mts`, and `.github/actions/ci-reviewed-npm-audit/action.yaml`. Source-fix constraint: #5242 retains general dependency-pin and canary design ownership; this slice records only the current production audit inventory and tests it against the caller-owned pins. Regression tests: the integrity-pin suites and `test/messaging-build-applier-integrity.test.ts` retain malicious filename, registry drift, packed-SRI drift, and local-install proof at each caller; `test/reviewed-npm-archive.test.ts` tests the shared primitive; and `test/reviewed-npm-audit.test.ts` pins inventory alignment, Node version, threshold behavior, default workflow gating, and unconditional artifact upload. Removal condition: keep the shared helper and audit gate while reviewed npm archives remain production build inputs. +Invalid state: a caller bypasses the helper, the audit inventory diverges from a production pin, CI audits a graph other than the verified local archives and committed mcporter lock, an exception is missing required review metadata or does not exactly match a current finding, base provenance uses a different exception policy, or audit evidence is lost when the threshold fails. Source boundary: `scripts/lib/reviewed-npm-archive.mts`, `scripts/lib/reviewed-npm-audit.mts`, the thin Docker and messaging callers, `ci/reviewed-npm-audit.json`, `ci/npm-audit-exceptions.json`, `scripts/audit-reviewed-npm-graph.mts`, and `.github/actions/ci-reviewed-npm-audit/action.yaml`. Source-fix constraint: #5242 retains general dependency-pin and canary design ownership; this slice records only the current production audit inventory and tests it against the caller-owned pins. Regression tests: the integrity-pin suites and `test/messaging-build-applier-integrity.test.ts` retain malicious filename, registry drift, packed-SRI drift, and local-install proof at each caller; `test/reviewed-npm-archive.test.ts` tests the shared archive primitive; and `test/reviewed-npm-audit.test.ts` pins the empty default, exact exception matching, expiry, threshold behavior, and fail-closed validation. Removal condition: keep the shared helpers and audit gate while reviewed npm archives remain production build inputs. ### OpenClaw Compiled-Dist Patch Runtime Boundary @@ -340,7 +342,7 @@ No real Microsoft Teams tenant proof is included in this PR. The work remains tr The low `body-parser` and moderate `protobufjs` findings remain documented at the configured `high` threshold. Current NemoClaw closes the WeChat residual with `agents/openclaw/wechat-runtime/package-lock.json` and post-install graph verification. - `src/lib/messaging/channels/manifests.test.ts` remains below the shared `test-size:check` threshold and does not need extraction in this dependency bump. -- The npm audit result in this note remains a point-in-time snapshot. Default PR and main CI rematerialize the production-compatible graph from the reviewed local archives, audit it and the committed mcporter lock with `npm audit --omit=dev --json`, upload both raw reports, and fail at the configured `high` threshold. The separate `wechat-runtime-audit` gate uses Node `22.19.0` and npm `10.9.4`, installs the committed WeChat production lock with scripts disabled, fails on any low-or-higher production advisory, verifies registry signatures, exercises the reviewed archive through a copied writable cache, and uploads its evidence. Pull requests execute that WeChat audit action from the base SHA; because PR #6739's base predates the action, that PR alone may bootstrap it from signed immutable commit `HOYALIM/NemoClaw@0d2256d71d5bbba3bcaaaa4d01714fa56f22d1e2`, while every other PR fails closed if its base lacks the action. The production installer routes registry metadata lookup, archive packing, and installation through the disposable writable-cache boundary so retrieval cannot fall back to `HOME/.npm`; the trusted source cache remains read-only and the disposable copy is removed in the same image layer. +- The npm audit result in this note remains a point-in-time snapshot. Default PR and main CI rematerialize the production-compatible graph from the reviewed local archives, audit it and the committed mcporter lock with `npm audit --omit=dev --json` through the reviewed evaluator, upload the raw reports and normalized policy results, and fail on unaccepted findings at the configured `high` threshold. The separate `wechat-runtime-audit` gate uses Node `22.19.0` and npm `10.9.4`, installs the committed WeChat production lock with scripts disabled, fails on any low-or-higher production advisory, verifies registry signatures, exercises the reviewed archive through a copied writable cache, and uploads its evidence. Pull requests execute that WeChat audit action from the base SHA; because PR #6739's base predates the action, that PR alone may bootstrap it from signed immutable commit `HOYALIM/NemoClaw@0d2256d71d5bbba3bcaaaa4d01714fa56f22d1e2`, while every other PR fails closed if its base lacks the action. The production installer routes registry metadata lookup, archive packing, and installation through the disposable writable-cache boundary so retrieval cannot fall back to `HOME/.npm`; the trusted source cache remains read-only and the disposable copy is removed in the same image layer. - The stale nonterminal rebuild-resume repair in `src/lib/actions/sandbox/rebuild-resume-session.ts` remains a migration compatibility shim tracked against #4533's onboard FSM/resume compatibility boundary. Its removal condition is to delete it after a session-version migration proves recreate sessions are always persisted at a resumable pre-sandbox boundary; `src/lib/actions/sandbox/rebuild-resume-session.test.ts` covers the helper directly, `test/onboard-resume-provider-recovery.test.ts` carries the onboard-suite producer-level regression for `machine.state='openclaw'`, and `src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts` owns the rebuild handoff regression. - Production OpenClaw image build paths call `scripts/check-production-build-args.sh` before production `docker build` or `docker/build-push-action` use. `test/openclaw-dependency-review.test.ts` keeps that workflow contract documented. - The rebuild-reasoning cases added by this PR live in the focused `rebuild-resume-reasoning.test.ts` file; the smaller route-provenance additions remain with their `rebuild-resume-config.ts` boundary tests. diff --git a/scripts/audit-reviewed-npm-graph.mts b/scripts/audit-reviewed-npm-graph.mts index b2c0317e940..24fc40f3727 100755 --- a/scripts/audit-reviewed-npm-graph.mts +++ b/scripts/audit-reviewed-npm-graph.mts @@ -9,21 +9,28 @@ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { remediateReviewedOpenClawArchive } from "./lib/openclaw-npm-remediation.mts"; import { packReviewedNpmArchive, verifyReviewedNpmMetadata } from "./lib/reviewed-npm-archive.mts"; +import { + assertExceptionGraphs, + readAuditExceptionRegistry, + runReviewedNpmAudit, + type Severity, +} from "./lib/reviewed-npm-audit.mts"; -type Severity = "info" | "low" | "moderate" | "high" | "critical"; type ReviewedPackage = Readonly<{ integrity: string; label: string; packageSpec: string; tarballUrl: string; }>; -type LockedGraph = ReviewedPackage & Readonly<{ directory: string }>; +type LockedGraph = ReviewedPackage & Readonly<{ directory: string; id: string }>; type AuditConfig = Readonly<{ archivePackages: readonly ReviewedPackage[]; + archiveGraphId: string; artifactDirectory: string; + exceptionFile: string; lockedGraphs: readonly LockedGraph[]; nodeVersion: string; - schemaVersion: 1; + schemaVersion: 2; severityThreshold: Severity; }>; @@ -31,7 +38,19 @@ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".. const CONFIG_PATH = path.join(REPO_ROOT, "ci", "reviewed-npm-audit.json"); const SEVERITIES: readonly Severity[] = ["info", "low", "moderate", "high", "critical"]; -function run(command: string, args: readonly string[], cwd: string, allowAuditFindings = false) { +function repositoryPath(relativePath: string, label: string): string { + const resolved = path.resolve(REPO_ROOT, relativePath); + if ( + !relativePath || + path.isAbsolute(relativePath) || + !resolved.startsWith(`${REPO_ROOT}${path.sep}`) + ) { + throw new Error(`${label} must stay inside the repository`); + } + return resolved; +} + +function run(command: string, args: readonly string[], cwd: string) { const result = spawnSync(command, args, { cwd, encoding: "utf-8", @@ -40,7 +59,7 @@ function run(command: string, args: readonly string[], cwd: string, allowAuditFi stdio: ["ignore", "pipe", "pipe"], }); if (result.error) throw result.error; - if (result.status !== 0 && !allowAuditFindings) { + if (result.status !== 0) { throw new Error(`${command} ${args.join(" ")} failed: ${result.stderr || result.stdout}`); } return result; @@ -49,86 +68,23 @@ function run(command: string, args: readonly string[], cwd: string, allowAuditFi function readConfig(): AuditConfig { const parsed = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")) as AuditConfig; if ( - parsed.schemaVersion !== 1 || + parsed.schemaVersion !== 2 || !SEVERITIES.includes(parsed.severityThreshold) || + typeof parsed.archiveGraphId !== "string" || + !parsed.archiveGraphId || + typeof parsed.exceptionFile !== "string" || + !parsed.exceptionFile || !Array.isArray(parsed.archivePackages) || - !Array.isArray(parsed.lockedGraphs) + !Array.isArray(parsed.lockedGraphs) || + parsed.lockedGraphs.some( + (graph) => typeof graph.id !== "string" || !graph.id || typeof graph.directory !== "string", + ) ) { throw new Error("ci/reviewed-npm-audit.json is invalid"); } return parsed; } -function auditGraph(directory: string, reportPath: string): Record { - const result = run("npm", ["audit", "--omit=dev", "--json"], directory, true); - fs.writeFileSync(reportPath, result.stdout); - return parseAuditReport(result); -} - -export function parseAuditReport(result: { - status: number | null; - stderr: string; - stdout: string; -}): Record { - if (!result.stdout.trim()) { - throw new Error(`npm audit did not produce JSON: ${result.stderr}`); - } - let report: Record; - try { - report = JSON.parse(result.stdout) as Record; - } catch (error) { - throw new Error(`npm audit returned invalid JSON: ${String(error)}`); - } - let counts: Record; - try { - counts = vulnerabilityCounts(report); - } catch (error) { - const detail = report.error === undefined ? result.stderr : JSON.stringify(report.error); - throw new Error( - `npm audit failed without a complete vulnerability report: ${error instanceof Error ? error.message : String(error)}${detail ? `; ${detail}` : ""}`, - ); - } - const findingCount = SEVERITIES.reduce((total, severity) => total + counts[severity], 0); - if ( - report.error !== undefined || - result.status === null || - result.status > 1 || - (result.status !== 0 && findingCount === 0) - ) { - const detail = report.error === undefined ? result.stderr : JSON.stringify(report.error); - throw new Error( - `npm audit failed without vulnerability findings${detail ? `: ${detail}` : ""}`, - ); - } - return report; -} - -export function vulnerabilityCounts(report: Record): Record { - const metadata = report.metadata as Record | undefined; - const vulnerabilities = metadata?.vulnerabilities as Record | undefined; - if (!vulnerabilities || Array.isArray(vulnerabilities)) { - throw new Error("npm audit report is missing metadata.vulnerabilities"); - } - const entries = SEVERITIES.map((severity) => { - const value = vulnerabilities[severity]; - if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { - throw new Error(`npm audit report has invalid ${severity} vulnerability count`); - } - return [severity, value] as const; - }); - return Object.fromEntries(entries) as Record; -} - -export function exceedsAuditThreshold( - counts: Readonly>, - threshold: Severity, -): number { - return SEVERITIES.slice(SEVERITIES.indexOf(threshold)).reduce( - (total, severity) => total + counts[severity], - 0, - ); -} - function materializeArchiveGraph(packages: readonly ReviewedPackage[], tempRoot: string): string { const graphDirectory = path.join(tempRoot, "reviewed-archive-graph"); fs.mkdirSync(graphDirectory); @@ -172,7 +128,7 @@ function materializeLockedGraph(graph: LockedGraph, tempRoot: string): string { packageSpec: graph.packageSpec, tarballUrl: graph.tarballUrl, }); - const source = path.join(REPO_ROOT, graph.directory); + const source = repositoryPath(graph.directory, `${graph.label} directory`); const destination = path.join(tempRoot, `locked-${path.basename(graph.directory)}`); fs.mkdirSync(destination); for (const filename of ["package.json", "package-lock.json"]) { @@ -188,7 +144,13 @@ function main(): void { if (process.version !== expectedNode) { throw new Error(`reviewed npm audit requires Node ${expectedNode}; running ${process.version}`); } - const artifactDirectory = path.join(REPO_ROOT, config.artifactDirectory); + const artifactDirectory = repositoryPath(config.artifactDirectory, "audit artifact directory"); + const exceptionFile = repositoryPath(config.exceptionFile, "npm audit exception file"); + const exceptionRegistry = readAuditExceptionRegistry(exceptionFile); + assertExceptionGraphs( + exceptionRegistry.policy, + new Set([config.archiveGraphId, ...config.lockedGraphs.map((graph) => graph.id)]), + ); fs.rmSync(artifactDirectory, { recursive: true, force: true }); fs.mkdirSync(artifactDirectory, { recursive: true }); const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-npm-audit-")); @@ -196,27 +158,36 @@ function main(): void { const reports = [ { label: "reviewed archive graph", - report: auditGraph( - materializeArchiveGraph(config.archivePackages, tempRoot), - path.join(artifactDirectory, "reviewed-archive-graph.json"), - ), + result: runReviewedNpmAudit({ + directory: materializeArchiveGraph(config.archivePackages, tempRoot), + exceptionFile, + graph: config.archiveGraphId, + reportFile: path.join(artifactDirectory, "reviewed-archive-graph.json"), + resultFile: path.join(artifactDirectory, "reviewed-archive-graph-policy.json"), + threshold: config.severityThreshold, + throwOnBlock: false, + }), }, ...config.lockedGraphs.map((graph, index) => ({ label: graph.label, - report: auditGraph( - materializeLockedGraph(graph, tempRoot), - path.join(artifactDirectory, `locked-graph-${index + 1}.json`), - ), + result: runReviewedNpmAudit({ + directory: materializeLockedGraph(graph, tempRoot), + exceptionFile, + graph: graph.id, + reportFile: path.join(artifactDirectory, `locked-graph-${index + 1}.json`), + resultFile: path.join(artifactDirectory, `locked-graph-${index + 1}-policy.json`), + threshold: config.severityThreshold, + throwOnBlock: false, + }), })), ]; const failures: string[] = []; - for (const { label, report } of reports) { - const counts = vulnerabilityCounts(report); - const summary = SEVERITIES.map((severity) => `${severity}=${counts[severity]}`).join(" "); - console.log(`${label}: ${summary}`); - const blocked = exceedsAuditThreshold(counts, config.severityThreshold); - if (blocked > 0) - failures.push(`${label}: ${blocked} at or above ${config.severityThreshold}`); + for (const { label, result } of reports) { + if (result.unacceptedBlockingAdvisories.length > 0) { + failures.push( + `${label}: ${result.unacceptedBlockingAdvisories.length} unaccepted at or above ${config.severityThreshold}`, + ); + } } if (failures.length > 0) throw new Error(`reviewed npm audit threshold failed\n${failures.join("\n")}`); diff --git a/scripts/lib/reviewed-npm-audit.mts b/scripts/lib/reviewed-npm-audit.mts new file mode 100755 index 00000000000..5d308109139 --- /dev/null +++ b/scripts/lib/reviewed-npm-audit.mts @@ -0,0 +1,549 @@ +#!/usr/bin/env -S node --experimental-strip-types +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +export const SEVERITIES = ["info", "low", "moderate", "high", "critical"] as const; +export type Severity = (typeof SEVERITIES)[number]; + +export type AuditException = Readonly<{ + advisory: string; + decision: "not-affected" | "temporary-risk-acceptance"; + expires: string; + graph: string; + installedVersion: string; + owner: string; + package: string; + rationale: string; + severity: Severity; + trackingIssue: string; + compensatingControls?: readonly string[]; +}>; + +export type AuditExceptionRegistry = Readonly<{ + exceptions: readonly AuditException[]; + schemaVersion: 1; +}>; + +type DirectFinding = Readonly<{ + advisory: string; + installedVersion: string; + package: string; + severity: Severity; +}>; + +export type AuditPolicyResult = Readonly<{ + acceptedAdvisories: readonly string[]; + blockingThreshold: Severity; + exceptionPolicySha256: string; + graph: string; + reported: Readonly>; + schemaVersion: 1; + status: "clean" | "accepted-exceptions" | "blocked"; + unacceptedBlockingAdvisories: readonly DirectFinding[]; +}>; + +const EXCEPTION_KEYS = new Set([ + "advisory", + "compensatingControls", + "decision", + "expires", + "graph", + "installedVersion", + "owner", + "package", + "rationale", + "severity", + "trackingIssue", +]); +const NEMOCLAW_TRACKING_URL = /^https:\/\/github\.com\/NVIDIA\/NemoClaw\/(?:issues|pull)\/\d+$/u; +const ADVISORY_ID = /^(?:GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}|CVE-\d{4}-\d+|NPM-\d+)$/u; +const GRAPH_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/u; +const MAX_EXCEPTION_LIFETIME_DAYS = 30; + +function asRecord(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + return value as Record; +} + +function requireExactKeys( + value: Readonly>, + allowed: ReadonlySet, + label: string, +): void { + const unknown = Object.keys(value).filter((key) => !allowed.has(key)); + if (unknown.length > 0) throw new Error(`${label} has unknown fields: ${unknown.join(", ")}`); +} + +function nonEmptyString(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) throw new Error(`${label} must be a string`); + return value; +} + +function parseException(value: unknown, index: number, now: Date): AuditException { + const label = `npm audit exception ${index + 1}`; + const parsed = asRecord(value, label); + requireExactKeys(parsed, EXCEPTION_KEYS, label); + + const advisory = nonEmptyString(parsed.advisory, `${label}.advisory`); + if (!ADVISORY_ID.test(advisory)) throw new Error(`${label}.advisory is invalid`); + const graph = nonEmptyString(parsed.graph, `${label}.graph`); + if (!GRAPH_ID.test(graph)) throw new Error(`${label}.graph is invalid`); + const severity = nonEmptyString(parsed.severity, `${label}.severity`); + if (!SEVERITIES.includes(severity as Severity)) throw new Error(`${label}.severity is invalid`); + const decision = nonEmptyString(parsed.decision, `${label}.decision`); + if (decision !== "not-affected" && decision !== "temporary-risk-acceptance") { + throw new Error(`${label}.decision is invalid`); + } + const expires = nonEmptyString(parsed.expires, `${label}.expires`); + const expiresAt = new Date(`${expires}T23:59:59.999Z`); + if ( + !ISO_DATE.test(expires) || + Number.isNaN(expiresAt.valueOf()) || + expiresAt.toISOString().slice(0, 10) !== expires + ) { + throw new Error(`${label}.expires must use YYYY-MM-DD`); + } + if (expiresAt.valueOf() < now.valueOf()) throw new Error(`${label} expired on ${expires}`); + const currentDate = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + const maximumExpiry = currentDate + (MAX_EXCEPTION_LIFETIME_DAYS + 1) * 24 * 60 * 60 * 1000 - 1; + if (expiresAt.valueOf() > maximumExpiry) { + throw new Error(`${label}.expires must be within ${MAX_EXCEPTION_LIFETIME_DAYS} days`); + } + const trackingIssue = nonEmptyString(parsed.trackingIssue, `${label}.trackingIssue`); + if (!NEMOCLAW_TRACKING_URL.test(trackingIssue)) { + throw new Error(`${label}.trackingIssue must identify a NemoClaw issue or PR`); + } + const controls = parsed.compensatingControls; + if ( + controls !== undefined && + (!Array.isArray(controls) || + controls.length === 0 || + controls.some((control) => typeof control !== "string" || !control.trim())) + ) { + throw new Error(`${label}.compensatingControls must contain non-empty strings`); + } + if (decision === "temporary-risk-acceptance" && controls === undefined) { + throw new Error(`${label}.compensatingControls is required for temporary risk acceptance`); + } + + return { + advisory, + decision, + expires, + graph, + installedVersion: nonEmptyString(parsed.installedVersion, `${label}.installedVersion`), + owner: nonEmptyString(parsed.owner, `${label}.owner`), + package: nonEmptyString(parsed.package, `${label}.package`), + rationale: nonEmptyString(parsed.rationale, `${label}.rationale`), + severity: severity as Severity, + trackingIssue, + ...(controls === undefined ? {} : { compensatingControls: controls as string[] }), + }; +} + +export function parseAuditExceptionRegistry( + source: string, + now = new Date(), +): AuditExceptionRegistry { + let value: unknown; + try { + value = JSON.parse(source); + } catch (error) { + throw new Error(`npm audit exception registry is invalid JSON: ${String(error)}`); + } + const parsed = asRecord(value, "npm audit exception registry"); + requireExactKeys( + parsed, + new Set(["exceptions", "schemaVersion"]), + "npm audit exception registry", + ); + if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.exceptions)) { + throw new Error( + "npm audit exception registry must use schemaVersion 1 and an exceptions array", + ); + } + const exceptions = parsed.exceptions.map((entry, index) => parseException(entry, index, now)); + const identities = new Set(); + for (const exception of exceptions) { + const identity = [ + exception.graph, + exception.advisory, + exception.package, + exception.installedVersion, + ].join(":"); + if (identities.has(identity)) throw new Error(`duplicate npm audit exception: ${identity}`); + identities.add(identity); + } + return { schemaVersion: 1, exceptions }; +} + +export function readAuditExceptionRegistry( + filename: string, + now = new Date(), +): Readonly<{ policy: AuditExceptionRegistry; sha256: string }> { + const source = fs.readFileSync(filename, "utf-8"); + return { + policy: parseAuditExceptionRegistry(source, now), + sha256: createHash("sha256").update(source).digest("hex"), + }; +} + +export function assertExceptionGraphs( + registry: AuditExceptionRegistry, + graphIds: ReadonlySet, +): void { + const unknown = [...new Set(registry.exceptions.map((entry) => entry.graph))].filter( + (graph) => !graphIds.has(graph), + ); + if (unknown.length > 0) + throw new Error(`npm audit exceptions use unknown graphs: ${unknown.join(", ")}`); +} + +export function parseAuditReport(result: { + status: number | null; + stderr: string; + stdout: string; +}): Record { + if (!result.stdout.trim()) throw new Error(`npm audit did not produce JSON: ${result.stderr}`); + let report: Record; + try { + report = JSON.parse(result.stdout) as Record; + } catch (error) { + throw new Error(`npm audit returned invalid JSON: ${String(error)}`); + } + let counts: Record; + try { + counts = vulnerabilityCounts(report); + } catch (error) { + const detail = report.error === undefined ? result.stderr : JSON.stringify(report.error); + throw new Error( + `npm audit failed without a complete vulnerability report: ${error instanceof Error ? error.message : String(error)}${detail ? `; ${detail}` : ""}`, + ); + } + const findingCount = SEVERITIES.reduce((total, severity) => total + counts[severity], 0); + if ( + report.error !== undefined || + result.status === null || + result.status > 1 || + (result.status !== 0 && findingCount === 0) + ) { + const detail = report.error === undefined ? result.stderr : JSON.stringify(report.error); + throw new Error( + `npm audit failed without vulnerability findings${detail ? `: ${detail}` : ""}`, + ); + } + return report; +} + +export function vulnerabilityCounts(report: Record): Record { + const metadata = asRecord(report.metadata, "npm audit report metadata"); + const vulnerabilities = asRecord( + metadata.vulnerabilities, + "npm audit report metadata.vulnerabilities", + ); + const entries = SEVERITIES.map((severity) => { + const value = vulnerabilities[severity]; + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`npm audit report has invalid ${severity} vulnerability count`); + } + return [severity, value] as const; + }); + return Object.fromEntries(entries) as Record; +} + +export function exceedsAuditThreshold( + counts: Readonly>, + threshold: Severity, +): number { + return SEVERITIES.slice(SEVERITIES.indexOf(threshold)).reduce( + (total, severity) => total + counts[severity], + 0, + ); +} + +function advisoryId(value: Readonly>): string { + const url = nonEmptyString(value.url, "npm audit advisory URL"); + const match = url.match(/\/advisories\/([^/]+)$/u); + if (match?.[1] && ADVISORY_ID.test(match[1])) return match[1]; + const source = value.source; + if (typeof source === "number" && Number.isSafeInteger(source) && source > 0) { + return `NPM-${source}`; + } + throw new Error(`npm audit advisory has no supported identifier: ${url}`); +} + +function installedVersion(directory: string, node: string, expectedPackage: string): string { + if (!node.startsWith("node_modules/") || node.split("/").includes("..")) { + throw new Error(`npm audit reported an unsafe dependency node: ${node}`); + } + const root = path.resolve(directory); + const packageJson = path.resolve(root, node, "package.json"); + if (!packageJson.startsWith(`${root}${path.sep}`)) { + throw new Error(`npm audit dependency node escapes the audited graph: ${node}`); + } + const metadata = asRecord( + JSON.parse(fs.readFileSync(packageJson, "utf-8")), + `installed package metadata for ${node}`, + ); + if (metadata.name !== expectedPackage) { + throw new Error( + `npm audit node ${node} contains ${String(metadata.name)}, expected ${expectedPackage}`, + ); + } + return nonEmptyString(metadata.version, `installed version for ${node}`); +} + +function vulnerabilityEntries( + report: Record, +): Record> { + const parsed = asRecord(report.vulnerabilities, "npm audit report vulnerabilities"); + return Object.fromEntries( + Object.entries(parsed).map(([name, value]) => [ + name, + asRecord(value, `npm audit finding ${name}`), + ]), + ); +} + +function directBlockingFindings( + report: Record, + directory: string, + threshold: Severity, +): DirectFinding[] { + const entries = vulnerabilityEntries(report); + const thresholdIndex = SEVERITIES.indexOf(threshold); + const direct = new Map(); + const directIdsByPackage = new Map>(); + + for (const [packageName, entry] of Object.entries(entries)) { + if (!Array.isArray(entry.via) || !Array.isArray(entry.nodes)) { + throw new Error(`npm audit finding ${packageName} has invalid via or nodes data`); + } + for (const via of entry.via) { + if (typeof via === "string") continue; + const advisory = asRecord(via, `npm audit advisory for ${packageName}`); + const severity = nonEmptyString( + advisory.severity, + `npm audit advisory severity for ${packageName}`, + ); + if (!SEVERITIES.includes(severity as Severity)) { + throw new Error(`npm audit advisory for ${packageName} has invalid severity`); + } + if (SEVERITIES.indexOf(severity as Severity) < thresholdIndex) continue; + const id = advisoryId(advisory); + const ids = directIdsByPackage.get(packageName) ?? new Set(); + ids.add(id); + directIdsByPackage.set(packageName, ids); + for (const node of entry.nodes) { + if (typeof node !== "string") + throw new Error(`npm audit finding ${packageName} has an invalid node`); + const version = installedVersion(directory, node, packageName); + direct.set(`${id}:${packageName}:${version}`, { + advisory: id, + installedVersion: version, + package: packageName, + severity: severity as Severity, + }); + } + } + } + + function tracedBlockingAdvisories( + packageName: string, + visited: ReadonlySet, + ): Set { + if (visited.has(packageName)) + throw new Error(`npm audit meta-vulnerability cycle at ${packageName}`); + const own = directIdsByPackage.get(packageName); + if (own?.size) return new Set(own); + const entry = entries[packageName]; + if (!entry || !Array.isArray(entry.via)) return new Set(); + const nextVisited = new Set(visited).add(packageName); + return new Set( + entry.via.flatMap((via) => + typeof via === "string" ? [...tracedBlockingAdvisories(via, nextVisited)] : [], + ), + ); + } + + for (const [packageName, entry] of Object.entries(entries)) { + const severity = nonEmptyString( + entry.severity, + `npm audit finding severity for ${packageName}`, + ); + if (!SEVERITIES.includes(severity as Severity)) { + throw new Error(`npm audit finding ${packageName} has invalid severity`); + } + if (SEVERITIES.indexOf(severity as Severity) < thresholdIndex) continue; + if (tracedBlockingAdvisories(packageName, new Set()).size === 0) { + throw new Error(`npm audit blocking finding ${packageName} has no traceable advisory`); + } + } + return [...direct.values()].sort((left, right) => + [left.advisory, left.package, left.installedVersion] + .join(":") + .localeCompare([right.advisory, right.package, right.installedVersion].join(":")), + ); +} + +export function evaluateAuditPolicy( + options: Readonly<{ + directory: string; + exceptionPolicy: AuditExceptionRegistry; + exceptionPolicySha256: string; + graph: string; + report: Record; + threshold: Severity; + }>, +): AuditPolicyResult { + const findings = directBlockingFindings(options.report, options.directory, options.threshold); + const relevantExceptions = options.exceptionPolicy.exceptions.filter( + (entry) => entry.graph === options.graph, + ); + const used = new Set(); + const unaccepted = findings.filter((finding) => { + const matched = relevantExceptions.find( + (entry) => + entry.advisory === finding.advisory && + entry.package === finding.package && + entry.installedVersion === finding.installedVersion && + entry.severity === finding.severity, + ); + if (matched) used.add(matched); + return matched === undefined; + }); + const unused = relevantExceptions.filter((entry) => !used.has(entry)); + if (unused.length > 0) { + throw new Error( + `${options.graph}: unused npm audit exceptions: ${unused.map((entry) => entry.advisory).join(", ")}`, + ); + } + const acceptedAdvisories = [...new Set([...used].map((entry) => entry.advisory))].sort(); + return { + acceptedAdvisories, + blockingThreshold: options.threshold, + exceptionPolicySha256: options.exceptionPolicySha256, + graph: options.graph, + reported: vulnerabilityCounts(options.report), + schemaVersion: 1, + status: unaccepted.length > 0 ? "blocked" : used.size > 0 ? "accepted-exceptions" : "clean", + unacceptedBlockingAdvisories: unaccepted, + }; +} + +export function runReviewedNpmAudit( + options: Readonly<{ + directory: string; + exceptionFile: string; + graph: string; + reportFile?: string; + resultFile?: string; + threshold: Severity; + throwOnBlock?: boolean; + }>, +): AuditPolicyResult { + const exceptionRegistry = readAuditExceptionRegistry(options.exceptionFile); + const result = spawnSync("npm", ["audit", "--omit=dev", "--json"], { + cwd: options.directory, + encoding: "utf-8", + env: { ...process.env, NPM_CONFIG_UPDATE_NOTIFIER: "false" }, + maxBuffer: 64 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error) throw result.error; + if (options.reportFile) fs.writeFileSync(options.reportFile, result.stdout); + const report = parseAuditReport(result); + const policyResult = evaluateAuditPolicy({ + directory: options.directory, + exceptionPolicy: exceptionRegistry.policy, + exceptionPolicySha256: exceptionRegistry.sha256, + graph: options.graph, + report, + threshold: options.threshold, + }); + if (options.resultFile) + fs.writeFileSync(options.resultFile, `${JSON.stringify(policyResult, null, 2)}\n`); + const summary = SEVERITIES.map( + (severity) => `${severity}=${policyResult.reported[severity]}`, + ).join(" "); + console.log(`${options.graph}: ${summary} status=${policyResult.status}`); + if ((options.throwOnBlock ?? true) && policyResult.unacceptedBlockingAdvisories.length > 0) { + throw new Error( + `${options.graph}: unaccepted npm audit findings at or above ${options.threshold}: ${policyResult.unacceptedBlockingAdvisories.map((finding) => finding.advisory).join(", ")}`, + ); + } + return policyResult; +} + +function parseCliArgs(args: readonly string[]): { + directory: string; + exceptionFile: string; + graph: string; + reportFile?: string; + resultFile?: string; + threshold: Severity; +} { + const values = new Map(); + for (let index = 0; index < args.length; index += 2) { + const key = args[index]; + const value = args[index + 1]; + if (!key?.startsWith("--") || value === undefined) + throw new Error("invalid reviewed npm audit arguments"); + if (values.has(key)) throw new Error(`duplicate reviewed npm audit argument: ${key}`); + values.set(key, value); + } + const allowed = new Set([ + "--directory", + "--exceptions", + "--graph", + "--report", + "--result", + "--threshold", + ]); + const unknown = [...values.keys()].filter((key) => !allowed.has(key)); + if (unknown.length > 0) + throw new Error(`unknown reviewed npm audit arguments: ${unknown.join(", ")}`); + const directory = values.get("--directory"); + const exceptionFile = values.get("--exceptions"); + const graph = values.get("--graph"); + const threshold = values.get("--threshold"); + if (!directory || !exceptionFile || !graph || !threshold) { + throw new Error( + "reviewed npm audit requires --directory, --exceptions, --graph, and --threshold", + ); + } + if (!SEVERITIES.includes(threshold as Severity)) + throw new Error("reviewed npm audit threshold is invalid"); + return { + directory, + exceptionFile, + graph, + threshold: threshold as Severity, + ...(values.has("--report") ? { reportFile: values.get("--report") } : {}), + ...(values.has("--result") ? { resultFile: values.get("--result") } : {}), + }; +} + +function isMainModule(): boolean { + return process.argv[1] + ? import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href + : false; +} + +if (isMainModule()) { + try { + runReviewedNpmAudit(parseCliArgs(process.argv.slice(2))); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/test/mcporter-supply-chain.test.ts b/test/mcporter-supply-chain.test.ts index fd34ae32b5c..213c8a39831 100644 --- a/test/mcporter-supply-chain.test.ts +++ b/test/mcporter-supply-chain.test.ts @@ -119,7 +119,17 @@ describe("mcporter image supply-chain controls", () => { }); it.each(dockerfiles)("audits the committed dependency graph in $name", ({ contents }) => { - expect(contents).toContain(`${runtimePrefix} audit --omit=dev --audit-level=low`); + const flattenedContents = contents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); + expect(contents).toContain( + "COPY ci/npm-audit-exceptions.json /scripts/npm-audit-exceptions.json", + ); + expect(contents).toContain( + "COPY scripts/lib/reviewed-npm-audit.mts /scripts/lib/reviewed-npm-audit.mts", + ); + expect(flattenedContents).toContain( + "node --experimental-strip-types /scripts/lib/reviewed-npm-audit.mts --directory /usr/local/lib/nemoclaw/mcporter-runtime --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high", + ); + expect(contents).not.toContain(`${runtimePrefix} audit --omit=dev --audit-level=low`); expect(contents).toContain(`${runtimePrefix} audit signatures`); }); }); diff --git a/test/openclaw-dependency-review.test.ts b/test/openclaw-dependency-review.test.ts index 300be2d0f14..9f35c4df224 100644 --- a/test/openclaw-dependency-review.test.ts +++ b/test/openclaw-dependency-review.test.ts @@ -247,6 +247,10 @@ describe("OpenClaw 2026.6.10 dependency review contract", () => { expect(review).toContain("Default PR and main CI now rematerialize"); expect(review).toContain("`npm audit --omit=dev --json`"); expect(review).toContain("configured threshold in `ci/reviewed-npm-audit.json` is `high`"); + expect(review).toContain( + "exception registry at `ci/npm-audit-exceptions.json` is empty by default", + ); + expect(review).toContain("contains no exception for `GHSA-v2hh-gcrm-f6hx`"); expect(review).toContain("Transitive Dependency Graph Rationale"); expect(review).toContain("Transitive Remediation Boundary"); expect(review).toContain("Transitive Remediation Concern Ledger"); @@ -436,7 +440,10 @@ for dockerfile in Dockerfile Dockerfile.base; do check_contains "$openclaw_block" 'mcporter-package=mcporter@' "$dockerfile mcporter provenance package" check_contains "$openclaw_block" 'mcporter-integrity=' "$dockerfile mcporter provenance integrity" check_contains "$openclaw_block" 'mcporter-lock-sha256=' "$dockerfile mcporter provenance lock hash" - check_contains "$openclaw_block" 'mcporter-recipe=locked-ci+audit-signatures-v1' "$dockerfile mcporter provenance recipe" + check_contains "$openclaw_block" 'mcporter-audit-policy-sha256=' "$dockerfile mcporter audit policy hash" + check_contains "$openclaw_block" 'mcporter-audit-status=' "$dockerfile mcporter audit status" + check_contains "$openclaw_block" 'mcporter-audit-exceptions=' "$dockerfile mcporter audit exceptions" + check_contains "$openclaw_block" 'mcporter-recipe=locked-ci+reviewed-audit+signatures-v2' "$dockerfile mcporter provenance recipe" done check_contains "$(cat Dockerfile.base)" 'chmod 0444 "$OPENCLAW_PROVENANCE_TMP"' "base provenance protected mode" diff --git a/test/openclaw-integrity-pin-suite.ts b/test/openclaw-integrity-pin-suite.ts index 3be3522a309..3d8d0fcea90 100644 --- a/test/openclaw-integrity-pin-suite.ts +++ b/test/openclaw-integrity-pin-suite.ts @@ -60,9 +60,13 @@ const MCPORTER_LOCKFILE = path.join( "mcporter-runtime", "package-lock.json", ); +const NPM_AUDIT_EXCEPTION_FILE = path.join(REPO_ROOT, "ci", "npm-audit-exceptions.json"); const PINNED_MCPORTER_LOCK_SHA256 = createHash("sha256") .update(fs.readFileSync(MCPORTER_LOCKFILE)) .digest("hex"); +const NPM_AUDIT_EXCEPTION_POLICY_SHA256 = createHash("sha256") + .update(fs.readFileSync(NPM_AUDIT_EXCEPTION_FILE)) + .digest("hex"); const PINNED_OPENCLAW_DIAGNOSTICS_OTEL_INTEGRITY = "sha512-EJt0fjk4bcR3N/9u00f1pL0BJYG5yfC09DV3l6rWDmytpE2vUeBZWpx4pOmFDreGV+7DKxhCbQDgDAmvZGjLag=="; const PINNED_OPENCLAW_DIAGNOSTICS_OTEL_TARBALL = @@ -103,7 +107,7 @@ function openClawBaseProvenance( ? "ignore-scripts+reviewed-lifecycle+transitive-remediation-v1" : "ignore-scripts+reviewed-lifecycle-v1"; return [ - "schema=2", + "schema=3", `package=openclaw@${version}`, `integrity=${integrity}`, `tarball=${tarball}`, @@ -112,7 +116,10 @@ function openClawBaseProvenance( `mcporter-integrity=${PINNED_MCPORTER_INTEGRITY}`, `mcporter-tarball=${PINNED_MCPORTER_TARBALL}`, `mcporter-lock-sha256=${PINNED_MCPORTER_LOCK_SHA256}`, - "mcporter-recipe=locked-ci+audit-signatures-v1", + `mcporter-audit-policy-sha256=${NPM_AUDIT_EXCEPTION_POLICY_SHA256}`, + "mcporter-audit-status=clean", + "mcporter-audit-exceptions=none", + "mcporter-recipe=locked-ci+reviewed-audit+signatures-v2", "", ].join("\n"); } @@ -187,6 +194,7 @@ function runInstallBlock( const mcporterBin = path.join(tmp, "bin", "mcporter"); const reviewedNpmExecutable = path.join(tmp, "bin", "reviewed-npm-fixture"); const remediationHelper = path.join(tmp, "openclaw-npm-remediation.cjs"); + const auditHelper = path.join(tmp, "reviewed-npm-audit.cjs"); fs.mkdirSync(path.dirname(mcporterBin), { recursive: true }); fs.mkdirSync(mcporterRuntime, { recursive: true }); fs.copyFileSync(MCPORTER_LOCKFILE, path.join(mcporterRuntime, "package-lock.json")); @@ -238,6 +246,21 @@ function runInstallBlock( "", ].join("\n"), ); + fs.writeFileSync( + auditHelper, + [ + 'const fs = require("node:fs");', + "const args = process.argv.slice(2);", + "const value = (name) => args[args.indexOf(name) + 1];", + "const counts = { info: 0, low: 0, moderate: 0, high: 0, critical: 0 };", + "const report = { auditReportVersion: 2, vulnerabilities: {}, metadata: { vulnerabilities: counts } };", + `const policy = { schemaVersion: 1, graph: value("--graph"), blockingThreshold: value("--threshold"), exceptionPolicySha256: ${JSON.stringify(NPM_AUDIT_EXCEPTION_POLICY_SHA256)}, reported: counts, status: "clean", acceptedAdvisories: [], unacceptedBlockingAdvisories: [] };`, + 'if (args.includes("--report")) fs.writeFileSync(value("--report"), `${JSON.stringify(report)}\\n`);', + 'if (args.includes("--result")) fs.writeFileSync(value("--result"), `${JSON.stringify(policy)}\\n`);', + "console.log(`npm audit policy ${policy.graph}: clean`);", + "", + ].join("\n"), + ); const writeProvenanceFile = () => { fs.writeFileSync(provenancePath, baseProvenance as string, { mode: 0o444 }); }; @@ -286,6 +309,11 @@ function runInstallBlock( ' if [ "${1:-}" = "-c" ] && [ "${3:-}" = "$openclaw_provenance_path" ]; then printf "%s\\n" "$openclaw_provenance_metadata"; return 0; fi', ' command stat "$@"', "}", + "sha256sum() {", + ` if [ "\${1:-}" = ${JSON.stringify(path.join(mcporterRuntime, "package-lock.json"))} ]; then printf '%s %s\\n' ${JSON.stringify(PINNED_MCPORTER_LOCK_SHA256)} "$1"; return 0; fi`, + ` if [ "\${1:-}" = ${JSON.stringify(NPM_AUDIT_EXCEPTION_FILE)} ]; then printf '%s %s\\n' ${JSON.stringify(NPM_AUDIT_EXCEPTION_POLICY_SHA256)} "$1"; return 0; fi`, + ' printf "unexpected sha256sum input: %s\\n" "${1:-}" >&2; return 1', + "}", "npm() {", ' printf "npm %s\\n" "$*" >> "$call_log";', ' [ "${1:-}" != "--prefix" ] || [ "${3:-}" != "ci" ] || installed_mcporter_version="$MCPORTER_VERSION"', @@ -323,7 +351,9 @@ function runInstallBlock( .replaceAll("/usr/local/lib/nemoclaw/mcporter-runtime", mcporterRuntime) .replaceAll("/usr/local/bin/mcporter", mcporterBin) .replaceAll("/scripts/lib/reviewed-npm-archive.mts", REVIEWED_NPM_ARCHIVE_HELPER) - .replaceAll("/scripts/lib/openclaw-npm-remediation.mts", remediationHelper), + .replaceAll("/scripts/lib/openclaw-npm-remediation.mts", remediationHelper) + .replaceAll("/scripts/lib/reviewed-npm-audit.mts", auditHelper) + .replaceAll("/scripts/npm-audit-exceptions.json", NPM_AUDIT_EXCEPTION_FILE), ].join("\n"); const scriptPath = path.join(tmp, "run.sh"); fs.writeFileSync(scriptPath, script, { mode: 0o700 }); @@ -857,7 +887,7 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes ["missing marker", { baseProvenance: null }], [ "wrong schema", - { baseProvenance: openClawBaseProvenance().replace("schema=2", "schema=1") }, + { baseProvenance: openClawBaseProvenance().replace("schema=3", "schema=2") }, ], [ "wrong version", @@ -935,11 +965,38 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes "wrong mcporter recipe", { baseProvenance: openClawBaseProvenance().replace( - "mcporter-recipe=locked-ci+audit-signatures-v1", + "mcporter-recipe=locked-ci+reviewed-audit+signatures-v2", "mcporter-recipe=locked-ci-only-v1", ), }, ], + [ + "wrong mcporter audit policy", + { + baseProvenance: openClawBaseProvenance().replace( + `mcporter-audit-policy-sha256=${NPM_AUDIT_EXCEPTION_POLICY_SHA256}`, + `mcporter-audit-policy-sha256=${"0".repeat(64)}`, + ), + }, + ], + [ + "wrong mcporter audit status", + { + baseProvenance: openClawBaseProvenance().replace( + "mcporter-audit-status=clean", + "mcporter-audit-status=accepted-exceptions", + ), + }, + ], + [ + "wrong mcporter audit exceptions", + { + baseProvenance: openClawBaseProvenance().replace( + "mcporter-audit-exceptions=none", + "mcporter-audit-exceptions=GHSA-aaaa-bbbb-cccc", + ), + }, + ], [ "writable marker", { baseProvenance: openClawBaseProvenance(), baseProvenanceMetadata: "0:0:644" }, diff --git a/test/reviewed-npm-audit.test.ts b/test/reviewed-npm-audit.test.ts index 2fbdee52641..9a6a9fc9e8b 100644 --- a/test/reviewed-npm-audit.test.ts +++ b/test/reviewed-npm-audit.test.ts @@ -2,13 +2,19 @@ // SPDX-License-Identifier: Apache-2.0 import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { + type AuditExceptionRegistry, + assertExceptionGraphs, + evaluateAuditPolicy, exceedsAuditThreshold, + parseAuditExceptionRegistry, parseAuditReport, + readAuditExceptionRegistry, vulnerabilityCounts, -} from "../scripts/audit-reviewed-npm-graph.mts"; +} from "../scripts/lib/reviewed-npm-audit.mts"; const REPO_ROOT = path.join(import.meta.dirname, ".."); const CONFIG = JSON.parse( @@ -16,8 +22,100 @@ const CONFIG = JSON.parse( ) as { severityThreshold: "info" | "low" | "moderate" | "high" | "critical"; }; +const EMPTY_POLICY = parseAuditExceptionRegistry( + fs.readFileSync(path.join(REPO_ROOT, "ci", "npm-audit-exceptions.json"), "utf-8"), +); +const NOW = new Date("2026-07-21T12:00:00Z"); + +function withInstalledGraph( + packages: Readonly>, + run: (directory: string) => void, +): void { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-audit-test-")); + try { + for (const [name, version] of Object.entries(packages)) { + const packageDirectory = path.join(directory, "node_modules", ...name.split("/")); + fs.mkdirSync(packageDirectory, { recursive: true }); + fs.writeFileSync( + path.join(packageDirectory, "package.json"), + `${JSON.stringify({ name, version })}\n`, + ); + } + run(directory); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +} + +function highFindingReport(advisory = "GHSA-aaaa-bbbb-cccc") { + return { + auditReportVersion: 2, + vulnerabilities: { + parent: { + name: "parent", + severity: "high", + isDirect: true, + via: ["vulnerable-package"], + effects: [], + nodes: ["node_modules/parent"], + }, + "vulnerable-package": { + name: "vulnerable-package", + severity: "high", + isDirect: false, + via: [ + { + source: 123456, + name: "vulnerable-package", + dependency: "vulnerable-package", + title: "test advisory", + url: `https://github.com/advisories/${advisory}`, + severity: "high", + range: "<=1.0.0", + }, + ], + effects: ["parent"], + nodes: ["node_modules/vulnerable-package"], + }, + }, + metadata: { + vulnerabilities: { info: 0, low: 0, moderate: 0, high: 2, critical: 0 }, + }, + }; +} + +function exceptionPolicy( + overrides: Readonly> = {}, +): AuditExceptionRegistry { + return parseAuditExceptionRegistry( + JSON.stringify({ + schemaVersion: 1, + exceptions: [ + { + advisory: "GHSA-aaaa-bbbb-cccc", + package: "vulnerable-package", + installedVersion: "1.0.0", + graph: "test-graph", + severity: "high", + decision: "temporary-risk-acceptance", + expires: "2026-07-28", + owner: "security-maintainers", + trackingIssue: "https://github.com/NVIDIA/NemoClaw/issues/1234", + rationale: "The fix is in validation.", + compensatingControls: ["The vulnerable input is rejected before this package runs."], + ...overrides, + }, + ], + }), + NOW, + ); +} describe("reviewed npm audit gate", () => { + it("uses an empty exception registry by default", () => { + expect(EMPTY_POLICY).toEqual({ schemaVersion: 1, exceptions: [] }); + }); + it("fails at high or critical findings while retaining lower severities", () => { const report = { metadata: { @@ -63,4 +161,107 @@ describe("reviewed npm audit gate", () => { parseAuditReport({ status: 0, stderr: "", stdout: JSON.stringify(report) }), ).toThrow(/vulnerability report|vulnerability count/); }); + + it("accepts one exact blocking advisory and its propagated meta-vulnerability", () => { + withInstalledGraph({ parent: "2.0.0", "vulnerable-package": "1.0.0" }, (directory) => { + const result = evaluateAuditPolicy({ + directory, + exceptionPolicy: exceptionPolicy(), + exceptionPolicySha256: "a".repeat(64), + graph: "test-graph", + report: highFindingReport(), + threshold: "high", + }); + expect(result.status).toBe("accepted-exceptions"); + expect(result.acceptedAdvisories).toEqual(["GHSA-aaaa-bbbb-cccc"]); + expect(result.unacceptedBlockingAdvisories).toEqual([]); + }); + }); + + it("does not let one exception suppress another blocking advisory", () => { + withInstalledGraph( + { parent: "2.0.0", "other-package": "3.0.0", "vulnerable-package": "1.0.0" }, + (directory) => { + const report = highFindingReport() as Record; + const vulnerabilities = report.vulnerabilities as Record; + vulnerabilities["other-package"] = { + name: "other-package", + severity: "high", + isDirect: false, + via: [ + { + source: 654321, + name: "other-package", + dependency: "other-package", + title: "another advisory", + url: "https://github.com/advisories/GHSA-dddd-eeee-ffff", + severity: "high", + range: "<=3.0.0", + }, + ], + effects: [], + nodes: ["node_modules/other-package"], + }; + const metadata = report.metadata as { + vulnerabilities: { high: number }; + }; + metadata.vulnerabilities.high = 3; + const result = evaluateAuditPolicy({ + directory, + exceptionPolicy: exceptionPolicy(), + exceptionPolicySha256: "a".repeat(64), + graph: "test-graph", + report, + threshold: "high", + }); + expect(result.status).toBe("blocked"); + expect(result.unacceptedBlockingAdvisories).toEqual([ + { + advisory: "GHSA-dddd-eeee-ffff", + installedVersion: "3.0.0", + package: "other-package", + severity: "high", + }, + ]); + }, + ); + }); + + it("rejects an exception that does not match a reported finding", () => { + withInstalledGraph({ parent: "2.0.0", "vulnerable-package": "1.0.0" }, (directory) => { + expect(() => + evaluateAuditPolicy({ + directory, + exceptionPolicy: exceptionPolicy({ installedVersion: "1.0.1" }), + exceptionPolicySha256: "a".repeat(64), + graph: "test-graph", + report: highFindingReport(), + threshold: "high", + }), + ).toThrow(/unused npm audit exceptions/); + }); + }); + + it("rejects exception graph IDs outside the configured production inventory", () => { + expect(() => assertExceptionGraphs(exceptionPolicy(), new Set(["production-graph"]))).toThrow( + /unknown graphs: test-graph/, + ); + }); + + it.each([ + ["expired", { expires: "2026-07-20" }, /expired/], + ["invalid date", { expires: "2026-02-31" }, /YYYY-MM-DD/], + ["overlong", { expires: "2026-09-01" }, /within 30 days/], + ["unknown field", { extra: true }, /unknown fields/], + ["missing controls", { compensatingControls: undefined }, /compensatingControls is required/], + ["foreign issue", { trackingIssue: "https://github.com/example/project/issues/1" }, /NemoClaw/], + ])("rejects an %s exception", (_label, overrides, message) => { + expect(() => exceptionPolicy(overrides)).toThrow(message); + }); + + it("rejects a missing exception registry instead of treating it as empty", () => { + expect(() => readAuditExceptionRegistry(path.join(REPO_ROOT, "ci", "missing.json"))).toThrow( + /ENOENT/, + ); + }); }); From d99a0cb3fc3d7d4119aa3d4698fefef40f41de0a Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Thu, 23 Jul 2026 10:45:22 -0700 Subject: [PATCH 2/5] fix(ci): stage audit policy in sandbox builds Signed-off-by: Senthil Ravichandran --- src/lib/sandbox/build-context.ts | 8 ++++++++ test/sandbox-build-context.test.ts | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index 46eed367ee8..fe7ba226695 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -106,6 +106,7 @@ function stageOptimizedSandboxBuildContext( const stagedNemoclawDir = path.join(buildCtx, "nemoclaw"); const sourceBlueprintDir = path.join(rootDir, "nemoclaw-blueprint"); const stagedBlueprintDir = path.join(buildCtx, "nemoclaw-blueprint"); + const stagedCiDir = path.join(buildCtx, "ci"); const stagedScriptsDir = path.join(buildCtx, "scripts"); fs.copyFileSync(path.join(rootDir, "Dockerfile"), stagedDockerfile); @@ -115,6 +116,13 @@ function stageOptimizedSandboxBuildContext( ); stageOpenClawRuntimeGraphs(rootDir, buildCtx); + fs.mkdirSync(stagedCiDir, { recursive: true }); + fs.copyFileSync( + path.join(rootDir, "ci", "npm-audit-exceptions.json"), + path.join(stagedCiDir, "npm-audit-exceptions.json"), + ); + normalizeReadModesForDockerCopy(stagedCiDir); + fs.mkdirSync(stagedNemoclawDir, { recursive: true }); for (const fileName of [ "package.json", diff --git a/test/sandbox-build-context.test.ts b/test/sandbox-build-context.test.ts index e87394e5e59..3643b4a184e 100644 --- a/test/sandbox-build-context.test.ts +++ b/test/sandbox-build-context.test.ts @@ -35,6 +35,10 @@ describe("sandbox build context staging", () => { writeFixture("Dockerfile"); writeFixture("tsconfig.runtime-preloads.json", "{}\n"); + writeFixture( + path.join("ci", "npm-audit-exceptions.json"), + `${JSON.stringify({ schemaVersion: 1, exceptions: [] })}\n`, + ); for (const runtimeName of ["mcporter-runtime", "wechat-runtime"]) { for (const fileName of ["package.json", "package-lock.json"]) { writeFixture( @@ -346,6 +350,9 @@ describe("sandbox build context staging", () => { const { buildCtx, stagedDockerfile } = stageOptimizedSandboxBuildContext(repoRoot, tmpDir); expectDockerfileScriptCopiesExist(buildCtx, stagedDockerfile); expect(fs.existsSync(path.join(buildCtx, "tsconfig.runtime-preloads.json"))).toBe(true); + expect(fs.readFileSync(path.join(buildCtx, "ci", "npm-audit-exceptions.json"), "utf8")).toBe( + fs.readFileSync(path.join(repoRoot, "ci", "npm-audit-exceptions.json"), "utf8"), + ); expectStagedOpenClawRuntimeGraphs(buildCtx, repoRoot); expect(fs.existsSync(path.join(buildCtx, "nemoclaw-blueprint", ".venv"))).toBe(false); expect(fs.existsSync(path.join(buildCtx, "nemoclaw-blueprint", "blueprint.yaml"))).toBe(true); From 8e523642bce63a40bf07fbaf0a017bc49a7cfac6 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Thu, 23 Jul 2026 14:08:00 -0700 Subject: [PATCH 3/5] fix(ci): revalidate audit exception expiry Signed-off-by: Senthil Ravichandran --- Dockerfile | 3 +- test/openclaw-integrity-pin-suite.ts | 92 ++++++++++++++++++++++++++-- 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9ddb4d94485..7878f34fce2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -318,7 +318,8 @@ RUN set -eu; \ [ -n "$MCPORTER_LOCK_SHA256" ] \ || { echo "ERROR: Could not hash the committed mcporter lockfile" >&2; exit 1; }; \ MCPORTER_AUDIT_POLICY_SHA256="$(sha256sum /scripts/npm-audit-exceptions.json | awk '{print $1}')"; \ - MCPORTER_EXPECTED_AUDIT_EXCEPTIONS="$(node -e "const policy=require('/scripts/npm-audit-exceptions.json'); const ids=policy.exceptions.filter((entry)=>entry.graph==='mcporter-runtime').map((entry)=>entry.advisory).sort(); process.stdout.write(ids.join(',') || 'none');")"; \ + MCPORTER_EXPECTED_AUDIT_EXCEPTIONS="$(node --experimental-strip-types --input-type=module -e \ + 'import fs from "node:fs"; import { parseAuditExceptionRegistry } from "/scripts/lib/reviewed-npm-audit.mts"; const policy=parseAuditExceptionRegistry(fs.readFileSync("/scripts/npm-audit-exceptions.json", "utf-8")); const ids=policy.exceptions.filter((entry)=>entry.graph==="mcporter-runtime").map((entry)=>entry.advisory).sort(); process.stdout.write(ids.join(",") || "none");')"; \ MCPORTER_EXPECTED_AUDIT_STATUS=clean; \ if [ "$MCPORTER_EXPECTED_AUDIT_EXCEPTIONS" != "none" ]; then MCPORTER_EXPECTED_AUDIT_STATUS=accepted-exceptions; fi; \ CUR_VER=$(openclaw --version 2>/dev/null | awk '{print $2}' || true); \ diff --git a/test/openclaw-integrity-pin-suite.ts b/test/openclaw-integrity-pin-suite.ts index 5e291a1eabb..b7457e65730 100644 --- a/test/openclaw-integrity-pin-suite.ts +++ b/test/openclaw-integrity-pin-suite.ts @@ -105,6 +105,15 @@ function openClawBaseProvenance( version = PINNED_OPENCLAW_VERSION, integrity = PINNED_OPENCLAW_INTEGRITY, tarball = PINNED_OPENCLAW_TARBALL, + auditPolicy: Readonly<{ + exceptions: string; + sha256: string; + status: "accepted-exceptions" | "clean"; + }> = { + exceptions: "none", + sha256: NPM_AUDIT_EXCEPTION_POLICY_SHA256, + status: "clean", + }, ): string { const recipe = version === LEGACY_REBUILD_OPENCLAW_VERSION @@ -120,9 +129,9 @@ function openClawBaseProvenance( `mcporter-integrity=${PINNED_MCPORTER_INTEGRITY}`, `mcporter-tarball=${PINNED_MCPORTER_TARBALL}`, `mcporter-lock-sha256=${PINNED_MCPORTER_LOCK_SHA256}`, - `mcporter-audit-policy-sha256=${NPM_AUDIT_EXCEPTION_POLICY_SHA256}`, - "mcporter-audit-status=clean", - "mcporter-audit-exceptions=none", + `mcporter-audit-policy-sha256=${auditPolicy.sha256}`, + `mcporter-audit-status=${auditPolicy.status}`, + `mcporter-audit-exceptions=${auditPolicy.exceptions}`, "mcporter-recipe=locked-ci+reviewed-audit+signatures-v2", "", ].join("\n"); @@ -169,6 +178,7 @@ function runInstallBlock( baseProvenance?: string | null; baseProvenanceMetadata?: string; baseProvenanceSymlink?: boolean; + auditExceptionPolicy?: string; } = {}, ) { const { @@ -189,6 +199,7 @@ function runInstallBlock( baseProvenance = null, baseProvenanceMetadata = "0:0:444", baseProvenanceSymlink = false, + auditExceptionPolicy = fs.readFileSync(NPM_AUDIT_EXCEPTION_FILE, "utf-8"), } = options; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-integrity-")); const blueprint = path.join(tmp, "blueprint.yaml"); @@ -199,10 +210,15 @@ function runInstallBlock( const reviewedNpmExecutable = path.join(tmp, "bin", "reviewed-npm-fixture"); const remediationHelper = path.join(tmp, "openclaw-npm-remediation.cjs"); const auditHelper = path.join(tmp, "reviewed-npm-audit.cjs"); + const auditExceptionFile = path.join(tmp, "npm-audit-exceptions.json"); + const auditExceptionPolicySha256 = createHash("sha256") + .update(auditExceptionPolicy) + .digest("hex"); fs.mkdirSync(path.dirname(mcporterBin), { recursive: true }); fs.mkdirSync(mcporterRuntime, { recursive: true }); fs.copyFileSync(MCPORTER_LOCKFILE, path.join(mcporterRuntime, "package-lock.json")); fs.writeFileSync(blueprint, fs.readFileSync(BLUEPRINT, "utf-8")); + fs.writeFileSync(auditExceptionFile, auditExceptionPolicy); fs.writeFileSync( reviewedNpmExecutable, [ @@ -254,14 +270,24 @@ function runInstallBlock( auditHelper, [ 'const fs = require("node:fs");', + "exports.parseAuditExceptionRegistry = (source) => {", + " const policy = JSON.parse(source);", + " for (const entry of policy.exceptions) {", + " const expiresAt = new Date(`${entry.expires}T23:59:59.999Z`);", + " if (expiresAt.valueOf() < Date.now()) throw new Error(`npm audit exception 1 expired on ${entry.expires}`);", + " }", + " return policy;", + "};", + "if (require.main === module) {", "const args = process.argv.slice(2);", "const value = (name) => args[args.indexOf(name) + 1];", "const counts = { info: 0, low: 0, moderate: 0, high: 0, critical: 0 };", "const report = { auditReportVersion: 2, vulnerabilities: {}, metadata: { vulnerabilities: counts } };", - `const policy = { schemaVersion: 1, graph: value("--graph"), blockingThreshold: value("--threshold"), exceptionPolicySha256: ${JSON.stringify(NPM_AUDIT_EXCEPTION_POLICY_SHA256)}, reported: counts, status: "clean", acceptedAdvisories: [], unacceptedBlockingAdvisories: [] };`, + `const policy = { schemaVersion: 1, graph: value("--graph"), blockingThreshold: value("--threshold"), exceptionPolicySha256: ${JSON.stringify(auditExceptionPolicySha256)}, reported: counts, status: "clean", acceptedAdvisories: [], unacceptedBlockingAdvisories: [] };`, 'if (args.includes("--report")) fs.writeFileSync(value("--report"), `${JSON.stringify(report)}\\n`);', 'if (args.includes("--result")) fs.writeFileSync(value("--result"), `${JSON.stringify(policy)}\\n`);', "console.log(`npm audit policy ${policy.graph}: clean`);", + "}", "", ].join("\n"), ); @@ -316,7 +342,7 @@ function runInstallBlock( "}", "sha256sum() {", ` if [ "\${1:-}" = ${JSON.stringify(path.join(mcporterRuntime, "package-lock.json"))} ]; then printf '%s %s\\n' ${JSON.stringify(PINNED_MCPORTER_LOCK_SHA256)} "$1"; return 0; fi`, - ` if [ "\${1:-}" = ${JSON.stringify(NPM_AUDIT_EXCEPTION_FILE)} ]; then printf '%s %s\\n' ${JSON.stringify(NPM_AUDIT_EXCEPTION_POLICY_SHA256)} "$1"; return 0; fi`, + ` if [ "\${1:-}" = ${JSON.stringify(auditExceptionFile)} ]; then printf '%s %s\\n' ${JSON.stringify(auditExceptionPolicySha256)} "$1"; return 0; fi`, ' printf "unexpected sha256sum input: %s\\n" "${1:-}" >&2; return 1', "}", "npm() {", @@ -358,7 +384,7 @@ function runInstallBlock( .replaceAll("/scripts/lib/reviewed-npm-archive.mts", REVIEWED_NPM_ARCHIVE_HELPER) .replaceAll("/scripts/lib/openclaw-npm-remediation.mts", remediationHelper) .replaceAll("/scripts/lib/reviewed-npm-audit.mts", auditHelper) - .replaceAll("/scripts/npm-audit-exceptions.json", NPM_AUDIT_EXCEPTION_FILE), + .replaceAll("/scripts/npm-audit-exceptions.json", auditExceptionFile), ].join("\n"); const scriptPath = path.join(tmp, "run.sh"); fs.writeFileSync(scriptPath, script, { mode: 0o700 }); @@ -929,6 +955,60 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes expect(provenanceExists).toBe(false); }); + it("rejects matching trusted-base provenance when its audit exception has expired", () => { + const advisory = "GHSA-aaaa-bbbb-cccc"; + const auditExceptionPolicy = `${JSON.stringify({ + schemaVersion: 1, + exceptions: [ + { + advisory, + package: "fast-uri", + installedVersion: "3.1.3", + graph: "mcporter-runtime", + severity: "high", + decision: "temporary-risk-acceptance", + expires: "2000-01-01", + owner: "security-maintainers", + trackingIssue: "https://github.com/NVIDIA/NemoClaw/issues/1234", + rationale: "Regression fixture for trusted-base expiry.", + compensatingControls: ["The child build revalidates exception expiry."], + }, + ], + })}\n`; + const auditPolicy = { + exceptions: advisory, + sha256: createHash("sha256").update(auditExceptionPolicy).digest("hex"), + status: "accepted-exceptions" as const, + }; + const { result, calls, provenanceExists } = runInstallBlock( + extractRunBlock( + DOCKERFILE, + "# OPENCLAW_VERSION is the NemoClaw runtime build target", + "# Patch OpenClaw media fetch", + ), + { + openclawVersion: PINNED_OPENCLAW_VERSION, + installedOpenClawVersion: PINNED_OPENCLAW_VERSION, + committedIntegrity: PINNED_OPENCLAW_INTEGRITY, + registryIntegrity: PINNED_OPENCLAW_INTEGRITY, + auditExceptionPolicy, + baseProvenance: openClawBaseProvenance( + PINNED_OPENCLAW_VERSION, + PINNED_OPENCLAW_INTEGRITY, + PINNED_OPENCLAW_TARBALL, + auditPolicy, + ), + }, + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("expired on 2000-01-01"); + expect(result.stdout).not.toContain("Reusing reviewed base OpenClaw"); + expect(result.stdout).not.toContain("Reusing reviewed base mcporter"); + expect(calls).toBe(""); + expect(provenanceExists).toBe(true); + }); + it.each([ ["missing marker", { baseProvenance: null }], [ From 56334ba9e2edd4bb272a7f4943a6db1cbc071c29 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Thu, 23 Jul 2026 14:17:54 -0700 Subject: [PATCH 4/5] test(ci): reuse audit parser in provenance fixture Signed-off-by: Senthil Ravichandran --- test/openclaw-integrity-pin-suite.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/test/openclaw-integrity-pin-suite.ts b/test/openclaw-integrity-pin-suite.ts index b7457e65730..7606e998c3d 100644 --- a/test/openclaw-integrity-pin-suite.ts +++ b/test/openclaw-integrity-pin-suite.ts @@ -39,6 +39,7 @@ const REVIEWED_NPM_ARCHIVE_HELPER = path.join( "lib", "reviewed-npm-archive.mts", ); +const REVIEWED_NPM_AUDIT_HELPER = path.join(REPO_ROOT, "scripts", "lib", "reviewed-npm-audit.mts"); const UNPINNED_OPENCLAW_VERSION = "2026.7.2"; const PINNED_OPENCLAW_VERSION = "2026.7.1"; const PINNED_OPENCLAW_INTEGRITY = @@ -270,14 +271,7 @@ function runInstallBlock( auditHelper, [ 'const fs = require("node:fs");', - "exports.parseAuditExceptionRegistry = (source) => {", - " const policy = JSON.parse(source);", - " for (const entry of policy.exceptions) {", - " const expiresAt = new Date(`${entry.expires}T23:59:59.999Z`);", - " if (expiresAt.valueOf() < Date.now()) throw new Error(`npm audit exception 1 expired on ${entry.expires}`);", - " }", - " return policy;", - "};", + `exports.parseAuditExceptionRegistry = require(${JSON.stringify(REVIEWED_NPM_AUDIT_HELPER)}).parseAuditExceptionRegistry;`, "if (require.main === module) {", "const args = process.argv.slice(2);", "const value = (name) => args[args.indexOf(name) + 1];", From 7a3696960a36aba38a0d3127998b5fec8b6352a1 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Thu, 23 Jul 2026 14:28:55 -0700 Subject: [PATCH 5/5] test(ci): map audit parser in fetch guard harness Signed-off-by: Senthil Ravichandran --- test/fetch-guard-patch-regression.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/fetch-guard-patch-regression.test.ts b/test/fetch-guard-patch-regression.test.ts index 1e33d91acba..d6ed5bd58e1 100644 --- a/test/fetch-guard-patch-regression.test.ts +++ b/test/fetch-guard-patch-regression.test.ts @@ -16,6 +16,13 @@ import { const DOCKERFILE = path.join(import.meta.dirname, "..", "Dockerfile"); const DOCKERFILE_BASE = path.join(import.meta.dirname, "..", "Dockerfile.base"); const BLUEPRINT = path.join(import.meta.dirname, "..", "nemoclaw-blueprint", "blueprint.yaml"); +const REVIEWED_NPM_AUDIT_HELPER = path.join( + import.meta.dirname, + "..", + "scripts", + "lib", + "reviewed-npm-audit.mts", +); const REVIEWED_OPENCLAW_PATCH_CLASSIFIER_VERSIONS = [ "2026.4.24", "2026.5.18", @@ -170,6 +177,10 @@ function runOpenClawUpgradeBlock(currentVersion: string) { .replaceAll("/usr/local/lib/node_modules/mcporter", mcporterInstall) .replaceAll("/usr/local/lib/nemoclaw/mcporter-runtime", mcporterInstall) .replaceAll("/usr/local/bin/mcporter", mcporterShim) + .replaceAll( + 'from "/scripts/lib/reviewed-npm-audit.mts"', + `from ${JSON.stringify(REVIEWED_NPM_AUDIT_HELPER)}`, + ) .replaceAll("/scripts/npm-audit-exceptions.json", auditExceptions); const script = [ "#!/usr/bin/env bash",