From 0a1853cecb12397b54f1a9f251b3ae47387d8a6d Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 5 Sep 2026 11:39:23 -0700 Subject: [PATCH 01/31] fix(ci): trust npm 12 Brev template Signed-off-by: Charan Jagwani --- scripts/checks/extract-installer-pins.mts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/checks/extract-installer-pins.mts b/scripts/checks/extract-installer-pins.mts index d23f7c921cd..c9d7e6cdd71 100644 --- a/scripts/checks/extract-installer-pins.mts +++ b/scripts/checks/extract-installer-pins.mts @@ -351,9 +351,12 @@ const TRUSTED_OPENSHELL_RELEASES: readonly OpenShellReleaseTrust[] = [ version: "0.0.103", }, { + // The third template pins Node 24.18.1 and installs reviewed npm 12.0.2 before + // any repository dependency graph is installed. brevTemplateSha256: [ "c0a4ddf25a02a9fe02b2df53a60942ea887610f04d4ce16a121b6e79a5aeff1a", "56fc6482d1508b73604099e6fd6c16daea16275cf36cc25c1c5366c82a4394e3", + "9a30f006ac59b6acdcef843bff62ce3fd0fe0d681df993ec1c6a24811690caf5", ], formula: { asset: "openshell.rb", From a1a0a6801811bcef6dfc43745697e44d050290ef Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 5 Sep 2026 12:45:17 -0700 Subject: [PATCH 02/31] test(ci): bind npm 12 Brev trust evidence Signed-off-by: Charan Jagwani --- ci/source-shape-test-budget.json | 5 + scripts/checks/extract-installer-pins.mts | 4 +- ...nstaller-brev-npm12-template-trust.test.ts | 198 ++++++++++++++++++ 3 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 test/install/installer-brev-npm12-template-trust.test.ts diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index a0bdef95a75..78a9a95b30d 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -251,6 +251,11 @@ "test": "accepts the reviewed %s OpenShell 0.0.106 installer template", "category": "security" }, + { + "file": "test/install/installer-brev-npm12-template-trust.test.ts", + "test": "binds the exact reviewed npm 12 Brev successor and rejects version drift", + "category": "security" + }, { "file": "test/install/installer-supervisor-manifest-trust.test.ts", "test": "accepts the prospective shared gateway state resolver template (#10544)", diff --git a/scripts/checks/extract-installer-pins.mts b/scripts/checks/extract-installer-pins.mts index c9d7e6cdd71..7b8a0b42b18 100644 --- a/scripts/checks/extract-installer-pins.mts +++ b/scripts/checks/extract-installer-pins.mts @@ -351,8 +351,8 @@ const TRUSTED_OPENSHELL_RELEASES: readonly OpenShellReleaseTrust[] = [ version: "0.0.103", }, { - // The third template pins Node 24.18.1 and installs reviewed npm 12.0.2 before - // any repository dependency graph is installed. + // The third template authorizes the reviewed Node 24.18.1 and npm 12.0.2 + // successor constructed and verified by installer-brev-npm12-template-trust.test.ts. brevTemplateSha256: [ "c0a4ddf25a02a9fe02b2df53a60942ea887610f04d4ce16a121b6e79a5aeff1a", "56fc6482d1508b73604099e6fd6c16daea16275cf36cc25c1c5366c82a4394e3", diff --git a/test/install/installer-brev-npm12-template-trust.test.ts b/test/install/installer-brev-npm12-template-trust.test.ts new file mode 100644 index 00000000000..a2b99bd7d59 --- /dev/null +++ b/test/install/installer-brev-npm12-template-trust.test.ts @@ -0,0 +1,198 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +const REPO_ROOT = path.join(import.meta.dirname, "../.."); +const PARSER = path.join(REPO_ROOT, "scripts/checks/extract-installer-pins.mts"); +const BREV_TEMPLATE = fs.readFileSync( + path.join(REPO_ROOT, "scripts/brev-launchable-ci-cpu.sh"), + "utf8", +); +const REVIEWED_SOURCE_SHA256 = "cf2636e55d0a1d3a6b2cccd19332011a8ca51679064103f724f9067b7da66e52"; +const REVIEWED_TEMPLATE_SHA256 = "9a30f006ac59b6acdcef843bff62ce3fd0fe0d681df993ec1c6a24811690caf5"; +const tempDirs: string[] = []; + +afterEach(() => { + for (const tempDir of tempDirs.splice(0)) { + fs.rmSync(tempDir, { force: true, recursive: true }); + } +}); + +function replaceUniqueSource( + source: string, + current: string, + replacement: string, + label: string, +): string { + const start = source.indexOf(current); + assert.notEqual(start, -1, `${label} source must exist`); + assert.equal( + source.indexOf(current, start + current.length), + -1, + `${label} source must be unique`, + ); + return `${source.slice(0, start)}${replacement}${source.slice(start + current.length)}`; +} + +function renderReviewedNpm12BrevTemplate(source: string): string { + const nodeSectionStart = "# 3. Node.js 22\n"; + const nodeSectionEnd = "# 4. OpenShell CLI\n"; + const start = source.indexOf(nodeSectionStart); + const end = source.indexOf(nodeSectionEnd, start + nodeSectionStart.length); + assert.notEqual(start, -1, "reviewed npm Brev Node section start must exist"); + assert.notEqual(end, -1, "reviewed npm Brev Node section end must exist"); + const reviewedNodeSection = `# 3. Node.js 24.18.1 +NODE_VERSION="24.18.1" +if command -v node >/dev/null 2>&1 && [[ "$(node --version)" == "v\${NODE_VERSION}" ]]; then + info "Node.js already installed: $(node --version)" +else + case "$(uname -m)" in + x86_64) + node_arch="x64" + node_sha256="9f5eb6ac21845a66c493c91a253b1da32fd684e89e9b7202d4936982336be4ca" + ;; + aarch64 | arm64) + node_arch="arm64" + node_sha256="df224555a083b918e46260cc969838501b9f9a87140c1195e5b9597b56d5dae2" + ;; + *) fail "Unsupported Node.js architecture: $(uname -m)" ;; + esac + info "Installing Node.js \${NODE_VERSION}..." + node_tmp="$(mktemp)" + node_url="https://nodejs.org/dist/v\${NODE_VERSION}/node-v\${NODE_VERSION}-linux-\${node_arch}.tar.gz" + curl -fsSL --proto '=https' --tlsv1.2 "$node_url" -o "$node_tmp" || { + rm -f "$node_tmp" + fail "Failed to download Node.js archive" + } + if command -v sha256sum >/dev/null 2>&1; then + actual_hash="$(sha256sum "$node_tmp" | awk '{print $1}')" + elif command -v shasum >/dev/null 2>&1; then + actual_hash="$(shasum -a 256 "$node_tmp" | awk '{print $1}')" + else + rm -f "$node_tmp" + fail "No SHA-256 tool available (sha256sum/shasum)" + fi + if [[ "$actual_hash" != "$node_sha256" ]]; then + rm -f "$node_tmp" + fail "Node.js archive integrity check failed\\n Expected: $node_sha256\\n Actual: $actual_hash" + fi + sudo tar -xzf "$node_tmp" -C /usr/local --strip-components=1 --no-same-owner + rm -f "$node_tmp" + [[ "$(node --version)" == "v\${NODE_VERSION}" ]] || fail "Node.js installation did not produce v\${NODE_VERSION}" + info "Node.js $(node --version) installed" +fi + +`; + const withNodePin = `${source.slice(0, start)}${reviewedNodeSection}${source.slice(end)}`; + const withDescription = replaceUniqueSource( + withNodePin, + "# 2. Node.js 22 (nodesource)", + "# 2. Node.js 24.18.1 and verified npm 12.0.2", + "reviewed npm Brev description", + ); + const dependencyInstallStart = `info "Installing npm dependencies..." +cd "$NEMOCLAW_CLONE_DIR" +`; + return replaceUniqueSource( + withDescription, + dependencyInstallStart, + `${dependencyInstallStart}reviewed_npm_tmp="$(mktemp -d)" +sudo env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN \\ + RUNNER_TEMP="$reviewed_npm_tmp" \\ + bash .github/actions/setup-reviewed-npm/verify-and-install-npm.sh ci/reviewed-npm-audit.json +rm -rf "$reviewed_npm_tmp" +[[ "$(npm --version)" == "12.0.2" ]] || fail "Reviewed npm 12.0.2 installation failed" +`, + "reviewed npm Brev dependency install", + ); +} + +function runParser(brevInstaller: string) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-brev-npm12-trust-")); + tempDirs.push(tempDir); + const brevPath = path.join(tempDir, "brev-launchable-ci-cpu.sh"); + fs.writeFileSync(brevPath, brevInstaller); + return spawnSync( + process.execPath, + [ + "--experimental-strip-types", + PARSER, + "--blueprint", + path.join(REPO_ROOT, "nemoclaw-blueprint/blueprint.yaml"), + "--installer", + path.join(REPO_ROOT, "scripts/install-openshell.sh"), + "--brev-installer", + brevPath, + "--supervisor-runtime", + path.join(REPO_ROOT, "src/lib/onboard/docker-driver-gateway-runtime.ts"), + "--format", + "json", + ], + { cwd: REPO_ROOT, encoding: "utf8" }, + ); +} + +describe("reviewed npm 12 Brev template trust", () => { + // source-shape-contract: security -- Exact prospective Brev bytes and install order must be base-authorized before trusted CI can admit the npm 12 runtime change + it("binds the exact reviewed npm 12 Brev successor and rejects version drift", () => { + const reviewedTemplate = renderReviewedNpm12BrevTemplate(BREV_TEMPLATE); + const npmVerifier = reviewedTemplate.indexOf( + "bash .github/actions/setup-reviewed-npm/verify-and-install-npm.sh ci/reviewed-npm-audit.json", + ); + const npmVersionCheck = reviewedTemplate.indexOf( + '[[ "$(npm --version)" == "12.0.2" ]]', + npmVerifier, + ); + const dependencyInstall = reviewedTemplate.indexOf( + "npm install --ignore-scripts", + npmVersionCheck, + ); + + expect(createHash("sha256").update(reviewedTemplate).digest("hex")).toBe( + REVIEWED_SOURCE_SHA256, + ); + expect(reviewedTemplate).toContain('NODE_VERSION="24.18.1"'); + expect(reviewedTemplate).toContain( + 'node_sha256="9f5eb6ac21845a66c493c91a253b1da32fd684e89e9b7202d4936982336be4ca"', + ); + expect(reviewedTemplate).toContain( + 'node_sha256="df224555a083b918e46260cc969838501b9f9a87140c1195e5b9597b56d5dae2"', + ); + expect(npmVerifier).toBeGreaterThan(-1); + expect(npmVersionCheck).toBeGreaterThan(npmVerifier); + expect(dependencyInstall).toBeGreaterThan(npmVersionCheck); + + const accepted = runParser(reviewedTemplate); + expect(accepted.status, accepted.stderr).toBe(0); + const records = JSON.parse(accepted.stdout) as Array<{ + operationalTemplateSha256: string; + source: string; + }>; + expect( + new Set( + records + .filter((record) => record.source === "Brev launchable") + .map((record) => record.operationalTemplateSha256), + ), + ).toEqual(new Set([REVIEWED_TEMPLATE_SHA256])); + + const forged = runParser( + reviewedTemplate.replace( + '[[ "$(npm --version)" == "12.0.2" ]]', + '[[ "$(npm --version)" == "12.0.3" ]]', + ), + ); + expect(forged.status).toBe(1); + expect(forged.stderr).toContain("Brev launchable operational template is not base-trusted"); + expect(forged.stderr).toContain(REVIEWED_TEMPLATE_SHA256); + expect(forged.stdout).toBe(""); + }); +}); From 42060083f1e0c5a24bb2e618269ac67442d55bb9 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 5 Sep 2026 13:03:21 -0700 Subject: [PATCH 03/31] fix(ci): stage reviewed npm bootstrap before trust Signed-off-by: Charan Jagwani --- .../actions/setup-reviewed-npm/action.yaml | 15 ++ .../verify-and-install-npm.sh | 74 ++++++++ ci/reviewed-npm-audit.json | 1 + .../releases/reviewed-npm-bootstrap.test.ts | 176 ++++++++++++++++++ ...nstaller-brev-npm12-template-trust.test.ts | 10 + 5 files changed, 276 insertions(+) create mode 100644 .github/actions/setup-reviewed-npm/action.yaml create mode 100755 .github/actions/setup-reviewed-npm/verify-and-install-npm.sh create mode 100644 test/automation/releases/reviewed-npm-bootstrap.test.ts diff --git a/.github/actions/setup-reviewed-npm/action.yaml b/.github/actions/setup-reviewed-npm/action.yaml new file mode 100644 index 00000000000..5f2e26d6343 --- /dev/null +++ b/.github/actions/setup-reviewed-npm/action.yaml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: setup-reviewed-npm +description: Install the exact npm release approved by the reviewed npm audit identity. + +runs: + using: composite + steps: + - name: Download, verify, and install reviewed npm + shell: bash + run: >- + env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN + "$GITHUB_ACTION_PATH/verify-and-install-npm.sh" + "$GITHUB_ACTION_PATH/../../../ci/reviewed-npm-audit.json" diff --git a/.github/actions/setup-reviewed-npm/verify-and-install-npm.sh b/.github/actions/setup-reviewed-npm/verify-and-install-npm.sh new file mode 100755 index 00000000000..7bed0811784 --- /dev/null +++ b/.github/actions/setup-reviewed-npm/verify-and-install-npm.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "ERROR: reviewed npm identity path is required." >&2 + exit 1 +fi + +config_file="$1" +download_dir="$(mktemp -d "$RUNNER_TEMP/reviewed-npm.XXXXXX")" +trap 'rm -rf "$download_dir"' EXIT +identity_file="$download_dir/identity" + +node --input-type=module - "$config_file" >"$identity_file" <<'NODE' +import { readFileSync } from "node:fs"; + +const [configFile] = process.argv.slice(2); +const config = JSON.parse(readFileSync(configFile, "utf8")); +if (!/^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/.test(config.npmVersion)) { + throw new Error("reviewed npm audit configuration has an invalid npmVersion"); +} +if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(config.npmIntegrity)) { + throw new Error("reviewed npm audit configuration has an invalid npmIntegrity"); +} +if (!/^[a-f0-9]{64}$/.test(config.npmArchiveSha256)) { + throw new Error("reviewed npm audit configuration has an invalid npmArchiveSha256"); +} +process.stdout.write(`${config.npmVersion}\n${config.npmIntegrity}\n${config.npmArchiveSha256}\n`); +NODE + +IFS= read -r version <"$identity_file" +IFS= read -r expected_integrity < <(sed -n '2p' "$identity_file") +IFS= read -r expected_sha256 < <(sed -n '3p' "$identity_file") +[ -n "$version" ] +[ -n "$expected_integrity" ] +[ -n "$expected_sha256" ] + +npm pack "npm@$version" \ + --pack-destination "$download_dir" \ + --userconfig /dev/null \ + --registry https://registry.npmjs.org/ \ + --ignore-scripts --no-audit --no-fund >/dev/null + +archive="$download_dir/npm-$version.tgz" +actual_hashes="$download_dir/actual-hashes" +node -e ' + const fs = require("node:fs"); + const crypto = require("node:crypto"); + const archive = fs.readFileSync(process.argv[1]); + process.stdout.write( + crypto.createHash("sha512").update(archive).digest("base64") + "\n" + + crypto.createHash("sha256").update(archive).digest("hex") + "\n", + ); +' "$archive" >"$actual_hashes" +IFS= read -r actual_sha512 <"$actual_hashes" +IFS= read -r actual_sha256 < <(sed -n '2p' "$actual_hashes") +actual_integrity="sha512-$actual_sha512" +if [ "$actual_integrity" != "$expected_integrity" ] || [ "$actual_sha256" != "$expected_sha256" ]; then + echo "ERROR: npm@$version archive integrity mismatch." >&2 + exit 1 +fi + +npm install --global "$archive" \ + --userconfig /dev/null \ + --ignore-scripts --no-audit --no-fund --offline + +installed_version="$(npm --version)" +if [ "$installed_version" != "$version" ]; then + echo "ERROR: installed npm@$installed_version does not match reviewed npm@$version." >&2 + exit 1 +fi diff --git a/ci/reviewed-npm-audit.json b/ci/reviewed-npm-audit.json index 0c18030a4dd..97bb2570429 100644 --- a/ci/reviewed-npm-audit.json +++ b/ci/reviewed-npm-audit.json @@ -3,6 +3,7 @@ "nodeVersion": "22.23.2", "npmVersion": "10.9.4", "npmIntegrity": "sha512-OnUG836FwboQIbqtefDNlyR0gTHzIfwRfE3DuiNewBvnMnWEpB0VEXwBlFVgqpNzIgYo/MHh3d2Hel/pszapAA==", + "npmArchiveSha256": "4bfba8a0c823024d1926ec9d97a37a00eb60fd2adf44b3d34a686fc32e8f51e4", "registryOrigin": "https://registry.npmjs.org/", "sourceRegistryPackage": { "artifactName": "nvidia-openshell-sdk-0.0.106.tgz", diff --git a/test/automation/releases/reviewed-npm-bootstrap.test.ts b/test/automation/releases/reviewed-npm-bootstrap.test.ts new file mode 100644 index 00000000000..11f334646ee --- /dev/null +++ b/test/automation/releases/reviewed-npm-bootstrap.test.ts @@ -0,0 +1,176 @@ +// 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 os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const REPO_ROOT = path.join(import.meta.dirname, "../../.."); +const BOOTSTRAP = path.join( + REPO_ROOT, + ".github", + "actions", + "setup-reviewed-npm", + "verify-and-install-npm.sh", +); + +function identity(archive: string): Record { + return { + npmArchiveSha256: createHash("sha256").update(archive).digest("hex"), + npmIntegrity: `sha512-${createHash("sha512").update(archive).digest("base64")}`, + npmVersion: "12.0.2", + }; +} + +type BootstrapFixtureOptions = { + archive: string; + environment?: NodeJS.ProcessEnv; + installedVersion?: string; + reviewedIdentity?: Record; +}; + +function runBootstrapFixture(options: BootstrapFixtureOptions) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-npm-bootstrap-")); + const bin = path.join(root, "bin"); + const npmLog = path.join(root, "npm.log"); + const installMarker = path.join(root, "install-called"); + const identityPath = path.join(root, "reviewed-npm-audit.json"); + + fs.mkdirSync(bin); + fs.writeFileSync( + path.join(bin, "npm"), + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$*" >> "$NEMOCLAW_TEST_NPM_LOG" +case "$1" in + pack) + pack_args="$*" + shift + download_dir="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "--pack-destination" ]; then + download_dir="$2" + break + fi + shift + done + [ -n "$download_dir" ] + [ "$pack_args" = "pack npm@12.0.2 --pack-destination $download_dir --userconfig /dev/null --registry https://registry.npmjs.org/ --ignore-scripts --no-audit --no-fund" ] + printf '%s' "$NEMOCLAW_TEST_ARCHIVE" > "$download_dir/npm-12.0.2.tgz" + ;; + install) + : > "$NEMOCLAW_TEST_INSTALL_MARKER" + ;; + --version) + printf '%s\\n' "$NEMOCLAW_TEST_INSTALLED_VERSION" + ;; + *) + exit 2 + ;; +esac +`, + { mode: 0o755 }, + ); + fs.writeFileSync( + identityPath, + `${JSON.stringify(options.reviewedIdentity ?? identity(options.archive))}\n`, + ); + const result = spawnSync("bash", [BOOTSTRAP, identityPath], { + encoding: "utf8", + env: { + ...process.env, + ...options.environment, + NEMOCLAW_TEST_ARCHIVE: options.archive, + NEMOCLAW_TEST_INSTALL_MARKER: installMarker, + NEMOCLAW_TEST_INSTALLED_VERSION: options.installedVersion ?? "12.0.2", + NEMOCLAW_TEST_NPM_LOG: npmLog, + PATH: `${bin}:${process.env.PATH ?? ""}`, + RUNNER_TEMP: root, + }, + }); + return { + cleanup: () => fs.rmSync(root, { recursive: true, force: true }), + installCalled: fs.existsSync(installMarker), + npmInvocations: fs.readFileSync(npmLog, "utf8").trim().split("\n"), + result, + }; +} + +describe("reviewed npm bootstrap", () => { + const archive = "verified archive\n"; + + it.each([ + ["SHA-256", { ...identity(archive), npmArchiveSha256: "0".repeat(64) }], + [ + "SHA-512 SRI", + { ...identity(archive), npmIntegrity: `sha512-${Buffer.alloc(64).toString("base64")}` }, + ], + ])( + "rejects an independent %s mismatch before installation (#8253)", + (_digest, reviewedIdentity) => { + const fixture = runBootstrapFixture({ archive, reviewedIdentity }); + try { + expect(fixture.result.status).toBe(1); + expect(fixture.result.stderr).toContain("npm@12.0.2 archive integrity mismatch"); + expect(fixture.npmInvocations).toHaveLength(1); + expect(fixture.npmInvocations[0]).toContain("pack npm@12.0.2 --pack-destination"); + expect(fixture.installCalled).toBe(false); + } finally { + fixture.cleanup(); + } + }, + ); + + it("rejects a post-install npm version mismatch (#8253)", () => { + const fixture = runBootstrapFixture({ archive, installedVersion: "12.0.3" }); + try { + expect(fixture.result.status).toBe(1); + expect(fixture.result.stderr).toContain( + "installed npm@12.0.3 does not match reviewed npm@12.0.2", + ); + expect(fixture.npmInvocations).toHaveLength(3); + expect(fixture.installCalled).toBe(true); + } finally { + fixture.cleanup(); + } + }); + + it("installs a matching archive offline (#8253)", () => { + const fixture = runBootstrapFixture({ archive }); + try { + const { npmInvocations, result } = fixture; + expect(result.status).toBe(0); + expect(npmInvocations).toHaveLength(3); + expect(npmInvocations[0]).toMatch( + /^pack npm@12\.0\.2 --pack-destination .* --userconfig \/dev\/null --registry https:\/\/registry\.npmjs\.org\/ --ignore-scripts --no-audit --no-fund$/, + ); + expect(npmInvocations[1]).toMatch( + /^install --global .*\/npm-12\.0\.2\.tgz --userconfig \/dev\/null --ignore-scripts --no-audit --no-fund --offline$/, + ); + expect(npmInvocations[2]).toBe("--version"); + } finally { + fixture.cleanup(); + } + }); + + it("overrides ambient npm configuration for the archive download (#8253)", () => { + const fixture = runBootstrapFixture({ + archive, + environment: { + NPM_CONFIG_REGISTRY: "https://registry.example.test/", + NPM_CONFIG_USERCONFIG: "/tmp/untrusted-npmrc", + }, + }); + try { + expect(fixture.result.status).toBe(0); + expect(fixture.npmInvocations[0]).toMatch( + /^pack npm@12\.0\.2 --pack-destination .* --userconfig \/dev\/null --registry https:\/\/registry\.npmjs\.org\/ --ignore-scripts --no-audit --no-fund$/, + ); + } finally { + fixture.cleanup(); + } + }); +}); diff --git a/test/install/installer-brev-npm12-template-trust.test.ts b/test/install/installer-brev-npm12-template-trust.test.ts index a2b99bd7d59..9630ea861a1 100644 --- a/test/install/installer-brev-npm12-template-trust.test.ts +++ b/test/install/installer-brev-npm12-template-trust.test.ts @@ -12,6 +12,10 @@ import { afterEach, describe, expect, it } from "vitest"; const REPO_ROOT = path.join(import.meta.dirname, "../.."); const PARSER = path.join(REPO_ROOT, "scripts/checks/extract-installer-pins.mts"); +const NPM_BOOTSTRAP = path.join( + REPO_ROOT, + ".github/actions/setup-reviewed-npm/verify-and-install-npm.sh", +); const BREV_TEMPLATE = fs.readFileSync( path.join(REPO_ROOT, "scripts/brev-launchable-ci-cpu.sh"), "utf8", @@ -169,6 +173,12 @@ describe("reviewed npm 12 Brev template trust", () => { expect(npmVerifier).toBeGreaterThan(-1); expect(npmVersionCheck).toBeGreaterThan(npmVerifier); expect(dependencyInstall).toBeGreaterThan(npmVersionCheck); + expect(fs.statSync(NPM_BOOTSTRAP).isFile()).toBe(true); + expect(fs.statSync(NPM_BOOTSTRAP).mode & 0o111).not.toBe(0); + const bootstrapSource = fs.readFileSync(NPM_BOOTSTRAP, "utf8"); + expect(bootstrapSource).toContain("config.npmVersion"); + expect(bootstrapSource).toContain("config.npmIntegrity"); + expect(bootstrapSource).toContain("config.npmArchiveSha256"); const accepted = runParser(reviewedTemplate); expect(accepted.status, accepted.stderr).toBe(0); From 90ee87692b0409a6468e96a55aec7e8216a4ea0d Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 5 Sep 2026 13:26:20 -0700 Subject: [PATCH 04/31] refactor(ci): consolidate reviewed npm bootstrap Signed-off-by: Charan Jagwani --- .../actions/ci-reviewed-npm-audit/action.yaml | 21 ++- .../verify-and-install-npm.sh | 33 ---- .../actions/setup-reviewed-npm/action.yaml | 15 -- .../verify-and-install-npm.sh | 23 ++- ci/source-shape-test-budget.json | 5 + .../reviewed-npm-audit-workflow.test.ts | 159 +++--------------- .../releases/reviewed-npm-bootstrap.test.ts | 30 ++-- ...managed-image-publication-workflow.test.ts | 4 +- 8 files changed, 75 insertions(+), 215 deletions(-) delete mode 100755 .github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh delete mode 100644 .github/actions/setup-reviewed-npm/action.yaml diff --git a/.github/actions/ci-reviewed-npm-audit/action.yaml b/.github/actions/ci-reviewed-npm-audit/action.yaml index c70ce662068..fac2c471ffe 100644 --- a/.github/actions/ci-reviewed-npm-audit/action.yaml +++ b/.github/actions/ci-reviewed-npm-audit/action.yaml @@ -58,10 +58,13 @@ runs: if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(config.npmIntegrity) || /[\r\n]/.test(config.npmIntegrity)) { throw new Error("reviewed npm audit configuration has an invalid npmIntegrity"); } + if (!/^[a-f0-9]{64}$/.test(config.npmArchiveSha256)) { + throw new Error("reviewed npm audit configuration has an invalid npmArchiveSha256"); + } const directories = ["", ...config.lockedGraphs.map((graph) => graph.directory)].sort(); const hash = createHash("sha256"); hash.update(configSource); - hash.update(JSON.stringify({ argv: ["audit", "--registry=https://registry.yarnpkg.com", "--omit=dev", "--json"], npmVersion: config.npmVersion, registry: "https://registry.yarnpkg.com/", schemaVersion: 1 })); + hash.update(JSON.stringify({ argv: ["audit", "--registry=https://registry.yarnpkg.com", "--omit=dev", "--json"], npmArchiveSha256: config.npmArchiveSha256, npmIntegrity: config.npmIntegrity, npmVersion: config.npmVersion, registry: "https://registry.yarnpkg.com/", schemaVersion: 2 })); for (const directory of directories) { for (const file of ["package.json", "package-lock.json"]) { const relative = join(directory, file); @@ -71,7 +74,7 @@ runs: } appendFileSync( process.env.GITHUB_OUTPUT, - `input-digest=${hash.digest("hex")}\nnpm-version=${config.npmVersion}\nnpm-integrity=${config.npmIntegrity}\n`, + `input-digest=${hash.digest("hex")}\n`, ); NODE printf 'current=%s\nprevious=%s\n' "$current_bucket" "$previous_bucket" >> "$GITHUB_OUTPUT" @@ -82,21 +85,21 @@ runs: uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: ${{ inputs.cache-directory }} - key: reviewed-npm-audit-v1-${{ runner.os }}-${{ steps.cache-buckets.outputs.input-digest }}-${{ steps.cache-buckets.outputs.current }} + key: reviewed-npm-audit-v2-${{ runner.os }}-${{ steps.cache-buckets.outputs.input-digest }}-${{ steps.cache-buckets.outputs.current }} - name: Restore previous reviewed npm audit cache bucket if: steps.cache-current.outputs.cache-hit != 'true' uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: ${{ inputs.cache-directory }} - key: reviewed-npm-audit-v1-${{ runner.os }}-${{ steps.cache-buckets.outputs.input-digest }}-${{ steps.cache-buckets.outputs.previous }} + key: reviewed-npm-audit-v2-${{ runner.os }}-${{ steps.cache-buckets.outputs.input-digest }}-${{ steps.cache-buckets.outputs.previous }} - name: Download and verify production npm shell: bash - env: - NEMOCLAW_REVIEWED_NPM_VERSION: ${{ steps.cache-buckets.outputs.npm-version }} - NEMOCLAW_REVIEWED_NPM_INTEGRITY: ${{ steps.cache-buckets.outputs.npm-integrity }} - run: env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN "$GITHUB_ACTION_PATH/verify-and-install-npm.sh" + run: >- + env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN + "$GITHUB_ACTION_PATH/../setup-reviewed-npm/verify-and-install-npm.sh" + "$GITHUB_ACTION_PATH/../../../ci/reviewed-npm-audit.json" - name: Materialize and audit reviewed npm graphs shell: bash @@ -113,7 +116,7 @@ runs: uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: ${{ inputs.cache-directory }} - key: reviewed-npm-audit-v1-${{ runner.os }}-${{ steps.cache-buckets.outputs.input-digest }}-${{ steps.cache-buckets.outputs.current }} + key: reviewed-npm-audit-v2-${{ runner.os }}-${{ steps.cache-buckets.outputs.input-digest }}-${{ steps.cache-buckets.outputs.current }} - name: Upload reviewed npm audit reports if: always() diff --git a/.github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh b/.github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh deleted file mode 100755 index 7e984e477bd..00000000000 --- a/.github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -set -euo pipefail - -version="${NEMOCLAW_REVIEWED_NPM_VERSION:?}" -expected_integrity="${NEMOCLAW_REVIEWED_NPM_INTEGRITY:?}" -download_dir="$(mktemp -d "$RUNNER_TEMP/reviewed-npm.XXXXXX")" - -npm pack "npm@$version" \ - --pack-destination "$download_dir" \ - --userconfig /dev/null \ - --registry https://registry.npmjs.org/ \ - --ignore-scripts --no-audit --no-fund >/dev/null - -archive="$download_dir/npm-$version.tgz" -actual_sha512="$( - node -e ' - const fs = require("node:fs"); - const crypto = require("node:crypto"); - process.stdout.write(crypto.createHash("sha512").update(fs.readFileSync(process.argv[1])).digest("base64")); - ' "$archive" -)" -actual_integrity="sha512-$actual_sha512" -if [ "$actual_integrity" != "$expected_integrity" ]; then - echo "ERROR: npm@$version archive integrity mismatch." >&2 - exit 1 -fi - -npm install --global "$archive" \ - --userconfig /dev/null \ - --ignore-scripts --no-audit --no-fund --offline diff --git a/.github/actions/setup-reviewed-npm/action.yaml b/.github/actions/setup-reviewed-npm/action.yaml deleted file mode 100644 index 5f2e26d6343..00000000000 --- a/.github/actions/setup-reviewed-npm/action.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -name: setup-reviewed-npm -description: Install the exact npm release approved by the reviewed npm audit identity. - -runs: - using: composite - steps: - - name: Download, verify, and install reviewed npm - shell: bash - run: >- - env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN - "$GITHUB_ACTION_PATH/verify-and-install-npm.sh" - "$GITHUB_ACTION_PATH/../../../ci/reviewed-npm-audit.json" diff --git a/.github/actions/setup-reviewed-npm/verify-and-install-npm.sh b/.github/actions/setup-reviewed-npm/verify-and-install-npm.sh index 7bed0811784..69421aa8342 100755 --- a/.github/actions/setup-reviewed-npm/verify-and-install-npm.sh +++ b/.github/actions/setup-reviewed-npm/verify-and-install-npm.sh @@ -63,12 +63,23 @@ if [ "$actual_integrity" != "$expected_integrity" ] || [ "$actual_sha256" != "$e exit 1 fi +archive_version="$( + tar -xOf "$archive" package/package.json | node -e ' + let source = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { source += chunk; }); + process.stdin.on("end", () => { + const version = JSON.parse(source).version; + if (typeof version !== "string") process.exit(1); + process.stdout.write(version); + }); + ' +)" +if [ "$archive_version" != "$version" ]; then + echo "ERROR: npm archive version $archive_version does not match reviewed npm@$version." >&2 + exit 1 +fi + npm install --global "$archive" \ --userconfig /dev/null \ --ignore-scripts --no-audit --no-fund --offline - -installed_version="$(npm --version)" -if [ "$installed_version" != "$version" ]; then - echo "ERROR: installed npm@$installed_version does not match reviewed npm@$version." >&2 - exit 1 -fi diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index 78a9a95b30d..789dd6b7683 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -206,6 +206,11 @@ "test": "passes the cache identity target root without interpolating it into shell source", "category": "security" }, + { + "file": "test/automation/releases/reviewed-npm-audit-workflow.test.ts", + "test": "uses the single JSON-bound reviewed npm bootstrap owner", + "category": "security" + }, { "file": "test/automation/releases/reviewed-npm-audit-workflow.test.ts", "test": "rejects the removed plural source-registry package shape", diff --git a/test/automation/releases/reviewed-npm-audit-workflow.test.ts b/test/automation/releases/reviewed-npm-audit-workflow.test.ts index 8d12e2b4b9d..55dd97156dd 100644 --- a/test/automation/releases/reviewed-npm-audit-workflow.test.ts +++ b/test/automation/releases/reviewed-npm-audit-workflow.test.ts @@ -340,10 +340,31 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { expect(cacheBucketStep.run).toContain( "const targetRoot = process.env.NEMOCLAW_REVIEWED_NPM_AUDIT_TARGET_ROOT;", ); + expect(cacheBucketStep.run).toContain("npmIntegrity: config.npmIntegrity"); + expect(cacheBucketStep.run).toContain("npmArchiveSha256: config.npmArchiveSha256"); expect(cacheBucketStep.run).not.toContain("${{ inputs.cache-directory }}"); expect(cacheBucketStep.run).not.toContain("${{ inputs.target-root }}"); }); + // source-shape-contract: security -- The trusted audit action must execute the sole identity-bound npm bootstrap owner + it("uses the single JSON-bound reviewed npm bootstrap owner", () => { + const action = YAML.parse( + fs.readFileSync( + path.join(REPO_ROOT, ".github", "actions", "ci-reviewed-npm-audit", "action.yaml"), + "utf8", + ), + ) as CompositeAction; + const bootstrapStep = requiredStep(action.runs, "Download and verify production npm"); + + expect(bootstrapStep.env).toBeUndefined(); + expect(bootstrapStep.run).toContain( + '"$GITHUB_ACTION_PATH/../setup-reviewed-npm/verify-and-install-npm.sh"', + ); + expect(bootstrapStep.run).toContain( + '"$GITHUB_ACTION_PATH/../../../ci/reviewed-npm-audit.json"', + ); + }); + it("rejects audit production when installed npm differs from the reviewed identity", () => { const fixture = runConsolidatedAuditFixture(() => {}, undefined, 0, 0, "11.18.0"); @@ -662,144 +683,6 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { ); }); - it("rejects a mismatched npm bootstrap archive before installation (#8253)", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-npm-bootstrap-")); - const bin = path.join(root, "bin"); - const npmLog = path.join(root, "npm.log"); - const installMarker = path.join(root, "install-called"); - const npmStub = path.join(bin, "npm"); - const bootstrap = path.join( - REPO_ROOT, - ".github", - "actions", - "ci-reviewed-npm-audit", - "verify-and-install-npm.sh", - ); - - try { - fs.mkdirSync(bin); - fs.writeFileSync( - npmStub, - `#!/usr/bin/env bash -set -euo pipefail -printf '%s\\n' "$1" >> "$NEMOCLAW_TEST_NPM_LOG" -case "$1" in - pack) - shift - download_dir="" - while [ "$#" -gt 0 ]; do - if [ "$1" = "--pack-destination" ]; then - download_dir="$2" - break - fi - shift - done - [ -n "$download_dir" ] - printf 'tampered archive\\n' > "$download_dir/npm-10.9.4.tgz" - ;; - install) - : > "$NEMOCLAW_TEST_INSTALL_MARKER" - ;; - *) - exit 2 - ;; -esac -`, - { mode: 0o755 }, - ); - - const result = spawnSync("bash", [bootstrap], { - encoding: "utf8", - env: { - ...process.env, - NEMOCLAW_REVIEWED_NPM_INTEGRITY: "sha512-invalid", - NEMOCLAW_REVIEWED_NPM_VERSION: "10.9.4", - NEMOCLAW_TEST_INSTALL_MARKER: installMarker, - NEMOCLAW_TEST_NPM_LOG: npmLog, - PATH: `${bin}:${process.env.PATH ?? ""}`, - RUNNER_TEMP: root, - }, - }); - - expect(result.status).toBe(1); - expect(result.stderr).toContain("npm@10.9.4 archive integrity mismatch"); - expect(fs.readFileSync(npmLog, "utf8")).toBe("pack\n"); - expect(fs.existsSync(installMarker)).toBe(false); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } - }); - - it("installs a matching npm bootstrap archive offline (#8253)", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-npm-bootstrap-")); - const bin = path.join(root, "bin"); - const npmLog = path.join(root, "npm.log"); - const npmStub = path.join(bin, "npm"); - const archiveContents = "verified archive\n"; - const bootstrap = path.join( - REPO_ROOT, - ".github", - "actions", - "ci-reviewed-npm-audit", - "verify-and-install-npm.sh", - ); - - try { - fs.mkdirSync(bin); - fs.writeFileSync( - npmStub, - `#!/usr/bin/env bash -set -euo pipefail -printf '%s\\n' "$*" >> "$NEMOCLAW_TEST_NPM_LOG" -case "$1" in - pack) - shift - download_dir="" - while [ "$#" -gt 0 ]; do - if [ "$1" = "--pack-destination" ]; then - download_dir="$2" - break - fi - shift - done - [ -n "$download_dir" ] - printf 'verified archive\\n' > "$download_dir/npm-10.9.4.tgz" - ;; - install) - ;; - *) - exit 2 - ;; -esac -`, - { mode: 0o755 }, - ); - - const integrity = `sha512-${createHash("sha512").update(archiveContents).digest("base64")}`; - const result = spawnSync("bash", [bootstrap], { - encoding: "utf8", - env: { - ...process.env, - NEMOCLAW_REVIEWED_NPM_INTEGRITY: integrity, - NEMOCLAW_REVIEWED_NPM_VERSION: "10.9.4", - NEMOCLAW_TEST_NPM_LOG: npmLog, - PATH: `${bin}:${process.env.PATH ?? ""}`, - RUNNER_TEMP: root, - }, - }); - - const npmInvocations = fs.readFileSync(npmLog, "utf8").trim().split("\n"); - expect(result.status).toBe(0); - expect(npmInvocations).toHaveLength(2); - expect(npmInvocations[0]).toContain("pack npm@10.9.4 --pack-destination"); - expect(npmInvocations[1]).toMatch( - /^install --global .*\/npm-10\.9\.4\.tgz --userconfig \/dev\/null --ignore-scripts --no-audit --no-fund --offline$/, - ); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } - }); - it("materializes the NemoClaw production graph without changing its lock (#8116)", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-source-graph-")); const source = path.join(root, "source"); diff --git a/test/automation/releases/reviewed-npm-bootstrap.test.ts b/test/automation/releases/reviewed-npm-bootstrap.test.ts index 11f334646ee..7168fb2f524 100644 --- a/test/automation/releases/reviewed-npm-bootstrap.test.ts +++ b/test/automation/releases/reviewed-npm-bootstrap.test.ts @@ -27,8 +27,8 @@ function identity(archive: string): Record { type BootstrapFixtureOptions = { archive: string; + archiveVersion?: string; environment?: NodeJS.ProcessEnv; - installedVersion?: string; reviewedIdentity?: Record; }; @@ -64,13 +64,20 @@ case "$1" in install) : > "$NEMOCLAW_TEST_INSTALL_MARKER" ;; - --version) - printf '%s\\n' "$NEMOCLAW_TEST_INSTALLED_VERSION" - ;; *) exit 2 ;; esac +`, + { mode: 0o755 }, + ); + fs.writeFileSync( + path.join(bin, "tar"), + `#!/usr/bin/env bash +set -euo pipefail +[ "$1" = "-xOf" ] +[ "$3" = "package/package.json" ] +printf '{"version":"%s"}\\n' "$NEMOCLAW_TEST_ARCHIVE_VERSION" `, { mode: 0o755 }, ); @@ -84,8 +91,8 @@ esac ...process.env, ...options.environment, NEMOCLAW_TEST_ARCHIVE: options.archive, + NEMOCLAW_TEST_ARCHIVE_VERSION: options.archiveVersion ?? "12.0.2", NEMOCLAW_TEST_INSTALL_MARKER: installMarker, - NEMOCLAW_TEST_INSTALLED_VERSION: options.installedVersion ?? "12.0.2", NEMOCLAW_TEST_NPM_LOG: npmLog, PATH: `${bin}:${process.env.PATH ?? ""}`, RUNNER_TEMP: root, @@ -124,15 +131,15 @@ describe("reviewed npm bootstrap", () => { }, ); - it("rejects a post-install npm version mismatch (#8253)", () => { - const fixture = runBootstrapFixture({ archive, installedVersion: "12.0.3" }); + it("rejects an archive package version mismatch before installation (#8253)", () => { + const fixture = runBootstrapFixture({ archive, archiveVersion: "12.0.3" }); try { expect(fixture.result.status).toBe(1); expect(fixture.result.stderr).toContain( - "installed npm@12.0.3 does not match reviewed npm@12.0.2", + "npm archive version 12.0.3 does not match reviewed npm@12.0.2", ); - expect(fixture.npmInvocations).toHaveLength(3); - expect(fixture.installCalled).toBe(true); + expect(fixture.npmInvocations).toHaveLength(1); + expect(fixture.installCalled).toBe(false); } finally { fixture.cleanup(); } @@ -143,14 +150,13 @@ describe("reviewed npm bootstrap", () => { try { const { npmInvocations, result } = fixture; expect(result.status).toBe(0); - expect(npmInvocations).toHaveLength(3); + expect(npmInvocations).toHaveLength(2); expect(npmInvocations[0]).toMatch( /^pack npm@12\.0\.2 --pack-destination .* --userconfig \/dev\/null --registry https:\/\/registry\.npmjs\.org\/ --ignore-scripts --no-audit --no-fund$/, ); expect(npmInvocations[1]).toMatch( /^install --global .*\/npm-12\.0\.2\.tgz --userconfig \/dev\/null --ignore-scripts --no-audit --no-fund --offline$/, ); - expect(npmInvocations[2]).toBe("--version"); } finally { fixture.cleanup(); } diff --git a/test/inference/managed/managed-image-publication-workflow.test.ts b/test/inference/managed/managed-image-publication-workflow.test.ts index c20211d8e1a..a5470079660 100644 --- a/test/inference/managed/managed-image-publication-workflow.test.ts +++ b/test/inference/managed/managed-image-publication-workflow.test.ts @@ -104,11 +104,11 @@ describe("complete managed-image publication workflow", () => { ]); expect(restores.map(({ with: inputs }) => inputs)).toEqual([ { - key: "reviewed-npm-audit-v1-${{ runner.os }}-${{ steps.cache-buckets.outputs.input-digest }}-${{ steps.cache-buckets.outputs.current }}", + key: "reviewed-npm-audit-v2-${{ runner.os }}-${{ steps.cache-buckets.outputs.input-digest }}-${{ steps.cache-buckets.outputs.current }}", path: "${{ inputs.cache-directory }}", }, { - key: "reviewed-npm-audit-v1-${{ runner.os }}-${{ steps.cache-buckets.outputs.input-digest }}-${{ steps.cache-buckets.outputs.previous }}", + key: "reviewed-npm-audit-v2-${{ runner.os }}-${{ steps.cache-buckets.outputs.input-digest }}-${{ steps.cache-buckets.outputs.previous }}", path: "${{ inputs.cache-directory }}", }, ]); From 87ee5c1f965db65f0ff4ab80830f1edfe53b2499 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 5 Sep 2026 13:37:09 -0700 Subject: [PATCH 05/31] fix(ci): reject npm version drift before install Signed-off-by: Charan Jagwani --- ci/source-shape-test-budget.json | 2 +- scripts/checks/extract-installer-pins.mts | 2 +- ...nstaller-brev-npm12-template-trust.test.ts | 24 +++++-------------- 3 files changed, 8 insertions(+), 20 deletions(-) diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index 789dd6b7683..ebf7224c184 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -258,7 +258,7 @@ }, { "file": "test/install/installer-brev-npm12-template-trust.test.ts", - "test": "binds the exact reviewed npm 12 Brev successor and rejects version drift", + "test": "binds the exact reviewed npm 12 Brev successor and rejects bootstrap drift", "category": "security" }, { diff --git a/scripts/checks/extract-installer-pins.mts b/scripts/checks/extract-installer-pins.mts index 7b8a0b42b18..8119a3345e4 100644 --- a/scripts/checks/extract-installer-pins.mts +++ b/scripts/checks/extract-installer-pins.mts @@ -356,7 +356,7 @@ const TRUSTED_OPENSHELL_RELEASES: readonly OpenShellReleaseTrust[] = [ brevTemplateSha256: [ "c0a4ddf25a02a9fe02b2df53a60942ea887610f04d4ce16a121b6e79a5aeff1a", "56fc6482d1508b73604099e6fd6c16daea16275cf36cc25c1c5366c82a4394e3", - "9a30f006ac59b6acdcef843bff62ce3fd0fe0d681df993ec1c6a24811690caf5", + "773f3728a3b6404d909cbf395abee2a3b95872d6b93ec90b7814adbacc683470", ], formula: { asset: "openshell.rb", diff --git a/test/install/installer-brev-npm12-template-trust.test.ts b/test/install/installer-brev-npm12-template-trust.test.ts index 9630ea861a1..d77cfd6bea4 100644 --- a/test/install/installer-brev-npm12-template-trust.test.ts +++ b/test/install/installer-brev-npm12-template-trust.test.ts @@ -20,8 +20,8 @@ const BREV_TEMPLATE = fs.readFileSync( path.join(REPO_ROOT, "scripts/brev-launchable-ci-cpu.sh"), "utf8", ); -const REVIEWED_SOURCE_SHA256 = "cf2636e55d0a1d3a6b2cccd19332011a8ca51679064103f724f9067b7da66e52"; -const REVIEWED_TEMPLATE_SHA256 = "9a30f006ac59b6acdcef843bff62ce3fd0fe0d681df993ec1c6a24811690caf5"; +const REVIEWED_SOURCE_SHA256 = "aa6e42c034bf36a1bd28ae542159af8cb140bcb471008627609fb78d82ec9b32"; +const REVIEWED_TEMPLATE_SHA256 = "773f3728a3b6404d909cbf395abee2a3b95872d6b93ec90b7814adbacc683470"; const tempDirs: string[] = []; afterEach(() => { @@ -113,7 +113,6 @@ sudo env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN \\ RUNNER_TEMP="$reviewed_npm_tmp" \\ bash .github/actions/setup-reviewed-npm/verify-and-install-npm.sh ci/reviewed-npm-audit.json rm -rf "$reviewed_npm_tmp" -[[ "$(npm --version)" == "12.0.2" ]] || fail "Reviewed npm 12.0.2 installation failed" `, "reviewed npm Brev dependency install", ); @@ -146,19 +145,12 @@ function runParser(brevInstaller: string) { describe("reviewed npm 12 Brev template trust", () => { // source-shape-contract: security -- Exact prospective Brev bytes and install order must be base-authorized before trusted CI can admit the npm 12 runtime change - it("binds the exact reviewed npm 12 Brev successor and rejects version drift", () => { + it("binds the exact reviewed npm 12 Brev successor and rejects bootstrap drift", () => { const reviewedTemplate = renderReviewedNpm12BrevTemplate(BREV_TEMPLATE); const npmVerifier = reviewedTemplate.indexOf( "bash .github/actions/setup-reviewed-npm/verify-and-install-npm.sh ci/reviewed-npm-audit.json", ); - const npmVersionCheck = reviewedTemplate.indexOf( - '[[ "$(npm --version)" == "12.0.2" ]]', - npmVerifier, - ); - const dependencyInstall = reviewedTemplate.indexOf( - "npm install --ignore-scripts", - npmVersionCheck, - ); + const dependencyInstall = reviewedTemplate.indexOf("npm install --ignore-scripts", npmVerifier); expect(createHash("sha256").update(reviewedTemplate).digest("hex")).toBe( REVIEWED_SOURCE_SHA256, @@ -171,8 +163,7 @@ describe("reviewed npm 12 Brev template trust", () => { 'node_sha256="df224555a083b918e46260cc969838501b9f9a87140c1195e5b9597b56d5dae2"', ); expect(npmVerifier).toBeGreaterThan(-1); - expect(npmVersionCheck).toBeGreaterThan(npmVerifier); - expect(dependencyInstall).toBeGreaterThan(npmVersionCheck); + expect(dependencyInstall).toBeGreaterThan(npmVerifier); expect(fs.statSync(NPM_BOOTSTRAP).isFile()).toBe(true); expect(fs.statSync(NPM_BOOTSTRAP).mode & 0o111).not.toBe(0); const bootstrapSource = fs.readFileSync(NPM_BOOTSTRAP, "utf8"); @@ -195,10 +186,7 @@ describe("reviewed npm 12 Brev template trust", () => { ).toEqual(new Set([REVIEWED_TEMPLATE_SHA256])); const forged = runParser( - reviewedTemplate.replace( - '[[ "$(npm --version)" == "12.0.2" ]]', - '[[ "$(npm --version)" == "12.0.3" ]]', - ), + reviewedTemplate.replace("ci/reviewed-npm-audit.json", "ci/unreviewed-npm-audit.json"), ); expect(forged.status).toBe(1); expect(forged.stderr).toContain("Brev launchable operational template is not base-trusted"); From 2d3b536c7b7202cba59e88d156f00a2b4b2dfc36 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 5 Sep 2026 14:11:12 -0700 Subject: [PATCH 06/31] fix(ci): complete reviewed npm trust handoff Signed-off-by: Charan Jagwani --- .../actions/ci-reviewed-npm-audit/action.yaml | 18 ++--- .../verify-and-install-npm.sh | 22 +++--- .github/workflows/managed-images.yaml | 1 + .github/workflows/pr.yaml | 1 + scripts/audit-reviewed-npm-graph.mts | 11 +-- scripts/lib/npm-audit-receipt.mts | 20 +---- scripts/lib/reviewed-npm-audit.mts | 49 +++++++++++- .../releases/npm-audit-receipt.test.ts | 9 ++- .../reviewed-npm-audit-handoff.test.ts | 43 +++++++++- .../reviewed-npm-audit-workflow.test.ts | 6 +- .../releases/reviewed-npm-bootstrap.test.ts | 79 +++++++++++++++++-- ...nstaller-brev-npm12-template-trust.test.ts | 14 ++-- 12 files changed, 204 insertions(+), 69 deletions(-) diff --git a/.github/actions/ci-reviewed-npm-audit/action.yaml b/.github/actions/ci-reviewed-npm-audit/action.yaml index fac2c471ffe..66148408131 100644 --- a/.github/actions/ci-reviewed-npm-audit/action.yaml +++ b/.github/actions/ci-reviewed-npm-audit/action.yaml @@ -39,32 +39,26 @@ runs: previous_bucket="$(( current_bucket - 1 ))" node --experimental-strip-types --input-type=module - \ "$GITHUB_ACTION_PATH/../../../ci/reviewed-npm-audit.json" \ - "$GITHUB_ACTION_PATH/../../../scripts/lib/repository-input-path.mts" <<'NODE' + "$GITHUB_ACTION_PATH/../../../scripts/lib/repository-input-path.mts" \ + "$GITHUB_ACTION_PATH/../../../scripts/lib/reviewed-npm-audit.mts" <<'NODE' import { createHash } from "node:crypto"; import { appendFileSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; const targetRoot = process.env.NEMOCLAW_REVIEWED_NPM_AUDIT_TARGET_ROOT; - const [configFile, resolverFile] = process.argv.slice(2); + const [configFile, resolverFile, reviewedNpmAuditFile] = process.argv.slice(2); if (!targetRoot) { throw new Error("reviewed npm audit target root is required"); } const { resolvePathWithinRoot } = await import(pathToFileURL(resolverFile).href); + const { parseReviewedNpmIdentity } = await import(pathToFileURL(reviewedNpmAuditFile).href); const configSource = readFileSync(configFile, "utf8"); const config = JSON.parse(configSource); - if (!/^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/.test(config.npmVersion) || /[\r\n]/.test(config.npmVersion)) { - throw new Error("reviewed npm audit configuration has an invalid npmVersion"); - } - if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(config.npmIntegrity) || /[\r\n]/.test(config.npmIntegrity)) { - throw new Error("reviewed npm audit configuration has an invalid npmIntegrity"); - } - if (!/^[a-f0-9]{64}$/.test(config.npmArchiveSha256)) { - throw new Error("reviewed npm audit configuration has an invalid npmArchiveSha256"); - } + const identity = parseReviewedNpmIdentity(config); const directories = ["", ...config.lockedGraphs.map((graph) => graph.directory)].sort(); const hash = createHash("sha256"); hash.update(configSource); - hash.update(JSON.stringify({ argv: ["audit", "--registry=https://registry.yarnpkg.com", "--omit=dev", "--json"], npmArchiveSha256: config.npmArchiveSha256, npmIntegrity: config.npmIntegrity, npmVersion: config.npmVersion, registry: "https://registry.yarnpkg.com/", schemaVersion: 2 })); + hash.update(JSON.stringify({ argv: ["audit", "--registry=https://registry.yarnpkg.com", "--omit=dev", "--json"], ...identity, registry: "https://registry.yarnpkg.com/", schemaVersion: 2 })); for (const directory of directories) { for (const file of ["package.json", "package-lock.json"]) { const relative = join(directory, file); diff --git a/.github/actions/setup-reviewed-npm/verify-and-install-npm.sh b/.github/actions/setup-reviewed-npm/verify-and-install-npm.sh index 69421aa8342..79aa034fea4 100755 --- a/.github/actions/setup-reviewed-npm/verify-and-install-npm.sh +++ b/.github/actions/setup-reviewed-npm/verify-and-install-npm.sh @@ -10,25 +10,21 @@ if [ "$#" -ne 1 ]; then fi config_file="$1" +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" download_dir="$(mktemp -d "$RUNNER_TEMP/reviewed-npm.XXXXXX")" trap 'rm -rf "$download_dir"' EXIT identity_file="$download_dir/identity" -node --input-type=module - "$config_file" >"$identity_file" <<'NODE' +node --experimental-strip-types --input-type=module - \ + "$config_file" \ + "$script_dir/../../../scripts/lib/reviewed-npm-audit.mts" >"$identity_file" <<'NODE' import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; -const [configFile] = process.argv.slice(2); -const config = JSON.parse(readFileSync(configFile, "utf8")); -if (!/^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/.test(config.npmVersion)) { - throw new Error("reviewed npm audit configuration has an invalid npmVersion"); -} -if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(config.npmIntegrity)) { - throw new Error("reviewed npm audit configuration has an invalid npmIntegrity"); -} -if (!/^[a-f0-9]{64}$/.test(config.npmArchiveSha256)) { - throw new Error("reviewed npm audit configuration has an invalid npmArchiveSha256"); -} -process.stdout.write(`${config.npmVersion}\n${config.npmIntegrity}\n${config.npmArchiveSha256}\n`); +const [configFile, reviewedNpmAuditFile] = process.argv.slice(2); +const { parseReviewedNpmIdentityConfig } = await import(pathToFileURL(reviewedNpmAuditFile).href); +const identity = parseReviewedNpmIdentityConfig(readFileSync(configFile, "utf8")); +process.stdout.write(`${identity.npmVersion}\n${identity.npmIntegrity}\n${identity.npmArchiveSha256}\n`); NODE IFS= read -r version <"$identity_file" diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index 55b0cb980b6..ae5e3b5fce4 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -96,6 +96,7 @@ jobs: persist-credentials: false sparse-checkout: | .github/actions/ci-reviewed-npm-audit + .github/actions/setup-reviewed-npm ci/npm-audit-exceptions.json ci/reviewed-npm-audit.json scripts/audit-reviewed-npm-graph.mts diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 1fb2ffa0a89..ee127d8ee51 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -543,6 +543,7 @@ jobs: persist-credentials: false sparse-checkout: | .github/actions/ci-reviewed-npm-audit + .github/actions/setup-reviewed-npm ci/npm-audit-exceptions.json ci/reviewed-npm-audit.json scripts/audit-reviewed-npm-graph.mts diff --git a/scripts/audit-reviewed-npm-graph.mts b/scripts/audit-reviewed-npm-graph.mts index d3ea94f4ea7..b560a9c54e8 100755 --- a/scripts/audit-reviewed-npm-graph.mts +++ b/scripts/audit-reviewed-npm-graph.mts @@ -21,6 +21,7 @@ import { type AuditPolicyResult, NPM_AUDIT_REGISTRY, assertExceptionGraphs, + parseReviewedNpmIdentity, readAuditExceptionRegistry, runReviewedNpmAudit, type Severity, @@ -57,6 +58,7 @@ type AuditConfig = Readonly<{ exceptionFile: string; lockedGraphs: readonly LockedGraph[]; nodeVersion: string; + npmArchiveSha256: string; npmIntegrity: string; npmVersion: string; registryOrigin: string; @@ -166,6 +168,7 @@ function run(command: string, args: readonly string[], cwd: string) { export function parseAuditConfig(contents: string): AuditConfig { const parsed = JSON.parse(contents) as AuditConfig; + const reviewedNpmIdentity = parseReviewedNpmIdentity(parsed); if ( parsed.schemaVersion !== 2 || !SEVERITIES.includes(parsed.severityThreshold) || @@ -174,12 +177,6 @@ export function parseAuditConfig(contents: string): AuditConfig { parsed.archiveTarVersion !== "7.5.21" || typeof parsed.exceptionFile !== "string" || !parsed.exceptionFile || - typeof parsed.npmVersion !== "string" || - !/^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/.test(parsed.npmVersion) || - /[\r\n]/.test(parsed.npmVersion) || - typeof parsed.npmIntegrity !== "string" || - !/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(parsed.npmIntegrity) || - /[\r\n]/.test(parsed.npmIntegrity) || typeof parsed.registryOrigin !== "string" || !parsed.registryOrigin || !Array.isArray(parsed.archivePackages) || @@ -246,7 +243,7 @@ export function parseAuditConfig(contents: string): AuditConfig { ) { throw new Error("ci/reviewed-npm-audit.json is invalid"); } - return parsed; + return { ...parsed, ...reviewedNpmIdentity }; } function readConfig(): AuditConfig { diff --git a/scripts/lib/npm-audit-receipt.mts b/scripts/lib/npm-audit-receipt.mts index ad1eb8a670a..723fc79b1e6 100755 --- a/scripts/lib/npm-audit-receipt.mts +++ b/scripts/lib/npm-audit-receipt.mts @@ -11,6 +11,7 @@ import { parseAuditExceptionRegistry, parseAuditReport, NPM_AUDIT_ARGV, + parseReviewedNpmIdentityConfig, } from "./reviewed-npm-audit.mts"; export const AUDIT_ARGV = NPM_AUDIT_ARGV; @@ -212,24 +213,7 @@ export function canonicalAuditReceipt(receipt: AuditReceipt): string { } export function reviewedNpmVersionFromConfig(contents: string): string { - let parsed: unknown; - try { - parsed = JSON.parse(contents); - } catch { - throw new Error("reviewed npm audit configuration is not valid JSON"); - } - const npmVersion = - typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) - ? (parsed as Record).npmVersion - : undefined; - if ( - typeof npmVersion !== "string" || - !/^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/.test(npmVersion) || - /[\r\n]/.test(npmVersion) - ) { - throw new Error("reviewed npm audit configuration has an invalid npmVersion"); - } - return npmVersion; + return parseReviewedNpmIdentityConfig(contents).npmVersion; } function cli(args: readonly string[]): void { diff --git a/scripts/lib/reviewed-npm-audit.mts b/scripts/lib/reviewed-npm-audit.mts index b79b271c930..98f83d0e4fb 100755 --- a/scripts/lib/reviewed-npm-audit.mts +++ b/scripts/lib/reviewed-npm-audit.mts @@ -11,6 +11,52 @@ import { pathToFileURL } from "node:url"; export const SEVERITIES = ["info", "low", "moderate", "high", "critical"] as const; export type Severity = (typeof SEVERITIES)[number]; +export type ReviewedNpmIdentity = Readonly<{ + npmArchiveSha256: string; + npmIntegrity: string; + npmVersion: string; +}>; + +export function parseReviewedNpmIdentity(value: unknown): ReviewedNpmIdentity { + const record = + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; + const { npmArchiveSha256, npmIntegrity, npmVersion } = record; + if ( + typeof npmVersion !== "string" || + !/^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/.test(npmVersion) || + /[\r\n]/.test(npmVersion) + ) { + throw new Error("reviewed npm audit configuration has an invalid npmVersion"); + } + if ( + typeof npmIntegrity !== "string" || + !/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(npmIntegrity) || + /[\r\n]/.test(npmIntegrity) + ) { + throw new Error("reviewed npm audit configuration has an invalid npmIntegrity"); + } + if ( + typeof npmArchiveSha256 !== "string" || + !/^[a-f0-9]{64}$/.test(npmArchiveSha256) || + /[\r\n]/.test(npmArchiveSha256) + ) { + throw new Error("reviewed npm audit configuration has an invalid npmArchiveSha256"); + } + return { npmArchiveSha256, npmIntegrity, npmVersion }; +} + +export function parseReviewedNpmIdentityConfig(contents: string): ReviewedNpmIdentity { + let parsed: unknown; + try { + parsed = JSON.parse(contents); + } catch { + throw new Error("reviewed npm audit configuration is not valid JSON"); + } + return parseReviewedNpmIdentity(parsed); +} + export type AuditException = Readonly<{ advisory: string; decision: "not-affected" | "temporary-risk-acceptance"; @@ -887,8 +933,7 @@ export function runReviewedNpmAudit( const audit = cached ? runNpmAuditWithRetry({ run: () => cached.result, wait: () => {}, warn: () => {} }) : runNpmAuditWithRetry({ - run: () => - spawnSync("npm", NPM_AUDIT_ARGV, npmAuditProcessOptions(options.directory)), + run: () => spawnSync("npm", NPM_AUDIT_ARGV, npmAuditProcessOptions(options.directory)), }); const finishedAt = new Date().toISOString(); if (!cached && cacheFile && cacheInput && audit.report) diff --git a/test/automation/releases/npm-audit-receipt.test.ts b/test/automation/releases/npm-audit-receipt.test.ts index 410ab8559bd..b67ad20f087 100644 --- a/test/automation/releases/npm-audit-receipt.test.ts +++ b/test/automation/releases/npm-audit-receipt.test.ts @@ -27,6 +27,11 @@ const inputs = { registryOrigin: "https://registry.yarnpkg.com", now: NOW, } as const; +const reviewedNpmIdentity = { + npmArchiveSha256: "0".repeat(64), + npmIntegrity: `sha512-${Buffer.alloc(64).toString("base64")}`, + npmVersion: inputs.npmVersion, +}; function receipt(createdAt = NOW) { return createAuditReceipt({ acceptedAdvisoryIds: ["GHSA-b", "GHSA-a"], @@ -173,7 +178,7 @@ describe("reviewed npm audit receipt", () => { fs.writeFileSync(path.join(root, "raw.json"), inputs.rawResponse); fs.writeFileSync( path.join(root, "reviewed-npm-audit.json"), - JSON.stringify({ npmVersion: inputs.npmVersion }), + JSON.stringify(reviewedNpmIdentity), ); const auditReceipt = legacy ? { @@ -232,7 +237,7 @@ describe("reviewed npm audit receipt", () => { fs.writeFileSync(path.join(root, "raw.json"), inputs.rawResponse); fs.writeFileSync( path.join(root, "reviewed-npm-audit.json"), - JSON.stringify({ npmVersion: inputs.npmVersion }), + JSON.stringify(reviewedNpmIdentity), ); fs.writeFileSync(path.join(root, "receipt.json"), canonicalAuditReceipt(receipt(new Date()))); diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index 8b29d29cba4..036384a29a6 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -50,6 +50,9 @@ const TRUSTED_AUDIT_SPARSE_CHECKOUTS = TRUSTED_WORKFLOWS.flatMap((workflowFile) })), ); }); +const TRUSTED_AUDIT_ACTION_SPARSE_CHECKOUTS = TRUSTED_AUDIT_SPARSE_CHECKOUTS.filter( + ({ sparseCheckout }) => sparseCheckout.includes(".github/actions/ci-reviewed-npm-audit"), +); function stageSparseCheckout(root: string, sparseCheckout: string): void { sparseCheckout @@ -90,6 +93,28 @@ describe("reviewed npm audit handoff", () => { }, ); + it.each(TRUSTED_AUDIT_ACTION_SPARSE_CHECKOUTS)( + "loads the audit bootstrap from the $name trusted sparse checkout", + ({ sparseCheckout }) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-audit-action-checkout-")); + try { + expect(sparseCheckout.split("\n").map((entry) => entry.trim())).toContain( + ".github/actions/setup-reviewed-npm", + ); + stageSparseCheckout(root, sparseCheckout); + expect( + fs + .statSync( + path.join(root, ".github/actions/setup-reviewed-npm/verify-and-install-npm.sh"), + ) + .isFile(), + ).toBe(true); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); + it("passes producer output to the Docker receipt verifier and rejects an npm mismatch", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-audit-receipt-handoff-")); const packageJsonFile = path.join(root, "package.json"); @@ -108,7 +133,14 @@ describe("reviewed npm audit handoff", () => { fs.writeFileSync(packageLockFile, packageLock); fs.writeFileSync(rawReportFile, rawReport); fs.writeFileSync(exceptionFile, exceptionPolicy); - fs.writeFileSync(auditConfigFile, JSON.stringify({ npmVersion: "10.9.4" })); + fs.writeFileSync( + auditConfigFile, + JSON.stringify({ + npmArchiveSha256: "0".repeat(64), + npmIntegrity: `sha512-${Buffer.alloc(64).toString("base64")}`, + npmVersion: "10.9.4", + }), + ); fs.writeFileSync( path.join(root, "report.provenance.json"), JSON.stringify({ run: { startedAt: new Date().toISOString() } }), @@ -173,7 +205,14 @@ describe("reviewed npm audit handoff", () => { }); fs.rmSync(resultFile); - fs.writeFileSync(auditConfigFile, JSON.stringify({ npmVersion: "11.18.0" })); + fs.writeFileSync( + auditConfigFile, + JSON.stringify({ + npmArchiveSha256: "0".repeat(64), + npmIntegrity: `sha512-${Buffer.alloc(64).toString("base64")}`, + npmVersion: "11.18.0", + }), + ); const rejected = spawnSync(process.execPath, verifierArgs, { encoding: "utf8" }); expect(rejected.status).not.toBe(0); expect(rejected.stderr).toContain("receipt identity does not match expected graph and npm"); diff --git a/test/automation/releases/reviewed-npm-audit-workflow.test.ts b/test/automation/releases/reviewed-npm-audit-workflow.test.ts index 55dd97156dd..79e746e8ee2 100644 --- a/test/automation/releases/reviewed-npm-audit-workflow.test.ts +++ b/test/automation/releases/reviewed-npm-audit-workflow.test.ts @@ -133,6 +133,7 @@ function runConsolidatedAuditFixture( }, ], nodeVersion: process.version.slice(1), + npmArchiveSha256: REVIEWED_AUDIT_CONFIG.npmArchiveSha256, npmIntegrity: REVIEWED_AUDIT_CONFIG.npmIntegrity, npmVersion: REVIEWED_AUDIT_CONFIG.npmVersion, registryOrigin: "https://registry.npmjs.org/", @@ -340,8 +341,8 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { expect(cacheBucketStep.run).toContain( "const targetRoot = process.env.NEMOCLAW_REVIEWED_NPM_AUDIT_TARGET_ROOT;", ); - expect(cacheBucketStep.run).toContain("npmIntegrity: config.npmIntegrity"); - expect(cacheBucketStep.run).toContain("npmArchiveSha256: config.npmArchiveSha256"); + expect(cacheBucketStep.run).toContain("parseReviewedNpmIdentity(config)"); + expect(cacheBucketStep.run).toContain("...identity"); expect(cacheBucketStep.run).not.toContain("${{ inputs.cache-directory }}"); expect(cacheBucketStep.run).not.toContain("${{ inputs.target-root }}"); }); @@ -604,6 +605,7 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { }, ], nodeVersion: "22.23.2", + npmArchiveSha256: REVIEWED_AUDIT_CONFIG.npmArchiveSha256, npmIntegrity: REVIEWED_AUDIT_CONFIG.npmIntegrity, npmVersion: REVIEWED_AUDIT_CONFIG.npmVersion, registryOrigin: "https://registry.npmjs.org/", diff --git a/test/automation/releases/reviewed-npm-bootstrap.test.ts b/test/automation/releases/reviewed-npm-bootstrap.test.ts index 7168fb2f524..2a8425e3ae1 100644 --- a/test/automation/releases/reviewed-npm-bootstrap.test.ts +++ b/test/automation/releases/reviewed-npm-bootstrap.test.ts @@ -17,7 +17,7 @@ const BOOTSTRAP = path.join( "verify-and-install-npm.sh", ); -function identity(archive: string): Record { +function identity(archive: string | Buffer): Record { return { npmArchiveSha256: createHash("sha256").update(archive).digest("hex"), npmIntegrity: `sha512-${createHash("sha512").update(archive).digest("base64")}`, @@ -26,9 +26,10 @@ function identity(archive: string): Record { } type BootstrapFixtureOptions = { - archive: string; + archive: string | Buffer; archiveVersion?: string; environment?: NodeJS.ProcessEnv; + realTar?: boolean; reviewedIdentity?: Record; }; @@ -38,8 +39,10 @@ function runBootstrapFixture(options: BootstrapFixtureOptions) { const npmLog = path.join(root, "npm.log"); const installMarker = path.join(root, "install-called"); const identityPath = path.join(root, "reviewed-npm-audit.json"); + const archivePath = path.join(root, "fixture.tgz"); fs.mkdirSync(bin); + fs.writeFileSync(archivePath, options.archive); fs.writeFileSync( path.join(bin, "npm"), `#!/usr/bin/env bash @@ -59,7 +62,7 @@ case "$1" in done [ -n "$download_dir" ] [ "$pack_args" = "pack npm@12.0.2 --pack-destination $download_dir --userconfig /dev/null --registry https://registry.npmjs.org/ --ignore-scripts --no-audit --no-fund" ] - printf '%s' "$NEMOCLAW_TEST_ARCHIVE" > "$download_dir/npm-12.0.2.tgz" + cp "$NEMOCLAW_TEST_ARCHIVE_FILE" "$download_dir/npm-12.0.2.tgz" ;; install) : > "$NEMOCLAW_TEST_INSTALL_MARKER" @@ -75,9 +78,16 @@ esac path.join(bin, "tar"), `#!/usr/bin/env bash set -euo pipefail +case "$NEMOCLAW_TEST_REAL_TAR" in + true) + exec env PATH="$NEMOCLAW_TEST_ORIGINAL_PATH" tar "$@" + ;; + false) [ "$1" = "-xOf" ] [ "$3" = "package/package.json" ] printf '{"version":"%s"}\\n' "$NEMOCLAW_TEST_ARCHIVE_VERSION" + ;; +esac `, { mode: 0o755 }, ); @@ -90,10 +100,12 @@ printf '{"version":"%s"}\\n' "$NEMOCLAW_TEST_ARCHIVE_VERSION" env: { ...process.env, ...options.environment, - NEMOCLAW_TEST_ARCHIVE: options.archive, + NEMOCLAW_TEST_ARCHIVE_FILE: archivePath, NEMOCLAW_TEST_ARCHIVE_VERSION: options.archiveVersion ?? "12.0.2", NEMOCLAW_TEST_INSTALL_MARKER: installMarker, NEMOCLAW_TEST_NPM_LOG: npmLog, + NEMOCLAW_TEST_ORIGINAL_PATH: process.env.PATH ?? "", + NEMOCLAW_TEST_REAL_TAR: String(options.realTar ?? false), PATH: `${bin}:${process.env.PATH ?? ""}`, RUNNER_TEMP: root, }, @@ -101,14 +113,51 @@ printf '{"version":"%s"}\\n' "$NEMOCLAW_TEST_ARCHIVE_VERSION" return { cleanup: () => fs.rmSync(root, { recursive: true, force: true }), installCalled: fs.existsSync(installMarker), - npmInvocations: fs.readFileSync(npmLog, "utf8").trim().split("\n"), + npmInvocations: fs.existsSync(npmLog) ? fs.readFileSync(npmLog, "utf8").trim().split("\n") : [], result, }; } +function createRealArchive(version?: string): { archive: Buffer; cleanup: () => void } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-npm-archive-")); + const packageRoot = path.join(root, "package"); + const archivePath = path.join(root, "fixture.tgz"); + fs.mkdirSync(packageRoot); + const entry = + version === undefined + ? { contents: "missing package manifest\n", name: "README.md" } + : { contents: `${JSON.stringify({ version })}\n`, name: "package.json" }; + fs.writeFileSync(path.join(packageRoot, entry.name), entry.contents); + const packed = spawnSync("tar", ["-czf", archivePath, "-C", root, "package"], { + encoding: "utf8", + }); + expect(packed.status, packed.stderr).toBe(0); + return { + archive: fs.readFileSync(archivePath), + cleanup: () => fs.rmSync(root, { recursive: true, force: true }), + }; +} + describe("reviewed npm bootstrap", () => { const archive = "verified archive\n"; + it("rejects a malformed reviewed archive SHA-256 before download (#8253)", () => { + const fixture = runBootstrapFixture({ + archive, + reviewedIdentity: { ...identity(archive), npmArchiveSha256: "not-a-reviewed-digest" }, + }); + try { + expect(fixture.result.status).toBe(1); + expect(fixture.result.stderr).toContain( + "reviewed npm audit configuration has an invalid npmArchiveSha256", + ); + expect(fixture.npmInvocations).toEqual([]); + expect(fixture.installCalled).toBe(false); + } finally { + fixture.cleanup(); + } + }); + it.each([ ["SHA-256", { ...identity(archive), npmArchiveSha256: "0".repeat(64) }], [ @@ -145,6 +194,26 @@ describe("reviewed npm bootstrap", () => { } }); + it.each([ + ["matching", "12.0.2", true], + ["mismatched", "12.0.3", false], + ["missing", undefined, false], + ] as const)( + "%s real tar package metadata reaches installation only for the reviewed version (#8253)", + (_condition, archiveVersion, expectedInstall) => { + const archiveFixture = createRealArchive(archiveVersion); + const fixture = runBootstrapFixture({ archive: archiveFixture.archive, realTar: true }); + try { + expect(fixture.result.status === 0).toBe(expectedInstall); + expect(fixture.installCalled).toBe(expectedInstall); + expect(fixture.npmInvocations).toHaveLength(expectedInstall ? 2 : 1); + } finally { + fixture.cleanup(); + archiveFixture.cleanup(); + } + }, + ); + it("installs a matching archive offline (#8253)", () => { const fixture = runBootstrapFixture({ archive }); try { diff --git a/test/install/installer-brev-npm12-template-trust.test.ts b/test/install/installer-brev-npm12-template-trust.test.ts index d77cfd6bea4..c1869e4f131 100644 --- a/test/install/installer-brev-npm12-template-trust.test.ts +++ b/test/install/installer-brev-npm12-template-trust.test.ts @@ -164,12 +164,14 @@ describe("reviewed npm 12 Brev template trust", () => { ); expect(npmVerifier).toBeGreaterThan(-1); expect(dependencyInstall).toBeGreaterThan(npmVerifier); - expect(fs.statSync(NPM_BOOTSTRAP).isFile()).toBe(true); - expect(fs.statSync(NPM_BOOTSTRAP).mode & 0o111).not.toBe(0); - const bootstrapSource = fs.readFileSync(NPM_BOOTSTRAP, "utf8"); - expect(bootstrapSource).toContain("config.npmVersion"); - expect(bootstrapSource).toContain("config.npmIntegrity"); - expect(bootstrapSource).toContain("config.npmArchiveSha256"); + const bootstrap = fs.openSync(NPM_BOOTSTRAP, "r"); + try { + expect(fs.fstatSync(bootstrap).isFile()).toBe(true); + expect(fs.fstatSync(bootstrap).mode & 0o111).not.toBe(0); + expect(fs.readFileSync(bootstrap, "utf8")).toContain("parseReviewedNpmIdentityConfig"); + } finally { + fs.closeSync(bootstrap); + } const accepted = runParser(reviewedTemplate); expect(accepted.status, accepted.stderr).toBe(0); From e02c8395717cf8fa8f920247b8c66fad3ac6d01c Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 5 Sep 2026 14:29:21 -0700 Subject: [PATCH 07/31] fix(ci): qualify reviewed npm bootstrap changes Signed-off-by: Charan Jagwani --- .github/workflows/base-image.yaml | 1 + .github/workflows/managed-images.yaml | 1 + ci/source-shape-test-budget.json | 5 +++++ .../reviewed-npm-audit-handoff.test.ts | 18 ++++++++++++++++++ 4 files changed, 25 insertions(+) diff --git a/.github/workflows/base-image.yaml b/.github/workflows/base-image.yaml index 395bf57bd6f..6223048a499 100644 --- a/.github/workflows/base-image.yaml +++ b/.github/workflows/base-image.yaml @@ -26,6 +26,7 @@ on: - "test/e2e/live/managed-image-activation-e2e.test.ts" - "test/e2e/live/managed-image-activation-e2e-helpers.ts" - ".github/actions/ci-reviewed-npm-audit/**" + - ".github/actions/setup-reviewed-npm/**" - ".github/actions/publish-managed-image-digest/**" - ".github/actions/build-base-image-platform/**" - ".github/actions/publish-base-image-manifest/**" diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index ae5e3b5fce4..bc986bb214c 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -32,6 +32,7 @@ on: pull_request: paths: - ".github/actions/ci-reviewed-npm-audit/**" + - ".github/actions/setup-reviewed-npm/**" - ".github/workflows/base-image.yaml" - ".github/actions/publish-managed-image-digest/**" - ".github/workflows/managed-images.yaml" diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index ebf7224c184..bda436d04af 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -211,6 +211,11 @@ "test": "uses the single JSON-bound reviewed npm bootstrap owner", "category": "security" }, + { + "file": "test/automation/releases/reviewed-npm-audit-handoff.test.ts", + "test": "routes bootstrap-only changes through image qualification and publication", + "category": "security" + }, { "file": "test/automation/releases/reviewed-npm-audit-workflow.test.ts", "test": "rejects the removed plural source-registry package shape", diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index 036384a29a6..4018403148a 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -30,6 +30,10 @@ type Workflow = { } > >; + readonly on?: { + readonly pull_request?: { readonly paths?: readonly string[] }; + readonly push?: { readonly paths?: readonly string[] }; + }; }; const TRUSTED_AUDIT_SPARSE_CHECKOUTS = TRUSTED_WORKFLOWS.flatMap((workflowFile) => { @@ -67,6 +71,20 @@ function stageSparseCheckout(root: string, sparseCheckout: string): void { } describe("reviewed npm audit handoff", () => { + // source-shape-contract: security -- Image workflows must treat the shared reviewed npm bootstrap as a trigger so verifier drift cannot bypass qualification or publication + it("routes bootstrap-only changes through image qualification and publication", () => { + const bootstrapGlob = ".github/actions/setup-reviewed-npm/**"; + const managedImages = YAML.parse( + fs.readFileSync(path.join(REPO_ROOT, ".github/workflows/managed-images.yaml"), "utf8"), + ) as Workflow; + const baseImages = YAML.parse( + fs.readFileSync(path.join(REPO_ROOT, ".github/workflows/base-image.yaml"), "utf8"), + ) as Workflow; + + expect(managedImages.on?.pull_request?.paths).toContain(bootstrapGlob); + expect(baseImages.on?.push?.paths).toContain(bootstrapGlob); + }); + it.each(TRUSTED_AUDIT_SPARSE_CHECKOUTS)( "loads the audit producer from the $name trusted sparse checkout", ({ sparseCheckout }) => { From 0d112df90d10652d5fdc0a4d7f4916bed40a480c Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 5 Sep 2026 14:49:14 -0700 Subject: [PATCH 08/31] fix(ci): close npm bootstrap publication gaps Signed-off-by: Charan Jagwani --- scripts/checks/extract-installer-pins.mts | 2 +- test/e2e/support/base-image-publication.test.ts | 9 +++++++++ .../installer-brev-npm12-template-trust.test.ts | 12 ++++++++++-- tools/e2e/base-image-publication.mts | 1 + 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/scripts/checks/extract-installer-pins.mts b/scripts/checks/extract-installer-pins.mts index 8119a3345e4..a3b1408b75a 100644 --- a/scripts/checks/extract-installer-pins.mts +++ b/scripts/checks/extract-installer-pins.mts @@ -356,7 +356,7 @@ const TRUSTED_OPENSHELL_RELEASES: readonly OpenShellReleaseTrust[] = [ brevTemplateSha256: [ "c0a4ddf25a02a9fe02b2df53a60942ea887610f04d4ce16a121b6e79a5aeff1a", "56fc6482d1508b73604099e6fd6c16daea16275cf36cc25c1c5366c82a4394e3", - "773f3728a3b6404d909cbf395abee2a3b95872d6b93ec90b7814adbacc683470", + "5674b528f6604b30b31fbf3877e4f5d53abc08e88c0a352070a898cfd7eaa7bf", ], formula: { asset: "openshell.rb", diff --git a/test/e2e/support/base-image-publication.test.ts b/test/e2e/support/base-image-publication.test.ts index 427f72f806d..befdae8be04 100644 --- a/test/e2e/support/base-image-publication.test.ts +++ b/test/e2e/support/base-image-publication.test.ts @@ -15,6 +15,7 @@ import { githubRequest, type PublicationRun, isBaseImagePublicationEvent, + matchesBaseImagePushPath, parseBaseImagePushPaths, resolveFirstParentHistory, selectPublicationRun, @@ -224,15 +225,23 @@ describe("base-image publication evidence", () => { const expanded = expandBaseImagePushPaths(EXPECTED_SHA, [ "Dockerfile", "agents/**", + ".github/actions/setup-reviewed-npm/**", "src/lib/messaging/**", "test/e2e/live/managed-image-activation-e2e*.ts", ]); expect(expanded).toEqual([ + ":(glob).github/actions/setup-reviewed-npm/**", ":(glob)agents/**", ":(glob)src/lib/messaging/**", ":(glob)test/e2e/live/managed-image-activation-e2e*.ts", "Dockerfile", ]); + expect( + matchesBaseImagePushPath( + ".github/actions/setup-reviewed-npm/**", + ".github/actions/setup-reviewed-npm/verify-and-install-npm.sh", + ), + ).toBe(true); }); it("binds the applicable commit to the checked-out first-parent chain (#7372)", () => { diff --git a/test/install/installer-brev-npm12-template-trust.test.ts b/test/install/installer-brev-npm12-template-trust.test.ts index c1869e4f131..e2bc1c486d5 100644 --- a/test/install/installer-brev-npm12-template-trust.test.ts +++ b/test/install/installer-brev-npm12-template-trust.test.ts @@ -20,8 +20,8 @@ const BREV_TEMPLATE = fs.readFileSync( path.join(REPO_ROOT, "scripts/brev-launchable-ci-cpu.sh"), "utf8", ); -const REVIEWED_SOURCE_SHA256 = "aa6e42c034bf36a1bd28ae542159af8cb140bcb471008627609fb78d82ec9b32"; -const REVIEWED_TEMPLATE_SHA256 = "773f3728a3b6404d909cbf395abee2a3b95872d6b93ec90b7814adbacc683470"; +const REVIEWED_SOURCE_SHA256 = "a7c673cc7246f25e0ce25ba53680f70de6d58bcee95072c6889d4bfc00edad7b"; +const REVIEWED_TEMPLATE_SHA256 = "5674b528f6604b30b31fbf3877e4f5d53abc08e88c0a352070a898cfd7eaa7bf"; const tempDirs: string[] = []; afterEach(() => { @@ -109,10 +109,12 @@ cd "$NEMOCLAW_CLONE_DIR" withDescription, dependencyInstallStart, `${dependencyInstallStart}reviewed_npm_tmp="$(mktemp -d)" +trap 'rm -rf "$reviewed_npm_tmp"' EXIT sudo env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN \\ RUNNER_TEMP="$reviewed_npm_tmp" \\ bash .github/actions/setup-reviewed-npm/verify-and-install-npm.sh ci/reviewed-npm-audit.json rm -rf "$reviewed_npm_tmp" +trap - EXIT `, "reviewed npm Brev dependency install", ); @@ -150,6 +152,8 @@ describe("reviewed npm 12 Brev template trust", () => { const npmVerifier = reviewedTemplate.indexOf( "bash .github/actions/setup-reviewed-npm/verify-and-install-npm.sh ci/reviewed-npm-audit.json", ); + const cleanupTrap = reviewedTemplate.indexOf(`trap 'rm -rf "$reviewed_npm_tmp"' EXIT`); + const clearCleanupTrap = reviewedTemplate.indexOf("trap - EXIT", npmVerifier); const dependencyInstall = reviewedTemplate.indexOf("npm install --ignore-scripts", npmVerifier); expect(createHash("sha256").update(reviewedTemplate).digest("hex")).toBe( @@ -163,7 +167,11 @@ describe("reviewed npm 12 Brev template trust", () => { 'node_sha256="df224555a083b918e46260cc969838501b9f9a87140c1195e5b9597b56d5dae2"', ); expect(npmVerifier).toBeGreaterThan(-1); + expect(cleanupTrap).toBeGreaterThan(-1); + expect(cleanupTrap).toBeLessThan(npmVerifier); expect(dependencyInstall).toBeGreaterThan(npmVerifier); + expect(clearCleanupTrap).toBeGreaterThan(npmVerifier); + expect(clearCleanupTrap).toBeLessThan(dependencyInstall); const bootstrap = fs.openSync(NPM_BOOTSTRAP, "r"); try { expect(fs.fstatSync(bootstrap).isFile()).toBe(true); diff --git a/tools/e2e/base-image-publication.mts b/tools/e2e/base-image-publication.mts index d010fd6a718..774d2aa2685 100644 --- a/tools/e2e/base-image-publication.mts +++ b/tools/e2e/base-image-publication.mts @@ -30,6 +30,7 @@ const SHA_PATTERN = /^[0-9a-f]{40}$/u; const SAFE_PATH_PATTERN = /^[A-Za-z0-9._/-]+$/u; const REVIEWED_PATH_GLOBS = new Map([ [".github/actions/ci-reviewed-npm-audit/**", /^[.]github\/actions\/ci-reviewed-npm-audit\/.+$/u], + [".github/actions/setup-reviewed-npm/**", /^[.]github\/actions\/setup-reviewed-npm\/.+$/u], [ ".github/actions/publish-managed-image-digest/**", /^[.]github\/actions\/publish-managed-image-digest\/.+$/u, From 3786d510ecbc0347a3b37ca82d123fd4300c3535 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Sat, 5 Sep 2026 15:01:25 -0700 Subject: [PATCH 09/31] docs(security): describe npm archive verification Signed-off-by: Charan Jagwani --- tools/mcp-tool-discovery-runtime/dependency-review.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/mcp-tool-discovery-runtime/dependency-review.md b/tools/mcp-tool-discovery-runtime/dependency-review.md index 15088956d2f..5a28d5696ef 100644 --- a/tools/mcp-tool-discovery-runtime/dependency-review.md +++ b/tools/mcp-tool-discovery-runtime/dependency-review.md @@ -48,7 +48,7 @@ Concern ledger: Issue #8253 showed that image-build audits made sandbox creation depend on current registry and Sigstore TUF data instead of only the committed build inputs. Bundle regeneration uses the shared installer without live advisory or registry-signature queries. It installs the exact lock with lifecycle scripts disabled from integrity-pinned archives, runs the runtime tests and typecheck, and verifies the exact bundle inputs. -The reviewed npm audit CI check now owns production advisory enforcement for this lock. Its trusted action verifies the downloaded `npm@10.9.4` archive against the reviewed SHA-512 before installing it. The check verifies the lock SHA-256 and SDK package integrity, installs the production graph with lifecycle scripts disabled, records audit provenance and policy results, verifies registry signatures, and fails on unaccepted findings at the repository's configured threshold. +The reviewed npm audit CI check now owns production advisory enforcement for this lock. Its trusted action verifies the downloaded `npm@10.9.4` archive against the reviewed SHA-512 integrity and SHA-256 digest, then confirms that the archive metadata reports the reviewed version before installation. The check verifies the lock SHA-256 and SDK package integrity, installs the production graph with lifecycle scripts disabled, records audit provenance and policy results, verifies registry signatures, and fails on unaccepted findings at the repository's configured threshold. ## 1.29.0 to 1.30.0 migration review From 559b35a6f3483f436e70afff14fbb2e83206497d Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Tue, 8 Sep 2026 08:27:11 -0700 Subject: [PATCH 10/31] test(ci): complete reviewed npm fixture identity Signed-off-by: Charan Jagwani --- test/repository/prepare-ci-npm-install.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/repository/prepare-ci-npm-install.test.ts b/test/repository/prepare-ci-npm-install.test.ts index bfdee3c9e87..751ff1d09b0 100644 --- a/test/repository/prepare-ci-npm-install.test.ts +++ b/test/repository/prepare-ci-npm-install.test.ts @@ -79,6 +79,7 @@ function reviewedConfigSource(packageIdentity: ReviewedSourceRegistryPackage = r exceptionFile: "ci/npm-audit-exceptions.json", lockedGraphs: [], nodeVersion: "22.23.2", + npmArchiveSha256: "4bfba8a0c823024d1926ec9d97a37a00eb60fd2adf44b3d34a686fc32e8f51e4", npmIntegrity: "sha512-OnUGvKW3lJs/ooPKDKUNfz1UmMfF48YWbjNA20QdiWrCVnZaAPppOfHPnfGiPb+1lKIsxjKXQ4UAfDI7PcvLPg==", npmVersion: "10.9.4", From 9228ab94ff1f3e1262e1a5bc93df687a5ceada07 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Tue, 8 Sep 2026 10:46:18 -0700 Subject: [PATCH 11/31] test(ci): remove source-shape review coverage Signed-off-by: Charan Jagwani --- ci/source-shape-test-budget.json | 15 -- scripts/checks/extract-installer-pins.mts | 3 +- .../reviewed-npm-audit-handoff.test.ts | 44 ---- .../reviewed-npm-audit-workflow.test.ts | 21 -- ...nstaller-brev-npm12-template-trust.test.ts | 206 ------------------ 5 files changed, 1 insertion(+), 288 deletions(-) delete mode 100644 test/install/installer-brev-npm12-template-trust.test.ts diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index cfb621c75cf..acdaf2e50ca 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -211,16 +211,6 @@ "test": "passes the cache identity target root without interpolating it into shell source", "category": "security" }, - { - "file": "test/automation/releases/reviewed-npm-audit-workflow.test.ts", - "test": "uses the single JSON-bound reviewed npm bootstrap owner", - "category": "security" - }, - { - "file": "test/automation/releases/reviewed-npm-audit-handoff.test.ts", - "test": "routes bootstrap-only changes through image qualification and publication", - "category": "security" - }, { "file": "test/automation/releases/reviewed-npm-audit-workflow.test.ts", "test": "rejects the removed plural source-registry package shape", @@ -266,11 +256,6 @@ "test": "accepts the reviewed %s OpenShell 0.0.106 installer template", "category": "security" }, - { - "file": "test/install/installer-brev-npm12-template-trust.test.ts", - "test": "binds the exact reviewed npm 12 Brev successor and rejects bootstrap drift", - "category": "security" - }, { "file": "test/install/installer-supervisor-manifest-trust.test.ts", "test": "accepts the prospective shared gateway state resolver template (#10544)", diff --git a/scripts/checks/extract-installer-pins.mts b/scripts/checks/extract-installer-pins.mts index a3b1408b75a..6764f436a32 100644 --- a/scripts/checks/extract-installer-pins.mts +++ b/scripts/checks/extract-installer-pins.mts @@ -351,8 +351,7 @@ const TRUSTED_OPENSHELL_RELEASES: readonly OpenShellReleaseTrust[] = [ version: "0.0.103", }, { - // The third template authorizes the reviewed Node 24.18.1 and npm 12.0.2 - // successor constructed and verified by installer-brev-npm12-template-trust.test.ts. + // The third template authorizes the reviewed Node 24.18.1 and npm 12.0.2 successor. brevTemplateSha256: [ "c0a4ddf25a02a9fe02b2df53a60942ea887610f04d4ce16a121b6e79a5aeff1a", "56fc6482d1508b73604099e6fd6c16daea16275cf36cc25c1c5366c82a4394e3", diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index 4018403148a..cffecac6507 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -30,10 +30,6 @@ type Workflow = { } > >; - readonly on?: { - readonly pull_request?: { readonly paths?: readonly string[] }; - readonly push?: { readonly paths?: readonly string[] }; - }; }; const TRUSTED_AUDIT_SPARSE_CHECKOUTS = TRUSTED_WORKFLOWS.flatMap((workflowFile) => { @@ -54,10 +50,6 @@ const TRUSTED_AUDIT_SPARSE_CHECKOUTS = TRUSTED_WORKFLOWS.flatMap((workflowFile) })), ); }); -const TRUSTED_AUDIT_ACTION_SPARSE_CHECKOUTS = TRUSTED_AUDIT_SPARSE_CHECKOUTS.filter( - ({ sparseCheckout }) => sparseCheckout.includes(".github/actions/ci-reviewed-npm-audit"), -); - function stageSparseCheckout(root: string, sparseCheckout: string): void { sparseCheckout .split("\n") @@ -71,20 +63,6 @@ function stageSparseCheckout(root: string, sparseCheckout: string): void { } describe("reviewed npm audit handoff", () => { - // source-shape-contract: security -- Image workflows must treat the shared reviewed npm bootstrap as a trigger so verifier drift cannot bypass qualification or publication - it("routes bootstrap-only changes through image qualification and publication", () => { - const bootstrapGlob = ".github/actions/setup-reviewed-npm/**"; - const managedImages = YAML.parse( - fs.readFileSync(path.join(REPO_ROOT, ".github/workflows/managed-images.yaml"), "utf8"), - ) as Workflow; - const baseImages = YAML.parse( - fs.readFileSync(path.join(REPO_ROOT, ".github/workflows/base-image.yaml"), "utf8"), - ) as Workflow; - - expect(managedImages.on?.pull_request?.paths).toContain(bootstrapGlob); - expect(baseImages.on?.push?.paths).toContain(bootstrapGlob); - }); - it.each(TRUSTED_AUDIT_SPARSE_CHECKOUTS)( "loads the audit producer from the $name trusted sparse checkout", ({ sparseCheckout }) => { @@ -111,28 +89,6 @@ describe("reviewed npm audit handoff", () => { }, ); - it.each(TRUSTED_AUDIT_ACTION_SPARSE_CHECKOUTS)( - "loads the audit bootstrap from the $name trusted sparse checkout", - ({ sparseCheckout }) => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-audit-action-checkout-")); - try { - expect(sparseCheckout.split("\n").map((entry) => entry.trim())).toContain( - ".github/actions/setup-reviewed-npm", - ); - stageSparseCheckout(root, sparseCheckout); - expect( - fs - .statSync( - path.join(root, ".github/actions/setup-reviewed-npm/verify-and-install-npm.sh"), - ) - .isFile(), - ).toBe(true); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } - }, - ); - it("passes producer output to the Docker receipt verifier and rejects an npm mismatch", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-audit-receipt-handoff-")); const packageJsonFile = path.join(root, "package.json"); diff --git a/test/automation/releases/reviewed-npm-audit-workflow.test.ts b/test/automation/releases/reviewed-npm-audit-workflow.test.ts index 79e746e8ee2..166a68311e5 100644 --- a/test/automation/releases/reviewed-npm-audit-workflow.test.ts +++ b/test/automation/releases/reviewed-npm-audit-workflow.test.ts @@ -341,31 +341,10 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { expect(cacheBucketStep.run).toContain( "const targetRoot = process.env.NEMOCLAW_REVIEWED_NPM_AUDIT_TARGET_ROOT;", ); - expect(cacheBucketStep.run).toContain("parseReviewedNpmIdentity(config)"); - expect(cacheBucketStep.run).toContain("...identity"); expect(cacheBucketStep.run).not.toContain("${{ inputs.cache-directory }}"); expect(cacheBucketStep.run).not.toContain("${{ inputs.target-root }}"); }); - // source-shape-contract: security -- The trusted audit action must execute the sole identity-bound npm bootstrap owner - it("uses the single JSON-bound reviewed npm bootstrap owner", () => { - const action = YAML.parse( - fs.readFileSync( - path.join(REPO_ROOT, ".github", "actions", "ci-reviewed-npm-audit", "action.yaml"), - "utf8", - ), - ) as CompositeAction; - const bootstrapStep = requiredStep(action.runs, "Download and verify production npm"); - - expect(bootstrapStep.env).toBeUndefined(); - expect(bootstrapStep.run).toContain( - '"$GITHUB_ACTION_PATH/../setup-reviewed-npm/verify-and-install-npm.sh"', - ); - expect(bootstrapStep.run).toContain( - '"$GITHUB_ACTION_PATH/../../../ci/reviewed-npm-audit.json"', - ); - }); - it("rejects audit production when installed npm differs from the reviewed identity", () => { const fixture = runConsolidatedAuditFixture(() => {}, undefined, 0, 0, "11.18.0"); diff --git a/test/install/installer-brev-npm12-template-trust.test.ts b/test/install/installer-brev-npm12-template-trust.test.ts deleted file mode 100644 index e2bc1c486d5..00000000000 --- a/test/install/installer-brev-npm12-template-trust.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { afterEach, describe, expect, it } from "vitest"; - -const REPO_ROOT = path.join(import.meta.dirname, "../.."); -const PARSER = path.join(REPO_ROOT, "scripts/checks/extract-installer-pins.mts"); -const NPM_BOOTSTRAP = path.join( - REPO_ROOT, - ".github/actions/setup-reviewed-npm/verify-and-install-npm.sh", -); -const BREV_TEMPLATE = fs.readFileSync( - path.join(REPO_ROOT, "scripts/brev-launchable-ci-cpu.sh"), - "utf8", -); -const REVIEWED_SOURCE_SHA256 = "a7c673cc7246f25e0ce25ba53680f70de6d58bcee95072c6889d4bfc00edad7b"; -const REVIEWED_TEMPLATE_SHA256 = "5674b528f6604b30b31fbf3877e4f5d53abc08e88c0a352070a898cfd7eaa7bf"; -const tempDirs: string[] = []; - -afterEach(() => { - for (const tempDir of tempDirs.splice(0)) { - fs.rmSync(tempDir, { force: true, recursive: true }); - } -}); - -function replaceUniqueSource( - source: string, - current: string, - replacement: string, - label: string, -): string { - const start = source.indexOf(current); - assert.notEqual(start, -1, `${label} source must exist`); - assert.equal( - source.indexOf(current, start + current.length), - -1, - `${label} source must be unique`, - ); - return `${source.slice(0, start)}${replacement}${source.slice(start + current.length)}`; -} - -function renderReviewedNpm12BrevTemplate(source: string): string { - const nodeSectionStart = "# 3. Node.js 22\n"; - const nodeSectionEnd = "# 4. OpenShell CLI\n"; - const start = source.indexOf(nodeSectionStart); - const end = source.indexOf(nodeSectionEnd, start + nodeSectionStart.length); - assert.notEqual(start, -1, "reviewed npm Brev Node section start must exist"); - assert.notEqual(end, -1, "reviewed npm Brev Node section end must exist"); - const reviewedNodeSection = `# 3. Node.js 24.18.1 -NODE_VERSION="24.18.1" -if command -v node >/dev/null 2>&1 && [[ "$(node --version)" == "v\${NODE_VERSION}" ]]; then - info "Node.js already installed: $(node --version)" -else - case "$(uname -m)" in - x86_64) - node_arch="x64" - node_sha256="9f5eb6ac21845a66c493c91a253b1da32fd684e89e9b7202d4936982336be4ca" - ;; - aarch64 | arm64) - node_arch="arm64" - node_sha256="df224555a083b918e46260cc969838501b9f9a87140c1195e5b9597b56d5dae2" - ;; - *) fail "Unsupported Node.js architecture: $(uname -m)" ;; - esac - info "Installing Node.js \${NODE_VERSION}..." - node_tmp="$(mktemp)" - node_url="https://nodejs.org/dist/v\${NODE_VERSION}/node-v\${NODE_VERSION}-linux-\${node_arch}.tar.gz" - curl -fsSL --proto '=https' --tlsv1.2 "$node_url" -o "$node_tmp" || { - rm -f "$node_tmp" - fail "Failed to download Node.js archive" - } - if command -v sha256sum >/dev/null 2>&1; then - actual_hash="$(sha256sum "$node_tmp" | awk '{print $1}')" - elif command -v shasum >/dev/null 2>&1; then - actual_hash="$(shasum -a 256 "$node_tmp" | awk '{print $1}')" - else - rm -f "$node_tmp" - fail "No SHA-256 tool available (sha256sum/shasum)" - fi - if [[ "$actual_hash" != "$node_sha256" ]]; then - rm -f "$node_tmp" - fail "Node.js archive integrity check failed\\n Expected: $node_sha256\\n Actual: $actual_hash" - fi - sudo tar -xzf "$node_tmp" -C /usr/local --strip-components=1 --no-same-owner - rm -f "$node_tmp" - [[ "$(node --version)" == "v\${NODE_VERSION}" ]] || fail "Node.js installation did not produce v\${NODE_VERSION}" - info "Node.js $(node --version) installed" -fi - -`; - const withNodePin = `${source.slice(0, start)}${reviewedNodeSection}${source.slice(end)}`; - const withDescription = replaceUniqueSource( - withNodePin, - "# 2. Node.js 22 (nodesource)", - "# 2. Node.js 24.18.1 and verified npm 12.0.2", - "reviewed npm Brev description", - ); - const dependencyInstallStart = `info "Installing npm dependencies..." -cd "$NEMOCLAW_CLONE_DIR" -`; - return replaceUniqueSource( - withDescription, - dependencyInstallStart, - `${dependencyInstallStart}reviewed_npm_tmp="$(mktemp -d)" -trap 'rm -rf "$reviewed_npm_tmp"' EXIT -sudo env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN \\ - RUNNER_TEMP="$reviewed_npm_tmp" \\ - bash .github/actions/setup-reviewed-npm/verify-and-install-npm.sh ci/reviewed-npm-audit.json -rm -rf "$reviewed_npm_tmp" -trap - EXIT -`, - "reviewed npm Brev dependency install", - ); -} - -function runParser(brevInstaller: string) { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-brev-npm12-trust-")); - tempDirs.push(tempDir); - const brevPath = path.join(tempDir, "brev-launchable-ci-cpu.sh"); - fs.writeFileSync(brevPath, brevInstaller); - return spawnSync( - process.execPath, - [ - "--experimental-strip-types", - PARSER, - "--blueprint", - path.join(REPO_ROOT, "nemoclaw-blueprint/blueprint.yaml"), - "--installer", - path.join(REPO_ROOT, "scripts/install-openshell.sh"), - "--brev-installer", - brevPath, - "--supervisor-runtime", - path.join(REPO_ROOT, "src/lib/onboard/docker-driver-gateway-runtime.ts"), - "--format", - "json", - ], - { cwd: REPO_ROOT, encoding: "utf8" }, - ); -} - -describe("reviewed npm 12 Brev template trust", () => { - // source-shape-contract: security -- Exact prospective Brev bytes and install order must be base-authorized before trusted CI can admit the npm 12 runtime change - it("binds the exact reviewed npm 12 Brev successor and rejects bootstrap drift", () => { - const reviewedTemplate = renderReviewedNpm12BrevTemplate(BREV_TEMPLATE); - const npmVerifier = reviewedTemplate.indexOf( - "bash .github/actions/setup-reviewed-npm/verify-and-install-npm.sh ci/reviewed-npm-audit.json", - ); - const cleanupTrap = reviewedTemplate.indexOf(`trap 'rm -rf "$reviewed_npm_tmp"' EXIT`); - const clearCleanupTrap = reviewedTemplate.indexOf("trap - EXIT", npmVerifier); - const dependencyInstall = reviewedTemplate.indexOf("npm install --ignore-scripts", npmVerifier); - - expect(createHash("sha256").update(reviewedTemplate).digest("hex")).toBe( - REVIEWED_SOURCE_SHA256, - ); - expect(reviewedTemplate).toContain('NODE_VERSION="24.18.1"'); - expect(reviewedTemplate).toContain( - 'node_sha256="9f5eb6ac21845a66c493c91a253b1da32fd684e89e9b7202d4936982336be4ca"', - ); - expect(reviewedTemplate).toContain( - 'node_sha256="df224555a083b918e46260cc969838501b9f9a87140c1195e5b9597b56d5dae2"', - ); - expect(npmVerifier).toBeGreaterThan(-1); - expect(cleanupTrap).toBeGreaterThan(-1); - expect(cleanupTrap).toBeLessThan(npmVerifier); - expect(dependencyInstall).toBeGreaterThan(npmVerifier); - expect(clearCleanupTrap).toBeGreaterThan(npmVerifier); - expect(clearCleanupTrap).toBeLessThan(dependencyInstall); - const bootstrap = fs.openSync(NPM_BOOTSTRAP, "r"); - try { - expect(fs.fstatSync(bootstrap).isFile()).toBe(true); - expect(fs.fstatSync(bootstrap).mode & 0o111).not.toBe(0); - expect(fs.readFileSync(bootstrap, "utf8")).toContain("parseReviewedNpmIdentityConfig"); - } finally { - fs.closeSync(bootstrap); - } - - const accepted = runParser(reviewedTemplate); - expect(accepted.status, accepted.stderr).toBe(0); - const records = JSON.parse(accepted.stdout) as Array<{ - operationalTemplateSha256: string; - source: string; - }>; - expect( - new Set( - records - .filter((record) => record.source === "Brev launchable") - .map((record) => record.operationalTemplateSha256), - ), - ).toEqual(new Set([REVIEWED_TEMPLATE_SHA256])); - - const forged = runParser( - reviewedTemplate.replace("ci/reviewed-npm-audit.json", "ci/unreviewed-npm-audit.json"), - ); - expect(forged.status).toBe(1); - expect(forged.stderr).toContain("Brev launchable operational template is not base-trusted"); - expect(forged.stderr).toContain(REVIEWED_TEMPLATE_SHA256); - expect(forged.stdout).toBe(""); - }); -}); From f90f4aa6cd2ff312ce10838d20aa6b42353416c7 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Tue, 8 Sep 2026 11:52:20 -0700 Subject: [PATCH 12/31] test(ci): exercise reviewed npm trust handoff Signed-off-by: Charan Jagwani --- scripts/checks/extract-installer-pins.mts | 3 +- .../reviewed-npm-audit-handoff.test.ts | 137 ++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/scripts/checks/extract-installer-pins.mts b/scripts/checks/extract-installer-pins.mts index 6764f436a32..a4f94d22939 100644 --- a/scripts/checks/extract-installer-pins.mts +++ b/scripts/checks/extract-installer-pins.mts @@ -351,7 +351,8 @@ const TRUSTED_OPENSHELL_RELEASES: readonly OpenShellReleaseTrust[] = [ version: "0.0.103", }, { - // The third template authorizes the reviewed Node 24.18.1 and npm 12.0.2 successor. + // The third template is pre-authorized because dependent installer validation reads this + // trust record from the base branch. brevTemplateSha256: [ "c0a4ddf25a02a9fe02b2df53a60942ea887610f04d4ce16a121b6e79a5aeff1a", "56fc6482d1508b73604099e6fd6c16daea16275cf36cc25c1c5366c82a4394e3", diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index cffecac6507..d0042a1cc48 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -32,6 +32,15 @@ type Workflow = { >; }; +type CompositeAction = { + readonly runs?: { + readonly steps?: readonly { + readonly name?: string; + readonly run?: string; + }[]; + }; +}; + const TRUSTED_AUDIT_SPARSE_CHECKOUTS = TRUSTED_WORKFLOWS.flatMap((workflowFile) => { const workflow = YAML.parse( fs.readFileSync(path.join(REPO_ROOT, ".github", "workflows", workflowFile), "utf8"), @@ -50,6 +59,19 @@ const TRUSTED_AUDIT_SPARSE_CHECKOUTS = TRUSTED_WORKFLOWS.flatMap((workflowFile) })), ); }); +const TRUSTED_AUDIT_ACTION_CHECKOUTS = TRUSTED_AUDIT_SPARSE_CHECKOUTS.filter(({ sparseCheckout }) => + sparseCheckout.includes(".github/actions/ci-reviewed-npm-audit"), +); +const REVIEWED_NPM_ACTION = YAML.parse( + fs.readFileSync( + path.join(REPO_ROOT, ".github", "actions", "ci-reviewed-npm-audit", "action.yaml"), + "utf8", + ), +) as CompositeAction; +const REVIEWED_NPM_BOOTSTRAP_COMMAND = REVIEWED_NPM_ACTION.runs?.steps?.find( + (step) => step.name === "Download and verify production npm", +)?.run; + function stageSparseCheckout(root: string, sparseCheckout: string): void { sparseCheckout .split("\n") @@ -62,6 +84,88 @@ function stageSparseCheckout(root: string, sparseCheckout: string): void { }); } +function runTrustedBootstrapHandoff( + sparseCheckout: string, + mutateCheckout: (root: string) => void = () => {}, +) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-audit-bootstrap-handoff-")); + const bin = path.join(root, "bin"); + const archive = Buffer.from("verified archive\n"); + const archiveFile = path.join(root, "fixture.tgz"); + const installMarker = path.join(root, "install-called"); + const npmLog = path.join(root, "npm.log"); + stageSparseCheckout(root, sparseCheckout); + mutateCheckout(root); + fs.mkdirSync(bin); + fs.writeFileSync(archiveFile, archive); + fs.writeFileSync( + path.join(root, "ci", "reviewed-npm-audit.json"), + `${JSON.stringify({ + npmArchiveSha256: createHash("sha256").update(archive).digest("hex"), + npmIntegrity: `sha512-${createHash("sha512").update(archive).digest("base64")}`, + npmVersion: "12.0.2", + })}\n`, + ); + fs.writeFileSync( + path.join(bin, "npm"), + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$*" >> "$NEMOCLAW_TEST_NPM_LOG" +case "$1" in + pack) + shift + download_dir="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "--pack-destination" ]; then + download_dir="$2" + break + fi + shift + done + [ -n "$download_dir" ] + cp "$NEMOCLAW_TEST_ARCHIVE_FILE" "$download_dir/npm-12.0.2.tgz" + ;; + install) + : > "$NEMOCLAW_TEST_INSTALL_MARKER" + ;; + *) + exit 2 + ;; +esac +`, + { mode: 0o755 }, + ); + fs.writeFileSync( + path.join(bin, "tar"), + `#!/usr/bin/env bash +set -euo pipefail +[ "$1" = "-xOf" ] +[ "$3" = "package/package.json" ] +printf '{"version":"12.0.2"}\\n' +`, + { mode: 0o755 }, + ); + const result = spawnSync("bash", ["-c", REVIEWED_NPM_BOOTSTRAP_COMMAND ?? "exit 99"], { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + GITHUB_ACTION_PATH: path.join(root, ".github", "actions", "ci-reviewed-npm-audit"), + NEMOCLAW_TEST_ARCHIVE_FILE: archiveFile, + NEMOCLAW_TEST_INSTALL_MARKER: installMarker, + NEMOCLAW_TEST_NPM_LOG: npmLog, + PATH: `${bin}:${process.env.PATH ?? ""}`, + RUNNER_TEMP: root, + }, + }); + return { + cleanup: () => fs.rmSync(root, { recursive: true, force: true }), + installCalled: fs.existsSync(installMarker), + npmInvocations: fs.existsSync(npmLog) ? fs.readFileSync(npmLog, "utf8").trim().split("\n") : [], + result, + }; +} + describe("reviewed npm audit handoff", () => { it.each(TRUSTED_AUDIT_SPARSE_CHECKOUTS)( "loads the audit producer from the $name trusted sparse checkout", @@ -89,6 +193,39 @@ describe("reviewed npm audit handoff", () => { }, ); + it.each(TRUSTED_AUDIT_ACTION_CHECKOUTS)( + "executes the reviewed npm bootstrap from the $name trusted sparse checkout", + ({ sparseCheckout }) => { + const fixture = runTrustedBootstrapHandoff(sparseCheckout); + try { + expect(fixture.result.status, fixture.result.stderr).toBe(0); + expect(fixture.installCalled).toBe(true); + expect(fixture.npmInvocations).toHaveLength(2); + expect(fixture.npmInvocations[1]).toMatch(/install --global .* --offline$/u); + } finally { + fixture.cleanup(); + } + }, + ); + + it("fails before installation when the trusted checkout omits the reviewed npm bootstrap", () => { + const fixture = runTrustedBootstrapHandoff( + TRUSTED_AUDIT_ACTION_CHECKOUTS[0]?.sparseCheckout ?? "", + (root) => + fs.rmSync(path.join(root, ".github", "actions", "setup-reviewed-npm"), { + recursive: true, + force: true, + }), + ); + try { + expect(fixture.result.status).not.toBe(0); + expect(fixture.installCalled).toBe(false); + expect(fixture.npmInvocations).toEqual([]); + } finally { + fixture.cleanup(); + } + }); + it("passes producer output to the Docker receipt verifier and rejects an npm mismatch", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-audit-receipt-handoff-")); const packageJsonFile = path.join(root, "package.json"); From a838b44b0e547857d82dd5c9ba10622cf49fc2b2 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Tue, 8 Sep 2026 15:38:08 -0700 Subject: [PATCH 13/31] refactor(ci): clarify npm audit terminology Signed-off-by: Charan Jagwani --- .../SKILL.md | 2 +- .../scripts/classify-ci-failure.mts | 6 +- .dsh/tools/e2e_root_cause_correlator/index.ts | 2 +- .../actions/ci-reviewed-npm-audit/action.yaml | 22 ++-- .github/workflows/base-image-platform.yaml | 2 +- .github/workflows/managed-images.yaml | 10 +- .github/workflows/pr.yaml | 2 +- docs/security/advisory-early-warning.md | 6 +- scripts/advisory-early-warning-scan.mts | 2 +- scripts/audit-reviewed-npm-graph.mts | 16 +-- scripts/lib/advisory-early-warning.mts | 2 +- scripts/lib/npm-audit-receipt.mts | 2 +- scripts/lib/nvd-reconciliation.mts | 2 +- scripts/lib/reviewed-npm-audit.mts | 20 +-- ...penclaw-diagnostics-jaeger-runtime.test.ts | 2 +- .../advisory-early-warning.test.ts | 2 +- .../releases/npm-audit-receipt.test.ts | 2 +- .../reviewed-npm-audit-cache-key.test.ts | 4 +- .../reviewed-npm-audit-handoff.test.ts | 2 +- .../reviewed-npm-audit-workflow.test.ts | 8 +- .../releases/reviewed-npm-audit.test.ts | 6 +- .../releases/reviewed-npm-bootstrap.test.ts | 2 +- ...managed-image-publication-workflow.test.ts | 18 +-- .../dependency-review.md | 124 ------------------ 24 files changed, 71 insertions(+), 195 deletions(-) delete mode 100644 tools/mcp-tool-discovery-runtime/dependency-review.md diff --git a/.agents/skills/nemoclaw-contributor-update-dependencies/SKILL.md b/.agents/skills/nemoclaw-contributor-update-dependencies/SKILL.md index 6de22872161..d6806173f9c 100644 --- a/.agents/skills/nemoclaw-contributor-update-dependencies/SKILL.md +++ b/.agents/skills/nemoclaw-contributor-update-dependencies/SKILL.md @@ -73,7 +73,7 @@ Follow the current collector help when those controls evolve. ## Keep Point-in-Time Review Records out of the Repository -Do not commit point-in-time release ledgers, concern records, review reports, or qualification reports anywhere in the repository. Encode durable claims in executable configuration and tests. For a user-visible change, update the canonical `docs/` page with current supported behavior and operator action. Preserve historical executable fixtures only when they still support a current test. +Do not commit or update point-in-time release ledgers, concern records, dependency review documents, review reports, or qualification reports anywhere in the repository. Encode durable claims in executable configuration and tests. For a user-visible change, update the canonical `docs/` page with current supported behavior and operator action. Preserve historical executable fixtures only when they still support a current test. ## Resolve concerns diff --git a/.agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts b/.agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts index 2373a8bb2ab..950941a20ac 100644 --- a/.agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts +++ b/.agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts @@ -1081,12 +1081,12 @@ async function classifyCiFailureWithRuntime( ); if ( /reviewed-npm-audit/i.test(job.name) || - /reviewed npm audit|npm audit report|audit-reviewed-npm-graph/i.test(text) + /\bnpm audit\b|audit-reviewed-npm-graph/i.test(text) ) add( "reviewed-npm-audit", - "The reviewed npm audit check reported advisory drift.", - "Determine whether this is live advisory drift or update the reviewed baseline through the security process.", + "The npm audit check reported advisory drift.", + "Determine whether this is live advisory drift or update the accepted baseline through the security process.", ); if (/docs-review|Documentation writer review/i.test(text)) add( diff --git a/.dsh/tools/e2e_root_cause_correlator/index.ts b/.dsh/tools/e2e_root_cause_correlator/index.ts index aa57ad8c9a6..8a5e7da68cc 100644 --- a/.dsh/tools/e2e_root_cause_correlator/index.ts +++ b/.dsh/tools/e2e_root_cause_correlator/index.ts @@ -77,7 +77,7 @@ export default async function e2e_root_cause_correlator(input: { if (text.includes("sandbox_phase=deleting") || text.includes("sandbox in deleting")) return "openshell/lifecycle/sandbox-deleting"; if ( - text.includes("reviewed npm audit") || + text.includes("npm audit") || text.includes("unaccepted at or above high") || text.includes("advisory") ) diff --git a/.github/actions/ci-reviewed-npm-audit/action.yaml b/.github/actions/ci-reviewed-npm-audit/action.yaml index 66148408131..eb48142775e 100644 --- a/.github/actions/ci-reviewed-npm-audit/action.yaml +++ b/.github/actions/ci-reviewed-npm-audit/action.yaml @@ -1,8 +1,8 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -name: ci-reviewed-npm-audit -description: Audit a target tree's reviewed production npm graphs from trusted CI code. +name: npm audit +description: Audit a target tree's production dependency graphs with the repository's pinned npm. inputs: target-root: @@ -12,7 +12,7 @@ inputs: description: Report directory relative to target-root. required: true cache-directory: - description: Absolute directory used for identity-bound reviewed npm audit records. + description: Absolute directory used for identity-bound npm audit records. required: true trusted-cache-write: description: Whether this trusted caller may publish reusable audit records. @@ -27,7 +27,7 @@ runs: with: node-version: "22.23.2" - - name: Resolve reviewed npm audit cache buckets + - name: Resolve npm audit cache buckets id: cache-buckets shell: bash env: @@ -48,7 +48,7 @@ runs: const targetRoot = process.env.NEMOCLAW_REVIEWED_NPM_AUDIT_TARGET_ROOT; const [configFile, resolverFile, reviewedNpmAuditFile] = process.argv.slice(2); if (!targetRoot) { - throw new Error("reviewed npm audit target root is required"); + throw new Error("npm audit target root is required"); } const { resolvePathWithinRoot } = await import(pathToFileURL(resolverFile).href); const { parseReviewedNpmIdentity } = await import(pathToFileURL(reviewedNpmAuditFile).href); @@ -63,7 +63,7 @@ runs: for (const file of ["package.json", "package-lock.json"]) { const relative = join(directory, file); hash.update(relative); - hash.update(readFileSync(resolvePathWithinRoot(targetRoot, relative, "reviewed npm audit target input"))); + hash.update(readFileSync(resolvePathWithinRoot(targetRoot, relative, "npm audit target input"))); } } appendFileSync( @@ -74,14 +74,14 @@ runs: printf 'current=%s\nprevious=%s\n' "$current_bucket" "$previous_bucket" >> "$GITHUB_OUTPUT" mkdir -p "$NEMOCLAW_REVIEWED_NPM_AUDIT_CACHE_DIRECTORY" - - name: Restore current reviewed npm audit cache bucket + - name: Restore current npm audit cache bucket id: cache-current uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: ${{ inputs.cache-directory }} key: reviewed-npm-audit-v2-${{ runner.os }}-${{ steps.cache-buckets.outputs.input-digest }}-${{ steps.cache-buckets.outputs.current }} - - name: Restore previous reviewed npm audit cache bucket + - name: Restore previous npm audit cache bucket if: steps.cache-current.outputs.cache-hit != 'true' uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: @@ -95,7 +95,7 @@ runs: "$GITHUB_ACTION_PATH/../setup-reviewed-npm/verify-and-install-npm.sh" "$GITHUB_ACTION_PATH/../../../ci/reviewed-npm-audit.json" - - name: Materialize and audit reviewed npm graphs + - name: Materialize and audit production dependency graphs shell: bash env: NEMOCLAW_REVIEWED_NPM_AUDIT_TARGET_ROOT: ${{ inputs.target-root }} @@ -105,14 +105,14 @@ runs: NPM_CONFIG_USERCONFIG: /dev/null run: env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN node --experimental-strip-types "$GITHUB_ACTION_PATH/../../../scripts/audit-reviewed-npm-graph.mts" - - name: Save current reviewed npm audit cache bucket + - name: Save current npm audit cache bucket if: inputs.trusted-cache-write == 'true' && steps.cache-current.outputs.cache-hit != 'true' uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: ${{ inputs.cache-directory }} key: reviewed-npm-audit-v2-${{ runner.os }}-${{ steps.cache-buckets.outputs.input-digest }}-${{ steps.cache-buckets.outputs.current }} - - name: Upload reviewed npm audit reports + - name: Upload npm audit reports if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/.github/workflows/base-image-platform.yaml b/.github/workflows/base-image-platform.yaml index 4e871b74355..2685e94613d 100644 --- a/.github/workflows/base-image-platform.yaml +++ b/.github/workflows/base-image-platform.yaml @@ -64,7 +64,7 @@ jobs: with: persist-credentials: false - - name: Download same-run reviewed npm audit evidence + - name: Download same-run npm audit evidence if: inputs.agent == 'openclaw' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index 40e97778bc8..de44187fafe 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -75,7 +75,7 @@ env: jobs: pr-reviewed-npm-audit: - name: PR reviewed npm audit + name: PR npm audit if: github.event_name == 'pull_request' runs-on: ubuntu-latest timeout-minutes: 25 @@ -89,7 +89,7 @@ jobs: path: candidate persist-credentials: false - - name: Checkout trusted reviewed npm audit + - name: Checkout npm audit code from the base commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.base.sha }} @@ -464,7 +464,7 @@ jobs: chmod 0664 "$artifact_path" done - - name: Download same-run reviewed npm audit evidence + - name: Download same-run npm audit evidence if: matrix.agent == 'openclaw' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -1581,7 +1581,7 @@ jobs: printf 'cohort=ghrun-%s-%s\n' "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" >> "$GITHUB_OUTPUT" reviewed-npm-audit: - name: Reviewed npm audit for managed image publication + name: npm audit for managed image publication if: github.event_name != 'pull_request' runs-on: ubuntu-latest timeout-minutes: 25 @@ -1698,7 +1698,7 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - name: Download same-run reviewed npm audit evidence + - name: Download same-run npm audit evidence if: matrix.agent == 'openclaw' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index ee127d8ee51..fef217e60b7 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -535,7 +535,7 @@ jobs: with: persist-credentials: false - - name: Checkout trusted reviewed npm audit + - name: Checkout npm audit code from the base commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.base.sha }} diff --git a/docs/security/advisory-early-warning.md b/docs/security/advisory-early-warning.md index 5f364ad4255..6eb143ab93c 100644 --- a/docs/security/advisory-early-warning.md +++ b/docs/security/advisory-early-warning.md @@ -43,7 +43,7 @@ This polling is the planned extension, and the correlation module already accept Only a match on the npm ecosystem, package name, and parseable semantic-version range yields `confidence: "exact"` and `action: "investigate"`. Name collisions from non-npm, CPE-derived records and unparseable ranges yield `confidence: "ambiguous"` and `action: "informational"`. Ambiguous matches never block or mutate a release. -- The reviewed npm audit gate in `scripts/audit-reviewed-npm-graph.mts` remains enabled in CI. +- The npm audit gate in `scripts/audit-reviewed-npm-graph.mts` remains enabled in CI. It is authoritative for npm package and version-range decisions. The early-warning path triggers only investigation and rescanning. @@ -93,7 +93,7 @@ The same #7338 sign-off gate applies to this work. ## Provenance Recorded for Each Audit -Each reviewed npm audit report has a `*.provenance.json` sidecar. +Each npm audit report has a `*.provenance.json` sidecar. The sidecars include `coverage/reviewed-npm-audit/` artifacts and `npm-audit.provenance.json` for the WeChat locked runtime graph audit. A configured cache reuses a response only when the package and lock bytes, npm version, fixed Yarn audit registry origin, command arguments, and parser identity match. Until 2026-09-11, image builds may accept a still-current npmjs receipt only through the explicit legacy transition. Remove the legacy option and verifier path after Yarn-bound receipts replace the retained npmjs receipts. The sidecar records whether the response came from the cache or a live registry request, plus its creation time, age, input digest, and response digest. @@ -161,5 +161,5 @@ Mapping each demonstrated gap to a mechanism: Rescanning maintained immutable image digests is not implemented. The image-scan pipeline waits for product and security owners to define the supported-image scope required by #7338. - Unproven trigger (`tar`): No trigger design can recover missing evidence. - Each reviewed npm audit now writes a provenance sidecar with endpoints, timestamps, and advisory IDs. + Each npm audit now writes a provenance sidecar with endpoints, timestamps, and advisory IDs. Consecutive retained runs can establish the last comparable non-detection and first detection for future findings. diff --git a/scripts/advisory-early-warning-scan.mts b/scripts/advisory-early-warning-scan.mts index 27109f7b098..617ab88b9e7 100755 --- a/scripts/advisory-early-warning-scan.mts +++ b/scripts/advisory-early-warning-scan.mts @@ -6,7 +6,7 @@ // GitHub Security Advisory JSON with the reviewed npm inventory derived from // ci/reviewed-npm-audit.json (committed package specs plus the locked-graph // package-locks) and prints structured, NON-blocking signals. Signals never -// fail the process: enforcement stays with the reviewed npm audit gate. +// fail the process: enforcement stays with the npm audit gate. // // Usage: // advisory-early-warning-scan.mts [--inventory ] --list-packages diff --git a/scripts/audit-reviewed-npm-graph.mts b/scripts/audit-reviewed-npm-graph.mts index b560a9c54e8..3a24cbc2bf4 100755 --- a/scripts/audit-reviewed-npm-graph.mts +++ b/scripts/audit-reviewed-npm-graph.mts @@ -113,7 +113,7 @@ export function resolveTrustedAuditConfigPath(trustedRoot: string): string { return resolvePathWithinRoot( trustedRoot, "ci/reviewed-npm-audit.json", - "trusted reviewed npm audit configuration", + "trusted npm audit configuration", ); } @@ -129,7 +129,7 @@ function graphCacheFile(graphId: string): string | undefined { const configuredDirectory = process.env.NEMOCLAW_REVIEWED_NPM_AUDIT_CACHE_DIR; if (!configuredDirectory) return undefined; if (!path.isAbsolute(configuredDirectory)) { - throw new Error("reviewed npm audit cache directory must be absolute"); + throw new Error("npm audit cache directory must be absolute"); } if (!/^[a-z0-9][a-z0-9._-]*$/.test(graphId)) { throw new Error(`npm audit cache graph ID is unsafe: ${graphId}`); @@ -140,13 +140,13 @@ function graphCacheFile(graphId: string): string | undefined { if (!component) continue; current = path.join(current, component); const stat = fs.lstatSync(current, { throwIfNoEntry: false }); - if (!stat) throw new Error("reviewed npm audit cache directory must exist"); + if (!stat) throw new Error("npm audit cache directory must exist"); if (stat.isSymbolicLink()) { - throw new Error("reviewed npm audit cache directory must not contain symbolic links"); + throw new Error("npm audit cache directory must not contain symbolic links"); } } if (!fs.statSync(directory).isDirectory()) { - throw new Error("reviewed npm audit cache directory must be a directory"); + throw new Error("npm audit cache directory must be a directory"); } return path.join(directory, `${graphId}.json`); } @@ -865,14 +865,14 @@ export function assertReviewedAuditReportsPass( `${label}: ${result.unacceptedBlockingAdvisories.length} unaccepted at or above ${reportThreshold ?? threshold}`, ); if (failures.length > 0) - throw new Error(`reviewed npm audit threshold failed\n${failures.join("\n")}`); + throw new Error(`npm audit threshold failed\n${failures.join("\n")}`); } function main(): void { const config = readConfig(); const expectedNode = `v${config.nodeVersion}`; if (process.version !== expectedNode) { - throw new Error(`reviewed npm audit requires Node ${expectedNode}; running ${process.version}`); + throw new Error(`npm audit requires Node ${expectedNode}; running ${process.version}`); } const artifactDirectory = targetRepositoryPath( process.env.NEMOCLAW_REVIEWED_NPM_AUDIT_REPORT_DIR ?? config.artifactDirectory, @@ -893,7 +893,7 @@ function main(): void { const npmVersion = run("npm", ["--version"], TRUSTED_REPO_ROOT).stdout.trim(); if (npmVersion !== config.npmVersion) { throw new Error( - `reviewed npm audit requires npm ${config.npmVersion}; running npm ${npmVersion}`, + `npm audit requires npm ${config.npmVersion}; running npm ${npmVersion}`, ); } const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-npm-audit-")); diff --git a/scripts/lib/advisory-early-warning.mts b/scripts/lib/advisory-early-warning.mts index fc554284af1..6ee322283b1 100644 --- a/scripts/lib/advisory-early-warning.mts +++ b/scripts/lib/advisory-early-warning.mts @@ -5,7 +5,7 @@ // and the reviewed npm package inventory (#7338). Upstream repository advisories // are often published weeks before the global reviewed ecosystem record that // `npm audit` enforces, so this module turns the earlier signal into a traceable, -// NON-blocking investigation prompt. It never replaces the reviewed npm audit +// NON-blocking investigation prompt. It never replaces the npm audit // gate: only exact npm package-name plus semver-range matches are marked // "investigate", and ambiguous CPE-to-npm matches stay "informational". diff --git a/scripts/lib/npm-audit-receipt.mts b/scripts/lib/npm-audit-receipt.mts index 723fc79b1e6..5ad8aa937fc 100755 --- a/scripts/lib/npm-audit-receipt.mts +++ b/scripts/lib/npm-audit-receipt.mts @@ -279,7 +279,7 @@ function cli(args: readonly string[]): void { ); if (values.has("--result")) fs.writeFileSync(values.get("--result")!, `${JSON.stringify(policyResult, null, 2)}\n`); - console.log("reviewed npm audit receipt and current policy verified"); + console.log("npm audit receipt and current policy verified"); } if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { diff --git a/scripts/lib/nvd-reconciliation.mts b/scripts/lib/nvd-reconciliation.mts index 3c87bb013b0..2ef4094c003 100644 --- a/scripts/lib/nvd-reconciliation.mts +++ b/scripts/lib/nvd-reconciliation.mts @@ -7,7 +7,7 @@ // applicability criteria are surfaced for awareness, never turned into npm // package matches. Reconciliations are purely informational annotations — they // never change a signal's action or confidence, and enforcement stays with the -// reviewed npm audit gate. +// npm audit gate. import type { AdvisorySignal } from "./advisory-early-warning.mts"; diff --git a/scripts/lib/reviewed-npm-audit.mts b/scripts/lib/reviewed-npm-audit.mts index 98f83d0e4fb..b138aabbd12 100755 --- a/scripts/lib/reviewed-npm-audit.mts +++ b/scripts/lib/reviewed-npm-audit.mts @@ -28,21 +28,21 @@ export function parseReviewedNpmIdentity(value: unknown): ReviewedNpmIdentity { !/^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/.test(npmVersion) || /[\r\n]/.test(npmVersion) ) { - throw new Error("reviewed npm audit configuration has an invalid npmVersion"); + throw new Error("npm audit configuration has an invalid npmVersion"); } if ( typeof npmIntegrity !== "string" || !/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(npmIntegrity) || /[\r\n]/.test(npmIntegrity) ) { - throw new Error("reviewed npm audit configuration has an invalid npmIntegrity"); + throw new Error("npm audit configuration has an invalid npmIntegrity"); } if ( typeof npmArchiveSha256 !== "string" || !/^[a-f0-9]{64}$/.test(npmArchiveSha256) || /[\r\n]/.test(npmArchiveSha256) ) { - throw new Error("reviewed npm audit configuration has an invalid npmArchiveSha256"); + throw new Error("npm audit configuration has an invalid npmArchiveSha256"); } return { npmArchiveSha256, npmIntegrity, npmVersion }; } @@ -52,7 +52,7 @@ export function parseReviewedNpmIdentityConfig(contents: string): ReviewedNpmIde try { parsed = JSON.parse(contents); } catch { - throw new Error("reviewed npm audit configuration is not valid JSON"); + throw new Error("npm audit configuration is not valid JSON"); } return parseReviewedNpmIdentity(parsed); } @@ -905,7 +905,7 @@ export function runReviewedNpmAudit( }>, ): AuditPolicyResult { if (options.provenance && !options.reportFile) { - throw new Error("reviewed npm audit provenance requires a report file"); + throw new Error("npm audit provenance requires a report file"); } const exceptionRegistry = readAuditExceptionRegistry(options.exceptionFile); const startedAt = new Date().toISOString(); @@ -1008,8 +1008,8 @@ function parseCliArgs(args: readonly string[]): { 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}`); + throw new Error("invalid npm audit arguments"); + if (values.has(key)) throw new Error(`duplicate npm audit argument: ${key}`); values.set(key, value); } const allowed = new Set([ @@ -1023,18 +1023,18 @@ function parseCliArgs(args: readonly string[]): { ]); const unknown = [...values.keys()].filter((key) => !allowed.has(key)); if (unknown.length > 0) - throw new Error(`unknown reviewed npm audit arguments: ${unknown.join(", ")}`); + throw new Error(`unknown 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", + "npm audit requires --directory, --exceptions, --graph, and --threshold", ); } if (!SEVERITIES.includes(threshold as Severity)) - throw new Error("reviewed npm audit threshold is invalid"); + throw new Error("npm audit threshold is invalid"); return { ...(values.has("--cache") ? { cacheFile: values.get("--cache") } : {}), directory, diff --git a/test/agents/openclaw/openclaw-diagnostics-jaeger-runtime.test.ts b/test/agents/openclaw/openclaw-diagnostics-jaeger-runtime.test.ts index c80c9946f90..25484ede348 100644 --- a/test/agents/openclaw/openclaw-diagnostics-jaeger-runtime.test.ts +++ b/test/agents/openclaw/openclaw-diagnostics-jaeger-runtime.test.ts @@ -125,7 +125,7 @@ function reviewedDiagnosticsPackage(): ReviewedPackage { const reviewed = config.archivePackages.find(({ packageSpec }) => packageSpec.startsWith("@openclaw/diagnostics-otel@"), ); - assert.ok(reviewed, "reviewed npm audit config must include OpenClaw diagnostics"); + assert.ok(reviewed, "npm audit config must include OpenClaw diagnostics"); return reviewed; } diff --git a/test/automation/pull-requests/advisory-early-warning.test.ts b/test/automation/pull-requests/advisory-early-warning.test.ts index 29985372f6b..070c8f6f8c9 100644 --- a/test/automation/pull-requests/advisory-early-warning.test.ts +++ b/test/automation/pull-requests/advisory-early-warning.test.ts @@ -221,7 +221,7 @@ describe("advisory early warning correlation", () => { }); describe("advisory early warning inventory parsing", () => { - it("parses package specs from the reviewed npm audit config", () => { + it("parses package specs from the npm audit config", () => { const config = { archivePackages: [ { packageSpec: "openclaw@2026.6.10" }, diff --git a/test/automation/releases/npm-audit-receipt.test.ts b/test/automation/releases/npm-audit-receipt.test.ts index b67ad20f087..9180a71d285 100644 --- a/test/automation/releases/npm-audit-receipt.test.ts +++ b/test/automation/releases/npm-audit-receipt.test.ts @@ -49,7 +49,7 @@ function receipt(createdAt = NOW) { }); } -describe("reviewed npm audit receipt", () => { +describe("npm audit receipt", () => { it("canonically binds all receipt inputs and verifies a fresh passing result", () => { const parsed = parseAndVerifyAuditReceipt(canonicalAuditReceipt(receipt()), inputs); expect(parsed.acceptedAdvisoryIds).toEqual(["GHSA-a", "GHSA-b"]); diff --git a/test/automation/releases/reviewed-npm-audit-cache-key.test.ts b/test/automation/releases/reviewed-npm-audit-cache-key.test.ts index 8a0f4dddf9e..5bc3d8ef744 100644 --- a/test/automation/releases/reviewed-npm-audit-cache-key.test.ts +++ b/test/automation/releases/reviewed-npm-audit-cache-key.test.ts @@ -34,7 +34,7 @@ function copyGraphInputs(targetRoot: string, directory: string) { ); } -describe("reviewed npm audit cache identity", () => { +describe("npm audit cache identity", () => { it("rejects a target input symbolic link before emitting a cache identity", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-audit-cache-key-")); const targetRoot = path.join(root, "target"); @@ -57,7 +57,7 @@ describe("reviewed npm audit cache identity", () => { ), ) as CompositeAction; const cacheBucketStep = action.runs.steps?.find( - (step) => step.name === "Resolve reviewed npm audit cache buckets", + (step) => step.name === "Resolve npm audit cache buckets", ); const result = spawnSync("bash", ["-c", cacheBucketStep?.run ?? ""], { cwd: REPO_ROOT, diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index d0042a1cc48..8485bda0846 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -166,7 +166,7 @@ printf '{"version":"12.0.2"}\\n' }; } -describe("reviewed npm audit handoff", () => { +describe("npm audit handoff", () => { it.each(TRUSTED_AUDIT_SPARSE_CHECKOUTS)( "loads the audit producer from the $name trusted sparse checkout", ({ sparseCheckout }) => { diff --git a/test/automation/releases/reviewed-npm-audit-workflow.test.ts b/test/automation/releases/reviewed-npm-audit-workflow.test.ts index 166a68311e5..32569cbfb48 100644 --- a/test/automation/releases/reviewed-npm-audit-workflow.test.ts +++ b/test/automation/releases/reviewed-npm-audit-workflow.test.ts @@ -323,7 +323,7 @@ function writeProductionSourceGraph( return { sourceLock, sourcePackage }; } -describe("trusted reviewed npm audit workflow (#5896)", () => { +describe("trusted npm audit workflow (#5896)", () => { // source-shape-contract: security -- Composite audit inputs must cross into executable shell only through the step environment it("passes the cache identity target root without interpolating it into shell source", () => { const action = YAML.parse( @@ -332,7 +332,7 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { "utf8", ), ) as CompositeAction; - const cacheBucketStep = requiredStep(action.runs, "Resolve reviewed npm audit cache buckets"); + const cacheBucketStep = requiredStep(action.runs, "Resolve npm audit cache buckets"); expect(cacheBucketStep.env).toEqual({ NEMOCLAW_REVIEWED_NPM_AUDIT_CACHE_DIRECTORY: "${{ inputs.cache-directory }}", @@ -350,7 +350,7 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { expect(fixture.result.status).not.toBe(0); expect(fixture.result.stderr).toContain( - `reviewed npm audit requires npm ${REVIEWED_AUDIT_CONFIG.npmVersion}; running npm 11.18.0`, + `npm audit requires npm ${REVIEWED_AUDIT_CONFIG.npmVersion}; running npm 11.18.0`, ); expect(fixture.lockedReceipt).toBeUndefined(); }); @@ -1289,7 +1289,7 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { "high", ), ).toThrow( - "reviewed npm audit threshold failed\nNemoClaw CLI locked production graph: 1 unaccepted at or above high", + "npm audit threshold failed\nNemoClaw CLI locked production graph: 1 unaccepted at or above high", ); }); diff --git a/test/automation/releases/reviewed-npm-audit.test.ts b/test/automation/releases/reviewed-npm-audit.test.ts index fbbd3eacf40..f812cfca956 100644 --- a/test/automation/releases/reviewed-npm-audit.test.ts +++ b/test/automation/releases/reviewed-npm-audit.test.ts @@ -127,7 +127,7 @@ function exceptionPolicy( ); } -describe("reviewed npm audit gate", () => { +describe("npm audit gate", () => { it("removes the checked-in brace-expansion exception after remediation (#8116)", () => { expect(CHECKED_IN_POLICY).toEqual(EMPTY_POLICY); }); @@ -468,7 +468,7 @@ describe("reviewed npm audit gate", () => { }); }); -describe("reviewed npm audit raw cache", () => { +describe("npm audit raw cache", () => { function fixture() { const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-audit-cache-")); fs.writeFileSync(path.join(directory, "package.json"), '{"name":"fixture"}\n'); @@ -574,7 +574,7 @@ describe("reviewed npm audit raw cache", () => { }); }); -describe("reviewed npm audit provenance", () => { +describe("npm audit provenance", () => { const detectionReport = { metadata: { vulnerabilities: { info: 0, low: 0, moderate: 0, high: 1, critical: 0 }, diff --git a/test/automation/releases/reviewed-npm-bootstrap.test.ts b/test/automation/releases/reviewed-npm-bootstrap.test.ts index 2a8425e3ae1..934d9ae3a4e 100644 --- a/test/automation/releases/reviewed-npm-bootstrap.test.ts +++ b/test/automation/releases/reviewed-npm-bootstrap.test.ts @@ -149,7 +149,7 @@ describe("reviewed npm bootstrap", () => { try { expect(fixture.result.status).toBe(1); expect(fixture.result.stderr).toContain( - "reviewed npm audit configuration has an invalid npmArchiveSha256", + "npm audit configuration has an invalid npmArchiveSha256", ); expect(fixture.npmInvocations).toEqual([]); expect(fixture.installCalled).toBe(false); diff --git a/test/inference/managed/managed-image-publication-workflow.test.ts b/test/inference/managed/managed-image-publication-workflow.test.ts index d8ee46cdc93..97117aee5db 100644 --- a/test/inference/managed/managed-image-publication-workflow.test.ts +++ b/test/inference/managed/managed-image-publication-workflow.test.ts @@ -68,7 +68,7 @@ function managedPrBuilder(workflow: Workflow): Job { function managedPrReviewedAudit(workflow: Workflow): Job { return required( workflow.jobs?.["pr-reviewed-npm-audit"], - "managed-image workflow is missing its exact PR reviewed npm audit", + "managed-image workflow is missing its PR npm audit", ); } @@ -84,7 +84,7 @@ function managedPrOpenClawMcpDiscovery(workflow: Workflow): Job { } describe("complete managed-image publication workflow", () => { - it("restricts reviewed npm audit cache publication to trusted callers (#11028)", () => { + it("restricts npm audit cache publication to trusted callers (#11028)", () => { const action = readAction("ci-reviewed-npm-audit") as ReturnType & { inputs: Record; }; @@ -113,8 +113,8 @@ describe("complete managed-image publication workflow", () => { ]); const save = step( { steps: actionSteps }, - "Save current reviewed npm audit cache bucket", - "reviewed npm audit action", + "Save current npm audit cache bucket", + "npm audit action", ); expect(save).toMatchObject({ if: "inputs.trusted-cache-write == 'true' && steps.cache-current.outputs.cache-hit != 'true'", @@ -124,8 +124,8 @@ describe("complete managed-image publication workflow", () => { expect( step( { steps: actionSteps }, - "Materialize and audit reviewed npm graphs", - "reviewed npm audit action", + "Materialize and audit production dependency graphs", + "npm audit action", ).env, ).toMatchObject({ NEMOCLAW_REVIEWED_NPM_AUDIT_CACHE_DIR: "${{ inputs.cache-directory }}", @@ -238,7 +238,7 @@ describe("complete managed-image publication workflow", () => { }); const reviewedAudit = required( baseWorkflow.jobs?.["reviewed-npm-audit"], - "base-image workflow is missing the reviewed npm audit", + "base-image workflow is missing the npm audit", ); expect(reviewedAudit).toMatchObject({ if: "github.repository == 'NVIDIA/NemoClaw'", @@ -456,7 +456,7 @@ describe("complete managed-image publication workflow", () => { path: "candidate", "persist-credentials": false, }); - const trustedCheckout = step(reviewedAudit, "Checkout trusted reviewed npm audit"); + const trustedCheckout = step(reviewedAudit, "Checkout npm audit code from the base commit"); expect(trustedCheckout.with).toMatchObject({ ref: "${{ github.event.pull_request.base.sha }}", path: ".trusted-reviewed-npm-audit", @@ -1148,7 +1148,7 @@ fi expect(publisher.needs).toEqual(["publication-identity", "reviewed-npm-audit"]); expect( [ - "Download same-run reviewed npm audit evidence", + "Download same-run npm audit evidence", "Prepare same-run mcporter audit evidence", "mcporter-runtime.receipt.json", "mcporter-runtime.raw.json", diff --git a/tools/mcp-tool-discovery-runtime/dependency-review.md b/tools/mcp-tool-discovery-runtime/dependency-review.md deleted file mode 100644 index 5a28d5696ef..00000000000 --- a/tools/mcp-tool-discovery-runtime/dependency-review.md +++ /dev/null @@ -1,124 +0,0 @@ - - - -# MCP tool discovery runtime dependency review - -The shared image runtime uses the official `@modelcontextprotocol/sdk` client so all NemoClaw agent images follow the same Streamable HTTP initialization, protocol-version, session, SSE, pagination, and cleanup behavior. It is not an agent adapter and never invokes a discovered tool. - -## Reviewed pin - -- Package: `@modelcontextprotocol/sdk@1.30.0` -- Registry tarball: `https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz` -- Integrity: `sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==` -- License: MIT -- Locked production graph: `package-lock.json` (lockfile version 3) -- Build-only tools: `typescript@6.0.3`, `@types/node@25.5.2`, and `esbuild@0.27.4` (not copied into the final image) -- Security overrides: - - `@hono/node-server@2.0.12`: `sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==` - - `fast-uri@3.1.6`: `sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==` - - `hono@4.12.34`: `sha512-GqXJqY/xJkJmuloTrnV1ZEXG3fqte+VjkUqoRNZXcrUidiUOP4fMSIHHY4tsqZBK++kVyWmt/AAfSUuy57/eSA==` - - `ip-address@10.3.1`: `sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==` - -OpenClaw's `mcporter` dependency graph also resolves the official SDK but remains separately locked. This runtime keeps a direct lock because Hermes and LangChain Deep Agents Code must not depend on OpenClaw's adapter package. -The client bundle includes the SDK's AJV validation path, including `ajv-formats` and `fast-uri`, plus `content-type` for standards-compliant response media-type parsing. The `fast-uri` override and `content-type` license are therefore runtime-relevant. The bundle does not include the SDK's Hono server adapter or its `hono` and `ip-address` dependencies, but those packages remain part of the installed production graph that the reviewed npm audit CI check evaluates. The build enforces the exact reviewed bundle-package allowlist and emits `BUNDLED_PACKAGES.json` alongside the generated third-party license notice. The exact overrides keep the installed graph outside the affected ranges for `GHSA-7p8r-x3mc-p8w7`, `GHSA-8j4g-w8fx-2239`, `GHSA-mwp4-54f8-5fhr`, `GHSA-4xrf-jv44-h6hh`, and `GHSA-22jq-vg5j-6vgg` without changing the SDK client pin. - -## 2026-08-03 security refresh - -The NemoClaw `v0.0.100` sandbox image build stopped before creating an image because the committed runtime lock resolved three packages in newly reported advisory ranges. -NemoClaw `main` at `3f3eb6139e089c24397d6a499a10fcde4bdc84da` reproduced the same failure. -At that revision, the image-build audit boundary worked as designed. -Issue #8177 records the source, build run, failure receipt, and resume condition. - -Registry metadata binds each audited range: - -- `fast-uri`: `3.1.4` at `6aeece669e4166b2446a89f17c07a3b15dfb7ed4` to `3.1.6` at `6f970b2951fd896aa0f3a7ff28eeb6640c137d33`, two patch releases -- Hono: `4.12.30` at `b2ae3a2204a48ce15a26448fd746d39745eb1837` to `4.12.34` at `734755ace341607628219ea1dd8ca17f01bf1a5c`, four patch releases -- `ip-address`: `10.2.0` at `80fccaae984618f35dc941efab55cf2440ab37e8` to `10.3.1` at `be7e626c0d49fccb518899f520a3fb64ee189741`, four release increments that cross the `10.3.0` minor boundary - -Each target commit descends from its outgoing commit. The target npm package integrities match the committed lock. - -Concern ledger: - -- `MCP-AUDIT-1` — earlier `fast-uri` 3.x releases accept malformed authority, IPv6, repeated percent-decoding, or encoded-scheme inputs that can produce host confusion or SSRF (`GHSA-5jgf-p345-68v8`, `GHSA-f65p-4m7j-42xc`, `GHSA-fph4-wmhf-6fwf`, `GHSA-jqff-g426-hqxp`). Surface: executable AJV format validation in the bundled client. Resolution: pin first-patched `fast-uri@3.1.6`, which remains within Ajv's declared range. Validation: exact lock metadata, `npm test`, bundle verification, and the production audit. -- `MCP-AUDIT-2` — `hono@4.12.30` uses a regular expression that can cause excessive work for a large CORS request-header value. Surface: installed SDK server dependency; excluded from the client bundle. Resolution: pin `hono@4.12.34`, which replaces the split expression and adds a regression test for a large request header. Validation: exact lock metadata, bundle-input exclusion, and the production audit. -- `MCP-AUDIT-3` — `ip-address@10.2.0` accepts address forms whose classification can differ across parsers. Surface: installed SDK server dependency; excluded from the client bundle. Resolution: pin `ip-address@10.3.1`, which rejects leading-zero IPv4 octets and stacked subnet suffixes and adds regression tests for IPv4 and IPv6 parsing. Validation: exact lock metadata, bundle-input exclusion, and the production audit. -- `MCP-AUDIT-4` — Live advisory and Sigstore TUF queries in the image build can prevent a released version from reproducing the same image after external metadata or network availability changes. Surface: `install-reviewed-runtime.sh`. Resolution: move both advisory enforcement and registry-signature verification for this exact lock to the trusted reviewed npm audit CI check. Image assembly copies the exact reviewed bundle and license outputs instead of materializing this npm graph, so protected rebuilds need neither registry access nor committed registry archives. Bundle regeneration remains fail-closed on the committed lock, its SHA-512 archive integrity, the runtime test, typecheck, and exact bundle-package allowlist. Validation: `test/mcp/mcp-tool-discovery-image-contract.test.ts` and `npm run bundle:reviewed:check`. - -## 2026-08-05 build audit boundary update - -Issue #8253 showed that image-build audits made sandbox creation depend on current registry and Sigstore TUF data instead of only the committed build inputs. Bundle regeneration uses the shared installer without live advisory or registry-signature queries. It installs the exact lock with lifecycle scripts disabled from integrity-pinned archives, runs the runtime tests and typecheck, and verifies the exact bundle inputs. - -The reviewed npm audit CI check now owns production advisory enforcement for this lock. Its trusted action verifies the downloaded `npm@10.9.4` archive against the reviewed SHA-512 integrity and SHA-256 digest, then confirms that the archive metadata reports the reviewed version before installation. The check verifies the lock SHA-256 and SDK package integrity, installs the production graph with lifecycle scripts disabled, records audit provenance and policy results, verifies registry signatures, and fails on unaccepted findings at the repository's configured threshold. - -## 1.29.0 to 1.30.0 migration review - -The audited adjacent range contains 10 upstream commits. The published `1.30.0` tag resolves to commit `2d889f2b329e46680ec9bdd565de4616c497825a`, descends from the published `v1.29.0` tag at `e12cbd7078db388152f6e839abdbe09ba01f3f32`, and contains the required client media-type fix at `69749aa5081ddfe675d36da8d96c7e27d83742b8`. The npm publication's `gitHead` matches the target tag, and its registry signature and build provenance verify. - -The required client change replaces case-sensitive substring checks with parsed, normalized media types when selecting JSON or SSE response handling. This fixes standards-valid case variants such as `Text/Event-Stream; Charset=UTF-8`. The remaining commits affect SDK server error formatting, server SSE keepalive lifecycle, stdio buffering, upstream tests and workflows, Zod type compatibility, the server-only Hono version range, and the release version. NemoClaw's bundled client does not include the server or stdio implementations. The committed `@hono/node-server` override is `2.0.12`. - -Concern ledger: - -- `MCP-SDK-130-1`: Client response dispatch rejected case-variant SSE media types. Surface: managed MCP tool discovery initialization and `tools/list`. Resolution: migrate to the official parsed-media-type implementation and cover the full session with a case-variant SSE fixture. Validation: `npm test`. -- `MCP-SDK-130-2`: `content-type@1.0.5` becomes executable bundle input. Surface: response media-type parsing and bundled notices. Resolution: add it to the exact bundle allowlist and verify its MIT text in the generated notice. Validation: `npm run bundle`. -- `MCP-SDK-130-3`: The upstream package widens its Hono server range. Surface: resolved install graph only; the Hono server adapter is absent from the client bundle. Resolution: use the reviewed `@hono/node-server@2.0.12` patch release. Validation: the lock diff and `BUNDLED_PACKAGES.json`. -- `MCP-SDK-130-4`: Other adjacent commits could alter unrelated transports or server behavior. Surface: upstream stdio and server entry points. Resolution: no migration because NemoClaw imports only `client/index.js` and `client/streamableHttp.js`; classify those commits as no runtime impact. Validation: esbuild's exact input graph. - -## `@hono/node-server` 2.0.12 review - -Version `2.0.12` is the next patch release and remains within the SDK's declared `^1.19.9 || ^2.0.5` range. It keeps the MIT license, Node.js `>=20` engine, `hono@^4` peer dependency, package exports, and lack of install scripts. - -The `v2.0.11..v2.0.12` source range contains three commits: a test transport replacement, a response-header fix for foreign `Response` objects, and the release commit. The server adapter remains outside NemoClaw's executable client bundle. The annotated tag and release commit are unsigned. During the 2026-08-03 security refresh, `npm audit signatures` verified the exact package's registry signature and Supply-chain Levels for Software Artifacts (SLSA) provenance against release commit `a813b6cdaa15baac3ead84e9e6ed5b72b2353c96`. Upstream Node.js 20, 22, and 24 checks, Windows checks, build checks, and the npm publication check passed. - -The reviewed archive is `https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz` with integrity `sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==`. This patch keeps the fail-closed signature check and adds no exception. - -## Build and audit contract - -The repository commits the generated ESM bundle, its checked package manifest, its deterministic third-party notice, and the dependency-free managed-startup CommonJS bundle under `reviewed-runtime-bundle/`; it does not commit registry archives for this graph. Every agent image copies those exact artifacts through scratch stages and probes the executable invalid-input contract after the copy. The trusted reviewed npm audit CI gate independently installs and verifies the exact production lock, including registry signatures and advisory policy, before main or tag production image publication and mutable cohort promotion. `bundle:reviewed:check` regenerates all four artifacts with the reviewed esbuild version and rejects any byte or file-set drift. This preserves the official SDK implementation and its license obligations without an image-build npm install or a large production dependency layer. -The installer remains the reviewed regeneration path. It applies the existing public corporate CA build argument to npm TLS when present, uses IPv4-first DNS resolution, a four-socket registry connection budget, and bounded fetch timeouts. Its recovery path remains capped and fail-closed on ambiguous, unpinned, integrity-invalid, or non-network failures. The runtime test, typecheck, exact bundle-package allowlist, and byte-for-byte reviewed bundle check run before an artifact update is accepted, while the trusted reviewed npm audit CI gate owns exact-lock registry-signature verification. -The root CLI TypeScript project excludes only this dependency-owning image entry point; the image package's dedicated `tsconfig.json` is the source-of-truth type gate, while the dependency-free core remains covered by the root project and host tests. -The image build requires a root-owned non-writable bundled runtime and an executable invalid-input contract check before it can complete. The reviewed bundle regeneration requires lock-pinned archive integrity and the case-variant SSE session test. The reviewed npm audit CI check verifies registry signatures and separately evaluates the locked production graph against the repository's advisory policy. The production base-image publication workflow requires that check before base-image platform-digest uploads, mutable base-image tags, or managed-image promotion for the same commit. - -Review evidence on 2026-07-14: - -- `npm audit --omit=dev --audit-level=low`: 0 vulnerabilities -- Pre-build `npm audit signatures`: 98 packages with verified registry signatures and 10 packages with verified attestations - -Replacement-port refresh evidence on 2026-07-26: - -- `npm audit --omit=dev --audit-level=low`: 0 vulnerabilities -- Pre-build `npm audit signatures`: 98 packages with verified registry signatures and 11 packages with verified attestations -- Exact bundle: 10 packages matching the reviewed allowlist in `BUNDLED_PACKAGES.json` - -SDK 1.30.0 migration evidence on 2026-07-28: - -- `npm test`: case-variant SSE discovery passed, including initialization, session propagation, `tools/list`, and session cleanup -- `npm audit --omit=dev --audit-level=low`: 0 vulnerabilities -- Pre-build `npm audit signatures`: 98 packages with verified registry signatures and 11 packages with verified attestations -- Exact bundle: 11 packages matching the reviewed allowlist in `BUNDLED_PACKAGES.json`, including `content-type@1.0.5` -- `npm run typecheck` and `npm run bundle`: passed - -Security refresh evidence on 2026-08-03: - -- `npm ci --ignore-scripts`: installed the exact 98-package lock -- `npm audit signatures`: verified 98 registry signatures and 12 provenance attestations -- `npm test` and `npm run typecheck`: passed -- `npm run bundle`: emitted the same 11-package client bundle with `fast-uri@3.1.6`; `hono` and `ip-address` remain outside the executable bundle -- `npm audit --omit=dev --audit-level=low`: 0 vulnerabilities - -## Updating - -Regenerate and review the graph explicitly: - -```console -$ npm --prefix tools/mcp-tool-discovery-runtime install --package-lock-only --ignore-scripts -$ npm --prefix tools/mcp-tool-discovery-runtime ci --ignore-scripts -$ npm --prefix tools/mcp-tool-discovery-runtime audit signatures --registry=https://registry.yarnpkg.com --omit=dev -$ npm --prefix tools/mcp-tool-discovery-runtime test -$ npm --prefix tools/mcp-tool-discovery-runtime run typecheck -$ npm --prefix tools/mcp-tool-discovery-runtime run bundle -$ npm --prefix tools/mcp-tool-discovery-runtime run bundle:reviewed -$ npm --prefix tools/mcp-tool-discovery-runtime run bundle:reviewed:check -$ npm --prefix tools/mcp-tool-discovery-runtime audit --registry=https://registry.yarnpkg.com --omit=dev --audit-level=low -``` - -Update this review, the exact package pin, and the committed lock together. Do not replace the lock with a floating install or reuse an agent-specific dependency tree. From 6c5207b11dc15c12b5bd8d708ec7fdc3bce8fb53 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Wed, 9 Sep 2026 09:13:48 -0700 Subject: [PATCH 14/31] fix(ci): require npm audit failure evidence Signed-off-by: Charan Jagwani --- .../scripts/classify-ci-failure.mts | 13 +++++++++---- .dsh/tools/e2e_root_cause_correlator/index.ts | 19 +++++++++++-------- scripts/checks/extract-installer-pins.mts | 6 +++--- test/automation/classify-ci-failure.test.ts | 19 +++++++++++++++++++ .../reviewed-npm-audit-handoff.test.ts | 16 +++++++++++++--- 5 files changed, 55 insertions(+), 18 deletions(-) diff --git a/.agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts b/.agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts index 950941a20ac..c9c1bb83634 100644 --- a/.agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts +++ b/.agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts @@ -1079,10 +1079,15 @@ async function classifyCiFailureWithRuntime( "The environment-variable documentation gate failed.", "Document the new NEMOCLAW_* variable in the required reference or remove it.", ); - if ( - /reviewed-npm-audit/i.test(job.name) || - /\bnpm audit\b|audit-reviewed-npm-graph/i.test(text) - ) + const isNpmAuditJob = + /^(?:reviewed-npm-audit|PR npm audit|npm audit for managed image publication)$/i.test( + job.name.trim(), + ); + const hasNpmAuditFailure = + /npm audit (?:threshold failed|scan remained incomplete|failed without vulnerability findings|requires npm [^\n;]+; running npm)|unused npm audit exceptions|\d+ unaccepted at or above (?:high|critical)/i.test( + text, + ); + if (isNpmAuditJob || hasNpmAuditFailure) add( "reviewed-npm-audit", "The npm audit check reported advisory drift.", diff --git a/.dsh/tools/e2e_root_cause_correlator/index.ts b/.dsh/tools/e2e_root_cause_correlator/index.ts index 8a5e7da68cc..3e1d3ac8e16 100644 --- a/.dsh/tools/e2e_root_cause_correlator/index.ts +++ b/.dsh/tools/e2e_root_cause_correlator/index.ts @@ -70,18 +70,21 @@ export default async function e2e_root_cause_correlator(input: { } if (relevantPathCount > 2000) throw new Error("relevantPaths exceed the total item bound"); if (inputCharacters > 400000) throw new Error("correlation input exceeds 400000 code units"); - const signatureKey = (lines: string[]) => { + const signatureKey = (jobName: string, lines: string[]) => { const text = lines.join(" ").toLowerCase(); if (text.includes("failedstage=publication") || text.includes("launch-readiness evidence")) return "launch-readiness/publication/evidence-failed"; if (text.includes("sandbox_phase=deleting") || text.includes("sandbox in deleting")) return "openshell/lifecycle/sandbox-deleting"; - if ( - text.includes("npm audit") || - text.includes("unaccepted at or above high") || - text.includes("advisory") - ) - return "dependency-audit/unaccepted-advisory"; + const isNpmAuditJob = + /^(?:reviewed-npm-audit|pr npm audit|npm audit for managed image publication)$/.test( + jobName.trim().toLowerCase(), + ); + const hasNpmAuditFailure = + /npm audit (?:threshold failed|scan remained incomplete|failed without vulnerability findings|requires npm [^\n;]+; running npm)|unused npm audit exceptions|\d+ unaccepted at or above (?:high|critical)/.test( + text, + ); + if (isNpmAuditJob || hasNpmAuditFailure) return "dependency-audit/unaccepted-advisory"; if (text.includes("timed out") || text.includes("timeout")) return "runtime/timeout/unclassified"; const first = @@ -95,7 +98,7 @@ export default async function e2e_root_cause_correlator(input: { }; const byKey = new Map(); for (const failure of input.failures) { - const key = signatureKey(failure.signatureLines); + const key = signatureKey(failure.jobName, failure.signatureLines); const group = byKey.get(key) ?? []; group.push(failure); byKey.set(key, group); diff --git a/scripts/checks/extract-installer-pins.mts b/scripts/checks/extract-installer-pins.mts index 7f73325817c..fc8d8a98af5 100644 --- a/scripts/checks/extract-installer-pins.mts +++ b/scripts/checks/extract-installer-pins.mts @@ -420,12 +420,12 @@ const TRUSTED_OPENSHELL_RELEASES: readonly OpenShellReleaseTrust[] = [ version: "0.0.103", }, { - // The third template is pre-authorized because dependent installer validation reads this - // trust record from the base branch. + // The final template pre-authorizes the exact Brev Node/npm bootstrap in #11080 because + // dependent installer validation reads this trust record from the base branch. brevTemplateSha256: [ "c0a4ddf25a02a9fe02b2df53a60942ea887610f04d4ce16a121b6e79a5aeff1a", "56fc6482d1508b73604099e6fd6c16daea16275cf36cc25c1c5366c82a4394e3", - "5674b528f6604b30b31fbf3877e4f5d53abc08e88c0a352070a898cfd7eaa7bf", + "9a30f006ac59b6acdcef843bff62ce3fd0fe0d681df993ec1c6a24811690caf5", ], formula: { asset: "openshell.rb", diff --git a/test/automation/classify-ci-failure.test.ts b/test/automation/classify-ci-failure.test.ts index 5dcbe0cbbe7..bf4119cead9 100644 --- a/test/automation/classify-ci-failure.test.ts +++ b/test/automation/classify-ci-failure.test.ts @@ -425,6 +425,25 @@ describe.skipIf(process.platform !== "linux")("CI failure classifier process", ( expect(result.status, result.stderr).toBe(0); expect(JSON.parse(result.stdout).result).toBe("unclassified"); }); + test("does not classify an unrelated job that mentions npm audit", () => { + const item = fixture( + "The documentation mentions npm audit.\nProcess completed with exit code 1", + ); + item.env.JOB_NAME = "Documentation checks"; + const result = run(item.env); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout).categories).not.toContain("reviewed-npm-audit"); + }); + test.each([ + ["PR npm audit", "Process completed with exit code 1"], + ["CLI tests", "npm audit threshold failed\n1 unaccepted at or above high"], + ])("classifies an npm audit failure from %s", (jobName, log) => { + const item = fixture(log); + item.env.JOB_NAME = jobName; + const result = run(item.env); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout).categories).toContain("reviewed-npm-audit"); + }); test.each(REDACTION_CASES)( "redacts a standalone %s from returned process logs", (_name, secret, exposed) => { diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index 8485bda0846..0ab89bae05c 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs"; @@ -68,9 +69,18 @@ const REVIEWED_NPM_ACTION = YAML.parse( "utf8", ), ) as CompositeAction; -const REVIEWED_NPM_BOOTSTRAP_COMMAND = REVIEWED_NPM_ACTION.runs?.steps?.find( +const reviewedNpmBootstrapCommand = REVIEWED_NPM_ACTION.runs?.steps?.find( (step) => step.name === "Download and verify production npm", )?.run; +const FIRST_TRUSTED_AUDIT_ACTION_CHECKOUT = TRUSTED_AUDIT_ACTION_CHECKOUTS[0]; +assert.ok( + FIRST_TRUSTED_AUDIT_ACTION_CHECKOUT, + "No trusted audit checkout includes the npm audit action", +); +const REVIEWED_NPM_BOOTSTRAP_COMMAND = + typeof reviewedNpmBootstrapCommand === "string" + ? reviewedNpmBootstrapCommand + : assert.fail("The npm audit action does not define the production npm bootstrap command"); function stageSparseCheckout(root: string, sparseCheckout: string): void { sparseCheckout @@ -145,7 +155,7 @@ printf '{"version":"12.0.2"}\\n' `, { mode: 0o755 }, ); - const result = spawnSync("bash", ["-c", REVIEWED_NPM_BOOTSTRAP_COMMAND ?? "exit 99"], { + const result = spawnSync("bash", ["-c", REVIEWED_NPM_BOOTSTRAP_COMMAND], { cwd: root, encoding: "utf8", env: { @@ -210,7 +220,7 @@ describe("npm audit handoff", () => { it("fails before installation when the trusted checkout omits the reviewed npm bootstrap", () => { const fixture = runTrustedBootstrapHandoff( - TRUSTED_AUDIT_ACTION_CHECKOUTS[0]?.sparseCheckout ?? "", + FIRST_TRUSTED_AUDIT_ACTION_CHECKOUT.sparseCheckout, (root) => fs.rmSync(path.join(root, ".github", "actions", "setup-reviewed-npm"), { recursive: true, From c670367fd48a8fe24dade9e19774710d9697de75 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Wed, 9 Sep 2026 10:25:45 -0700 Subject: [PATCH 15/31] fix(ci): preserve npm audit signatures Signed-off-by: Charan Jagwani --- .../scripts/classify-ci-failure.mts | 10 +++++----- test/automation/classify-ci-failure.test.ts | 8 +++++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts b/.agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts index c9c1bb83634..d1bff3f57e0 100644 --- a/.agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts +++ b/.agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts @@ -107,6 +107,8 @@ const SYSTEM_EXECUTABLES = { tail: "/usr/bin/tail", wc: "/usr/bin/wc", } as const; +const NPM_AUDIT_FAILURE_PATTERN = + /npm audit (?:threshold failed|scan remained incomplete|failed without vulnerability findings|requires npm [^\n;]+; running npm)|unused npm audit exceptions|\d+ unaccepted at or above (?:high|critical)/i; type TrustedExecutableStat = { isFile: () => boolean; @@ -801,7 +803,8 @@ async function classifyCiFailureWithRuntime( const selectedIndexes = new Set(); let matchedLines = 0; for (let index = 0; index < logLines.length; index += 1) { - if (!logPattern.test(logLines[index])) continue; + if (!logPattern.test(logLines[index]) && !NPM_AUDIT_FAILURE_PATTERN.test(logLines[index])) + continue; matchedLines += 1; const first = Math.max(0, index - 20); const last = Math.min(logLines.length - 1, index + 20); @@ -1083,10 +1086,7 @@ async function classifyCiFailureWithRuntime( /^(?:reviewed-npm-audit|PR npm audit|npm audit for managed image publication)$/i.test( job.name.trim(), ); - const hasNpmAuditFailure = - /npm audit (?:threshold failed|scan remained incomplete|failed without vulnerability findings|requires npm [^\n;]+; running npm)|unused npm audit exceptions|\d+ unaccepted at or above (?:high|critical)/i.test( - text, - ); + const hasNpmAuditFailure = NPM_AUDIT_FAILURE_PATTERN.test(text); if (isNpmAuditJob || hasNpmAuditFailure) add( "reviewed-npm-audit", diff --git a/test/automation/classify-ci-failure.test.ts b/test/automation/classify-ci-failure.test.ts index bf4119cead9..aea430c60ce 100644 --- a/test/automation/classify-ci-failure.test.ts +++ b/test/automation/classify-ci-failure.test.ts @@ -435,9 +435,11 @@ describe.skipIf(process.platform !== "linux")("CI failure classifier process", ( expect(JSON.parse(result.stdout).categories).not.toContain("reviewed-npm-audit"); }); test.each([ - ["PR npm audit", "Process completed with exit code 1"], - ["CLI tests", "npm audit threshold failed\n1 unaccepted at or above high"], - ])("classifies an npm audit failure from %s", (jobName, log) => { + ["known audit job", "PR npm audit", "Process completed with exit code 1"], + ["threshold failure", "CLI tests", "npm audit threshold failed"], + ["unused exception", "Dependency policy", "unused npm audit exceptions: GHSA-example"], + ["unaccepted advisory", "Release policy", "1 unaccepted at or above high"], + ])("classifies an npm audit failure from %s", (_caseName, jobName, log) => { const item = fixture(log); item.env.JOB_NAME = jobName; const result = run(item.env); From 450938d82228775d66f1944bb5d3554e910a4ba3 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Wed, 9 Sep 2026 11:41:59 -0700 Subject: [PATCH 16/31] fix(ci): complete reviewed npm audit handoff Signed-off-by: Charan Jagwani --- .../scripts/classify-ci-failure.mts | 17 +- .dsh/tools/e2e_root_cause_correlator/index.ts | 5 + .../actions/ci-reviewed-npm-audit/action.yaml | 2 +- .../verify-and-install-npm.sh | 0 .github/workflows/base-image.yaml | 1 - .github/workflows/managed-images.yaml | 2 - .github/workflows/pr.yaml | 1 - test/automation/classify-ci-failure.test.ts | 35 ++++ .../reviewed-npm-audit-handoff.test.ts | 150 +++++++++++++++--- .../releases/reviewed-npm-bootstrap.test.ts | 2 +- .../support/base-image-publication.test.ts | 8 +- tools/e2e/base-image-publication.mts | 1 - 12 files changed, 191 insertions(+), 33 deletions(-) rename .github/actions/{setup-reviewed-npm => ci-reviewed-npm-audit}/verify-and-install-npm.sh (100%) diff --git a/.agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts b/.agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts index d1bff3f57e0..44f59c14b26 100644 --- a/.agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts +++ b/.agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts @@ -109,6 +109,8 @@ const SYSTEM_EXECUTABLES = { } as const; const NPM_AUDIT_FAILURE_PATTERN = /npm audit (?:threshold failed|scan remained incomplete|failed without vulnerability findings|requires npm [^\n;]+; running npm)|unused npm audit exceptions|\d+ unaccepted at or above (?:high|critical)/i; +const NPM_BOOTSTRAP_FAILURE_PATTERN = + /npm(?:@[0-9A-Za-z.-]+ archive integrity mismatch| archive version [0-9A-Za-z.-]+ does not match reviewed npm@[0-9A-Za-z.-]+| audit configuration (?:is not valid JSON|has an invalid npm(?:Version|Integrity|ArchiveSha256)))/i; type TrustedExecutableStat = { isFile: () => boolean; @@ -803,7 +805,11 @@ async function classifyCiFailureWithRuntime( const selectedIndexes = new Set(); let matchedLines = 0; for (let index = 0; index < logLines.length; index += 1) { - if (!logPattern.test(logLines[index]) && !NPM_AUDIT_FAILURE_PATTERN.test(logLines[index])) + if ( + !logPattern.test(logLines[index]) && + !NPM_AUDIT_FAILURE_PATTERN.test(logLines[index]) && + !NPM_BOOTSTRAP_FAILURE_PATTERN.test(logLines[index]) + ) continue; matchedLines += 1; const first = Math.max(0, index - 20); @@ -1087,7 +1093,14 @@ async function classifyCiFailureWithRuntime( job.name.trim(), ); const hasNpmAuditFailure = NPM_AUDIT_FAILURE_PATTERN.test(text); - if (isNpmAuditJob || hasNpmAuditFailure) + const hasNpmBootstrapFailure = NPM_BOOTSTRAP_FAILURE_PATTERN.test(text); + if (hasNpmBootstrapFailure) + add( + "reviewed-npm-bootstrap", + "The reviewed npm bootstrap rejected the pinned npm archive or identity.", + "Inspect the pinned npm identity and downloaded archive; do not change the advisory exception baseline.", + ); + else if (isNpmAuditJob || hasNpmAuditFailure) add( "reviewed-npm-audit", "The npm audit check reported advisory drift.", diff --git a/.dsh/tools/e2e_root_cause_correlator/index.ts b/.dsh/tools/e2e_root_cause_correlator/index.ts index 3e1d3ac8e16..d57da46a504 100644 --- a/.dsh/tools/e2e_root_cause_correlator/index.ts +++ b/.dsh/tools/e2e_root_cause_correlator/index.ts @@ -80,10 +80,15 @@ export default async function e2e_root_cause_correlator(input: { /^(?:reviewed-npm-audit|pr npm audit|npm audit for managed image publication)$/.test( jobName.trim().toLowerCase(), ); + const hasNpmBootstrapFailure = + /npm(?:@[0-9a-z.-]+ archive integrity mismatch| archive version [0-9a-z.-]+ does not match reviewed npm@[0-9a-z.-]+| audit configuration (?:is not valid json|has an invalid npm(?:version|integrity|archivesha256)))/.test( + text, + ); const hasNpmAuditFailure = /npm audit (?:threshold failed|scan remained incomplete|failed without vulnerability findings|requires npm [^\n;]+; running npm)|unused npm audit exceptions|\d+ unaccepted at or above (?:high|critical)/.test( text, ); + if (hasNpmBootstrapFailure) return "dependency-audit/bootstrap-integrity"; if (isNpmAuditJob || hasNpmAuditFailure) return "dependency-audit/unaccepted-advisory"; if (text.includes("timed out") || text.includes("timeout")) return "runtime/timeout/unclassified"; diff --git a/.github/actions/ci-reviewed-npm-audit/action.yaml b/.github/actions/ci-reviewed-npm-audit/action.yaml index eb48142775e..121772b16dc 100644 --- a/.github/actions/ci-reviewed-npm-audit/action.yaml +++ b/.github/actions/ci-reviewed-npm-audit/action.yaml @@ -92,7 +92,7 @@ runs: shell: bash run: >- env -u NODE_AUTH_TOKEN -u NPM_TOKEN -u NPM_CONFIG__AUTH_TOKEN - "$GITHUB_ACTION_PATH/../setup-reviewed-npm/verify-and-install-npm.sh" + "$GITHUB_ACTION_PATH/verify-and-install-npm.sh" "$GITHUB_ACTION_PATH/../../../ci/reviewed-npm-audit.json" - name: Materialize and audit production dependency graphs diff --git a/.github/actions/setup-reviewed-npm/verify-and-install-npm.sh b/.github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh similarity index 100% rename from .github/actions/setup-reviewed-npm/verify-and-install-npm.sh rename to .github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh diff --git a/.github/workflows/base-image.yaml b/.github/workflows/base-image.yaml index 8ccf114a926..01572ee0834 100644 --- a/.github/workflows/base-image.yaml +++ b/.github/workflows/base-image.yaml @@ -27,7 +27,6 @@ on: - "test/e2e/live/managed-image-activation-e2e.test.ts" - "test/e2e/live/managed-image-activation-e2e-helpers.ts" - ".github/actions/ci-reviewed-npm-audit/**" - - ".github/actions/setup-reviewed-npm/**" - ".github/actions/publish-managed-image-digest/**" - ".github/actions/build-base-image-platform/**" - ".github/actions/publish-base-image-manifest/**" diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index 03ade74aab2..b03fa7d7721 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -32,7 +32,6 @@ on: pull_request: paths: - ".github/actions/ci-reviewed-npm-audit/**" - - ".github/actions/setup-reviewed-npm/**" - ".github/workflows/base-image.yaml" - ".github/actions/publish-managed-image-digest/**" - ".github/workflows/managed-images.yaml" @@ -98,7 +97,6 @@ jobs: persist-credentials: false sparse-checkout: | .github/actions/ci-reviewed-npm-audit - .github/actions/setup-reviewed-npm ci/npm-audit-exceptions.json ci/reviewed-npm-audit.json scripts/audit-reviewed-npm-graph.mts diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 2510da94e4c..0503d7db1d9 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -552,7 +552,6 @@ jobs: persist-credentials: false sparse-checkout: | .github/actions/ci-reviewed-npm-audit - .github/actions/setup-reviewed-npm ci/npm-audit-exceptions.json ci/reviewed-npm-audit.json scripts/audit-reviewed-npm-graph.mts diff --git a/test/automation/classify-ci-failure.test.ts b/test/automation/classify-ci-failure.test.ts index aea430c60ce..5f57355d60a 100644 --- a/test/automation/classify-ci-failure.test.ts +++ b/test/automation/classify-ci-failure.test.ts @@ -21,6 +21,7 @@ import { type GhResolverFilesystem, resolveProductionGhExecutableForTest, } from "../../.agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts"; +import correlateE2eRootCauses from "../../.dsh/tools/e2e_root_cause_correlator/index.ts"; import { artifactZip, artifactZipEntryDataOffset } from "../helpers/artifact-zip"; const script = resolve( ".agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts", @@ -307,6 +308,21 @@ describe("GitHub CLI production resolver", () => { }); }); +describe("reviewed npm root-cause correlation", () => { + test.each([ + ["npm@12.0.2 archive integrity mismatch", "dependency-audit/bootstrap-integrity"], + ["npm audit threshold failed", "dependency-audit/unaccepted-advisory"], + ])("separates %s", async (signature, expectedKey) => { + const result = await correlateE2eRootCauses({ + changedFiles: [], + failures: [{ jobId: 123, jobName: "PR npm audit", signatureLines: [signature] }], + }); + + expect(result.groups).toHaveLength(1); + expect(result.groups[0]?.key).toBe(expectedKey); + }); +}); + describe.skipIf(process.platform !== "linux")("CI failure classifier process", () => { test("redacts credentials from classified diagnostic output", () => { const secrets = [ @@ -446,6 +462,25 @@ describe.skipIf(process.platform !== "linux")("CI failure classifier process", ( expect(result.status, result.stderr).toBe(0); expect(JSON.parse(result.stdout).categories).toContain("reviewed-npm-audit"); }); + test.each([ + ["archive integrity mismatch", "ERROR: npm@12.0.2 archive integrity mismatch."], + [ + "archive version mismatch", + "ERROR: npm archive version 12.0.1 does not match reviewed npm@12.0.2.", + ], + ["invalid archive identity", "npm audit configuration has an invalid npmArchiveSha256"], + ])("classifies a reviewed npm bootstrap %s separately", (_caseName, log) => { + const item = fixture(log); + item.env.JOB_NAME = "PR npm audit"; + const result = run(item.env); + expect(result.status, result.stderr).toBe(0); + const value = JSON.parse(result.stdout); + expect(value.categories).toContain("reviewed-npm-bootstrap"); + expect(value.categories).not.toContain("reviewed-npm-audit"); + expect(value.nextActions).toContain( + "Inspect the pinned npm identity and downloaded archive; do not change the advisory exception baseline.", + ); + }); test.each(REDACTION_CASES)( "redacts a standalone %s from returned process logs", (_name, secret, exposed) => { diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index 0ab89bae05c..e5739e8bfd3 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -72,6 +72,9 @@ const REVIEWED_NPM_ACTION = YAML.parse( const reviewedNpmBootstrapCommand = REVIEWED_NPM_ACTION.runs?.steps?.find( (step) => step.name === "Download and verify production npm", )?.run; +const reviewedNpmAuditCommand = REVIEWED_NPM_ACTION.runs?.steps?.find( + (step) => step.name === "Materialize and audit production dependency graphs", +)?.run; const FIRST_TRUSTED_AUDIT_ACTION_CHECKOUT = TRUSTED_AUDIT_ACTION_CHECKOUTS[0]; assert.ok( FIRST_TRUSTED_AUDIT_ACTION_CHECKOUT, @@ -81,6 +84,10 @@ const REVIEWED_NPM_BOOTSTRAP_COMMAND = typeof reviewedNpmBootstrapCommand === "string" ? reviewedNpmBootstrapCommand : assert.fail("The npm audit action does not define the production npm bootstrap command"); +const REVIEWED_NPM_AUDIT_COMMAND = + typeof reviewedNpmAuditCommand === "string" + ? reviewedNpmAuditCommand + : assert.fail("The npm audit action does not define the production npm audit command"); function stageSparseCheckout(root: string, sparseCheckout: string): void { sparseCheckout @@ -97,6 +104,7 @@ function stageSparseCheckout(root: string, sparseCheckout: string): void { function runTrustedBootstrapHandoff( sparseCheckout: string, mutateCheckout: (root: string) => void = () => {}, + installUpdatesVersion = true, ) { const root = fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-audit-bootstrap-handoff-")); const bin = path.join(root, "bin"); @@ -104,16 +112,52 @@ function runTrustedBootstrapHandoff( const archiveFile = path.join(root, "fixture.tgz"); const installMarker = path.join(root, "install-called"); const npmLog = path.join(root, "npm.log"); + const npmVersionState = path.join(root, "npm-version"); + const reportDirectory = path.join(root, "artifacts", "reviewed-npm-audit"); stageSparseCheckout(root, sparseCheckout); mutateCheckout(root); fs.mkdirSync(bin); fs.writeFileSync(archiveFile, archive); + fs.writeFileSync(npmVersionState, "9.9.9\n"); + fs.writeFileSync( + path.join(root, "package.json"), + `${JSON.stringify({ name: "reviewed-npm-handoff-fixture", version: "1.0.0" })}\n`, + ); + fs.writeFileSync( + path.join(root, "package-lock.json"), + `${JSON.stringify({ + lockfileVersion: 3, + name: "reviewed-npm-handoff-fixture", + packages: { "": { name: "reviewed-npm-handoff-fixture", version: "1.0.0" } }, + requires: true, + version: "1.0.0", + })}\n`, + ); fs.writeFileSync( path.join(root, "ci", "reviewed-npm-audit.json"), `${JSON.stringify({ + archiveGraphId: "reviewed-archive-graph", + archivePackages: [], + archiveTarVersion: "7.5.21", + artifactDirectory: "artifacts/reviewed-npm-audit", + exceptionFile: "ci/npm-audit-exceptions.json", + lockedGraphs: [], + nodeVersion: process.version.slice(1), npmArchiveSha256: createHash("sha256").update(archive).digest("hex"), npmIntegrity: `sha512-${createHash("sha512").update(archive).digest("base64")}`, npmVersion: "12.0.2", + registryOrigin: "https://registry.npmjs.org", + schemaVersion: 2, + severityThreshold: "high", + sourceNestedShrinkwrapPackages: [], + sourceRegistryPackage: { + artifactName: "unused-1.0.0.tgz", + integrity: `sha512-${Buffer.alloc(64).toString("base64")}`, + label: "unused fixture package", + packageSpec: "unused@1.0.0", + tarballUrl: "https://registry.npmjs.org/unused/-/unused-1.0.0.tgz", + }, + sourceRegistryPackagesWithoutIntegrity: [], })}\n`, ); fs.writeFileSync( @@ -122,6 +166,9 @@ function runTrustedBootstrapHandoff( set -euo pipefail printf '%s\\n' "$*" >> "$NEMOCLAW_TEST_NPM_LOG" case "$1" in + --version) + cat "$NEMOCLAW_TEST_NPM_VERSION_STATE" + ;; pack) shift download_dir="" @@ -136,7 +183,21 @@ case "$1" in cp "$NEMOCLAW_TEST_ARCHIVE_FILE" "$download_dir/npm-12.0.2.tgz" ;; install) - : > "$NEMOCLAW_TEST_INSTALL_MARKER" + if [ "\${2:-}" = "--global" ]; then + : > "$NEMOCLAW_TEST_INSTALL_MARKER" + if [ "$NEMOCLAW_TEST_INSTALL_UPDATES_VERSION" = "true" ]; then + printf '12.0.2\\n' > "$NEMOCLAW_TEST_NPM_VERSION_STATE" + fi + else + printf '%s\\n' '{"name":"nemoclaw-reviewed-production-graph","version":"1.0.0","lockfileVersion":3,"requires":true,"packages":{"":{"name":"nemoclaw-reviewed-production-graph","version":"1.0.0"}}}' > package-lock.json + fi + ;; + ci) + ;; + audit) + if [ "\${2:-}" != "signatures" ]; then + printf '%s\\n' '{"vulnerabilities":{},"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":0,"critical":0}}}' + fi ;; *) exit 2 @@ -155,24 +216,43 @@ printf '{"version":"12.0.2"}\\n' `, { mode: 0o755 }, ); - const result = spawnSync("bash", ["-c", REVIEWED_NPM_BOOTSTRAP_COMMAND], { + const environment = { + ...process.env, + GITHUB_ACTION_PATH: path.join(root, ".github", "actions", "ci-reviewed-npm-audit"), + NEMOCLAW_REVIEWED_NPM_AUDIT_REPORT_DIR: path.relative(root, reportDirectory), + NEMOCLAW_REVIEWED_NPM_AUDIT_TARGET_ROOT: root, + NEMOCLAW_TEST_ARCHIVE_FILE: archiveFile, + NEMOCLAW_TEST_INSTALL_MARKER: installMarker, + NEMOCLAW_TEST_INSTALL_UPDATES_VERSION: String(installUpdatesVersion), + NEMOCLAW_TEST_NPM_LOG: npmLog, + NEMOCLAW_TEST_NPM_VERSION_STATE: npmVersionState, + NPM_CONFIG_REGISTRY: "https://registry.npmjs.org/", + NPM_CONFIG_USERCONFIG: "/dev/null", + PATH: `${bin}:${process.env.PATH ?? ""}`, + RUNNER_TEMP: root, + }; + delete environment.NEMOCLAW_NPM_AUDIT_CACHE_FILE; + delete environment.NEMOCLAW_REVIEWED_NPM_AUDIT_CACHE_DIR; + const bootstrapResult = spawnSync("bash", ["-c", REVIEWED_NPM_BOOTSTRAP_COMMAND], { cwd: root, encoding: "utf8", - env: { - ...process.env, - GITHUB_ACTION_PATH: path.join(root, ".github", "actions", "ci-reviewed-npm-audit"), - NEMOCLAW_TEST_ARCHIVE_FILE: archiveFile, - NEMOCLAW_TEST_INSTALL_MARKER: installMarker, - NEMOCLAW_TEST_NPM_LOG: npmLog, - PATH: `${bin}:${process.env.PATH ?? ""}`, - RUNNER_TEMP: root, - }, + env: environment, }); + const auditResult = + bootstrapResult.status === 0 + ? spawnSync("bash", ["-c", REVIEWED_NPM_AUDIT_COMMAND], { + cwd: root, + encoding: "utf8", + env: environment, + }) + : undefined; return { + auditResult, + bootstrapResult, cleanup: () => fs.rmSync(root, { recursive: true, force: true }), installCalled: fs.existsSync(installMarker), npmInvocations: fs.existsSync(npmLog) ? fs.readFileSync(npmLog, "utf8").trim().split("\n") : [], - result, + reportFiles: fs.existsSync(reportDirectory) ? fs.readdirSync(reportDirectory) : [], }; } @@ -204,14 +284,18 @@ describe("npm audit handoff", () => { ); it.each(TRUSTED_AUDIT_ACTION_CHECKOUTS)( - "executes the reviewed npm bootstrap from the $name trusted sparse checkout", + "installs and audits with the reviewed npm from the $name trusted sparse checkout", ({ sparseCheckout }) => { const fixture = runTrustedBootstrapHandoff(sparseCheckout); try { - expect(fixture.result.status, fixture.result.stderr).toBe(0); + expect(fixture.bootstrapResult.status, fixture.bootstrapResult.stderr).toBe(0); + expect(fixture.auditResult?.status, fixture.auditResult?.stderr).toBe(0); expect(fixture.installCalled).toBe(true); - expect(fixture.npmInvocations).toHaveLength(2); + expect(fixture.npmInvocations[0]).toMatch(/^pack npm@12\.0\.2 /u); expect(fixture.npmInvocations[1]).toMatch(/install --global .* --offline$/u); + expect(fixture.npmInvocations[2]).toBe("--version"); + expect(fixture.reportFiles).toContain("nemoclaw-cli.receipt.json"); + expect(fixture.reportFiles).toContain("reviewed-archive-graph.receipt.json"); } finally { fixture.cleanup(); } @@ -222,13 +306,20 @@ describe("npm audit handoff", () => { const fixture = runTrustedBootstrapHandoff( FIRST_TRUSTED_AUDIT_ACTION_CHECKOUT.sparseCheckout, (root) => - fs.rmSync(path.join(root, ".github", "actions", "setup-reviewed-npm"), { - recursive: true, - force: true, - }), + fs.rmSync( + path.join( + root, + ".github", + "actions", + "ci-reviewed-npm-audit", + "verify-and-install-npm.sh", + ), + { force: true }, + ), ); try { - expect(fixture.result.status).not.toBe(0); + expect(fixture.bootstrapResult.status).not.toBe(0); + expect(fixture.auditResult).toBeUndefined(); expect(fixture.installCalled).toBe(false); expect(fixture.npmInvocations).toEqual([]); } finally { @@ -236,6 +327,25 @@ describe("npm audit handoff", () => { } }); + it("rejects the audit before accepting results when installation leaves an older npm selected (#8253)", () => { + const fixture = runTrustedBootstrapHandoff( + FIRST_TRUSTED_AUDIT_ACTION_CHECKOUT.sparseCheckout, + () => {}, + false, + ); + try { + expect(fixture.bootstrapResult.status, fixture.bootstrapResult.stderr).toBe(0); + expect(fixture.auditResult?.status).toBe(1); + expect(fixture.auditResult?.stderr).toContain( + "npm audit requires npm 12.0.2; running npm 9.9.9", + ); + expect(fixture.reportFiles).not.toContain("nemoclaw-cli.receipt.json"); + expect(fixture.reportFiles).not.toContain("source-graph-policy.json"); + } finally { + fixture.cleanup(); + } + }); + it("passes producer output to the Docker receipt verifier and rejects an npm mismatch", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-audit-receipt-handoff-")); const packageJsonFile = path.join(root, "package.json"); diff --git a/test/automation/releases/reviewed-npm-bootstrap.test.ts b/test/automation/releases/reviewed-npm-bootstrap.test.ts index 934d9ae3a4e..4b72eaafd27 100644 --- a/test/automation/releases/reviewed-npm-bootstrap.test.ts +++ b/test/automation/releases/reviewed-npm-bootstrap.test.ts @@ -13,7 +13,7 @@ const BOOTSTRAP = path.join( REPO_ROOT, ".github", "actions", - "setup-reviewed-npm", + "ci-reviewed-npm-audit", "verify-and-install-npm.sh", ); diff --git a/test/e2e/support/base-image-publication.test.ts b/test/e2e/support/base-image-publication.test.ts index 9f338c41b4f..f82bc175acf 100644 --- a/test/e2e/support/base-image-publication.test.ts +++ b/test/e2e/support/base-image-publication.test.ts @@ -238,12 +238,12 @@ describe("base-image publication evidence", () => { const expanded = expandBaseImagePushPaths(EXPECTED_SHA, [ "Dockerfile", "agents/**", - ".github/actions/setup-reviewed-npm/**", + ".github/actions/ci-reviewed-npm-audit/**", "src/lib/messaging/**", "test/e2e/live/managed-image-activation-e2e*.ts", ]); expect(expanded).toEqual([ - ":(glob).github/actions/setup-reviewed-npm/**", + ":(glob).github/actions/ci-reviewed-npm-audit/**", ":(glob)agents/**", ":(glob)src/lib/messaging/**", ":(glob)test/e2e/live/managed-image-activation-e2e*.ts", @@ -251,8 +251,8 @@ describe("base-image publication evidence", () => { ]); expect( matchesBaseImagePushPath( - ".github/actions/setup-reviewed-npm/**", - ".github/actions/setup-reviewed-npm/verify-and-install-npm.sh", + ".github/actions/ci-reviewed-npm-audit/**", + ".github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh", ), ).toBe(true); }); diff --git a/tools/e2e/base-image-publication.mts b/tools/e2e/base-image-publication.mts index 774d2aa2685..d010fd6a718 100644 --- a/tools/e2e/base-image-publication.mts +++ b/tools/e2e/base-image-publication.mts @@ -30,7 +30,6 @@ const SHA_PATTERN = /^[0-9a-f]{40}$/u; const SAFE_PATH_PATTERN = /^[A-Za-z0-9._/-]+$/u; const REVIEWED_PATH_GLOBS = new Map([ [".github/actions/ci-reviewed-npm-audit/**", /^[.]github\/actions\/ci-reviewed-npm-audit\/.+$/u], - [".github/actions/setup-reviewed-npm/**", /^[.]github\/actions\/setup-reviewed-npm\/.+$/u], [ ".github/actions/publish-managed-image-digest/**", /^[.]github\/actions\/publish-managed-image-digest\/.+$/u, From 334cff2c82958d5c5c135f8d9dbf8dad4acdcd9e Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Wed, 9 Sep 2026 11:59:18 -0700 Subject: [PATCH 17/31] test(ci): validate reviewed npm failure routing Signed-off-by: Charan Jagwani --- test/automation/classify-ci-failure.test.ts | 28 +++++++++++++++---- .../reviewed-npm-audit-handoff.test.ts | 2 +- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/test/automation/classify-ci-failure.test.ts b/test/automation/classify-ci-failure.test.ts index 5f57355d60a..86b32ff0e58 100644 --- a/test/automation/classify-ci-failure.test.ts +++ b/test/automation/classify-ci-failure.test.ts @@ -14,6 +14,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, test, vi } from "vitest"; import { @@ -21,11 +22,11 @@ import { type GhResolverFilesystem, resolveProductionGhExecutableForTest, } from "../../.agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts"; -import correlateE2eRootCauses from "../../.dsh/tools/e2e_root_cause_correlator/index.ts"; import { artifactZip, artifactZipEntryDataOffset } from "../helpers/artifact-zip"; const script = resolve( ".agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts", ); +const rootCauseCorrelatorScript = resolve(".dsh/tools/e2e_root_cause_correlator/index.ts"); const roots: string[] = []; const uid = process.getuid?.() ?? "unknown"; const REDACTION_CASES = [ @@ -312,14 +313,29 @@ describe("reviewed npm root-cause correlation", () => { test.each([ ["npm@12.0.2 archive integrity mismatch", "dependency-audit/bootstrap-integrity"], ["npm audit threshold failed", "dependency-audit/unaccepted-advisory"], - ])("separates %s", async (signature, expectedKey) => { - const result = await correlateE2eRootCauses({ + ])("separates %s (#8253)", (signature, expectedKey) => { + const input = { changedFiles: [], failures: [{ jobId: 123, jobName: "PR npm audit", signatureLines: [signature] }], - }); + }; + const result = spawnSync( + process.execPath, + [ + "--experimental-strip-types", + "--no-warnings", + "--input-type=module", + "--eval", + [ + `const { default: correlate } = await import(${JSON.stringify(pathToFileURL(rootCauseCorrelatorScript).href)});`, + "console.log(JSON.stringify(await correlate(JSON.parse(process.argv[1]))));", + ].join("\n"), + JSON.stringify(input), + ], + { encoding: "utf8" }, + ); - expect(result.groups).toHaveLength(1); - expect(result.groups[0]?.key).toBe(expectedKey); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout).groups[0]?.key).toBe(expectedKey); }); }); diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index e5739e8bfd3..e77755f188f 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -216,7 +216,7 @@ printf '{"version":"12.0.2"}\\n' `, { mode: 0o755 }, ); - const environment = { + const environment: NodeJS.ProcessEnv = { ...process.env, GITHUB_ACTION_PATH: path.join(root, ".github", "actions", "ci-reviewed-npm-audit"), NEMOCLAW_REVIEWED_NPM_AUDIT_REPORT_DIR: path.relative(root, reportDirectory), From 3cc61fa8d9897398529b7ff3821669b240a5030b Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Wed, 9 Sep 2026 12:16:44 -0700 Subject: [PATCH 18/31] chore(node): use default TypeScript stripping Signed-off-by: Charan Jagwani --- .github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh | 2 +- test/automation/classify-ci-failure.test.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh b/.github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh index 79aa034fea4..fc8cb835df2 100755 --- a/.github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh +++ b/.github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh @@ -15,7 +15,7 @@ download_dir="$(mktemp -d "$RUNNER_TEMP/reviewed-npm.XXXXXX")" trap 'rm -rf "$download_dir"' EXIT identity_file="$download_dir/identity" -node --experimental-strip-types --input-type=module - \ +node --input-type=module - \ "$config_file" \ "$script_dir/../../../scripts/lib/reviewed-npm-audit.mts" >"$identity_file" <<'NODE' import { readFileSync } from "node:fs"; diff --git a/test/automation/classify-ci-failure.test.ts b/test/automation/classify-ci-failure.test.ts index 393187d33ac..f9df19df26f 100644 --- a/test/automation/classify-ci-failure.test.ts +++ b/test/automation/classify-ci-failure.test.ts @@ -319,7 +319,6 @@ describe("reviewed npm root-cause correlation", () => { const result = spawnSync( process.execPath, [ - "--experimental-strip-types", "--no-warnings", "--input-type=module", "--eval", From c6b6db9a4d60e23e6a2b20923037193af079d3bc Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Wed, 9 Sep 2026 15:32:26 -0700 Subject: [PATCH 19/31] fix(ci): classify npm audit failures by evidence Signed-off-by: Charan Jagwani --- .../scripts/classify-ci-failure.mts | 6 +----- .dsh/tools/e2e_root_cause_correlator/index.ts | 10 +++------- test/automation/classify-ci-failure.test.ts | 9 ++++++++- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/.agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts b/.agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts index 44f59c14b26..7ba850e82e6 100644 --- a/.agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts +++ b/.agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts @@ -1088,10 +1088,6 @@ async function classifyCiFailureWithRuntime( "The environment-variable documentation gate failed.", "Document the new NEMOCLAW_* variable in the required reference or remove it.", ); - const isNpmAuditJob = - /^(?:reviewed-npm-audit|PR npm audit|npm audit for managed image publication)$/i.test( - job.name.trim(), - ); const hasNpmAuditFailure = NPM_AUDIT_FAILURE_PATTERN.test(text); const hasNpmBootstrapFailure = NPM_BOOTSTRAP_FAILURE_PATTERN.test(text); if (hasNpmBootstrapFailure) @@ -1100,7 +1096,7 @@ async function classifyCiFailureWithRuntime( "The reviewed npm bootstrap rejected the pinned npm archive or identity.", "Inspect the pinned npm identity and downloaded archive; do not change the advisory exception baseline.", ); - else if (isNpmAuditJob || hasNpmAuditFailure) + else if (hasNpmAuditFailure) add( "reviewed-npm-audit", "The npm audit check reported advisory drift.", diff --git a/.dsh/tools/e2e_root_cause_correlator/index.ts b/.dsh/tools/e2e_root_cause_correlator/index.ts index d57da46a504..42b4d201c29 100644 --- a/.dsh/tools/e2e_root_cause_correlator/index.ts +++ b/.dsh/tools/e2e_root_cause_correlator/index.ts @@ -70,16 +70,12 @@ export default async function e2e_root_cause_correlator(input: { } if (relevantPathCount > 2000) throw new Error("relevantPaths exceed the total item bound"); if (inputCharacters > 400000) throw new Error("correlation input exceeds 400000 code units"); - const signatureKey = (jobName: string, lines: string[]) => { + const signatureKey = (lines: string[]) => { const text = lines.join(" ").toLowerCase(); if (text.includes("failedstage=publication") || text.includes("launch-readiness evidence")) return "launch-readiness/publication/evidence-failed"; if (text.includes("sandbox_phase=deleting") || text.includes("sandbox in deleting")) return "openshell/lifecycle/sandbox-deleting"; - const isNpmAuditJob = - /^(?:reviewed-npm-audit|pr npm audit|npm audit for managed image publication)$/.test( - jobName.trim().toLowerCase(), - ); const hasNpmBootstrapFailure = /npm(?:@[0-9a-z.-]+ archive integrity mismatch| archive version [0-9a-z.-]+ does not match reviewed npm@[0-9a-z.-]+| audit configuration (?:is not valid json|has an invalid npm(?:version|integrity|archivesha256)))/.test( text, @@ -89,7 +85,7 @@ export default async function e2e_root_cause_correlator(input: { text, ); if (hasNpmBootstrapFailure) return "dependency-audit/bootstrap-integrity"; - if (isNpmAuditJob || hasNpmAuditFailure) return "dependency-audit/unaccepted-advisory"; + if (hasNpmAuditFailure) return "dependency-audit/unaccepted-advisory"; if (text.includes("timed out") || text.includes("timeout")) return "runtime/timeout/unclassified"; const first = @@ -103,7 +99,7 @@ export default async function e2e_root_cause_correlator(input: { }; const byKey = new Map(); for (const failure of input.failures) { - const key = signatureKey(failure.jobName, failure.signatureLines); + const key = signatureKey(failure.signatureLines); const group = byKey.get(key) ?? []; group.push(failure); byKey.set(key, group); diff --git a/test/automation/classify-ci-failure.test.ts b/test/automation/classify-ci-failure.test.ts index f9df19df26f..7419f61d4c3 100644 --- a/test/automation/classify-ci-failure.test.ts +++ b/test/automation/classify-ci-failure.test.ts @@ -311,6 +311,7 @@ describe("reviewed npm root-cause correlation", () => { test.each([ ["npm@12.0.2 archive integrity mismatch", "dependency-audit/bootstrap-integrity"], ["npm audit threshold failed", "dependency-audit/unaccepted-advisory"], + ["The operation timed out", "runtime/timeout/unclassified"], ])("separates %s (#8253)", (signature, expectedKey) => { const input = { changedFiles: [], @@ -464,7 +465,6 @@ describe.skipIf(process.platform !== "linux")("CI failure classifier process", ( expect(JSON.parse(result.stdout).categories).not.toContain("reviewed-npm-audit"); }); test.each([ - ["known audit job", "PR npm audit", "Process completed with exit code 1"], ["threshold failure", "CLI tests", "npm audit threshold failed"], ["unused exception", "Dependency policy", "unused npm audit exceptions: GHSA-example"], ["unaccepted advisory", "Release policy", "1 unaccepted at or above high"], @@ -475,6 +475,13 @@ describe.skipIf(process.platform !== "linux")("CI failure classifier process", ( expect(result.status, result.stderr).toBe(0); expect(JSON.parse(result.stdout).categories).toContain("reviewed-npm-audit"); }); + test("does not classify an npm audit job without audit-policy evidence", () => { + const item = fixture("The operation timed out"); + item.env.JOB_NAME = "PR npm audit"; + const result = run(item.env); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout).categories).not.toContain("reviewed-npm-audit"); + }); test.each([ ["archive integrity mismatch", "ERROR: npm@12.0.2 archive integrity mismatch."], [ From 120f42919a6119fd2d56f27b31dbae9f9a9e8abc Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Wed, 9 Sep 2026 15:58:55 -0700 Subject: [PATCH 20/31] fix(ci): close npm audit review gaps Signed-off-by: Charan Jagwani --- .dsh/tools/e2e_root_cause_correlator/index.ts | 10 ------ docs/security/advisory-early-warning.md | 4 ++- test/automation/classify-ci-failure.test.ts | 32 ------------------- .../releases/reviewed-npm-bootstrap.test.ts | 12 ++++--- 4 files changed, 10 insertions(+), 48 deletions(-) diff --git a/.dsh/tools/e2e_root_cause_correlator/index.ts b/.dsh/tools/e2e_root_cause_correlator/index.ts index 42b4d201c29..9ec5eadad58 100644 --- a/.dsh/tools/e2e_root_cause_correlator/index.ts +++ b/.dsh/tools/e2e_root_cause_correlator/index.ts @@ -76,16 +76,6 @@ export default async function e2e_root_cause_correlator(input: { return "launch-readiness/publication/evidence-failed"; if (text.includes("sandbox_phase=deleting") || text.includes("sandbox in deleting")) return "openshell/lifecycle/sandbox-deleting"; - const hasNpmBootstrapFailure = - /npm(?:@[0-9a-z.-]+ archive integrity mismatch| archive version [0-9a-z.-]+ does not match reviewed npm@[0-9a-z.-]+| audit configuration (?:is not valid json|has an invalid npm(?:version|integrity|archivesha256)))/.test( - text, - ); - const hasNpmAuditFailure = - /npm audit (?:threshold failed|scan remained incomplete|failed without vulnerability findings|requires npm [^\n;]+; running npm)|unused npm audit exceptions|\d+ unaccepted at or above (?:high|critical)/.test( - text, - ); - if (hasNpmBootstrapFailure) return "dependency-audit/bootstrap-integrity"; - if (hasNpmAuditFailure) return "dependency-audit/unaccepted-advisory"; if (text.includes("timed out") || text.includes("timeout")) return "runtime/timeout/unclassified"; const first = diff --git a/docs/security/advisory-early-warning.md b/docs/security/advisory-early-warning.md index c256f71e1be..41a669d6c98 100644 --- a/docs/security/advisory-early-warning.md +++ b/docs/security/advisory-early-warning.md @@ -95,7 +95,9 @@ The same #7338 sign-off gate applies to this work. Each npm audit report has a `*.provenance.json` sidecar. The sidecars include `coverage/reviewed-npm-audit/` artifacts and `npm-audit.provenance.json` for the WeChat locked runtime graph audit. -A configured cache reuses a response only when the package and lock bytes, npm version, fixed Yarn audit registry origin, command arguments, and parser identity match. Until 2026-09-11, image builds may accept a still-current npmjs receipt only through the explicit legacy transition. Remove the legacy option and verifier path after Yarn-bound receipts replace the retained npmjs receipts. +A configured cache reuses a response only when the package and lock bytes, the pinned npm identity (version, SHA-512 SRI, and archive SHA-256), fixed Yarn audit registry origin, command arguments, and parser identity match. +Until 2026-09-11, image builds may accept a still-current npmjs receipt only through the explicit legacy transition. +Remove the legacy option and verifier path after Yarn-bound receipts replace the retained npmjs receipts. The sidecar records whether the response came from the cache or a live registry request, plus its creation time, age, input digest, and response digest. Each sidecar also records: diff --git a/test/automation/classify-ci-failure.test.ts b/test/automation/classify-ci-failure.test.ts index 7419f61d4c3..e9e854bde0f 100644 --- a/test/automation/classify-ci-failure.test.ts +++ b/test/automation/classify-ci-failure.test.ts @@ -14,7 +14,6 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, test, vi } from "vitest"; import { @@ -26,7 +25,6 @@ import { artifactZip, artifactZipEntryDataOffset } from "../helpers/artifact-zip const script = resolve( ".agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts", ); -const rootCauseCorrelatorScript = resolve(".dsh/tools/e2e_root_cause_correlator/index.ts"); const roots: string[] = []; const uid = process.getuid?.() ?? "unknown"; const REDACTION_CASES = [ @@ -307,36 +305,6 @@ describe("GitHub CLI production resolver", () => { }); }); -describe("reviewed npm root-cause correlation", () => { - test.each([ - ["npm@12.0.2 archive integrity mismatch", "dependency-audit/bootstrap-integrity"], - ["npm audit threshold failed", "dependency-audit/unaccepted-advisory"], - ["The operation timed out", "runtime/timeout/unclassified"], - ])("separates %s (#8253)", (signature, expectedKey) => { - const input = { - changedFiles: [], - failures: [{ jobId: 123, jobName: "PR npm audit", signatureLines: [signature] }], - }; - const result = spawnSync( - process.execPath, - [ - "--no-warnings", - "--input-type=module", - "--eval", - [ - `const { default: correlate } = await import(${JSON.stringify(pathToFileURL(rootCauseCorrelatorScript).href)});`, - "console.log(JSON.stringify(await correlate(JSON.parse(process.argv[1]))));", - ].join("\n"), - JSON.stringify(input), - ], - { encoding: "utf8" }, - ); - - expect(result.status, result.stderr).toBe(0); - expect(JSON.parse(result.stdout).groups[0]?.key).toBe(expectedKey); - }); -}); - describe.skipIf(process.platform !== "linux")("CI failure classifier process", () => { test("redacts credentials from classified diagnostic output", () => { const secrets = [ diff --git a/test/automation/releases/reviewed-npm-bootstrap.test.ts b/test/automation/releases/reviewed-npm-bootstrap.test.ts index 4b72eaafd27..a134f8eb867 100644 --- a/test/automation/releases/reviewed-npm-bootstrap.test.ts +++ b/test/automation/releases/reviewed-npm-bootstrap.test.ts @@ -141,16 +141,18 @@ function createRealArchive(version?: string): { archive: Buffer; cleanup: () => describe("reviewed npm bootstrap", () => { const archive = "verified archive\n"; - it("rejects a malformed reviewed archive SHA-256 before download (#8253)", () => { + it.each([ + ["npmVersion", { ...identity(archive), npmVersion: "12.x" }], + ["npmIntegrity", { ...identity(archive), npmIntegrity: "not-a-reviewed-integrity" }], + ["npmArchiveSha256", { ...identity(archive), npmArchiveSha256: "not-a-reviewed-digest" }], + ])("rejects a malformed %s before download (#8253)", (field, reviewedIdentity) => { const fixture = runBootstrapFixture({ archive, - reviewedIdentity: { ...identity(archive), npmArchiveSha256: "not-a-reviewed-digest" }, + reviewedIdentity, }); try { expect(fixture.result.status).toBe(1); - expect(fixture.result.stderr).toContain( - "npm audit configuration has an invalid npmArchiveSha256", - ); + expect(fixture.result.stderr).toContain(`npm audit configuration has an invalid ${field}`); expect(fixture.npmInvocations).toEqual([]); expect(fixture.installCalled).toBe(false); } finally { From 3885100eedb866f171084d702bb5843984315f0a Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Wed, 9 Sep 2026 16:23:01 -0700 Subject: [PATCH 21/31] fix(ci): classify invalid npm archive metadata Signed-off-by: Charan Jagwani --- .../scripts/classify-ci-failure.mts | 2 +- .../verify-and-install-npm.sh | 7 +++++-- test/automation/classify-ci-failure.test.ts | 4 ++++ .../releases/reviewed-npm-bootstrap.test.ts | 21 +++++++++++++------ 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/.agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts b/.agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts index 7ba850e82e6..adf5b0d88f0 100644 --- a/.agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts +++ b/.agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts @@ -110,7 +110,7 @@ const SYSTEM_EXECUTABLES = { const NPM_AUDIT_FAILURE_PATTERN = /npm audit (?:threshold failed|scan remained incomplete|failed without vulnerability findings|requires npm [^\n;]+; running npm)|unused npm audit exceptions|\d+ unaccepted at or above (?:high|critical)/i; const NPM_BOOTSTRAP_FAILURE_PATTERN = - /npm(?:@[0-9A-Za-z.-]+ archive integrity mismatch| archive version [0-9A-Za-z.-]+ does not match reviewed npm@[0-9A-Za-z.-]+| audit configuration (?:is not valid JSON|has an invalid npm(?:Version|Integrity|ArchiveSha256)))/i; + /npm(?:@[0-9A-Za-z.-]+ archive (?:integrity mismatch|package\/package\.json is missing or invalid)| archive version [0-9A-Za-z.-]+ does not match reviewed npm@[0-9A-Za-z.-]+| audit configuration (?:is not valid JSON|has an invalid npm(?:Version|Integrity|ArchiveSha256)))/i; type TrustedExecutableStat = { isFile: () => boolean; diff --git a/.github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh b/.github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh index fc8cb835df2..63b1c33c446 100755 --- a/.github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh +++ b/.github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh @@ -59,7 +59,7 @@ if [ "$actual_integrity" != "$expected_integrity" ] || [ "$actual_sha256" != "$e exit 1 fi -archive_version="$( +if ! archive_version="$( tar -xOf "$archive" package/package.json | node -e ' let source = ""; process.stdin.setEncoding("utf8"); @@ -70,7 +70,10 @@ archive_version="$( process.stdout.write(version); }); ' -)" +)"; then + echo "ERROR: npm@$version archive package/package.json is missing or invalid." >&2 + exit 1 +fi if [ "$archive_version" != "$version" ]; then echo "ERROR: npm archive version $archive_version does not match reviewed npm@$version." >&2 exit 1 diff --git a/test/automation/classify-ci-failure.test.ts b/test/automation/classify-ci-failure.test.ts index e9e854bde0f..0de97032fa4 100644 --- a/test/automation/classify-ci-failure.test.ts +++ b/test/automation/classify-ci-failure.test.ts @@ -456,6 +456,10 @@ describe.skipIf(process.platform !== "linux")("CI failure classifier process", ( "archive version mismatch", "ERROR: npm archive version 12.0.1 does not match reviewed npm@12.0.2.", ], + [ + "archive package metadata", + "ERROR: npm@12.0.2 archive package/package.json is missing or invalid.", + ], ["invalid archive identity", "npm audit configuration has an invalid npmArchiveSha256"], ])("classifies a reviewed npm bootstrap %s separately", (_caseName, log) => { const item = fixture(log); diff --git a/test/automation/releases/reviewed-npm-bootstrap.test.ts b/test/automation/releases/reviewed-npm-bootstrap.test.ts index a134f8eb867..ad7fe97f58c 100644 --- a/test/automation/releases/reviewed-npm-bootstrap.test.ts +++ b/test/automation/releases/reviewed-npm-bootstrap.test.ts @@ -118,7 +118,7 @@ esac }; } -function createRealArchive(version?: string): { archive: Buffer; cleanup: () => void } { +function createRealArchive(version?: string | null): { archive: Buffer; cleanup: () => void } { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-npm-archive-")); const packageRoot = path.join(root, "package"); const archivePath = path.join(root, "fixture.tgz"); @@ -126,7 +126,10 @@ function createRealArchive(version?: string): { archive: Buffer; cleanup: () => const entry = version === undefined ? { contents: "missing package manifest\n", name: "README.md" } - : { contents: `${JSON.stringify({ version })}\n`, name: "package.json" }; + : { + contents: version === null ? "{invalid json\n" : `${JSON.stringify({ version })}\n`, + name: "package.json", + }; fs.writeFileSync(path.join(packageRoot, entry.name), entry.contents); const packed = spawnSync("tar", ["-czf", archivePath, "-C", root, "package"], { encoding: "utf8", @@ -197,18 +200,24 @@ describe("reviewed npm bootstrap", () => { }); it.each([ - ["matching", "12.0.2", true], - ["mismatched", "12.0.3", false], - ["missing", undefined, false], + ["matching", "12.0.2", true, false], + ["mismatched", "12.0.3", false, false], + ["missing", undefined, false, true], + ["invalid", null, false, true], ] as const)( "%s real tar package metadata reaches installation only for the reviewed version (#8253)", - (_condition, archiveVersion, expectedInstall) => { + (_condition, archiveVersion, expectedInstall, expectedMetadataError) => { const archiveFixture = createRealArchive(archiveVersion); const fixture = runBootstrapFixture({ archive: archiveFixture.archive, realTar: true }); try { expect(fixture.result.status === 0).toBe(expectedInstall); expect(fixture.installCalled).toBe(expectedInstall); expect(fixture.npmInvocations).toHaveLength(expectedInstall ? 2 : 1); + expect( + fixture.result.stderr.includes( + "npm@12.0.2 archive package/package.json is missing or invalid", + ), + ).toBe(expectedMetadataError); } finally { fixture.cleanup(); archiveFixture.cleanup(); From 15bd4dfb2c06e88c66b251fa39c0f6d03399a423 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Wed, 9 Sep 2026 16:58:45 -0700 Subject: [PATCH 22/31] fix(ci): remove stale audit correlation branch Signed-off-by: Charan Jagwani --- .dsh/tools/e2e_root_cause_correlator/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.dsh/tools/e2e_root_cause_correlator/index.ts b/.dsh/tools/e2e_root_cause_correlator/index.ts index 9ec5eadad58..0dde1e36149 100644 --- a/.dsh/tools/e2e_root_cause_correlator/index.ts +++ b/.dsh/tools/e2e_root_cause_correlator/index.ts @@ -105,7 +105,7 @@ export default async function e2e_root_cause_correlator(input: { (path) => file === path || file.startsWith(`${path}/`) || path.startsWith(`${file}/`), ), ); - const externalSignature = key.includes("dependency-audit") || key.includes("sandbox-deleting"); + const externalSignature = key.includes("sandbox-deleting"); const classification = matched.length > 0 ? "source-change-candidate" From 9e2a42a4d636e40ecf069d7eb33ef0c190a3c88c Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Thu, 10 Sep 2026 00:20:05 -0700 Subject: [PATCH 23/31] fix(ci): bind audit evidence to installed npm Signed-off-by: Charan Jagwani --- scripts/audit-reviewed-npm-graph.mts | 32 +++------ scripts/lib/reviewed-npm-audit.mts | 40 +++++------ .../reviewed-npm-audit-handoff.test.ts | 69 ++++++++++++++----- .../reviewed-npm-audit-workflow.test.ts | 11 ++- .../releases/reviewed-npm-audit.test.ts | 58 ++++++++++++---- 5 files changed, 135 insertions(+), 75 deletions(-) diff --git a/scripts/audit-reviewed-npm-graph.mts b/scripts/audit-reviewed-npm-graph.mts index 3a39d6d4bcb..702c7c06eb1 100755 --- a/scripts/audit-reviewed-npm-graph.mts +++ b/scripts/audit-reviewed-npm-graph.mts @@ -19,6 +19,7 @@ import { } from "./lib/reviewed-npm-archive.mts"; import { type AuditPolicyResult, + type ReviewedNpmIdentity, NPM_AUDIT_REGISTRY, assertExceptionGraphs, parseReviewedNpmIdentity, @@ -713,7 +714,6 @@ function auditLockedGraph( tempRoot: string, exceptionFile: string, artifactDirectory: string, - npmVersion: string, ) { const directory = materializeLockedGraph(graph, tempRoot, config.registryOrigin); const result = runReviewedNpmAudit({ @@ -724,9 +724,10 @@ function auditLockedGraph( provenance: { label: graph.label, nodeVersion: process.version, - npmVersion, + npmVersion: config.npmVersion, packageSpecs: [graph.packageSpec], }, + reviewedNpmIdentity: config, reportFile: path.join(artifactDirectory, `locked-graph-${index + 1}.json`), resultFile: path.join(artifactDirectory, `locked-graph-${index + 1}-policy.json`), threshold: graph.severityThreshold ?? config.severityThreshold, @@ -754,7 +755,6 @@ function auditSourceGraph( tempRoot: string, exceptionFile: string, artifactDirectory: string, - npmVersion: string, ) { const sourcePackage = targetRepositoryPath("package.json", "NemoClaw CLI package manifest"); const sourceLock = targetRepositoryPath("package-lock.json", "NemoClaw CLI lockfile"); @@ -777,7 +777,7 @@ function auditSourceGraph( directory, exceptionFile, artifactDirectory, - npmVersion, + reviewedNpmIdentity: config, packageSpec: `${sourceManifest.name}@${sourceManifest.version}`, threshold: config.severityThreshold, }); @@ -788,8 +788,8 @@ export function auditMaterializedSourceGraph( artifactDirectory: string; directory: string; exceptionFile: string; - npmVersion: string; packageSpec: string; + reviewedNpmIdentity: ReviewedNpmIdentity; threshold: Severity; }>, dependencies: Readonly<{ @@ -805,9 +805,10 @@ export function auditMaterializedSourceGraph( provenance: { label: SOURCE_GRAPH.label, nodeVersion: process.version, - npmVersion: options.npmVersion, + npmVersion: options.reviewedNpmIdentity.npmVersion, packageSpecs: [options.packageSpec], }, + reviewedNpmIdentity: options.reviewedNpmIdentity, reportFile: path.join(options.artifactDirectory, "source-graph.json"), resultFile: path.join(options.artifactDirectory, "source-graph-policy.json"), threshold: options.threshold, @@ -925,13 +926,7 @@ function main(): void { } const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-npm-audit-")); try { - const sourceResult = auditSourceGraph( - config, - tempRoot, - exceptionFile, - artifactDirectory, - npmVersion, - ); + const sourceResult = auditSourceGraph(config, tempRoot, exceptionFile, artifactDirectory); const archiveDirectory = materializeArchiveGraph( config.archivePackages, tempRoot, @@ -948,21 +943,14 @@ function main(): void { npmVersion, packageSpecs: config.archivePackages.map((reviewed) => reviewed.packageSpec), }, + reviewedNpmIdentity: config, reportFile: path.join(artifactDirectory, "reviewed-archive-graph.json"), resultFile: path.join(artifactDirectory, "reviewed-archive-graph-policy.json"), threshold: config.severityThreshold, throwOnBlock: false, }); const lockedResults = config.lockedGraphs.map((graph, index) => - auditLockedGraph( - graph, - index, - config, - tempRoot, - exceptionFile, - artifactDirectory, - npmVersion, - ), + auditLockedGraph(graph, index, config, tempRoot, exceptionFile, artifactDirectory), ); const reports = [ { label: SOURCE_GRAPH.label, result: sourceResult }, diff --git a/scripts/lib/reviewed-npm-audit.mts b/scripts/lib/reviewed-npm-audit.mts index 7c747b36c9c..1b7b6db2dd9 100755 --- a/scripts/lib/reviewed-npm-audit.mts +++ b/scripts/lib/reviewed-npm-audit.mts @@ -169,7 +169,7 @@ export function npmAuditProcessOptions(directory: string) { }; } const NPM_AUDIT_CACHE_MAX_BYTES = 64 * 1024 * 1024; -const NPM_AUDIT_CACHE_SCHEMA_VERSION = 1; +const NPM_AUDIT_CACHE_SCHEMA_VERSION = 2; const NPM_AUDIT_PARSER_IDENTITY = "reviewed-npm-audit-report-v1"; type NpmAuditCommandResult = Readonly<{ @@ -531,20 +531,10 @@ export function provenanceSidecarPath(reportPath: string): string { return `${reportPath.replace(/\.json$/, "")}.provenance.json`; } -function npmVersion(directory: string): string { - const result = spawnSync("npm", ["--version"], { - cwd: directory, - encoding: "utf-8", - env: { ...process.env, NPM_CONFIG_UPDATE_NOTIFIER: "false" }, - stdio: ["ignore", "pipe", "pipe"], - }); - if (result.error || result.status !== 0) - throw new Error("npm version could not be determined for audit cache identity"); - return result.stdout.trim(); -} - type AuditCacheInput = Readonly<{ argv: readonly string[]; + npmArchiveSha256: string; + npmIntegrity: string; npmVersion: string; packageJsonSha256: string; packageLockSha256: string; @@ -553,7 +543,7 @@ type AuditCacheInput = Readonly<{ }>; type AuditCacheRecord = Readonly<{ - schemaVersion: 1; + schemaVersion: 2; createdAt: string; input: AuditCacheInput; result: Readonly<{ stdout: string; exitCode: number }>; @@ -575,14 +565,15 @@ function canonicalRegistryOrigin(registry: string): string | null { export function buildAuditCacheInput( directory: string, - npmVersion: string, + npmIdentity: ReviewedNpmIdentity, registry: string, ): AuditCacheInput { const registryOrigin = canonicalRegistryOrigin(registry); if (!registryOrigin) throw new Error("npm audit cache requires a valid HTTP(S) registry"); + const reviewedNpmIdentity = parseReviewedNpmIdentity(npmIdentity); return { argv: NPM_AUDIT_ARGV, - npmVersion, + ...reviewedNpmIdentity, packageJsonSha256: sha256(fs.readFileSync(path.join(directory, "package.json"))), packageLockSha256: sha256(fs.readFileSync(path.join(directory, "package-lock.json"))), parserIdentity: NPM_AUDIT_PARSER_IDENTITY, @@ -608,6 +599,8 @@ function parseAuditCacheRecord(source: string): AuditCacheRecord { input, new Set([ "argv", + "npmArchiveSha256", + "npmIntegrity", "npmVersion", "packageJsonSha256", "packageLockSha256", @@ -621,6 +614,8 @@ function parseAuditCacheRecord(source: string): AuditCacheRecord { if (!Array.isArray(input.argv) || JSON.stringify(input.argv) !== JSON.stringify(NPM_AUDIT_ARGV)) throw new Error("npm audit cache input.argv is invalid"); for (const key of [ + "npmArchiveSha256", + "npmIntegrity", "npmVersion", "packageJsonSha256", "packageLockSha256", @@ -628,6 +623,7 @@ function parseAuditCacheRecord(source: string): AuditCacheRecord { "registryOrigin", ] as const) nonEmptyString(input[key], `npm audit cache input.${key}`); + parseReviewedNpmIdentity(input); if ( typeof result.stdout !== "string" || Buffer.byteLength(result.stdout) > NPM_AUDIT_CACHE_MAX_BYTES || @@ -696,7 +692,7 @@ function writeAuditCache( ): void { if (!Number.isSafeInteger(result.status)) return; const record: AuditCacheRecord = { - schemaVersion: 1, + schemaVersion: 2, createdAt, input, result: { stdout: result.stdout, exitCode: result.status as number }, @@ -898,6 +894,7 @@ export function runReviewedNpmAudit( exceptionFile: string; graph: string; provenance?: AuditProvenanceContext; + reviewedNpmIdentity?: ReviewedNpmIdentity; reportFile?: string; resultFile?: string; threshold: Severity; @@ -913,12 +910,11 @@ export function runReviewedNpmAudit( const registry = NPM_AUDIT_REGISTRY; let cacheInput: AuditCacheInput | undefined; if (cacheFile) { + if (!options.reviewedNpmIdentity) { + throw new Error("npm audit cache requires the reviewed npm identity"); + } try { - cacheInput = buildAuditCacheInput( - options.directory, - options.provenance?.npmVersion ?? npmVersion(options.directory), - registry, - ); + cacheInput = buildAuditCacheInput(options.directory, options.reviewedNpmIdentity, registry); } catch (error) { if ( !(error instanceof Error) || diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index e738ab81adb..c0930be2026 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -104,21 +104,24 @@ function stageSparseCheckout(root: string, sparseCheckout: string): void { function runTrustedBootstrapHandoff( sparseCheckout: string, mutateCheckout: (root: string) => void = () => {}, - installUpdatesVersion = true, + activateInstalledNpm = true, ) { const root = fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-audit-bootstrap-handoff-")); const bin = path.join(root, "bin"); + const installedBin = path.join(root, "installed-bin"); + const activeNpm = path.join(bin, "npm"); + const bootstrapNpm = path.join(root, "bootstrap-npm"); + const installedNpm = path.join(installedBin, "npm"); const archive = Buffer.from("verified archive\n"); const archiveFile = path.join(root, "fixture.tgz"); const installMarker = path.join(root, "install-called"); const npmLog = path.join(root, "npm.log"); - const npmVersionState = path.join(root, "npm-version"); const reportDirectory = path.join(root, "artifacts", "reviewed-npm-audit"); stageSparseCheckout(root, sparseCheckout); mutateCheckout(root); fs.mkdirSync(bin); + fs.mkdirSync(installedBin); fs.writeFileSync(archiveFile, archive); - fs.writeFileSync(npmVersionState, "9.9.9\n"); fs.writeFileSync( path.join(root, "package.json"), `${JSON.stringify({ name: "reviewed-npm-handoff-fixture", version: "1.0.0" })}\n`, @@ -161,13 +164,13 @@ function runTrustedBootstrapHandoff( })}\n`, ); fs.writeFileSync( - path.join(bin, "npm"), + activeNpm, `#!/usr/bin/env bash set -euo pipefail -printf '%s\\n' "$*" >> "$NEMOCLAW_TEST_NPM_LOG" +printf 'bootstrap:%s\\n' "$*" >> "$NEMOCLAW_TEST_NPM_LOG" case "$1" in --version) - cat "$NEMOCLAW_TEST_NPM_VERSION_STATE" + printf '9.9.9\\n' ;; pack) shift @@ -183,15 +186,32 @@ case "$1" in cp "$NEMOCLAW_TEST_ARCHIVE_FILE" "$download_dir/npm-12.0.2.tgz" ;; install) - if [ "\${2:-}" = "--global" ]; then - : > "$NEMOCLAW_TEST_INSTALL_MARKER" - if [ "$NEMOCLAW_TEST_INSTALL_UPDATES_VERSION" = "true" ]; then - printf '12.0.2\\n' > "$NEMOCLAW_TEST_NPM_VERSION_STATE" - fi - else - printf '%s\\n' '{"name":"nemoclaw-reviewed-production-graph","version":"1.0.0","lockfileVersion":3,"requires":true,"packages":{"":{"name":"nemoclaw-reviewed-production-graph","version":"1.0.0"}}}' > package-lock.json + [ "\${2:-}" = "--global" ] + : > "$NEMOCLAW_TEST_INSTALL_MARKER" + if [ "$NEMOCLAW_TEST_ACTIVATE_INSTALLED_NPM" = "true" ]; then + mv "$NEMOCLAW_TEST_ACTIVE_NPM" "$NEMOCLAW_TEST_BOOTSTRAP_NPM" + ln -s "$NEMOCLAW_TEST_INSTALLED_NPM" "$NEMOCLAW_TEST_ACTIVE_NPM" fi ;; + *) + exit 2 + ;; +esac +`, + { mode: 0o755 }, + ); + fs.writeFileSync( + installedNpm, + `#!/usr/bin/env bash +set -euo pipefail +printf 'installed:%s\\n' "$*" >> "$NEMOCLAW_TEST_NPM_LOG" +case "$1" in + --version) + printf '12.0.2\\n' + ;; + install) + printf '%s\\n' '{"name":"nemoclaw-reviewed-production-graph","version":"1.0.0","lockfileVersion":3,"requires":true,"packages":{"":{"name":"nemoclaw-reviewed-production-graph","version":"1.0.0"}}}' > package-lock.json + ;; ci) ;; audit) @@ -221,11 +241,13 @@ printf '{"version":"12.0.2"}\\n' GITHUB_ACTION_PATH: path.join(root, ".github", "actions", "ci-reviewed-npm-audit"), NEMOCLAW_REVIEWED_NPM_AUDIT_REPORT_DIR: path.relative(root, reportDirectory), NEMOCLAW_REVIEWED_NPM_AUDIT_TARGET_ROOT: root, + NEMOCLAW_TEST_ACTIVE_NPM: activeNpm, + NEMOCLAW_TEST_ACTIVATE_INSTALLED_NPM: String(activateInstalledNpm), NEMOCLAW_TEST_ARCHIVE_FILE: archiveFile, + NEMOCLAW_TEST_BOOTSTRAP_NPM: bootstrapNpm, + NEMOCLAW_TEST_INSTALLED_NPM: installedNpm, NEMOCLAW_TEST_INSTALL_MARKER: installMarker, - NEMOCLAW_TEST_INSTALL_UPDATES_VERSION: String(installUpdatesVersion), NEMOCLAW_TEST_NPM_LOG: npmLog, - NEMOCLAW_TEST_NPM_VERSION_STATE: npmVersionState, NPM_CONFIG_REGISTRY: "https://registry.npmjs.org/", NPM_CONFIG_USERCONFIG: "/dev/null", PATH: `${bin}:${process.env.PATH ?? ""}`, @@ -290,9 +312,18 @@ describe("npm audit handoff", () => { expect(fixture.bootstrapResult.status, fixture.bootstrapResult.stderr).toBe(0); expect(fixture.auditResult?.status, fixture.auditResult?.stderr).toBe(0); expect(fixture.installCalled).toBe(true); - expect(fixture.npmInvocations[0]).toMatch(/^pack npm@12\.0\.2 /u); - expect(fixture.npmInvocations[1]).toMatch(/install --global .* --offline$/u); - expect(fixture.npmInvocations[2]).toBe("--version"); + expect(fixture.npmInvocations[0]).toMatch(/^bootstrap:pack npm@12\.0\.2 /u); + expect(fixture.npmInvocations[1]).toMatch(/^bootstrap:install --global .* --offline$/u); + expect( + fixture.npmInvocations.slice(2).every((entry) => entry.startsWith("installed:")), + ).toBe(true); + expect(fixture.npmInvocations).toContain("installed:--version"); + expect( + fixture.npmInvocations.some((entry) => /^installed:audit .*--json$/u.test(entry)), + ).toBe(true); + expect( + fixture.npmInvocations.some((entry) => /^installed:audit signatures /u.test(entry)), + ).toBe(true); expect(fixture.reportFiles).toContain("nemoclaw-cli.receipt.json"); expect(fixture.reportFiles).toContain("reviewed-archive-graph.receipt.json"); } finally { @@ -338,6 +369,8 @@ describe("npm audit handoff", () => { expect(fixture.auditResult?.stderr).toContain( "npm audit requires npm 12.0.2; running npm 9.9.9", ); + expect(fixture.npmInvocations).toContain("bootstrap:--version"); + expect(fixture.npmInvocations.some((entry) => entry.startsWith("installed:"))).toBe(false); expect(fixture.reportFiles).not.toContain("nemoclaw-cli.receipt.json"); expect(fixture.reportFiles).not.toContain("source-graph-policy.json"); } finally { diff --git a/test/automation/releases/reviewed-npm-audit-workflow.test.ts b/test/automation/releases/reviewed-npm-audit-workflow.test.ts index fd46ec80eac..9ebf6de4bbb 100644 --- a/test/automation/releases/reviewed-npm-audit-workflow.test.ts +++ b/test/automation/releases/reviewed-npm-audit-workflow.test.ts @@ -1254,8 +1254,12 @@ describe("trusted npm audit workflow (#5896)", () => { artifactDirectory: "/artifacts", directory: "/materialized", exceptionFile: "/exceptions.json", - npmVersion: "10.9.4", packageSpec: "nemoclaw@0.0.0", + reviewedNpmIdentity: { + npmArchiveSha256: "a".repeat(64), + npmIntegrity: `sha512-${Buffer.alloc(64).toString("base64")}`, + npmVersion: "10.9.4", + }, threshold: "high", }, { @@ -1270,6 +1274,11 @@ describe("trusted npm audit workflow (#5896)", () => { npmVersion: "10.9.4", packageSpecs: ["nemoclaw@0.0.0"], }, + reviewedNpmIdentity: { + npmArchiveSha256: "a".repeat(64), + npmIntegrity: `sha512-${Buffer.alloc(64).toString("base64")}`, + npmVersion: "10.9.4", + }, reportFile: path.join("/artifacts", "source-graph.json"), resultFile: path.join("/artifacts", "source-graph-policy.json"), threshold: "high", diff --git a/test/automation/releases/reviewed-npm-audit.test.ts b/test/automation/releases/reviewed-npm-audit.test.ts index f812cfca956..df4ed5e7c9e 100644 --- a/test/automation/releases/reviewed-npm-audit.test.ts +++ b/test/automation/releases/reviewed-npm-audit.test.ts @@ -469,6 +469,12 @@ describe("npm audit gate", () => { }); describe("npm audit raw cache", () => { + const npmIdentity = { + npmArchiveSha256: "a".repeat(64), + npmIntegrity: `sha512-${Buffer.alloc(64).toString("base64")}`, + npmVersion: "10.9.7", + }; + function fixture() { const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-audit-cache-")); fs.writeFileSync(path.join(directory, "package.json"), '{"name":"fixture"}\n'); @@ -476,12 +482,31 @@ describe("npm audit raw cache", () => { return directory; } + it("fails closed when a cache caller omits the reviewed npm identity", () => { + const directory = fixture(); + const exceptionFile = path.join(directory, "exceptions.json"); + try { + fs.writeFileSync(exceptionFile, '{"schemaVersion":1,"exceptions":[]}\n'); + expect(() => + runReviewedNpmAudit({ + cacheFile: path.join(directory, "cache.json"), + directory, + exceptionFile, + graph: "fixture", + threshold: "high", + }), + ).toThrow("npm audit cache requires the reviewed npm identity"); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + it("replays an exact fresh raw result and reports cache evidence (#11028)", () => { const directory = fixture(); try { const input = buildAuditCacheInput( directory, - "10.9.7", + npmIdentity, "https://user:secret@registry.npmjs.org/private/path?token=query#fragment", ); const filename = path.join(directory, "cache.json"); @@ -491,7 +516,7 @@ describe("npm audit raw cache", () => { fs.writeFileSync( filename, JSON.stringify({ - schemaVersion: 1, + schemaVersion: 2, createdAt: "2026-07-21T11:00:00.000Z", input, result: { stdout, exitCode: 0 }, @@ -501,6 +526,7 @@ describe("npm audit raw cache", () => { expect(hit?.result.stdout).toBe(stdout); expect(hit?.evidence).toMatchObject({ origin: "cache", ageMs: 3_600_000 }); expect(input.argv).toEqual(NPM_AUDIT_ARGV); + expect(input).toMatchObject(npmIdentity); expect(input.registryOrigin).toBe("https://registry.npmjs.org/"); expect(JSON.stringify(input)).not.toContain("secret"); expect(JSON.stringify(input)).not.toContain("private"); @@ -518,12 +544,12 @@ describe("npm audit raw cache", () => { ])("rejects a %s record", (_label, createdOffset) => { const directory = fixture(); try { - const input = buildAuditCacheInput(directory, "10.9.7", "https://registry.npmjs.org/"); + const input = buildAuditCacheInput(directory, npmIdentity, "https://registry.npmjs.org/"); const filename = path.join(directory, "cache.json"); fs.writeFileSync( filename, JSON.stringify({ - schemaVersion: 1, + schemaVersion: 2, createdAt: new Date(NOW.valueOf() + createdOffset).toISOString(), input, result: { stdout: "{}", exitCode: 0 }, @@ -538,14 +564,14 @@ describe("npm audit raw cache", () => { it("rejects malformed, extra-field, and input-mismatched records", () => { const directory = fixture(); try { - const input = buildAuditCacheInput(directory, "10.9.7", "https://registry.npmjs.org/"); + const input = buildAuditCacheInput(directory, npmIdentity, "https://registry.npmjs.org/"); const filename = path.join(directory, "cache.json"); fs.writeFileSync(filename, "not json"); expect(readAuditCache(filename, input, NOW)).toBeNull(); fs.writeFileSync( filename, JSON.stringify({ - schemaVersion: 1, + schemaVersion: 2, createdAt: NOW.toISOString(), input, result: { stdout: "{}", exitCode: 0 }, @@ -553,21 +579,29 @@ describe("npm audit raw cache", () => { }), ); expect(readAuditCache(filename, input, NOW)).toBeNull(); - const changed = { ...input, npmVersion: "11.0.0" }; + const changedInputs = [ + { ...input, npmArchiveSha256: "b".repeat(64) }, + { ...input, npmIntegrity: `sha512-${Buffer.alloc(64, 1).toString("base64")}` }, + { ...input, npmVersion: "11.0.0" }, + ]; fs.writeFileSync( filename, JSON.stringify({ - schemaVersion: 1, + schemaVersion: 2, createdAt: NOW.toISOString(), input, result: { stdout: "{}", exitCode: 0 }, }), ); - expect(readAuditCache(filename, changed, NOW)).toBeNull(); + expect(changedInputs.map((changed) => readAuditCache(filename, changed, NOW))).toEqual([ + null, + null, + null, + ]); fs.writeFileSync(path.join(directory, "package.json"), '{"name":"changed"}\n'); - expect(buildAuditCacheInput(directory, "10.9.7", "https://registry.npmjs.org/")).not.toEqual( - input, - ); + expect( + buildAuditCacheInput(directory, npmIdentity, "https://registry.npmjs.org/"), + ).not.toEqual(input); } finally { fs.rmSync(directory, { recursive: true, force: true }); } From c5607f8af2e72911141cf0eb07fc774d69a215bf Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Thu, 10 Sep 2026 01:36:50 -0700 Subject: [PATCH 24/31] fix(ci): pass audit identity through CLI cache Signed-off-by: Charan Jagwani --- scripts/lib/reviewed-npm-audit.mts | 27 ++++++--- .../releases/reviewed-npm-audit.test.ts | 59 ++++++++++++++++++- .../releases/reviewed-npm-bootstrap.test.ts | 21 +++++-- 3 files changed, 91 insertions(+), 16 deletions(-) diff --git a/scripts/lib/reviewed-npm-audit.mts b/scripts/lib/reviewed-npm-audit.mts index 1b7b6db2dd9..5f3404c8a4f 100755 --- a/scripts/lib/reviewed-npm-audit.mts +++ b/scripts/lib/reviewed-npm-audit.mts @@ -990,11 +990,15 @@ export function runReviewedNpmAudit( return policyResult; } -function parseCliArgs(args: readonly string[]): { +export function parseReviewedNpmAuditCliArgs( + args: readonly string[], + environment: NodeJS.ProcessEnv = process.env, +): { cacheFile?: string; directory: string; exceptionFile: string; graph: string; + reviewedNpmIdentity?: ReviewedNpmIdentity; reportFile?: string; resultFile?: string; threshold: Severity; @@ -1009,6 +1013,7 @@ function parseCliArgs(args: readonly string[]): { values.set(key, value); } const allowed = new Set([ + "--audit-config", "--cache", "--directory", "--exceptions", @@ -1018,24 +1023,30 @@ function parseCliArgs(args: readonly string[]): { "--threshold", ]); const unknown = [...values.keys()].filter((key) => !allowed.has(key)); - if (unknown.length > 0) - throw new Error(`unknown npm audit arguments: ${unknown.join(", ")}`); + if (unknown.length > 0) throw new Error(`unknown 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( - "npm audit requires --directory, --exceptions, --graph, and --threshold", - ); + throw new Error("npm audit requires --directory, --exceptions, --graph, and --threshold"); } if (!SEVERITIES.includes(threshold as Severity)) throw new Error("npm audit threshold is invalid"); + const cacheFile = values.get("--cache") ?? environment.NEMOCLAW_NPM_AUDIT_CACHE_FILE; + const auditConfigFile = values.get("--audit-config"); + if (cacheFile && !auditConfigFile) { + throw new Error("npm audit cache requires --audit-config"); + } + const reviewedNpmIdentity = auditConfigFile + ? parseReviewedNpmIdentityConfig(fs.readFileSync(auditConfigFile, "utf8")) + : undefined; return { - ...(values.has("--cache") ? { cacheFile: values.get("--cache") } : {}), + ...(cacheFile ? { cacheFile } : {}), directory, exceptionFile, graph, + ...(reviewedNpmIdentity ? { reviewedNpmIdentity } : {}), threshold: threshold as Severity, ...(values.has("--report") ? { reportFile: values.get("--report") } : {}), ...(values.has("--result") ? { resultFile: values.get("--result") } : {}), @@ -1050,7 +1061,7 @@ function isMainModule(): boolean { if (isMainModule()) { try { - runReviewedNpmAudit(parseCliArgs(process.argv.slice(2))); + runReviewedNpmAudit(parseReviewedNpmAuditCliArgs(process.argv.slice(2))); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); diff --git a/test/automation/releases/reviewed-npm-audit.test.ts b/test/automation/releases/reviewed-npm-audit.test.ts index df4ed5e7c9e..66308e8e6f1 100644 --- a/test/automation/releases/reviewed-npm-audit.test.ts +++ b/test/automation/releases/reviewed-npm-audit.test.ts @@ -21,6 +21,7 @@ import { extractAdvisoryIds, parseAuditExceptionRegistry, npmAuditProcessOptions, + parseReviewedNpmAuditCliArgs, parseAuditReport, provenanceSidecarPath, readAuditCache, @@ -262,9 +263,7 @@ describe("npm audit gate", () => { NPM_AUDIT_ATTEMPT_TIMEOUT_MS * (NPM_AUDIT_RETRY_DELAYS_MS.length + 1) + NPM_AUDIT_RETRY_DELAYS_MS.reduce((total, delay) => total + delay, 0); const minimumJobTimeoutMinutes = Math.ceil(retryBudgetMs / 60_000) + 4; - const callers = reviewedNpmAuditWorkflowDeadlines( - path.join(REPO_ROOT, ".github", "workflows"), - ); + const callers = reviewedNpmAuditWorkflowDeadlines(path.join(REPO_ROOT, ".github", "workflows")); expect(callers).toHaveLength(5); expect(callers.map(({ timeoutMinutes }) => timeoutMinutes)).toEqual([25, 25, 25, 25, 25]); @@ -482,6 +481,60 @@ describe("npm audit raw cache", () => { return directory; } + it.each([ + ["flag", ["--cache", "cache.json"], {}], + ["environment", [], { NEMOCLAW_NPM_AUDIT_CACHE_FILE: "cache.json" }], + ] as const)( + "loads the reviewed npm identity for a CLI cache configured by %s", + (_source, cacheArgs, environment) => { + const directory = fixture(); + const auditConfigFile = path.join(directory, "reviewed-npm-audit.json"); + try { + fs.writeFileSync(auditConfigFile, `${JSON.stringify(npmIdentity)}\n`); + expect( + parseReviewedNpmAuditCliArgs( + [ + "--directory", + directory, + "--exceptions", + "exceptions.json", + "--graph", + "fixture", + "--threshold", + "high", + "--audit-config", + auditConfigFile, + ...cacheArgs, + ], + environment, + ), + ).toMatchObject({ cacheFile: "cache.json", reviewedNpmIdentity: npmIdentity }); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }, + ); + + it("rejects a CLI cache without the reviewed npm configuration", () => { + expect(() => + parseReviewedNpmAuditCliArgs( + [ + "--directory", + ".", + "--exceptions", + "exceptions.json", + "--graph", + "fixture", + "--threshold", + "high", + "--cache", + "cache.json", + ], + {}, + ), + ).toThrow("npm audit cache requires --audit-config"); + }); + it("fails closed when a cache caller omits the reviewed npm identity", () => { const directory = fixture(); const exceptionFile = path.join(directory, "exceptions.json"); diff --git a/test/automation/releases/reviewed-npm-bootstrap.test.ts b/test/automation/releases/reviewed-npm-bootstrap.test.ts index ad7fe97f58c..d312ea558ab 100644 --- a/test/automation/releases/reviewed-npm-bootstrap.test.ts +++ b/test/automation/releases/reviewed-npm-bootstrap.test.ts @@ -200,13 +200,19 @@ describe("reviewed npm bootstrap", () => { }); it.each([ - ["matching", "12.0.2", true, false], - ["mismatched", "12.0.3", false, false], - ["missing", undefined, false, true], - ["invalid", null, false, true], + ["matching", "12.0.2", true, false, false], + ["mismatched", "12.0.3", false, false, true], + ["missing", undefined, false, true, false], + ["invalid", null, false, true, false], ] as const)( "%s real tar package metadata reaches installation only for the reviewed version (#8253)", - (_condition, archiveVersion, expectedInstall, expectedMetadataError) => { + ( + _condition, + archiveVersion, + expectedInstall, + expectedMetadataError, + expectedVersionMismatch, + ) => { const archiveFixture = createRealArchive(archiveVersion); const fixture = runBootstrapFixture({ archive: archiveFixture.archive, realTar: true }); try { @@ -218,6 +224,11 @@ describe("reviewed npm bootstrap", () => { "npm@12.0.2 archive package/package.json is missing or invalid", ), ).toBe(expectedMetadataError); + expect( + fixture.result.stderr.includes( + "npm archive version 12.0.3 does not match reviewed npm@12.0.2", + ), + ).toBe(expectedVersionMismatch); } finally { fixture.cleanup(); archiveFixture.cleanup(); From 62ef062dbd8592181abef892db31ecdd2f1adbcc Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Thu, 10 Sep 2026 02:14:54 -0700 Subject: [PATCH 25/31] fix(ci): bind receipts to reviewed npm identity Signed-off-by: Charan Jagwani --- Dockerfile | 2 +- Dockerfile.base | 2 +- docs/security/advisory-early-warning.md | 5 +- scripts/audit-reviewed-npm-graph.mts | 17 ++-- scripts/lib/npm-audit-receipt.mts | 81 +++++++++++++------ .../releases/npm-audit-receipt.test.ts | 61 +++++++++++--- .../reviewed-npm-audit-handoff.test.ts | 10 ++- .../reviewed-npm-audit-workflow.test.ts | 10 ++- test/security/mcporter-supply-chain.test.ts | 11 +-- 9 files changed, 136 insertions(+), 63 deletions(-) diff --git a/Dockerfile b/Dockerfile index 46bfe54c396..3223041736b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -995,7 +995,7 @@ node /scripts/lib/npm-audit-receipt.mts \ --package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json \ --raw-report "$MCPORTER_RAW_REPORT" --exceptions /scripts/npm-audit-exceptions.json \ --graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json \ ---registry https://registry.yarnpkg.com --threshold high --legacy-npmjs true; \ +--registry https://registry.yarnpkg.com --threshold high --legacy-audit true; \ else \ node /scripts/lib/reviewed-npm-audit.mts \ --directory /usr/local/lib/nemoclaw/mcporter-runtime \ diff --git a/Dockerfile.base b/Dockerfile.base index 60fe775ddf4..6132673ead1 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -592,7 +592,7 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep --raw-report "$MCPORTER_RAW_REPORT" --exceptions /scripts/npm-audit-exceptions.json \ --graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json \ --registry https://registry.yarnpkg.com --threshold high \ - --legacy-npmjs true \ + --legacy-audit true \ --result /tmp/mcporter-npm-audit-policy.json \ && cp "$MCPORTER_RAW_REPORT" /tmp/mcporter-npm-audit.json; \ else \ diff --git a/docs/security/advisory-early-warning.md b/docs/security/advisory-early-warning.md index 41a669d6c98..d977a28a08c 100644 --- a/docs/security/advisory-early-warning.md +++ b/docs/security/advisory-early-warning.md @@ -96,8 +96,9 @@ The same #7338 sign-off gate applies to this work. Each npm audit report has a `*.provenance.json` sidecar. The sidecars include `coverage/reviewed-npm-audit/` artifacts and `npm-audit.provenance.json` for the WeChat locked runtime graph audit. A configured cache reuses a response only when the package and lock bytes, the pinned npm identity (version, SHA-512 SRI, and archive SHA-256), fixed Yarn audit registry origin, command arguments, and parser identity match. -Until 2026-09-11, image builds may accept a still-current npmjs receipt only through the explicit legacy transition. -Remove the legacy option and verifier path after Yarn-bound receipts replace the retained npmjs receipts. +Current receipts bind the same complete npm identity. +Until 2026-09-18, image builds may accept a still-current version-only or npmjs receipt only through the explicit legacy transition. +Remove the legacy option and verifier path after schema version 2 receipts replace the retained receipts. The sidecar records whether the response came from the cache or a live registry request, plus its creation time, age, input digest, and response digest. Each sidecar also records: diff --git a/scripts/audit-reviewed-npm-graph.mts b/scripts/audit-reviewed-npm-graph.mts index 702c7c06eb1..367915c9e64 100755 --- a/scripts/audit-reviewed-npm-graph.mts +++ b/scripts/audit-reviewed-npm-graph.mts @@ -825,7 +825,7 @@ export function emitAuditReceipt( options: Readonly<{ artifactDirectory: string; graphId: string; - npmVersion: string; + reviewedNpmIdentity: ReviewedNpmIdentity; packageJsonFile: string; packageLockFile: string; preserveInputs?: boolean; @@ -857,7 +857,7 @@ export function emitAuditReceipt( ), exceptionPolicySha256: options.result.exceptionPolicySha256, graphId: options.graphId, - npmVersion: options.npmVersion, + reviewedNpmIdentity: options.reviewedNpmIdentity, packageJson: fs.readFileSync(options.packageJsonFile), packageLock: fs.readFileSync(options.packageLockFile), rawResponse: fs.readFileSync(options.rawReportFile), @@ -892,8 +892,7 @@ export function assertReviewedAuditReportsPass( ({ label, result, threshold: reportThreshold }) => `${label}: ${result.unacceptedBlockingAdvisories.length} unaccepted at or above ${reportThreshold ?? threshold}`, ); - if (failures.length > 0) - throw new Error(`npm audit threshold failed\n${failures.join("\n")}`); + if (failures.length > 0) throw new Error(`npm audit threshold failed\n${failures.join("\n")}`); } function main(): void { @@ -920,9 +919,7 @@ function main(): void { fs.mkdirSync(artifactDirectory, { recursive: true }); const npmVersion = run("npm", ["--version"], TRUSTED_REPO_ROOT).stdout.trim(); if (npmVersion !== config.npmVersion) { - throw new Error( - `npm audit requires npm ${config.npmVersion}; running npm ${npmVersion}`, - ); + throw new Error(`npm audit requires npm ${config.npmVersion}; running npm ${npmVersion}`); } const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-npm-audit-")); try { @@ -966,7 +963,7 @@ function main(): void { emitAuditReceipt({ artifactDirectory, graphId: SOURCE_GRAPH.id, - npmVersion, + reviewedNpmIdentity: config, packageJsonFile: targetRepositoryPath("package.json", "NemoClaw CLI package manifest"), packageLockFile: targetRepositoryPath("package-lock.json", "NemoClaw CLI lockfile"), rawReportFile: path.join(artifactDirectory, "source-graph.json"), @@ -977,7 +974,7 @@ function main(): void { emitAuditReceipt({ artifactDirectory, graphId: config.archiveGraphId, - npmVersion, + reviewedNpmIdentity: config, packageJsonFile: path.join(archiveDirectory, "package.json"), packageLockFile: path.join(archiveDirectory, "package-lock.json"), preserveInputs: true, @@ -990,7 +987,7 @@ function main(): void { emitAuditReceipt({ artifactDirectory, graphId: graph.id, - npmVersion, + reviewedNpmIdentity: config, packageJsonFile: targetRepositoryPath( path.join(graph.directory, "package.json"), `${graph.label} package manifest`, diff --git a/scripts/lib/npm-audit-receipt.mts b/scripts/lib/npm-audit-receipt.mts index 71a05edadba..7dd8bc6d841 100755 --- a/scripts/lib/npm-audit-receipt.mts +++ b/scripts/lib/npm-audit-receipt.mts @@ -7,7 +7,9 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { + type ReviewedNpmIdentity, evaluateAuditPolicy, + parseReviewedNpmIdentity, parseAuditExceptionRegistry, parseAuditReport, NPM_AUDIT_ARGV, @@ -15,10 +17,10 @@ import { } from "./reviewed-npm-audit.mts"; export const AUDIT_ARGV = NPM_AUDIT_ARGV; -// Remove this PR-only compatibility after main produces Yarn-bound audit receipts. +// Remove this bridge after main produces schema v2 receipts and retained legacy receipts expire. const LEGACY_NPM_AUDIT_REGISTRY = "https://registry.npmjs.org/"; const LEGACY_NPM_AUDIT_ARGV = ["audit", "--omit=dev", "--json"] as const; -export const LEGACY_NPM_AUDIT_RECEIPT_DEADLINE = Date.parse("2026-09-11T00:00:00.000Z"); +export const LEGACY_NPM_AUDIT_RECEIPT_DEADLINE = Date.parse("2026-09-18T00:00:00.000Z"); export const RECEIPT_LIFETIME_MS = 12 * 60 * 60 * 1000 - 1; export const MAX_FUTURE_SKEW_MS = 5 * 60 * 1000; const SEVERITIES = new Set(["info", "low", "moderate", "high", "critical"]); @@ -30,6 +32,8 @@ const RECEIPT_KEYS = [ "exceptionPolicySha256", "expiresAt", "graphId", + "npmArchiveSha256", + "npmIntegrity", "npmVersion", "packageJsonSha256", "packageLockSha256", @@ -39,6 +43,9 @@ const RECEIPT_KEYS = [ "schemaVersion", "severityThreshold", ]; +const LEGACY_RECEIPT_KEYS = RECEIPT_KEYS.filter( + (key) => key !== "npmArchiveSha256" && key !== "npmIntegrity", +); export type AuditReceipt = Readonly<{ acceptedAdvisoryIds: readonly string[]; @@ -48,16 +55,26 @@ export type AuditReceipt = Readonly<{ exceptionPolicySha256: string; expiresAt: string; graphId: string; + npmArchiveSha256: string; + npmIntegrity: string; npmVersion: string; packageJsonSha256: string; packageLockSha256: string; rawResponseSha256: string; registryOrigin: string; result: "pass"; - schemaVersion: 1; + schemaVersion: 2; severityThreshold: "info" | "low" | "moderate" | "high" | "critical"; }>; +type LegacyAuditReceipt = Readonly< + Omit & { + schemaVersion: 1; + } +>; + +type VerifiedAuditReceipt = AuditReceipt | LegacyAuditReceipt; + export function sha256(contents: string | Buffer): string { return createHash("sha256").update(contents).digest("hex"); } @@ -102,7 +119,7 @@ export function createAuditReceipt( createdAt?: Date; exceptionPolicySha256: string; graphId: string; - npmVersion: string; + reviewedNpmIdentity: ReviewedNpmIdentity; packageJson: string | Buffer; packageLock: string | Buffer; rawResponse: string | Buffer; @@ -113,6 +130,7 @@ export function createAuditReceipt( if (options.blockingAdvisoryIds.length > 0) throw new Error("cannot issue a passing receipt with blocking advisories"); const created = options.createdAt ?? new Date(); + const reviewedNpmIdentity = parseReviewedNpmIdentity(options.reviewedNpmIdentity); return { acceptedAdvisoryIds: [...new Set(options.acceptedAdvisoryIds)].sort(), argv: [...AUDIT_ARGV], @@ -121,13 +139,13 @@ export function createAuditReceipt( exceptionPolicySha256: options.exceptionPolicySha256, expiresAt: new Date(created.getTime() + RECEIPT_LIFETIME_MS).toISOString(), graphId: options.graphId, - npmVersion: options.npmVersion, + ...reviewedNpmIdentity, packageJsonSha256: sha256(options.packageJson), packageLockSha256: sha256(options.packageLock), rawResponseSha256: sha256(options.rawResponse), registryOrigin: options.registryOrigin, result: "pass", - schemaVersion: 1, + schemaVersion: 2, severityThreshold: options.severityThreshold, }; } @@ -136,7 +154,7 @@ export function parseAndVerifyAuditReceipt( contents: string, expected: Readonly<{ graphId: string; - npmVersion: string; + reviewedNpmIdentity: ReviewedNpmIdentity; exceptionPolicy: string | Buffer; severityThreshold: AuditReceipt["severityThreshold"]; packageJson: string | Buffer; @@ -144,9 +162,9 @@ export function parseAndVerifyAuditReceipt( rawResponse: string | Buffer; registryOrigin: string; now?: Date; - allowLegacyNpmjsReceipt?: boolean; + allowLegacyReceipt?: boolean; }>, -): AuditReceipt { +): VerifiedAuditReceipt { let parsed: unknown; try { parsed = JSON.parse(contents); @@ -156,19 +174,34 @@ export function parseAndVerifyAuditReceipt( if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("receipt must be an object"); const value = parsed as Record; - exactKeys(value, RECEIPT_KEYS, "receipt"); - if (value.schemaVersion !== 1 || value.result !== "pass") - throw new Error("receipt is not a passing schema version 1 receipt"); - if (value.graphId !== expected.graphId || value.npmVersion !== expected.npmVersion) - throw new Error("receipt identity does not match expected graph and npm"); const now = (expected.now ?? new Date()).getTime(); + const isLegacyReceipt = value.schemaVersion === 1; + exactKeys(value, isLegacyReceipt ? LEGACY_RECEIPT_KEYS : RECEIPT_KEYS, "receipt"); + if (value.result !== "pass" || (!isLegacyReceipt && value.schemaVersion !== 2)) + throw new Error("receipt is not a passing supported receipt"); + if ( + isLegacyReceipt && + (expected.allowLegacyReceipt !== true || now >= LEGACY_NPM_AUDIT_RECEIPT_DEADLINE) + ) { + throw new Error("version-only receipt schema is outside the allowed legacy transition"); + } + const reviewedNpmIdentity = parseReviewedNpmIdentity(expected.reviewedNpmIdentity); + if ( + value.graphId !== expected.graphId || + value.npmVersion !== reviewedNpmIdentity.npmVersion || + (!isLegacyReceipt && + (value.npmIntegrity !== reviewedNpmIdentity.npmIntegrity || + value.npmArchiveSha256 !== reviewedNpmIdentity.npmArchiveSha256)) + ) { + throw new Error("receipt identity does not match expected graph and reviewed npm"); + } const currentContract = value.registryOrigin === expected.registryOrigin && Array.isArray(value.argv) && value.argv.length === AUDIT_ARGV.length && value.argv.every((arg, index) => arg === AUDIT_ARGV[index]); const legacyContract = - expected.allowLegacyNpmjsReceipt === true && + expected.allowLegacyReceipt === true && now < LEGACY_NPM_AUDIT_RECEIPT_DEADLINE && value.registryOrigin === LEGACY_NPM_AUDIT_REGISTRY && Array.isArray(value.argv) && @@ -208,21 +241,17 @@ export function parseAndVerifyAuditReceipt( }; } -export function canonicalAuditReceipt(receipt: AuditReceipt): string { +export function canonicalAuditReceipt(receipt: VerifiedAuditReceipt): string { return `${JSON.stringify(receipt, null, 2)}\n`; } -export function reviewedNpmVersionFromConfig(contents: string): string { - return parseReviewedNpmIdentityConfig(contents).npmVersion; -} - function cli(args: readonly string[]): void { const values = new Map(); for (let index = 0; index < args.length; index += 2) { const value = args[index + 1]; if (!args[index]?.startsWith("--") || value === undefined) throw new Error( - "usage: npm-audit-receipt.mts --receipt FILE --package-json FILE --package-lock FILE --raw-report FILE --exceptions FILE --graph ID --audit-config FILE --registry ORIGIN --threshold SEVERITY [--legacy-npmjs true] [--result FILE]", + "usage: npm-audit-receipt.mts --receipt FILE --package-json FILE --package-lock FILE --raw-report FILE --exceptions FILE --graph ID --audit-config FILE --registry ORIGIN --threshold SEVERITY [--legacy-audit true] [--result FILE]", ); values.set(args[index], value); } @@ -237,10 +266,10 @@ function cli(args: readonly string[]): void { "--registry", "--threshold", ]; - const allowed = [...required, "--result", "--legacy-npmjs"]; + const allowed = [...required, "--result", "--legacy-audit"]; exactKeys( Object.fromEntries( - [...values].filter(([key]) => key !== "--result" && key !== "--legacy-npmjs"), + [...values].filter(([key]) => key !== "--result" && key !== "--legacy-audit"), ), required, "verifier arguments", @@ -251,19 +280,19 @@ function cli(args: readonly string[]): void { const packageLock = fs.readFileSync(values.get("--package-lock")!); const rawResponse = fs.readFileSync(values.get("--raw-report")!); const exceptionPolicy = fs.readFileSync(values.get("--exceptions")!); - const npmVersion = reviewedNpmVersionFromConfig( + const reviewedNpmIdentity = parseReviewedNpmIdentityConfig( fs.readFileSync(values.get("--audit-config")!, "utf8"), ); parseAndVerifyAuditReceipt(fs.readFileSync(values.get("--receipt")!, "utf8"), { graphId: values.get("--graph")!, - npmVersion, + reviewedNpmIdentity, exceptionPolicy, severityThreshold: values.get("--threshold")! as AuditReceipt["severityThreshold"], packageJson, packageLock, rawResponse, registryOrigin: values.get("--registry")!, - allowLegacyNpmjsReceipt: values.get("--legacy-npmjs") === "true", + allowLegacyReceipt: values.get("--legacy-audit") === "true", }); const policyResult = evaluateAuditPolicy({ directory: path.dirname(values.get("--package-json")!), diff --git a/test/automation/releases/npm-audit-receipt.test.ts b/test/automation/releases/npm-audit-receipt.test.ts index ca9a0b5a388..63163be3b3f 100644 --- a/test/automation/releases/npm-audit-receipt.test.ts +++ b/test/automation/releases/npm-audit-receipt.test.ts @@ -15,9 +15,14 @@ import { } from "../../../scripts/lib/npm-audit-receipt.mts"; const NOW = new Date("2026-09-04T00:00:00.000Z"); +const reviewedNpmIdentity = { + npmArchiveSha256: "0".repeat(64), + npmIntegrity: `sha512-${Buffer.alloc(64).toString("base64")}`, + npmVersion: "10.9.4", +}; const inputs = { graphId: "mcporter-runtime", - npmVersion: "10.9.4", + reviewedNpmIdentity, exceptionPolicy: '{"schemaVersion":1,"exceptions":[]}\n', severityThreshold: "high", packageJson: "package", @@ -27,11 +32,6 @@ const inputs = { registryOrigin: "https://registry.yarnpkg.com", now: NOW, } as const; -const reviewedNpmIdentity = { - npmArchiveSha256: "0".repeat(64), - npmIntegrity: `sha512-${Buffer.alloc(64).toString("base64")}`, - npmVersion: inputs.npmVersion, -}; function receipt(createdAt = NOW) { return createAuditReceipt({ acceptedAdvisoryIds: ["GHSA-b", "GHSA-a"], @@ -39,7 +39,7 @@ function receipt(createdAt = NOW) { createdAt, exceptionPolicySha256: sha256(inputs.exceptionPolicy), graphId: inputs.graphId, - npmVersion: inputs.npmVersion, + reviewedNpmIdentity, packageJson: inputs.packageJson, packageLock: inputs.packageLock, rawResponse: @@ -59,6 +59,7 @@ describe("npm audit receipt", () => { "--omit=dev", "--json", ]); + expect(parsed).toMatchObject(reviewedNpmIdentity); expect(new Date(parsed.expiresAt).getTime() - NOW.getTime()).toBeLessThan(12 * 60 * 60 * 1000); }); @@ -74,18 +75,58 @@ describe("npm audit receipt", () => { expect( parseAndVerifyAuditReceipt(canonicalAuditReceipt(legacy), { ...inputs, - allowLegacyNpmjsReceipt: true, + allowLegacyReceipt: true, }).registryOrigin, ).toBe("https://registry.npmjs.org/"); expect(() => parseAndVerifyAuditReceipt(canonicalAuditReceipt(legacy), { ...inputs, - allowLegacyNpmjsReceipt: true, + allowLegacyReceipt: true, now: new Date(LEGACY_NPM_AUDIT_RECEIPT_DEADLINE), }), ).toThrow(/allowed contract/); }); + it("accepts a version-only schema only through the bounded legacy transition", () => { + const { + npmArchiveSha256: _npmArchiveSha256, + npmIntegrity: _npmIntegrity, + ...versionOnly + } = receipt(); + const legacy = { ...versionOnly, schemaVersion: 1 } as const; + expect(() => parseAndVerifyAuditReceipt(canonicalAuditReceipt(legacy), inputs)).toThrow( + /version-only receipt schema/, + ); + expect( + parseAndVerifyAuditReceipt(canonicalAuditReceipt(legacy), { + ...inputs, + allowLegacyReceipt: true, + }).schemaVersion, + ).toBe(1); + expect(() => + parseAndVerifyAuditReceipt(canonicalAuditReceipt(legacy), { + ...inputs, + allowLegacyReceipt: true, + now: new Date(LEGACY_NPM_AUDIT_RECEIPT_DEADLINE), + }), + ).toThrow(/version-only receipt schema/); + }); + + it.each([ + [ + "SHA-512 SRI", + { ...reviewedNpmIdentity, npmIntegrity: `sha512-${Buffer.alloc(64, 1).toString("base64")}` }, + ], + ["archive SHA-256", { ...reviewedNpmIdentity, npmArchiveSha256: "1".repeat(64) }], + ] as const)("rejects a receipt when the expected npm %s changes", (_field, changedIdentity) => { + expect(() => + parseAndVerifyAuditReceipt(canonicalAuditReceipt(receipt()), { + ...inputs, + reviewedNpmIdentity: changedIdentity, + }), + ).toThrow(/receipt identity/); + }); + it("rejects a receipt whose registry identity differs from its audit command", () => { expect(() => parseAndVerifyAuditReceipt(canonicalAuditReceipt(receipt()), { @@ -208,7 +249,7 @@ describe("npm audit receipt", () => { registry, "--threshold", inputs.severityThreshold, - ...(legacy ? ["--legacy-npmjs", "true"] : []), + ...(legacy ? ["--legacy-audit", "true"] : []), ]; const result = spawnSync(process.execPath, verifierArgs, { encoding: "utf8" }); expect(result.status, result.stderr).toBe(0); diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index c0930be2026..4de14b9b417 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -411,7 +411,11 @@ describe("npm audit handoff", () => { const receiptFile = emitAuditReceipt({ artifactDirectory: root, graphId: "temporary-graph", - npmVersion: "10.9.4", + reviewedNpmIdentity: { + npmArchiveSha256: "0".repeat(64), + npmIntegrity: `sha512-${Buffer.alloc(64).toString("base64")}`, + npmVersion: "10.9.4", + }, packageJsonFile, packageLockFile, preserveInputs: true, @@ -477,7 +481,9 @@ describe("npm audit handoff", () => { ); const rejected = spawnSync(process.execPath, verifierArgs, { encoding: "utf8" }); expect(rejected.status).not.toBe(0); - expect(rejected.stderr).toContain("receipt identity does not match expected graph and npm"); + expect(rejected.stderr).toContain( + "receipt identity does not match expected graph and reviewed npm", + ); expect(fs.existsSync(resultFile)).toBe(false); } finally { fs.rmSync(root, { recursive: true, force: true }); diff --git a/test/automation/releases/reviewed-npm-audit-workflow.test.ts b/test/automation/releases/reviewed-npm-audit-workflow.test.ts index 9ebf6de4bbb..e13a3353521 100644 --- a/test/automation/releases/reviewed-npm-audit-workflow.test.ts +++ b/test/automation/releases/reviewed-npm-audit-workflow.test.ts @@ -230,9 +230,7 @@ process.exit(0); ); const result = spawnSync( process.execPath, - [ - path.join(trustedRootAlias, "scripts/audit-reviewed-npm-graph.mts"), - ], + [path.join(trustedRootAlias, "scripts/audit-reviewed-npm-graph.mts")], { cwd: trustedRoot, encoding: "utf-8", @@ -452,7 +450,11 @@ describe("trusted npm audit workflow (#5896)", () => { emitAuditReceipt({ artifactDirectory: root, graphId: "temporary-graph", - npmVersion: "10.9.4", + reviewedNpmIdentity: { + npmArchiveSha256: "0".repeat(64), + npmIntegrity: `sha512-${Buffer.alloc(64).toString("base64")}`, + npmVersion: "10.9.4", + }, packageJsonFile, packageLockFile, preserveInputs: true, diff --git a/test/security/mcporter-supply-chain.test.ts b/test/security/mcporter-supply-chain.test.ts index c0f7e7afc6b..9318ada4e00 100644 --- a/test/security/mcporter-supply-chain.test.ts +++ b/test/security/mcporter-supply-chain.test.ts @@ -51,8 +51,7 @@ const reviewedAuditDriver = fs.readFileSync( function extractIntegrityGate(contents: string): string { const startMarker = 'MCPORTER_EXPECTED_INTEGRITY=""'; const start = contents.indexOf(startMarker); - const helperMarker = - "node /scripts/lib/reviewed-npm-archive.mts --verify-only"; + const helperMarker = "node /scripts/lib/reviewed-npm-archive.mts --verify-only"; const helperStart = contents.indexOf(helperMarker, start); const helperEndMarker = '--label "mcporter ${MCPORTER_VERSION}"'; const helperEnd = contents.indexOf(helperEndMarker, helperStart) + helperEndMarker.length; @@ -70,7 +69,7 @@ function extractIntegrityGate(contents: string): string { function extractAuditReceiptInvocation(contents: string): string { const startMarker = "node /scripts/lib/npm-audit-receipt.mts"; - const endMarker = "--legacy-npmjs true"; + const endMarker = "--legacy-audit true"; const start = contents.indexOf(startMarker); const end = contents.indexOf(endMarker, start); expect(start).toBeGreaterThanOrEqual(0); @@ -213,14 +212,12 @@ describe("mcporter image supply-chain controls", () => { expect(contents).toContain( "--mount=type=secret,id=nemoclaw-mcporter-audit-raw-report,required=false", ); - expect(flattenedContents).toContain( - "node /scripts/lib/npm-audit-receipt.mts --receipt", - ); + expect(flattenedContents).toContain("node /scripts/lib/npm-audit-receipt.mts --receipt"); expect(flattenedContents).toContain( "--package-json /usr/local/lib/nemoclaw/mcporter-runtime/package.json --package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json --raw-report", ); expect(auditReceiptInvocation).toContain( - "--exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json --registry https://registry.yarnpkg.com --threshold high --legacy-npmjs true", + "--exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json --registry https://registry.yarnpkg.com --threshold high --legacy-audit true", ); expect(expectedReviewedNpmVersion).toMatch(/^[0-9]+\.[0-9]+\.[0-9]+$/); expect(auditReceiptInvocation).not.toContain("--npm-version"); From 03a94e9423d57e1dee9636b641717e2c7d5b6f03 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Thu, 10 Sep 2026 03:02:01 -0700 Subject: [PATCH 26/31] fix(ci): tighten reviewed npm audit validation Signed-off-by: Charan Jagwani --- scripts/lib/reviewed-npm-audit.mts | 2 +- test/automation/releases/npm-audit-receipt.test.ts | 7 ++++++- test/automation/releases/reviewed-npm-audit.test.ts | 7 +++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/scripts/lib/reviewed-npm-audit.mts b/scripts/lib/reviewed-npm-audit.mts index 5f3404c8a4f..5720e08ce3b 100755 --- a/scripts/lib/reviewed-npm-audit.mts +++ b/scripts/lib/reviewed-npm-audit.mts @@ -32,7 +32,7 @@ export function parseReviewedNpmIdentity(value: unknown): ReviewedNpmIdentity { } if ( typeof npmIntegrity !== "string" || - !/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(npmIntegrity) || + !/^sha512-[A-Za-z0-9+/]{86}==$/.test(npmIntegrity) || /[\r\n]/.test(npmIntegrity) ) { throw new Error("npm audit configuration has an invalid npmIntegrity"); diff --git a/test/automation/releases/npm-audit-receipt.test.ts b/test/automation/releases/npm-audit-receipt.test.ts index 63163be3b3f..9ca80bc75d7 100644 --- a/test/automation/releases/npm-audit-receipt.test.ts +++ b/test/automation/releases/npm-audit-receipt.test.ts @@ -93,7 +93,12 @@ describe("npm audit receipt", () => { npmIntegrity: _npmIntegrity, ...versionOnly } = receipt(); - const legacy = { ...versionOnly, schemaVersion: 1 } as const; + const legacy = { + ...versionOnly, + argv: ["audit", "--omit=dev", "--json"], + registryOrigin: "https://registry.npmjs.org/", + schemaVersion: 1, + } as const; expect(() => parseAndVerifyAuditReceipt(canonicalAuditReceipt(legacy), inputs)).toThrow( /version-only receipt schema/, ); diff --git a/test/automation/releases/reviewed-npm-audit.test.ts b/test/automation/releases/reviewed-npm-audit.test.ts index 66308e8e6f1..65c949424c6 100644 --- a/test/automation/releases/reviewed-npm-audit.test.ts +++ b/test/automation/releases/reviewed-npm-audit.test.ts @@ -22,6 +22,7 @@ import { parseAuditExceptionRegistry, npmAuditProcessOptions, parseReviewedNpmAuditCliArgs, + parseReviewedNpmIdentity, parseAuditReport, provenanceSidecarPath, readAuditCache, @@ -535,6 +536,12 @@ describe("npm audit raw cache", () => { ).toThrow("npm audit cache requires --audit-config"); }); + it("rejects a truncated reviewed npm SHA-512 integrity", () => { + expect(() => + parseReviewedNpmIdentity({ ...npmIdentity, npmIntegrity: "sha512-A" }), + ).toThrow("npm audit configuration has an invalid npmIntegrity"); + }); + it("fails closed when a cache caller omits the reviewed npm identity", () => { const directory = fixture(); const exceptionFile = path.join(directory, "exceptions.json"); From c70a28abd9a515665b082191dc671d7825024f2e Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Thu, 10 Sep 2026 04:42:13 -0700 Subject: [PATCH 27/31] docs(skills): distinguish durable dependency contracts Signed-off-by: Charan Jagwani --- .../nemoclaw-contributor-update-dependencies/SKILL.md | 2 +- test/skills/review-record-retention-guidance.test.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.agents/skills/nemoclaw-contributor-update-dependencies/SKILL.md b/.agents/skills/nemoclaw-contributor-update-dependencies/SKILL.md index d6806173f9c..62348947cfe 100644 --- a/.agents/skills/nemoclaw-contributor-update-dependencies/SKILL.md +++ b/.agents/skills/nemoclaw-contributor-update-dependencies/SKILL.md @@ -73,7 +73,7 @@ Follow the current collector help when those controls evolve. ## Keep Point-in-Time Review Records out of the Repository -Do not commit or update point-in-time release ledgers, concern records, dependency review documents, review reports, or qualification reports anywhere in the repository. Encode durable claims in executable configuration and tests. For a user-visible change, update the canonical `docs/` page with current supported behavior and operator action. Preserve historical executable fixtures only when they still support a current test. +Do not commit or update point-in-time release ledgers, concern records, dependency-review reports, review reports, or qualification reports anywhere in the repository. This prohibition does not apply to durable, code-synchronized dependency contract documents owned by a component. Encode durable claims in executable configuration and tests. For a user-visible change, update the canonical `docs/` page with current supported behavior and operator action. Preserve historical executable fixtures only when they still support a current test. ## Resolve concerns diff --git a/test/skills/review-record-retention-guidance.test.ts b/test/skills/review-record-retention-guidance.test.ts index 4961e4cdc1d..227cb8252f2 100644 --- a/test/skills/review-record-retention-guidance.test.ts +++ b/test/skills/review-record-retention-guidance.test.ts @@ -29,6 +29,13 @@ describe("dependency review record retention guidance", () => { expect(retentionSection).toMatch(/historical executable fixtures.*current test/i); }); + it("distinguishes point-in-time dependency reports from maintained contracts", () => { + expect(retentionSection).toMatch(/point-in-time.*dependency-review reports/i); + expect(retentionSection).toMatch( + /does not apply.*durable.*code-synchronized dependency contract documents/i, + ); + }); + it("does not track the retired review-ledger directory", () => { const retiredDirectory = ["internal", "security-reviews", "**"].join("/"); const tracked = execFileSync("git", ["ls-files", retiredDirectory], { From 27d3e140ff636f26019f51ba2a68a5349107cd9c Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Thu, 10 Sep 2026 04:57:40 -0700 Subject: [PATCH 28/31] chore(test): apply current formatter baseline Signed-off-by: Charan Jagwani --- test/automation/releases/reviewed-npm-audit.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/automation/releases/reviewed-npm-audit.test.ts b/test/automation/releases/reviewed-npm-audit.test.ts index c1644fc4336..c3ac921ba36 100644 --- a/test/automation/releases/reviewed-npm-audit.test.ts +++ b/test/automation/releases/reviewed-npm-audit.test.ts @@ -536,9 +536,9 @@ describe("npm audit raw cache", () => { }); it("rejects a truncated reviewed npm SHA-512 integrity", () => { - expect(() => - parseReviewedNpmIdentity({ ...npmIdentity, npmIntegrity: "sha512-A" }), - ).toThrow("npm audit configuration has an invalid npmIntegrity"); + expect(() => parseReviewedNpmIdentity({ ...npmIdentity, npmIntegrity: "sha512-A" })).toThrow( + "npm audit configuration has an invalid npmIntegrity", + ); }); it("fails closed when a cache caller omits the reviewed npm identity", () => { From 1dce30c4bd5b20337a6dec887f9a585c75ab6f10 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Thu, 10 Sep 2026 06:03:01 -0700 Subject: [PATCH 29/31] test(skills): enforce review report prohibition --- test/skills/review-record-retention-guidance.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/skills/review-record-retention-guidance.test.ts b/test/skills/review-record-retention-guidance.test.ts index 227cb8252f2..a593707f6ee 100644 --- a/test/skills/review-record-retention-guidance.test.ts +++ b/test/skills/review-record-retention-guidance.test.ts @@ -30,7 +30,9 @@ describe("dependency review record retention guidance", () => { }); it("distinguishes point-in-time dependency reports from maintained contracts", () => { - expect(retentionSection).toMatch(/point-in-time.*dependency-review reports/i); + expect(retentionSection).toMatch( + /do not commit or update point-in-time.*dependency-review reports/i, + ); expect(retentionSection).toMatch( /does not apply.*durable.*code-synchronized dependency contract documents/i, ); From 14e15b25ccc8fb354ff95fac7c7b00456841812c Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Thu, 10 Sep 2026 09:39:44 -0700 Subject: [PATCH 30/31] fix(ci): trust current Node 24 Brev template --- scripts/checks/extract-installer-pins.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/checks/extract-installer-pins.mts b/scripts/checks/extract-installer-pins.mts index ab5c6446089..a651746a05e 100644 --- a/scripts/checks/extract-installer-pins.mts +++ b/scripts/checks/extract-installer-pins.mts @@ -425,7 +425,7 @@ const TRUSTED_OPENSHELL_RELEASES: readonly OpenShellReleaseTrust[] = [ brevTemplateSha256: [ "c0a4ddf25a02a9fe02b2df53a60942ea887610f04d4ce16a121b6e79a5aeff1a", "56fc6482d1508b73604099e6fd6c16daea16275cf36cc25c1c5366c82a4394e3", - "9a30f006ac59b6acdcef843bff62ce3fd0fe0d681df993ec1c6a24811690caf5", + "ee86b418f29c48e4d4042cdb9bb5424eaaef0d89782134646c4b539e2849703e", ], formula: { asset: "openshell.rb", From 677317f822c1be2952c442389f8dfbea8b5126d4 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Thu, 10 Sep 2026 10:35:01 -0700 Subject: [PATCH 31/31] refactor(ci): reduce npm trust test duplication --- .../verify-and-install-npm.sh | 35 +-- scripts/lib/reviewed-npm-audit.mts | 44 ++- test/automation/classify-ci-failure.test.ts | 55 ++-- .../reviewed-npm-audit-handoff.test.ts | 266 +--------------- .../releases/reviewed-npm-audit.test.ts | 50 ++- .../releases/reviewed-npm-bootstrap.test.ts | 295 ++++-------------- test/support/reviewed-npm-bootstrap.ts | 125 ++++++++ 7 files changed, 262 insertions(+), 608 deletions(-) create mode 100644 test/support/reviewed-npm-bootstrap.ts diff --git a/.github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh b/.github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh index 63b1c33c446..d6675612ae2 100755 --- a/.github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh +++ b/.github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh @@ -13,23 +13,20 @@ config_file="$1" script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" download_dir="$(mktemp -d "$RUNNER_TEMP/reviewed-npm.XXXXXX")" trap 'rm -rf "$download_dir"' EXIT -identity_file="$download_dir/identity" -node --input-type=module - \ - "$config_file" \ - "$script_dir/../../../scripts/lib/reviewed-npm-audit.mts" >"$identity_file" <<'NODE' +IFS=$'\t' read -r version expected_integrity expected_sha256 < <( + node --input-type=module - \ + "$config_file" \ + "$script_dir/../../../scripts/lib/reviewed-npm-audit.mts" <<'NODE' import { readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; const [configFile, reviewedNpmAuditFile] = process.argv.slice(2); const { parseReviewedNpmIdentityConfig } = await import(pathToFileURL(reviewedNpmAuditFile).href); const identity = parseReviewedNpmIdentityConfig(readFileSync(configFile, "utf8")); -process.stdout.write(`${identity.npmVersion}\n${identity.npmIntegrity}\n${identity.npmArchiveSha256}\n`); +process.stdout.write(`${identity.npmVersion}\t${identity.npmIntegrity}\t${identity.npmArchiveSha256}\n`); NODE - -IFS= read -r version <"$identity_file" -IFS= read -r expected_integrity < <(sed -n '2p' "$identity_file") -IFS= read -r expected_sha256 < <(sed -n '3p' "$identity_file") +) [ -n "$version" ] [ -n "$expected_integrity" ] [ -n "$expected_sha256" ] @@ -41,18 +38,15 @@ npm pack "npm@$version" \ --ignore-scripts --no-audit --no-fund >/dev/null archive="$download_dir/npm-$version.tgz" -actual_hashes="$download_dir/actual-hashes" -node -e ' +IFS=$'\t' read -r actual_sha512 actual_sha256 < <(node -e ' const fs = require("node:fs"); const crypto = require("node:crypto"); const archive = fs.readFileSync(process.argv[1]); process.stdout.write( - crypto.createHash("sha512").update(archive).digest("base64") + "\n" + + crypto.createHash("sha512").update(archive).digest("base64") + "\t" + crypto.createHash("sha256").update(archive).digest("hex") + "\n", ); -' "$archive" >"$actual_hashes" -IFS= read -r actual_sha512 <"$actual_hashes" -IFS= read -r actual_sha256 < <(sed -n '2p' "$actual_hashes") +' "$archive") actual_integrity="sha512-$actual_sha512" if [ "$actual_integrity" != "$expected_integrity" ] || [ "$actual_sha256" != "$expected_sha256" ]; then echo "ERROR: npm@$version archive integrity mismatch." >&2 @@ -61,14 +55,9 @@ fi if ! archive_version="$( tar -xOf "$archive" package/package.json | node -e ' - let source = ""; - process.stdin.setEncoding("utf8"); - process.stdin.on("data", (chunk) => { source += chunk; }); - process.stdin.on("end", () => { - const version = JSON.parse(source).version; - if (typeof version !== "string") process.exit(1); - process.stdout.write(version); - }); + const version = JSON.parse(require("node:fs").readFileSync(0, "utf8")).version; + if (typeof version !== "string") process.exit(1); + process.stdout.write(version); ' )"; then echo "ERROR: npm@$version archive package/package.json is missing or invalid." >&2 diff --git a/scripts/lib/reviewed-npm-audit.mts b/scripts/lib/reviewed-npm-audit.mts index 5720e08ce3b..7b8c18293b6 100755 --- a/scripts/lib/reviewed-npm-audit.mts +++ b/scripts/lib/reviewed-npm-audit.mts @@ -17,34 +17,32 @@ export type ReviewedNpmIdentity = Readonly<{ npmVersion: string; }>; +function identityField( + record: Record, + field: keyof ReviewedNpmIdentity, + pattern: RegExp, +): string { + const value = record[field]; + if (typeof value !== "string" || !pattern.test(value) || /[\r\n]/.test(value)) { + throw new Error(`npm audit configuration has an invalid ${field}`); + } + return value; +} + export function parseReviewedNpmIdentity(value: unknown): ReviewedNpmIdentity { const record = typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : {}; - const { npmArchiveSha256, npmIntegrity, npmVersion } = record; - if ( - typeof npmVersion !== "string" || - !/^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/.test(npmVersion) || - /[\r\n]/.test(npmVersion) - ) { - throw new Error("npm audit configuration has an invalid npmVersion"); - } - if ( - typeof npmIntegrity !== "string" || - !/^sha512-[A-Za-z0-9+/]{86}==$/.test(npmIntegrity) || - /[\r\n]/.test(npmIntegrity) - ) { - throw new Error("npm audit configuration has an invalid npmIntegrity"); - } - if ( - typeof npmArchiveSha256 !== "string" || - !/^[a-f0-9]{64}$/.test(npmArchiveSha256) || - /[\r\n]/.test(npmArchiveSha256) - ) { - throw new Error("npm audit configuration has an invalid npmArchiveSha256"); - } - return { npmArchiveSha256, npmIntegrity, npmVersion }; + return { + npmArchiveSha256: identityField(record, "npmArchiveSha256", /^[a-f0-9]{64}$/), + npmIntegrity: identityField(record, "npmIntegrity", /^sha512-[A-Za-z0-9+/]{86}==$/), + npmVersion: identityField( + record, + "npmVersion", + /^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/, + ), + }; } export function parseReviewedNpmIdentityConfig(contents: string): ReviewedNpmIdentity { diff --git a/test/automation/classify-ci-failure.test.ts b/test/automation/classify-ci-failure.test.ts index 0de97032fa4..7b5970e5881 100644 --- a/test/automation/classify-ci-failure.test.ts +++ b/test/automation/classify-ci-failure.test.ts @@ -423,50 +423,33 @@ describe.skipIf(process.platform !== "linux")("CI failure classifier process", ( expect(result.status, result.stderr).toBe(0); expect(JSON.parse(result.stdout).result).toBe("unclassified"); }); - test("does not classify an unrelated job that mentions npm audit", () => { - const item = fixture( - "The documentation mentions npm audit.\nProcess completed with exit code 1", - ); - item.env.JOB_NAME = "Documentation checks"; - const result = run(item.env); - expect(result.status, result.stderr).toBe(0); - expect(JSON.parse(result.stdout).categories).not.toContain("reviewed-npm-audit"); - }); - test.each([ - ["threshold failure", "CLI tests", "npm audit threshold failed"], - ["unused exception", "Dependency policy", "unused npm audit exceptions: GHSA-example"], - ["unaccepted advisory", "Release policy", "1 unaccepted at or above high"], - ])("classifies an npm audit failure from %s", (_caseName, jobName, log) => { + function classifyNpmFailure(log: string, jobName: string) { const item = fixture(log); item.env.JOB_NAME = jobName; const result = run(item.env); expect(result.status, result.stderr).toBe(0); - expect(JSON.parse(result.stdout).categories).toContain("reviewed-npm-audit"); - }); - test("does not classify an npm audit job without audit-policy evidence", () => { - const item = fixture("The operation timed out"); - item.env.JOB_NAME = "PR npm audit"; - const result = run(item.env); - expect(result.status, result.stderr).toBe(0); - expect(JSON.parse(result.stdout).categories).not.toContain("reviewed-npm-audit"); + return JSON.parse(result.stdout); + } + + test.each([ + ["unrelated mention", "Documentation checks", "The documentation mentions npm audit", false], + ["job name only", "PR npm audit", "The operation timed out", false], + ["threshold failure", "CLI tests", "npm audit threshold failed", true], + ["unused exception", "Dependency policy", "unused npm audit exceptions: GHSA-example", true], + ["unaccepted advisory", "Release policy", "1 unaccepted at or above high", true], + ])("classifies npm audit evidence for %s", (_caseName, jobName, log, expected) => { + expect(classifyNpmFailure(log, jobName).categories.includes("reviewed-npm-audit")).toBe( + expected, + ); }); + test.each([ - ["archive integrity mismatch", "ERROR: npm@12.0.2 archive integrity mismatch."], - [ - "archive version mismatch", - "ERROR: npm archive version 12.0.1 does not match reviewed npm@12.0.2.", - ], - [ - "archive package metadata", - "ERROR: npm@12.0.2 archive package/package.json is missing or invalid.", - ], + ["archive integrity", "ERROR: npm@12.0.2 archive integrity mismatch."], + ["archive version", "ERROR: npm archive version 12.0.1 does not match reviewed npm@12.0.2."], + ["archive metadata", "ERROR: npm@12.0.2 archive package/package.json is missing or invalid."], ["invalid archive identity", "npm audit configuration has an invalid npmArchiveSha256"], ])("classifies a reviewed npm bootstrap %s separately", (_caseName, log) => { - const item = fixture(log); - item.env.JOB_NAME = "PR npm audit"; - const result = run(item.env); - expect(result.status, result.stderr).toBe(0); - const value = JSON.parse(result.stdout); + const value = classifyNpmFailure(log, "PR npm audit"); expect(value.categories).toContain("reviewed-npm-bootstrap"); expect(value.categories).not.toContain("reviewed-npm-audit"); expect(value.nextActions).toContain( diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index 4de14b9b417..ef1b40ce871 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -11,6 +11,7 @@ import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; import YAML from "yaml"; import { emitAuditReceipt } from "../../../scripts/audit-reviewed-npm-graph.mts"; +import { prepareReviewedNpmBootstrap } from "../../support/reviewed-npm-bootstrap"; const REPO_ROOT = path.join(import.meta.dirname, "../../.."); const TRUSTED_WORKFLOWS = [ @@ -72,22 +73,10 @@ const REVIEWED_NPM_ACTION = YAML.parse( const reviewedNpmBootstrapCommand = REVIEWED_NPM_ACTION.runs?.steps?.find( (step) => step.name === "Download and verify production npm", )?.run; -const reviewedNpmAuditCommand = REVIEWED_NPM_ACTION.runs?.steps?.find( - (step) => step.name === "Materialize and audit production dependency graphs", -)?.run; -const FIRST_TRUSTED_AUDIT_ACTION_CHECKOUT = TRUSTED_AUDIT_ACTION_CHECKOUTS[0]; -assert.ok( - FIRST_TRUSTED_AUDIT_ACTION_CHECKOUT, - "No trusted audit checkout includes the npm audit action", -); const REVIEWED_NPM_BOOTSTRAP_COMMAND = typeof reviewedNpmBootstrapCommand === "string" ? reviewedNpmBootstrapCommand : assert.fail("The npm audit action does not define the production npm bootstrap command"); -const REVIEWED_NPM_AUDIT_COMMAND = - typeof reviewedNpmAuditCommand === "string" - ? reviewedNpmAuditCommand - : assert.fail("The npm audit action does not define the production npm audit command"); function stageSparseCheckout(root: string, sparseCheckout: string): void { sparseCheckout @@ -101,183 +90,6 @@ function stageSparseCheckout(root: string, sparseCheckout: string): void { }); } -function runTrustedBootstrapHandoff( - sparseCheckout: string, - mutateCheckout: (root: string) => void = () => {}, - activateInstalledNpm = true, -) { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-audit-bootstrap-handoff-")); - const bin = path.join(root, "bin"); - const installedBin = path.join(root, "installed-bin"); - const activeNpm = path.join(bin, "npm"); - const bootstrapNpm = path.join(root, "bootstrap-npm"); - const installedNpm = path.join(installedBin, "npm"); - const archive = Buffer.from("verified archive\n"); - const archiveFile = path.join(root, "fixture.tgz"); - const installMarker = path.join(root, "install-called"); - const npmLog = path.join(root, "npm.log"); - const reportDirectory = path.join(root, "artifacts", "reviewed-npm-audit"); - stageSparseCheckout(root, sparseCheckout); - mutateCheckout(root); - fs.mkdirSync(bin); - fs.mkdirSync(installedBin); - fs.writeFileSync(archiveFile, archive); - fs.writeFileSync( - path.join(root, "package.json"), - `${JSON.stringify({ name: "reviewed-npm-handoff-fixture", version: "1.0.0" })}\n`, - ); - fs.writeFileSync( - path.join(root, "package-lock.json"), - `${JSON.stringify({ - lockfileVersion: 3, - name: "reviewed-npm-handoff-fixture", - packages: { "": { name: "reviewed-npm-handoff-fixture", version: "1.0.0" } }, - requires: true, - version: "1.0.0", - })}\n`, - ); - fs.writeFileSync( - path.join(root, "ci", "reviewed-npm-audit.json"), - `${JSON.stringify({ - archiveGraphId: "reviewed-archive-graph", - archivePackages: [], - archiveTarVersion: "7.5.21", - artifactDirectory: "artifacts/reviewed-npm-audit", - exceptionFile: "ci/npm-audit-exceptions.json", - lockedGraphs: [], - nodeVersion: process.version.slice(1), - npmArchiveSha256: createHash("sha256").update(archive).digest("hex"), - npmIntegrity: `sha512-${createHash("sha512").update(archive).digest("base64")}`, - npmVersion: "12.0.2", - registryOrigin: "https://registry.npmjs.org", - schemaVersion: 2, - severityThreshold: "high", - sourceNestedShrinkwrapPackages: [], - sourceRegistryPackage: { - artifactName: "unused-1.0.0.tgz", - integrity: `sha512-${Buffer.alloc(64).toString("base64")}`, - label: "unused fixture package", - packageSpec: "unused@1.0.0", - tarballUrl: "https://registry.npmjs.org/unused/-/unused-1.0.0.tgz", - }, - sourceRegistryPackagesWithoutIntegrity: [], - })}\n`, - ); - fs.writeFileSync( - activeNpm, - `#!/usr/bin/env bash -set -euo pipefail -printf 'bootstrap:%s\\n' "$*" >> "$NEMOCLAW_TEST_NPM_LOG" -case "$1" in - --version) - printf '9.9.9\\n' - ;; - pack) - shift - download_dir="" - while [ "$#" -gt 0 ]; do - if [ "$1" = "--pack-destination" ]; then - download_dir="$2" - break - fi - shift - done - [ -n "$download_dir" ] - cp "$NEMOCLAW_TEST_ARCHIVE_FILE" "$download_dir/npm-12.0.2.tgz" - ;; - install) - [ "\${2:-}" = "--global" ] - : > "$NEMOCLAW_TEST_INSTALL_MARKER" - if [ "$NEMOCLAW_TEST_ACTIVATE_INSTALLED_NPM" = "true" ]; then - mv "$NEMOCLAW_TEST_ACTIVE_NPM" "$NEMOCLAW_TEST_BOOTSTRAP_NPM" - ln -s "$NEMOCLAW_TEST_INSTALLED_NPM" "$NEMOCLAW_TEST_ACTIVE_NPM" - fi - ;; - *) - exit 2 - ;; -esac -`, - { mode: 0o755 }, - ); - fs.writeFileSync( - installedNpm, - `#!/usr/bin/env bash -set -euo pipefail -printf 'installed:%s\\n' "$*" >> "$NEMOCLAW_TEST_NPM_LOG" -case "$1" in - --version) - printf '12.0.2\\n' - ;; - install) - printf '%s\\n' '{"name":"nemoclaw-reviewed-production-graph","version":"1.0.0","lockfileVersion":3,"requires":true,"packages":{"":{"name":"nemoclaw-reviewed-production-graph","version":"1.0.0"}}}' > package-lock.json - ;; - ci) - ;; - audit) - if [ "\${2:-}" != "signatures" ]; then - printf '%s\\n' '{"vulnerabilities":{},"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":0,"critical":0}}}' - fi - ;; - *) - exit 2 - ;; -esac -`, - { mode: 0o755 }, - ); - fs.writeFileSync( - path.join(bin, "tar"), - `#!/usr/bin/env bash -set -euo pipefail -[ "$1" = "-xOf" ] -[ "$3" = "package/package.json" ] -printf '{"version":"12.0.2"}\\n' -`, - { mode: 0o755 }, - ); - const environment: NodeJS.ProcessEnv = { - ...process.env, - GITHUB_ACTION_PATH: path.join(root, ".github", "actions", "ci-reviewed-npm-audit"), - NEMOCLAW_REVIEWED_NPM_AUDIT_REPORT_DIR: path.relative(root, reportDirectory), - NEMOCLAW_REVIEWED_NPM_AUDIT_TARGET_ROOT: root, - NEMOCLAW_TEST_ACTIVE_NPM: activeNpm, - NEMOCLAW_TEST_ACTIVATE_INSTALLED_NPM: String(activateInstalledNpm), - NEMOCLAW_TEST_ARCHIVE_FILE: archiveFile, - NEMOCLAW_TEST_BOOTSTRAP_NPM: bootstrapNpm, - NEMOCLAW_TEST_INSTALLED_NPM: installedNpm, - NEMOCLAW_TEST_INSTALL_MARKER: installMarker, - NEMOCLAW_TEST_NPM_LOG: npmLog, - NPM_CONFIG_REGISTRY: "https://registry.npmjs.org/", - NPM_CONFIG_USERCONFIG: "/dev/null", - PATH: `${bin}:${process.env.PATH ?? ""}`, - RUNNER_TEMP: root, - }; - delete environment.NEMOCLAW_NPM_AUDIT_CACHE_FILE; - delete environment.NEMOCLAW_REVIEWED_NPM_AUDIT_CACHE_DIR; - const bootstrapResult = spawnSync("bash", ["-c", REVIEWED_NPM_BOOTSTRAP_COMMAND], { - cwd: root, - encoding: "utf8", - env: environment, - }); - const auditResult = - bootstrapResult.status === 0 - ? spawnSync("bash", ["-c", REVIEWED_NPM_AUDIT_COMMAND], { - cwd: root, - encoding: "utf8", - env: environment, - }) - : undefined; - return { - auditResult, - bootstrapResult, - cleanup: () => fs.rmSync(root, { recursive: true, force: true }), - installCalled: fs.existsSync(installMarker), - npmInvocations: fs.existsSync(npmLog) ? fs.readFileSync(npmLog, "utf8").trim().split("\n") : [], - reportFiles: fs.existsSync(reportDirectory) ? fs.readdirSync(reportDirectory) : [], - }; -} - describe("npm audit handoff", () => { it.each(TRUSTED_AUDIT_SPARSE_CHECKOUTS)( "loads the audit producer from the $name trusted sparse checkout", @@ -305,79 +117,25 @@ describe("npm audit handoff", () => { ); it.each(TRUSTED_AUDIT_ACTION_CHECKOUTS)( - "installs and audits with the reviewed npm from the $name trusted sparse checkout", + "runs the reviewed npm bootstrap from the $name trusted sparse checkout (#8253)", ({ sparseCheckout }) => { - const fixture = runTrustedBootstrapHandoff(sparseCheckout); + const fixture = prepareReviewedNpmBootstrap({ + command: REVIEWED_NPM_BOOTSTRAP_COMMAND, + configFile: (root) => path.join(root, "ci", "reviewed-npm-audit.json"), + environment: (root) => ({ + GITHUB_ACTION_PATH: path.join(root, ".github", "actions", "ci-reviewed-npm-audit"), + }), + prepare: (root) => stageSparseCheckout(root, sparseCheckout), + }); + const result = spawnSync("bash", fixture.args, fixture.spawnOptions); try { - expect(fixture.bootstrapResult.status, fixture.bootstrapResult.stderr).toBe(0); - expect(fixture.auditResult?.status, fixture.auditResult?.stderr).toBe(0); - expect(fixture.installCalled).toBe(true); - expect(fixture.npmInvocations[0]).toMatch(/^bootstrap:pack npm@12\.0\.2 /u); - expect(fixture.npmInvocations[1]).toMatch(/^bootstrap:install --global .* --offline$/u); - expect( - fixture.npmInvocations.slice(2).every((entry) => entry.startsWith("installed:")), - ).toBe(true); - expect(fixture.npmInvocations).toContain("installed:--version"); - expect( - fixture.npmInvocations.some((entry) => /^installed:audit .*--json$/u.test(entry)), - ).toBe(true); - expect( - fixture.npmInvocations.some((entry) => /^installed:audit signatures /u.test(entry)), - ).toBe(true); - expect(fixture.reportFiles).toContain("nemoclaw-cli.receipt.json"); - expect(fixture.reportFiles).toContain("reviewed-archive-graph.receipt.json"); + expect(result.status, result.stderr).toBe(0); } finally { fixture.cleanup(); } }, ); - it("fails before installation when the trusted checkout omits the reviewed npm bootstrap", () => { - const fixture = runTrustedBootstrapHandoff( - FIRST_TRUSTED_AUDIT_ACTION_CHECKOUT.sparseCheckout, - (root) => - fs.rmSync( - path.join( - root, - ".github", - "actions", - "ci-reviewed-npm-audit", - "verify-and-install-npm.sh", - ), - { force: true }, - ), - ); - try { - expect(fixture.bootstrapResult.status).not.toBe(0); - expect(fixture.auditResult).toBeUndefined(); - expect(fixture.installCalled).toBe(false); - expect(fixture.npmInvocations).toEqual([]); - } finally { - fixture.cleanup(); - } - }); - - it("rejects the audit before accepting results when installation leaves an older npm selected (#8253)", () => { - const fixture = runTrustedBootstrapHandoff( - FIRST_TRUSTED_AUDIT_ACTION_CHECKOUT.sparseCheckout, - () => {}, - false, - ); - try { - expect(fixture.bootstrapResult.status, fixture.bootstrapResult.stderr).toBe(0); - expect(fixture.auditResult?.status).toBe(1); - expect(fixture.auditResult?.stderr).toContain( - "npm audit requires npm 12.0.2; running npm 9.9.9", - ); - expect(fixture.npmInvocations).toContain("bootstrap:--version"); - expect(fixture.npmInvocations.some((entry) => entry.startsWith("installed:"))).toBe(false); - expect(fixture.reportFiles).not.toContain("nemoclaw-cli.receipt.json"); - expect(fixture.reportFiles).not.toContain("source-graph-policy.json"); - } finally { - fixture.cleanup(); - } - }); - it("passes producer output to the Docker receipt verifier and rejects an npm mismatch", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-audit-receipt-handoff-")); const packageJsonFile = path.join(root, "package.json"); diff --git a/test/automation/releases/reviewed-npm-audit.test.ts b/test/automation/releases/reviewed-npm-audit.test.ts index c3ac921ba36..68e8793028d 100644 --- a/test/automation/releases/reviewed-npm-audit.test.ts +++ b/test/automation/releases/reviewed-npm-audit.test.ts @@ -481,11 +481,25 @@ describe("npm audit raw cache", () => { return directory; } + function cliArgs(directory: string, ...extra: string[]) { + return [ + "--directory", + directory, + "--exceptions", + "exceptions.json", + "--graph", + "fixture", + "--threshold", + "high", + ...extra, + ]; + } + it.each([ ["flag", ["--cache", "cache.json"], {}], ["environment", [], { NEMOCLAW_NPM_AUDIT_CACHE_FILE: "cache.json" }], ] as const)( - "loads the reviewed npm identity for a CLI cache configured by %s", + "loads the reviewed npm identity for a CLI cache configured by %s (#8253)", (_source, cacheArgs, environment) => { const directory = fixture(); const auditConfigFile = path.join(directory, "reviewed-npm-audit.json"); @@ -493,19 +507,7 @@ describe("npm audit raw cache", () => { fs.writeFileSync(auditConfigFile, `${JSON.stringify(npmIdentity)}\n`); expect( parseReviewedNpmAuditCliArgs( - [ - "--directory", - directory, - "--exceptions", - "exceptions.json", - "--graph", - "fixture", - "--threshold", - "high", - "--audit-config", - auditConfigFile, - ...cacheArgs, - ], + cliArgs(directory, "--audit-config", auditConfigFile, ...cacheArgs), environment, ), ).toMatchObject({ cacheFile: "cache.json", reviewedNpmIdentity: npmIdentity }); @@ -516,23 +518,9 @@ describe("npm audit raw cache", () => { ); it("rejects a CLI cache without the reviewed npm configuration", () => { - expect(() => - parseReviewedNpmAuditCliArgs( - [ - "--directory", - ".", - "--exceptions", - "exceptions.json", - "--graph", - "fixture", - "--threshold", - "high", - "--cache", - "cache.json", - ], - {}, - ), - ).toThrow("npm audit cache requires --audit-config"); + expect(() => parseReviewedNpmAuditCliArgs(cliArgs(".", "--cache", "cache.json"), {})).toThrow( + "npm audit cache requires --audit-config", + ); }); it("rejects a truncated reviewed npm SHA-512 integrity", () => { diff --git a/test/automation/releases/reviewed-npm-bootstrap.test.ts b/test/automation/releases/reviewed-npm-bootstrap.test.ts index d312ea558ab..185f616258a 100644 --- a/test/automation/releases/reviewed-npm-bootstrap.test.ts +++ b/test/automation/releases/reviewed-npm-bootstrap.test.ts @@ -1,183 +1,60 @@ // 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 os from "node:os"; -import path from "node:path"; import { describe, expect, it } from "vitest"; -const REPO_ROOT = path.join(import.meta.dirname, "../../.."); -const BOOTSTRAP = path.join( - REPO_ROOT, - ".github", - "actions", - "ci-reviewed-npm-audit", - "verify-and-install-npm.sh", -); +import { runReviewedNpmBootstrap } from "../../support/reviewed-npm-bootstrap"; -function identity(archive: string | Buffer): Record { - return { - npmArchiveSha256: createHash("sha256").update(archive).digest("hex"), - npmIntegrity: `sha512-${createHash("sha512").update(archive).digest("base64")}`, - npmVersion: "12.0.2", - }; -} - -type BootstrapFixtureOptions = { - archive: string | Buffer; - archiveVersion?: string; - environment?: NodeJS.ProcessEnv; - realTar?: boolean; - reviewedIdentity?: Record; -}; - -function runBootstrapFixture(options: BootstrapFixtureOptions) { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-npm-bootstrap-")); - const bin = path.join(root, "bin"); - const npmLog = path.join(root, "npm.log"); - const installMarker = path.join(root, "install-called"); - const identityPath = path.join(root, "reviewed-npm-audit.json"); - const archivePath = path.join(root, "fixture.tgz"); - - fs.mkdirSync(bin); - fs.writeFileSync(archivePath, options.archive); - fs.writeFileSync( - path.join(bin, "npm"), - `#!/usr/bin/env bash -set -euo pipefail -printf '%s\\n' "$*" >> "$NEMOCLAW_TEST_NPM_LOG" -case "$1" in - pack) - pack_args="$*" - shift - download_dir="" - while [ "$#" -gt 0 ]; do - if [ "$1" = "--pack-destination" ]; then - download_dir="$2" - break - fi - shift - done - [ -n "$download_dir" ] - [ "$pack_args" = "pack npm@12.0.2 --pack-destination $download_dir --userconfig /dev/null --registry https://registry.npmjs.org/ --ignore-scripts --no-audit --no-fund" ] - cp "$NEMOCLAW_TEST_ARCHIVE_FILE" "$download_dir/npm-12.0.2.tgz" - ;; - install) - : > "$NEMOCLAW_TEST_INSTALL_MARKER" - ;; - *) - exit 2 - ;; -esac -`, - { mode: 0o755 }, - ); - fs.writeFileSync( - path.join(bin, "tar"), - `#!/usr/bin/env bash -set -euo pipefail -case "$NEMOCLAW_TEST_REAL_TAR" in - true) - exec env PATH="$NEMOCLAW_TEST_ORIGINAL_PATH" tar "$@" - ;; - false) -[ "$1" = "-xOf" ] -[ "$3" = "package/package.json" ] -printf '{"version":"%s"}\\n' "$NEMOCLAW_TEST_ARCHIVE_VERSION" - ;; -esac -`, - { mode: 0o755 }, - ); - fs.writeFileSync( - identityPath, - `${JSON.stringify(options.reviewedIdentity ?? identity(options.archive))}\n`, - ); - const result = spawnSync("bash", [BOOTSTRAP, identityPath], { - encoding: "utf8", - env: { - ...process.env, - ...options.environment, - NEMOCLAW_TEST_ARCHIVE_FILE: archivePath, - NEMOCLAW_TEST_ARCHIVE_VERSION: options.archiveVersion ?? "12.0.2", - NEMOCLAW_TEST_INSTALL_MARKER: installMarker, - NEMOCLAW_TEST_NPM_LOG: npmLog, - NEMOCLAW_TEST_ORIGINAL_PATH: process.env.PATH ?? "", - NEMOCLAW_TEST_REAL_TAR: String(options.realTar ?? false), - PATH: `${bin}:${process.env.PATH ?? ""}`, - RUNNER_TEMP: root, - }, - }); - return { - cleanup: () => fs.rmSync(root, { recursive: true, force: true }), - installCalled: fs.existsSync(installMarker), - npmInvocations: fs.existsSync(npmLog) ? fs.readFileSync(npmLog, "utf8").trim().split("\n") : [], - result, - }; -} - -function createRealArchive(version?: string | null): { archive: Buffer; cleanup: () => void } { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-npm-archive-")); - const packageRoot = path.join(root, "package"); - const archivePath = path.join(root, "fixture.tgz"); - fs.mkdirSync(packageRoot); - const entry = - version === undefined - ? { contents: "missing package manifest\n", name: "README.md" } - : { - contents: version === null ? "{invalid json\n" : `${JSON.stringify({ version })}\n`, - name: "package.json", - }; - fs.writeFileSync(path.join(packageRoot, entry.name), entry.contents); - const packed = spawnSync("tar", ["-czf", archivePath, "-C", root, "package"], { - encoding: "utf8", - }); - expect(packed.status, packed.stderr).toBe(0); - return { - archive: fs.readFileSync(archivePath), - cleanup: () => fs.rmSync(root, { recursive: true, force: true }), - }; -} +type BootstrapOptions = Parameters[0]; describe("reviewed npm bootstrap", () => { - const archive = "verified archive\n"; - - it.each([ - ["npmVersion", { ...identity(archive), npmVersion: "12.x" }], - ["npmIntegrity", { ...identity(archive), npmIntegrity: "not-a-reviewed-integrity" }], - ["npmArchiveSha256", { ...identity(archive), npmArchiveSha256: "not-a-reviewed-digest" }], - ])("rejects a malformed %s before download (#8253)", (field, reviewedIdentity) => { - const fixture = runBootstrapFixture({ - archive, - reviewedIdentity, - }); - try { - expect(fixture.result.status).toBe(1); - expect(fixture.result.stderr).toContain(`npm audit configuration has an invalid ${field}`); - expect(fixture.npmInvocations).toEqual([]); - expect(fixture.installCalled).toBe(false); - } finally { - fixture.cleanup(); - } - }); - it.each([ - ["SHA-256", { ...identity(archive), npmArchiveSha256: "0".repeat(64) }], [ - "SHA-512 SRI", - { ...identity(archive), npmIntegrity: `sha512-${Buffer.alloc(64).toString("base64")}` }, + "malformed version", + { mutateIdentity: (identity) => ({ ...identity, npmVersion: "12.x" }) }, + "invalid npmVersion", + 0, + ], + [ + "malformed SRI", + { mutateIdentity: (identity) => ({ ...identity, npmIntegrity: "invalid" }) }, + "invalid npmIntegrity", + 0, + ], + [ + "malformed SHA-256", + { mutateIdentity: (identity) => ({ ...identity, npmArchiveSha256: "invalid" }) }, + "invalid npmArchiveSha256", + 0, + ], + [ + "SHA-256 mismatch", + { mutateIdentity: (identity) => ({ ...identity, npmArchiveSha256: "0".repeat(64) }) }, + "archive integrity mismatch", + 1, + ], + [ + "SHA-512 SRI mismatch", + { + mutateIdentity: (identity) => ({ + ...identity, + npmIntegrity: `sha512-${Buffer.alloc(64).toString("base64")}`, + }), + }, + "archive integrity mismatch", + 1, ], - ])( - "rejects an independent %s mismatch before installation (#8253)", - (_digest, reviewedIdentity) => { - const fixture = runBootstrapFixture({ archive, reviewedIdentity }); + ["version mismatch", { archiveManifest: "mismatched" }, "archive version 12.0.3", 1], + ["missing metadata", { archiveManifest: "missing" }, "is missing or invalid", 1], + ["invalid metadata", { archiveManifest: "invalid" }, "is missing or invalid", 1], + ] as [string, BootstrapOptions, string, number][])( + "rejects %s before installation (#8253)", + (_caseName, options, error, npmInvocations) => { + const fixture = runReviewedNpmBootstrap(options); try { expect(fixture.result.status).toBe(1); - expect(fixture.result.stderr).toContain("npm@12.0.2 archive integrity mismatch"); - expect(fixture.npmInvocations).toHaveLength(1); - expect(fixture.npmInvocations[0]).toContain("pack npm@12.0.2 --pack-destination"); + expect(fixture.result.stderr).toContain(error); + expect(fixture.npmInvocations).toHaveLength(npmInvocations); expect(fixture.installCalled).toBe(false); } finally { fixture.cleanup(); @@ -185,87 +62,23 @@ describe("reviewed npm bootstrap", () => { }, ); - it("rejects an archive package version mismatch before installation (#8253)", () => { - const fixture = runBootstrapFixture({ archive, archiveVersion: "12.0.3" }); - try { - expect(fixture.result.status).toBe(1); - expect(fixture.result.stderr).toContain( - "npm archive version 12.0.3 does not match reviewed npm@12.0.2", - ); - expect(fixture.npmInvocations).toHaveLength(1); - expect(fixture.installCalled).toBe(false); - } finally { - fixture.cleanup(); - } - }); - - it.each([ - ["matching", "12.0.2", true, false, false], - ["mismatched", "12.0.3", false, false, true], - ["missing", undefined, false, true, false], - ["invalid", null, false, true, false], - ] as const)( - "%s real tar package metadata reaches installation only for the reviewed version (#8253)", - ( - _condition, - archiveVersion, - expectedInstall, - expectedMetadataError, - expectedVersionMismatch, - ) => { - const archiveFixture = createRealArchive(archiveVersion); - const fixture = runBootstrapFixture({ archive: archiveFixture.archive, realTar: true }); - try { - expect(fixture.result.status === 0).toBe(expectedInstall); - expect(fixture.installCalled).toBe(expectedInstall); - expect(fixture.npmInvocations).toHaveLength(expectedInstall ? 2 : 1); - expect( - fixture.result.stderr.includes( - "npm@12.0.2 archive package/package.json is missing or invalid", - ), - ).toBe(expectedMetadataError); - expect( - fixture.result.stderr.includes( - "npm archive version 12.0.3 does not match reviewed npm@12.0.2", - ), - ).toBe(expectedVersionMismatch); - } finally { - fixture.cleanup(); - archiveFixture.cleanup(); - } - }, - ); - - it("installs a matching archive offline (#8253)", () => { - const fixture = runBootstrapFixture({ archive }); - try { - const { npmInvocations, result } = fixture; - expect(result.status).toBe(0); - expect(npmInvocations).toHaveLength(2); - expect(npmInvocations[0]).toMatch( - /^pack npm@12\.0\.2 --pack-destination .* --userconfig \/dev\/null --registry https:\/\/registry\.npmjs\.org\/ --ignore-scripts --no-audit --no-fund$/, - ); - expect(npmInvocations[1]).toMatch( - /^install --global .*\/npm-12\.0\.2\.tgz --userconfig \/dev\/null --ignore-scripts --no-audit --no-fund --offline$/, - ); - } finally { - fixture.cleanup(); - } - }); - - it("overrides ambient npm configuration for the archive download (#8253)", () => { - const fixture = runBootstrapFixture({ - archive, - environment: { + it("installs the verified archive offline despite ambient npm configuration (#8253)", () => { + const fixture = runReviewedNpmBootstrap({ + environment: () => ({ NPM_CONFIG_REGISTRY: "https://registry.example.test/", NPM_CONFIG_USERCONFIG: "/tmp/untrusted-npmrc", - }, + }), }); try { - expect(fixture.result.status).toBe(0); + expect(fixture.result.status, fixture.result.stderr).toBe(0); + expect(fixture.installCalled).toBe(true); + expect(fixture.npmInvocations).toHaveLength(2); expect(fixture.npmInvocations[0]).toMatch( /^pack npm@12\.0\.2 --pack-destination .* --userconfig \/dev\/null --registry https:\/\/registry\.npmjs\.org\/ --ignore-scripts --no-audit --no-fund$/, ); + expect(fixture.npmInvocations[1]).toMatch( + /^install --global .*\/npm-12\.0\.2\.tgz --userconfig \/dev\/null --ignore-scripts --no-audit --no-fund --offline$/, + ); } finally { fixture.cleanup(); } diff --git a/test/support/reviewed-npm-bootstrap.ts b/test/support/reviewed-npm-bootstrap.ts new file mode 100644 index 00000000000..0c953eba242 --- /dev/null +++ b/test/support/reviewed-npm-bootstrap.ts @@ -0,0 +1,125 @@ +// 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 os from "node:os"; +import path from "node:path"; + +const REPO_ROOT = path.join(import.meta.dirname, "../.."); +const BOOTSTRAP = path.join( + REPO_ROOT, + ".github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh", +); + +export type ReviewedNpmIdentity = Record< + "npmArchiveSha256" | "npmIntegrity" | "npmVersion", + string +>; + +type FixtureOptions = { + archiveManifest?: "invalid" | "matching" | "mismatched" | "missing"; + command?: string; + configFile?: (root: string) => string; + environment?: (root: string) => NodeJS.ProcessEnv; + mutateIdentity?: (identity: ReviewedNpmIdentity) => ReviewedNpmIdentity; + prepare?: (root: string) => void; +}; + +function createArchive(root: string, manifest: NonNullable) { + const packageRoot = path.join(root, "package"); + const archiveFile = path.join(root, "fixture.tgz"); + fs.mkdirSync(packageRoot); + if (manifest === "missing") { + fs.writeFileSync(path.join(packageRoot, "README.md"), "missing package manifest\n"); + } else { + const source = + manifest === "invalid" + ? "{invalid json\n" + : `${JSON.stringify({ version: manifest === "mismatched" ? "12.0.3" : "12.0.2" })}\n`; + fs.writeFileSync(path.join(packageRoot, "package.json"), source); + } + const packed = spawnSync("tar", ["-czf", archiveFile, "-C", root, "package"], { + encoding: "utf8", + }); + if (packed.status !== 0) throw new Error(packed.stderr); + return { archive: fs.readFileSync(archiveFile), archiveFile }; +} + +export function prepareReviewedNpmBootstrap(options: FixtureOptions = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-npm-bootstrap-")); + const bin = path.join(root, "bin"); + const installMarker = path.join(root, "install-called"); + const npmLog = path.join(root, "npm.log"); + fs.mkdirSync(bin); + options.prepare?.(root); + + const { archive, archiveFile } = createArchive(root, options.archiveManifest ?? "matching"); + const identity: ReviewedNpmIdentity = { + npmArchiveSha256: createHash("sha256").update(archive).digest("hex"), + npmIntegrity: `sha512-${createHash("sha512").update(archive).digest("base64")}`, + npmVersion: "12.0.2", + }; + const configFile = options.configFile?.(root) ?? path.join(root, "reviewed-npm-audit.json"); + fs.mkdirSync(path.dirname(configFile), { recursive: true }); + fs.writeFileSync( + configFile, + `${JSON.stringify(options.mutateIdentity?.(identity) ?? identity)}\n`, + ); + fs.writeFileSync( + path.join(bin, "npm"), + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$*" >> "$NEMOCLAW_TEST_NPM_LOG" +case "$1" in + pack) + while [ "$#" -gt 1 ]; do + if [ "$1" = "--pack-destination" ]; then + cp "$NEMOCLAW_TEST_ARCHIVE_FILE" "$2/npm-12.0.2.tgz" + exit 0 + fi + shift + done + exit 2 + ;; + install) : > "$NEMOCLAW_TEST_INSTALL_MARKER" ;; + *) exit 2 ;; +esac +`, + { mode: 0o755 }, + ); + + return { + args: options.command ? ["-c", options.command] : [BOOTSTRAP, configFile], + cleanup: () => fs.rmSync(root, { recursive: true, force: true }), + installMarker, + npmLog, + spawnOptions: { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + ...options.environment?.(root), + NEMOCLAW_TEST_ARCHIVE_FILE: archiveFile, + NEMOCLAW_TEST_INSTALL_MARKER: installMarker, + NEMOCLAW_TEST_NPM_LOG: npmLog, + PATH: `${bin}:${process.env.PATH ?? ""}`, + RUNNER_TEMP: root, + }, + }, + } as const; +} + +export function runReviewedNpmBootstrap(options: FixtureOptions = {}) { + const fixture = prepareReviewedNpmBootstrap(options); + const result = spawnSync("bash", fixture.args, fixture.spawnOptions); + return { + cleanup: fixture.cleanup, + installCalled: fs.existsSync(fixture.installMarker), + npmInvocations: fs.existsSync(fixture.npmLog) + ? fs.readFileSync(fixture.npmLog, "utf8").trim().split("\n") + : [], + result, + }; +}