diff --git a/.github/actions/ci-reviewed-npm-audit/action.yaml b/.github/actions/ci-reviewed-npm-audit/action.yaml index a40dac73160..c70ce662068 100644 --- a/.github/actions/ci-reviewed-npm-audit/action.yaml +++ b/.github/actions/ci-reviewed-npm-audit/action.yaml @@ -37,15 +37,19 @@ runs: set -euo pipefail current_bucket="$(( $(date -u +%s) / 43200 ))" previous_bucket="$(( current_bucket - 1 ))" - node --input-type=module - "$GITHUB_ACTION_PATH/../../../ci/reviewed-npm-audit.json" <<'NODE' + 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' 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] = process.argv.slice(2); + const [configFile, resolverFile] = process.argv.slice(2); if (!targetRoot) { throw new Error("reviewed npm audit target root is required"); } + const { resolvePathWithinRoot } = await import(pathToFileURL(resolverFile).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)) { @@ -62,7 +66,7 @@ runs: for (const file of ["package.json", "package-lock.json"]) { const relative = join(directory, file); hash.update(relative); - hash.update(readFileSync(join(targetRoot, relative))); + hash.update(readFileSync(resolvePathWithinRoot(targetRoot, relative, "reviewed npm audit target input"))); } } appendFileSync( diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 62b2ed02c89..dc41432e90e 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -137,6 +137,7 @@ jobs: scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts scripts/lib/npm-audit-receipt.mts + scripts/lib/repository-input-path.mts sparse-checkout-cone-mode: false - name: Set up Node for reviewed package download diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index c681a1cd388..55b0cb980b6 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -100,6 +100,7 @@ jobs: ci/reviewed-npm-audit.json scripts/audit-reviewed-npm-graph.mts scripts/lib/npm-audit-receipt.mts + scripts/lib/repository-input-path.mts scripts/lib/openclaw-npm-remediation.mts scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts diff --git a/.github/workflows/openshell-sdk-package-pr.yaml b/.github/workflows/openshell-sdk-package-pr.yaml index 04a9b44451c..c8f88a4872b 100644 --- a/.github/workflows/openshell-sdk-package-pr.yaml +++ b/.github/workflows/openshell-sdk-package-pr.yaml @@ -40,6 +40,7 @@ jobs: scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts scripts/lib/npm-audit-receipt.mts + scripts/lib/repository-input-path.mts sparse-checkout-cone-mode: false - name: Setup Node.js for reviewed package download diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 60f6d7b943f..1fb2ffa0a89 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -132,6 +132,7 @@ jobs: scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts scripts/lib/npm-audit-receipt.mts + scripts/lib/repository-input-path.mts sparse-checkout-cone-mode: false - name: Download verified OpenShell SDK archive @@ -202,6 +203,7 @@ jobs: scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts scripts/lib/npm-audit-receipt.mts + scripts/lib/repository-input-path.mts sparse-checkout-cone-mode: false - name: Locate exact base-controlled SDK package run @@ -388,6 +390,7 @@ jobs: scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts scripts/lib/npm-audit-receipt.mts + scripts/lib/repository-input-path.mts sparse-checkout-cone-mode: false - name: Download verified OpenShell SDK archive @@ -448,6 +451,7 @@ jobs: scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts scripts/lib/npm-audit-receipt.mts + scripts/lib/repository-input-path.mts sparse-checkout-cone-mode: false - name: Download verified OpenShell SDK archive @@ -507,6 +511,7 @@ jobs: scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts scripts/lib/npm-audit-receipt.mts + scripts/lib/repository-input-path.mts sparse-checkout-cone-mode: false - name: Download verified OpenShell SDK archive @@ -545,6 +550,7 @@ jobs: scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts scripts/lib/npm-audit-receipt.mts + scripts/lib/repository-input-path.mts sparse-checkout-cone-mode: false - name: Audit reviewed production npm graphs @@ -597,6 +603,7 @@ jobs: scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts scripts/lib/npm-audit-receipt.mts + scripts/lib/repository-input-path.mts sparse-checkout-cone-mode: false - name: Download verified OpenShell SDK archive @@ -724,6 +731,7 @@ jobs: scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts scripts/lib/npm-audit-receipt.mts + scripts/lib/repository-input-path.mts sparse-checkout-cone-mode: false - name: Download verified OpenShell SDK archive @@ -775,6 +783,7 @@ jobs: scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts scripts/lib/npm-audit-receipt.mts + scripts/lib/repository-input-path.mts sparse-checkout-cone-mode: false - name: Download verified OpenShell SDK archive diff --git a/Dockerfile.base b/Dockerfile.base index b2ab4c2e660..5a23d7ca43e 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -597,7 +597,7 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep --registry https://registry.yarnpkg.com --threshold high \ --legacy-npmjs true \ --result /tmp/mcporter-npm-audit-policy.json \ - --raw-copy /tmp/mcporter-npm-audit.json; \ + && cp "$MCPORTER_RAW_REPORT" /tmp/mcporter-npm-audit.json; \ else \ node --experimental-strip-types /scripts/lib/reviewed-npm-audit.mts \ --directory /usr/local/lib/nemoclaw/mcporter-runtime \ diff --git a/scripts/audit-reviewed-npm-graph.mts b/scripts/audit-reviewed-npm-graph.mts index ca794d55adb..d3ea94f4ea7 100755 --- a/scripts/audit-reviewed-npm-graph.mts +++ b/scripts/audit-reviewed-npm-graph.mts @@ -10,6 +10,7 @@ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { remediateReviewedOpenClawPluginArchive } from "./lib/openclaw-npm-remediation.mts"; import { canonicalAuditReceipt, createAuditReceipt } from "./lib/npm-audit-receipt.mts"; +import { resolvePathWithinRoot } from "./lib/repository-input-path.mts"; import { packReviewedNpmArchive, verifyInstalledNpmLock, @@ -104,31 +105,7 @@ const OPENCLAW_DOMEXCEPTION_ALIAS = { version: "1.0.28", } as const; -export function resolvePathWithinRoot(root: string, relativePath: string, label: string): string { - if (!relativePath || path.isAbsolute(relativePath)) { - throw new Error(`${label} must be a nonempty relative path`); - } - const canonicalRoot = fs.realpathSync(path.resolve(root)); - const resolved = path.resolve(canonicalRoot, relativePath); - if (!resolved.startsWith(`${canonicalRoot}${path.sep}`)) { - throw new Error(`${label} escapes its repository root: ${relativePath}`); - } - let current = canonicalRoot; - for (const component of path.relative(canonicalRoot, resolved).split(path.sep)) { - current = path.join(current, component); - let stat: fs.Stats; - try { - stat = fs.lstatSync(current); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") break; - throw error; - } - if (stat.isSymbolicLink()) { - throw new Error(`${label} contains a symbolic-link component: ${relativePath}`); - } - } - return resolved; -} +export { resolvePathWithinRoot } from "./lib/repository-input-path.mts"; export function resolveTrustedAuditConfigPath(trustedRoot: string): string { return resolvePathWithinRoot( diff --git a/scripts/lib/npm-audit-receipt.mts b/scripts/lib/npm-audit-receipt.mts index 1a1f9df116b..ad1eb8a670a 100755 --- a/scripts/lib/npm-audit-receipt.mts +++ b/scripts/lib/npm-audit-receipt.mts @@ -238,7 +238,7 @@ function cli(args: readonly string[]): void { 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] [--raw-copy 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-npmjs true] [--result FILE]", ); values.set(args[index], value); } @@ -253,12 +253,10 @@ function cli(args: readonly string[]): void { "--registry", "--threshold", ]; - const allowed = [...required, "--result", "--raw-copy", "--legacy-npmjs"]; + const allowed = [...required, "--result", "--legacy-npmjs"]; exactKeys( Object.fromEntries( - [...values].filter( - ([key]) => key !== "--result" && key !== "--raw-copy" && key !== "--legacy-npmjs", - ), + [...values].filter(([key]) => key !== "--result" && key !== "--legacy-npmjs"), ), required, "verifier arguments", @@ -297,8 +295,6 @@ function cli(args: readonly string[]): void { ); if (values.has("--result")) fs.writeFileSync(values.get("--result")!, `${JSON.stringify(policyResult, null, 2)}\n`); - if (values.has("--raw-copy")) - fs.copyFileSync(values.get("--raw-report")!, values.get("--raw-copy")!); console.log("reviewed npm audit receipt and current policy verified"); } diff --git a/scripts/lib/repository-input-path.mts b/scripts/lib/repository-input-path.mts new file mode 100644 index 00000000000..f9a0b01c60a --- /dev/null +++ b/scripts/lib/repository-input-path.mts @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; + +export function resolvePathWithinRoot(root: string, relativePath: string, label: string): string { + if (!relativePath || path.isAbsolute(relativePath)) { + throw new Error(`${label} must be a nonempty relative path`); + } + const canonicalRoot = fs.realpathSync(path.resolve(root)); + const resolved = path.resolve(canonicalRoot, relativePath); + if (!resolved.startsWith(`${canonicalRoot}${path.sep}`)) { + throw new Error(`${label} escapes its repository root: ${relativePath}`); + } + let current = canonicalRoot; + for (const component of path.relative(canonicalRoot, resolved).split(path.sep)) { + current = path.join(current, component); + let stat: fs.Stats; + try { + stat = fs.lstatSync(current); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") break; + throw error; + } + if (stat.isSymbolicLink()) { + throw new Error(`${label} contains a symbolic-link component: ${relativePath}`); + } + } + return resolved; +} diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index 77154902781..e0e96f7a55b 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -151,6 +151,15 @@ function stageManagedStartupRuntimeSources(rootDir: string, buildCtx: string): v ); } +function stageReviewedNpmAuditPolicy(rootDir: string, buildCtx: string): void { + const stagedCiDir = path.join(buildCtx, "ci"); + fs.mkdirSync(stagedCiDir, { recursive: true }); + for (const fileName of ["npm-audit-exceptions.json", "reviewed-npm-audit.json"]) { + fs.copyFileSync(path.join(rootDir, "ci", fileName), path.join(stagedCiDir, fileName)); + } + normalizeReadModesForDockerCopy(stagedCiDir); +} + function stageLegacySandboxBuildContext( rootDir: string, tmpDir: string = os.tmpdir(), @@ -161,6 +170,7 @@ function stageLegacySandboxBuildContext( path.join(rootDir, "tsconfig.runtime-preloads.json"), path.join(buildCtx, "tsconfig.runtime-preloads.json"), ); + stageReviewedNpmAuditPolicy(rootDir, buildCtx); stageOpenClawRuntimeGraphs(rootDir, buildCtx); stageMcpToolDiscoveryRuntime(rootDir, buildCtx); fs.cpSync(path.join(rootDir, "nemoclaw"), path.join(buildCtx, "nemoclaw"), { @@ -207,7 +217,6 @@ function stageOptimizedSandboxBuildContext( const stagedNemoclawDir = path.join(buildCtx, "nemoclaw"); const sourceBlueprintDir = path.join(rootDir, "nemoclaw-blueprint"); const stagedBlueprintDir = path.join(buildCtx, "nemoclaw-blueprint"); - const stagedCiDir = path.join(buildCtx, "ci"); const stagedScriptsDir = path.join(buildCtx, "scripts"); fs.copyFileSync(path.join(rootDir, "Dockerfile"), stagedDockerfile); @@ -218,16 +227,7 @@ function stageOptimizedSandboxBuildContext( stageOpenClawRuntimeGraphs(rootDir, buildCtx); stageMcpToolDiscoveryRuntime(rootDir, buildCtx); - fs.mkdirSync(stagedCiDir, { recursive: true }); - fs.copyFileSync( - path.join(rootDir, "ci", "npm-audit-exceptions.json"), - path.join(stagedCiDir, "npm-audit-exceptions.json"), - ); - fs.copyFileSync( - path.join(rootDir, "ci", "reviewed-npm-audit.json"), - path.join(stagedCiDir, "reviewed-npm-audit.json"), - ); - normalizeReadModesForDockerCopy(stagedCiDir); + stageReviewedNpmAuditPolicy(rootDir, buildCtx); fs.mkdirSync(stagedNemoclawDir, { recursive: true }); for (const fileName of [ diff --git a/test/automation/releases/npm-audit-receipt.test.ts b/test/automation/releases/npm-audit-receipt.test.ts index 63aede6fe08..410ab8559bd 100644 --- a/test/automation/releases/npm-audit-receipt.test.ts +++ b/test/automation/releases/npm-audit-receipt.test.ts @@ -221,92 +221,58 @@ describe("reviewed npm audit receipt", () => { }, ); - it.each([ - { - error: "", - label: "the canonical npm version", - npmVersion: inputs.npmVersion, - policy: { graph: inputs.graphId, status: "clean" }, - rawCopy: inputs.rawResponse, - status: 0, - }, - { - error: "receipt identity does not match expected graph and npm", - label: "a mismatched npm version", - npmVersion: "11.18.0", - policy: undefined, - rawCopy: undefined, - status: 1, - }, - ])( - "executes the receipt producer-to-Docker-consumer contract for $label", - ({ error, npmVersion, policy, rawCopy, status }) => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "receipt-docker-contract-")); - const resultFile = path.join(root, "policy.json"); - const rawCopyFile = path.join(root, "copied-raw.json"); - try { - fs.writeFileSync(path.join(root, "package.json"), inputs.packageJson); - fs.writeFileSync(path.join(root, "package-lock.json"), inputs.packageLock); - fs.writeFileSync(path.join(root, "exceptions.json"), inputs.exceptionPolicy); - fs.writeFileSync(path.join(root, "raw.json"), inputs.rawResponse); - fs.writeFileSync( + it("rejects the removed raw-copy option before writing either output", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "receipt-removed-option-")); + const resultFile = path.join(root, "policy.json"); + const rawCopyFile = path.join(root, "copied-raw.json"); + try { + fs.writeFileSync(path.join(root, "package.json"), inputs.packageJson); + fs.writeFileSync(path.join(root, "package-lock.json"), inputs.packageLock); + fs.writeFileSync(path.join(root, "exceptions.json"), inputs.exceptionPolicy); + fs.writeFileSync(path.join(root, "raw.json"), inputs.rawResponse); + fs.writeFileSync( + path.join(root, "reviewed-npm-audit.json"), + JSON.stringify({ npmVersion: inputs.npmVersion }), + ); + fs.writeFileSync(path.join(root, "receipt.json"), canonicalAuditReceipt(receipt(new Date()))); + + const result = spawnSync( + process.execPath, + [ + "--experimental-strip-types", + path.join(import.meta.dirname, "../../../scripts/lib/npm-audit-receipt.mts"), + "--receipt", path.join(root, "receipt.json"), - canonicalAuditReceipt(receipt(new Date())), - ); - fs.writeFileSync( + "--package-json", + path.join(root, "package.json"), + "--package-lock", + path.join(root, "package-lock.json"), + "--raw-report", + path.join(root, "raw.json"), + "--exceptions", + path.join(root, "exceptions.json"), + "--graph", + inputs.graphId, + "--audit-config", path.join(root, "reviewed-npm-audit.json"), - JSON.stringify({ npmVersion }), - ); - - const result = spawnSync( - process.execPath, - [ - "--experimental-strip-types", - path.join(import.meta.dirname, "../../../scripts/lib/npm-audit-receipt.mts"), - "--receipt", - path.join(root, "receipt.json"), - "--package-json", - path.join(root, "package.json"), - "--package-lock", - path.join(root, "package-lock.json"), - "--raw-report", - path.join(root, "raw.json"), - "--exceptions", - path.join(root, "exceptions.json"), - "--graph", - inputs.graphId, - "--audit-config", - path.join(root, "reviewed-npm-audit.json"), - "--registry", - inputs.registryOrigin, - "--threshold", - inputs.severityThreshold, - "--result", - resultFile, - "--raw-copy", - rawCopyFile, - ], - { encoding: "utf8" }, - ); + "--registry", + inputs.registryOrigin, + "--threshold", + inputs.severityThreshold, + "--result", + resultFile, + "--raw-copy", + rawCopyFile, + ], + { encoding: "utf8" }, + ); - expect(result.status, result.stderr).toBe(status); - expect(fs.existsSync(resultFile)).toBe(status === 0); - expect(fs.existsSync(rawCopyFile)).toBe(status === 0); - const observedPolicy = fs.existsSync(resultFile) - ? (JSON.parse(fs.readFileSync(resultFile, "utf8")) as Record) - : undefined; - expect( - observedPolicy === undefined - ? undefined - : { graph: observedPolicy.graph, status: observedPolicy.status }, - ).toEqual(policy); - expect(fs.existsSync(rawCopyFile) ? fs.readFileSync(rawCopyFile, "utf8") : undefined).toBe( - rawCopy, - ); - expect(result.stderr).toContain(error); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } - }, - ); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("unexpected or missing keys"); + expect(fs.existsSync(resultFile)).toBe(false); + expect(fs.existsSync(rawCopyFile)).toBe(false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/test/automation/releases/reviewed-npm-audit-cache-key.test.ts b/test/automation/releases/reviewed-npm-audit-cache-key.test.ts new file mode 100644 index 00000000000..8a0f4dddf9e --- /dev/null +++ b/test/automation/releases/reviewed-npm-audit-cache-key.test.ts @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import YAML from "yaml"; + +type WorkflowStep = { + readonly env?: Record; + readonly name?: string; + readonly run?: string; +}; + +type CompositeAction = { + readonly runs: { readonly steps?: readonly WorkflowStep[] }; +}; + +const REPO_ROOT = path.join(import.meta.dirname, "../../.."); + +function copyGraphInputs(targetRoot: string, directory: string) { + const sourceDirectory = path.join(REPO_ROOT, directory); + const targetDirectory = path.join(targetRoot, directory); + fs.mkdirSync(targetDirectory, { recursive: true }); + fs.copyFileSync( + path.join(sourceDirectory, "package.json"), + path.join(targetDirectory, "package.json"), + ); + fs.copyFileSync( + path.join(sourceDirectory, "package-lock.json"), + path.join(targetDirectory, "package-lock.json"), + ); +} + +describe("reviewed 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"); + const outputFile = path.join(root, "github-output"); + const externalFile = path.join(root, "external-sentinel"); + try { + copyGraphInputs(targetRoot, ""); + copyGraphInputs(targetRoot, "agents/openclaw/openclaw-runtime"); + copyGraphInputs(targetRoot, "agents/openclaw/mcporter-runtime"); + copyGraphInputs(targetRoot, "agents/openclaw/wechat-runtime"); + copyGraphInputs(targetRoot, "tools/mcp-tool-discovery-runtime"); + fs.writeFileSync(externalFile, "do not read\n"); + fs.rmSync(path.join(targetRoot, "package.json")); + fs.symlinkSync(externalFile, path.join(targetRoot, "package.json")); + + const action = YAML.parse( + fs.readFileSync( + path.join(REPO_ROOT, ".github", "actions", "ci-reviewed-npm-audit", "action.yaml"), + "utf8", + ), + ) as CompositeAction; + const cacheBucketStep = action.runs.steps?.find( + (step) => step.name === "Resolve reviewed npm audit cache buckets", + ); + const result = spawnSync("bash", ["-c", cacheBucketStep?.run ?? ""], { + cwd: REPO_ROOT, + encoding: "utf8", + env: { + ...process.env, + GITHUB_ACTION_PATH: path.join(REPO_ROOT, ".github", "actions", "ci-reviewed-npm-audit"), + GITHUB_OUTPUT: outputFile, + NEMOCLAW_REVIEWED_NPM_AUDIT_CACHE_DIRECTORY: path.join(root, "cache"), + NEMOCLAW_REVIEWED_NPM_AUDIT_TARGET_ROOT: targetRoot, + }, + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("symbolic-link component"); + expect(fs.existsSync(outputFile) ? fs.readFileSync(outputFile, "utf8") : "").not.toContain( + "input-digest=", + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts new file mode 100644 index 00000000000..8b29d29cba4 --- /dev/null +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -0,0 +1,185 @@ +// 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 { pathToFileURL } from "node:url"; +import { describe, expect, it } from "vitest"; +import YAML from "yaml"; +import { emitAuditReceipt } from "../../../scripts/audit-reviewed-npm-graph.mts"; + +const REPO_ROOT = path.join(import.meta.dirname, "../../.."); +const TRUSTED_WORKFLOWS = [ + "e2e.yaml", + "managed-images.yaml", + "openshell-sdk-package-pr.yaml", + "pr.yaml", +]; + +type Workflow = { + readonly jobs?: Readonly< + Record< + string, + { + readonly steps?: readonly { + readonly with?: Readonly>; + }[]; + } + > + >; +}; + +const TRUSTED_AUDIT_SPARSE_CHECKOUTS = TRUSTED_WORKFLOWS.flatMap((workflowFile) => { + const workflow = YAML.parse( + fs.readFileSync(path.join(REPO_ROOT, ".github", "workflows", workflowFile), "utf8"), + ) as Workflow; + return Object.values(workflow.jobs ?? {}).flatMap((job, jobIndex) => + (job.steps ?? []) + .map((step) => step.with?.["sparse-checkout"]) + .filter( + (sparseCheckout): sparseCheckout is string => + typeof sparseCheckout === "string" && + sparseCheckout.includes("scripts/audit-reviewed-npm-graph.mts"), + ) + .map((sparseCheckout, checkoutIndex) => ({ + name: `${workflowFile}-${jobIndex}-${checkoutIndex}`, + sparseCheckout, + })), + ); +}); + +function stageSparseCheckout(root: string, sparseCheckout: string): void { + sparseCheckout + .split("\n") + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + const destination = path.join(root, entry); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.cpSync(path.join(REPO_ROOT, entry), destination, { recursive: true }); + }); +} + +describe("reviewed npm audit handoff", () => { + it.each(TRUSTED_AUDIT_SPARSE_CHECKOUTS)( + "loads the audit producer from the $name trusted sparse checkout", + ({ sparseCheckout }) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-audit-sparse-checkout-")); + try { + stageSparseCheckout(root, sparseCheckout); + const auditProducer = path.join(root, "scripts", "audit-reviewed-npm-graph.mts"); + const result = spawnSync( + process.execPath, + [ + "--experimental-strip-types", + "--input-type=module", + "--eval", + "await import(process.argv[1])", + pathToFileURL(auditProducer).href, + ], + { encoding: "utf8" }, + ); + + expect(result.status, result.stderr).toBe(0); + } 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"); + const packageLockFile = path.join(root, "package-lock.json"); + const rawReportFile = path.join(root, "report.json"); + const exceptionFile = path.join(root, "exceptions.json"); + const auditConfigFile = path.join(root, "reviewed-npm-audit.json"); + const resultFile = path.join(root, "policy.json"); + const packageJson = Buffer.from("temporary manifest\n"); + const packageLock = Buffer.from("temporary lock\n"); + const exceptionPolicy = '{"schemaVersion":1,"exceptions":[]}\n'; + const rawReport = + '{"vulnerabilities":{},"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":0,"critical":0}}}\n'; + try { + fs.writeFileSync(packageJsonFile, packageJson); + fs.writeFileSync(packageLockFile, packageLock); + fs.writeFileSync(rawReportFile, rawReport); + fs.writeFileSync(exceptionFile, exceptionPolicy); + fs.writeFileSync(auditConfigFile, JSON.stringify({ npmVersion: "10.9.4" })); + fs.writeFileSync( + path.join(root, "report.provenance.json"), + JSON.stringify({ run: { startedAt: new Date().toISOString() } }), + ); + const receiptFile = emitAuditReceipt({ + artifactDirectory: root, + graphId: "temporary-graph", + npmVersion: "10.9.4", + packageJsonFile, + packageLockFile, + preserveInputs: true, + rawReportFile, + registryOrigin: "https://registry.yarnpkg.com", + result: { + acceptedAdvisories: [], + blockingThreshold: "high", + exceptionPolicySha256: createHash("sha256").update(exceptionPolicy).digest("hex"), + graph: "temporary-graph", + reported: { info: 0, low: 0, moderate: 0, high: 0, critical: 0 }, + schemaVersion: 1, + status: "clean", + unacceptedBlockingAdvisories: [], + }, + threshold: "high", + }); + + const retainedPackageJson = path.join(root, "temporary-graph.package.json"); + const retainedPackageLock = path.join(root, "temporary-graph.package-lock.json"); + const transportRawReport = path.join(root, "temporary-graph.raw.json"); + const verifierArgs = [ + "--experimental-strip-types", + path.join(REPO_ROOT, "scripts", "lib", "npm-audit-receipt.mts"), + "--receipt", + receiptFile, + "--package-json", + retainedPackageJson, + "--package-lock", + retainedPackageLock, + "--raw-report", + transportRawReport, + "--exceptions", + exceptionFile, + "--graph", + "temporary-graph", + "--audit-config", + auditConfigFile, + "--registry", + "https://registry.yarnpkg.com", + "--threshold", + "high", + "--result", + resultFile, + ]; + const accepted = spawnSync(process.execPath, verifierArgs, { encoding: "utf8" }); + expect(accepted.status, accepted.stderr).toBe(0); + expect(fs.readFileSync(retainedPackageJson)).toEqual(packageJson); + expect(fs.readFileSync(retainedPackageLock)).toEqual(packageLock); + expect(fs.readFileSync(transportRawReport, "utf8")).toBe(rawReport); + expect(JSON.parse(fs.readFileSync(resultFile, "utf8"))).toMatchObject({ + graph: "temporary-graph", + status: "clean", + }); + + fs.rmSync(resultFile); + fs.writeFileSync(auditConfigFile, JSON.stringify({ 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"); + expect(fs.existsSync(resultFile)).toBe(false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/test/runtime/sandbox/sandbox-build-context.test.ts b/test/runtime/sandbox/sandbox-build-context.test.ts index 8818962410e..b4dc2a1a1e5 100644 --- a/test/runtime/sandbox/sandbox-build-context.test.ts +++ b/test/runtime/sandbox/sandbox-build-context.test.ts @@ -541,6 +541,16 @@ describe("sandbox build context staging", () => { ).toBe("644"); } + function expectStagedReviewedNpmAuditPolicy(buildCtx: string, sourceRoot: string) { + for (const fileName of ["npm-audit-exceptions.json", "reviewed-npm-audit.json"]) { + const stagedFile = path.join(buildCtx, "ci", fileName); + expect(fs.readFileSync(stagedFile, "utf8")).toBe( + fs.readFileSync(path.join(sourceRoot, "ci", fileName), "utf8"), + ); + expect((fs.statSync(stagedFile).mode & 0o777).toString(8)).toBe("644"); + } + } + it("normalizes restrictive and group-writable modes for Docker COPY", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-build-context-unit-")); const blueprintDir = path.join(tmpDir, "nemoclaw-blueprint"); @@ -592,6 +602,7 @@ describe("sandbox build context staging", () => { try { writeBuildContextFixture(sourceRoot); const { buildCtx } = stageOptimizedSandboxBuildContext(sourceRoot, tmpDir); + expectStagedReviewedNpmAuditPolicy(buildCtx, sourceRoot); expectStagedBlueprintModes(buildCtx); expectStagedOpenClawRuntimeGraphs(buildCtx, sourceRoot); expectStagedMcpToolDiscoveryRuntime(buildCtx, sourceRoot); @@ -623,6 +634,7 @@ describe("sandbox build context staging", () => { try { writeBuildContextFixture(sourceRoot); const { buildCtx } = stageLegacySandboxBuildContext(sourceRoot, tmpDir); + expectStagedReviewedNpmAuditPolicy(buildCtx, sourceRoot); expectStagedBlueprintModes(buildCtx); expectStagedOpenClawRuntimeGraphs(buildCtx, sourceRoot); expectStagedMcpToolDiscoveryRuntime(buildCtx, sourceRoot); diff --git a/test/security/mcporter-supply-chain.test.ts b/test/security/mcporter-supply-chain.test.ts index d5e3a185feb..e48a3208400 100644 --- a/test/security/mcporter-supply-chain.test.ts +++ b/test/security/mcporter-supply-chain.test.ts @@ -224,6 +224,7 @@ describe("mcporter image supply-chain controls", () => { ); expect(expectedReviewedNpmVersion).toMatch(/^[0-9]+\.[0-9]+\.[0-9]+$/); expect(auditReceiptInvocation).not.toContain("--npm-version"); + expect(contents).not.toContain("--raw-copy"); expect(auditReceiptInvocation).not.toMatch(/\bnpm\s+--version\b/); expect(auditReceiptInvocation).not.toMatch(/\$\(|`/); expect(contents).not.toContain(`${runtimePrefix} audit --omit=dev --audit-level=low`); @@ -234,6 +235,15 @@ describe("mcporter image supply-chain controls", () => { expect(contents).toContain("StreamableHTTPServerTransport"); }); + it("copies the cached base-image audit report only after receipt verification succeeds", () => { + const contents = fs.readFileSync(path.join(repoRoot, "Dockerfile.base"), "utf8"); + const flattenedContents = contents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); + + expect(flattenedContents).toContain( + '--result /tmp/mcporter-npm-audit-policy.json && cp "$MCPORTER_RAW_REPORT" /tmp/mcporter-npm-audit.json;', + ); + }); + it("verifies the exact committed dependency graph signatures in trusted CI (#8925)", () => { const graph = reviewedAuditConfig.lockedGraphs.find( (candidate) => candidate.id === "mcporter-runtime",