From 15cbd3fd39f8c0f3310bc7b72ea32b1c44a3ff29 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 3 Aug 2026 18:04:29 +0700 Subject: [PATCH 01/15] fix(security): audit root production dependencies --- package-lock.json | 38 +++++++----- scripts/advisory-early-warning-scan.mts | 29 ++++++---- scripts/audit-reviewed-npm-graph.mts | 74 +++++++++++++++++++++++- scripts/lib/advisory-early-warning.mts | 7 ++- test/advisory-early-warning.test.ts | 58 +++++++++++++++++++ test/reviewed-npm-audit-workflow.test.ts | 38 +++++++++++- 6 files changed, 216 insertions(+), 28 deletions(-) diff --git a/package-lock.json b/package-lock.json index a3bf6c6e29..208fb908f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1634,7 +1634,7 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "./dist/cli.js" + "pi-ai": "dist/cli.js" }, "engines": { "node": ">=22.19.0" @@ -4504,15 +4504,15 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/call-bind-apply-helpers": { @@ -5120,9 +5120,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -5220,9 +5220,9 @@ "license": "MIT" }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -5770,9 +5770,19 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/scripts/advisory-early-warning-scan.mts b/scripts/advisory-early-warning-scan.mts index 27109f7b09..3da7c82d55 100755 --- a/scripts/advisory-early-warning-scan.mts +++ b/scripts/advisory-early-warning-scan.mts @@ -4,8 +4,8 @@ // // CLI entry for the advisory early-warning path (#7338). Correlates public // 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 +// ci/reviewed-npm-audit.json (committed package specs plus the root and +// locked-graph production package-locks) and prints structured, NON-blocking signals. Signals never // fail the process: enforcement stays with the reviewed npm audit gate. // // Usage: @@ -17,7 +17,7 @@ // --inventory replaces the repo-derived reviewed inventory with an explicit // JSON array of {name, version[, origin]} entries, so offline callers and // tests can run hermetically; without it (the workflow default) the inventory -// is built from ci/reviewed-npm-audit.json and the locked-graph package-locks. +// is built from ci/reviewed-npm-audit.json and the production package-locks. // // --nvd-records attaches supplementary NVD reconciliations from a file of // previously fetched NVD 2.0 API responses. The CLI itself never performs @@ -36,21 +36,28 @@ import { attachNvdReconciliations, type NvdAnnotatedSignal } from "./lib/nvd-rec const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const CONFIG_RELATIVE_PATH = path.join("ci", "reviewed-npm-audit.json"); +const ROOT_LOCK_RELATIVE_PATH = "package-lock.json"; -function loadReviewedInventory(): InventoryEntry[] { +export function loadReviewedInventory(repoRoot = REPO_ROOT): InventoryEntry[] { const config = JSON.parse( - fs.readFileSync(path.join(REPO_ROOT, CONFIG_RELATIVE_PATH), "utf-8"), + fs.readFileSync(path.join(repoRoot, CONFIG_RELATIVE_PATH), "utf-8"), ) as Record; const inventory = parseInventoryFromAuditConfig(config, CONFIG_RELATIVE_PATH); const lockedGraphs = Array.isArray(config.lockedGraphs) ? config.lockedGraphs : []; - for (const graph of lockedGraphs) { - const directory = (graph as Record | null)?.directory; - if (typeof directory !== "string" || directory.length === 0) continue; - const lockRelativePath = path.join(directory, "package-lock.json"); - const lockPath = path.join(REPO_ROOT, lockRelativePath); + const lockRelativePaths = [ + ROOT_LOCK_RELATIVE_PATH, + ...lockedGraphs.flatMap((graph) => { + const directory = (graph as Record | null)?.directory; + return typeof directory === "string" && directory.length > 0 + ? [path.join(directory, "package-lock.json")] + : []; + }), + ]; + for (const lockRelativePath of lockRelativePaths) { + const lockPath = path.join(repoRoot, lockRelativePath); if (!fs.existsSync(lockPath)) continue; const lock = JSON.parse(fs.readFileSync(lockPath, "utf-8")) as unknown; - inventory.push(...parseInventoryFromPackageLock(lock, lockRelativePath)); + inventory.push(...parseInventoryFromPackageLock(lock, lockRelativePath, { omitDev: true })); } return inventory; } diff --git a/scripts/audit-reviewed-npm-graph.mts b/scripts/audit-reviewed-npm-graph.mts index e2e46dbc04..cb4548deda 100755 --- a/scripts/audit-reviewed-npm-graph.mts +++ b/scripts/audit-reviewed-npm-graph.mts @@ -3,6 +3,7 @@ // 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"; @@ -46,6 +47,10 @@ const TARGET_REPO_ROOT = fs.realpathSync( ); const CONFIG_PATH = resolveTrustedAuditConfigPath(TRUSTED_REPO_ROOT); const SEVERITIES: readonly Severity[] = ["info", "low", "moderate", "high", "critical"]; +const SOURCE_GRAPH = { + id: "nemoclaw-cli", + label: "NemoClaw CLI locked production graph", +} as const; const OPENCLAW_DOMEXCEPTION_ALIAS = { actualName: "@nolyfill/domexception", aliasPackagePath: "node_modules/openclaw/node_modules/node-domexception", @@ -230,6 +235,28 @@ function assertRegularFile(file: string, label: string): void { } } +export function materializeSourceGraph( + sourcePackage: string, + sourceLock: string, + destination: string, +): string { + assertRegularFile(sourcePackage, "NemoClaw CLI package manifest"); + assertRegularFile(sourceLock, "NemoClaw CLI lockfile"); + const lockSha256 = createHash("sha256").update(fs.readFileSync(sourceLock)).digest("hex"); + fs.mkdirSync(destination); + fs.copyFileSync(sourcePackage, path.join(destination, "package.json")); + const installedLock = path.join(destination, "package-lock.json"); + fs.copyFileSync(sourceLock, installedLock); + run("npm", ["ci", "--ignore-scripts", "--omit=dev", "--no-audit", "--no-fund"], destination); + const installedLockSha256 = createHash("sha256") + .update(fs.readFileSync(installedLock)) + .digest("hex"); + if (installedLockSha256 !== lockSha256) { + throw new Error("NemoClaw CLI lockfile changed while materializing its production graph"); + } + return destination; +} + export function normalizeOpenClawSignatureAlias(directory: string): void { const { actualName, @@ -332,6 +359,43 @@ function auditLockedGraph( return result; } +function auditSourceGraph( + config: AuditConfig, + 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"); + const sourceManifest = readJsonObject(sourcePackage, "NemoClaw CLI package manifest"); + if (typeof sourceManifest.name !== "string" || typeof sourceManifest.version !== "string") { + throw new Error("NemoClaw CLI package manifest must declare its name and version"); + } + const directory = materializeSourceGraph( + sourcePackage, + sourceLock, + path.join(tempRoot, "source-graph"), + ); + const result = runReviewedNpmAudit({ + directory, + exceptionFile, + graph: SOURCE_GRAPH.id, + provenance: { + label: SOURCE_GRAPH.label, + nodeVersion: process.version, + npmVersion, + packageSpecs: [`${sourceManifest.name}@${sourceManifest.version}`], + }, + reportFile: path.join(artifactDirectory, "source-graph.json"), + resultFile: path.join(artifactDirectory, "source-graph-policy.json"), + threshold: config.severityThreshold, + throwOnBlock: false, + }); + run("npm", ["audit", "signatures", "--omit=dev"], directory); + return result; +} + function main(): void { const config = readConfig(); const expectedNode = `v${config.nodeVersion}`; @@ -346,7 +410,11 @@ function main(): void { const exceptionRegistry = readAuditExceptionRegistry(exceptionFile); assertExceptionGraphs( exceptionRegistry.policy, - new Set([config.archiveGraphId, ...config.lockedGraphs.map((graph) => graph.id)]), + new Set([ + SOURCE_GRAPH.id, + config.archiveGraphId, + ...config.lockedGraphs.map((graph) => graph.id), + ]), ); fs.rmSync(artifactDirectory, { recursive: true, force: true }); fs.mkdirSync(artifactDirectory, { recursive: true }); @@ -354,6 +422,10 @@ function main(): void { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-npm-audit-")); try { const reports = [ + { + label: SOURCE_GRAPH.label, + result: auditSourceGraph(config, tempRoot, exceptionFile, artifactDirectory, npmVersion), + }, { label: "reviewed archive graph", result: runReviewedNpmAudit({ diff --git a/scripts/lib/advisory-early-warning.mts b/scripts/lib/advisory-early-warning.mts index fc554284af..ff9fb42390 100644 --- a/scripts/lib/advisory-early-warning.mts +++ b/scripts/lib/advisory-early-warning.mts @@ -199,7 +199,11 @@ export function parseInventoryFromAuditConfig(config: unknown, origin: string): * Build an installed-package inventory from a lockfile-version-3 package-lock * subset: every `node_modules/...` entry that records an installed version. */ -export function parseInventoryFromPackageLock(lock: unknown, origin: string): InventoryEntry[] { +export function parseInventoryFromPackageLock( + lock: unknown, + origin: string, + options: Readonly<{ omitDev?: boolean }> = {}, +): InventoryEntry[] { if (typeof lock !== "object" || lock === null) return []; const packages = (lock as Record).packages; if (typeof packages !== "object" || packages === null || Array.isArray(packages)) return []; @@ -210,6 +214,7 @@ export function parseInventoryFromPackageLock(lock: unknown, origin: string): In const pathName = location.slice(marker + "node_modules/".length); if (pathName.length === 0) continue; if (typeof entry !== "object" || entry === null) continue; + if (options.omitDev && (entry as Record).dev === true) continue; const version = (entry as Record).version; if (typeof version !== "string" || version.length === 0) continue; // Aliased installs (`npm install alias@npm:real-name`) live under the alias diff --git a/test/advisory-early-warning.test.ts b/test/advisory-early-warning.test.ts index 0b01b31146..a14f9f42a1 100644 --- a/test/advisory-early-warning.test.ts +++ b/test/advisory-early-warning.test.ts @@ -1,7 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; +import { loadReviewedInventory } from "../scripts/advisory-early-warning-scan.mts"; import { compareSemver, correlateAdvisories, @@ -263,6 +267,60 @@ describe("advisory early warning inventory parsing", () => { ]); }); + it("omits development packages from a production lock inventory", () => { + const lock = { + lockfileVersion: 3, + packages: { + "node_modules/fast-uri": { version: "3.1.5" }, + "node_modules/dev-only": { version: "1.0.0", dev: true }, + }, + }; + expect(parseInventoryFromPackageLock(lock, "fixture-lock.json", { omitDev: true })).toEqual([ + { name: "fast-uri", version: "3.1.5", origin: "fixture-lock.json" }, + ]); + }); + + it("includes the root production lock in the reviewed inventory (#8116)", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-advisory-inventory-")); + try { + fs.mkdirSync(path.join(root, "ci")); + fs.mkdirSync(path.join(root, "runtime")); + fs.writeFileSync( + path.join(root, "ci", "reviewed-npm-audit.json"), + `${JSON.stringify({ + archivePackages: [{ packageSpec: "openclaw@2026.7.1" }], + lockedGraphs: [{ directory: "runtime", packageSpec: "mcporter@0.7.3" }], + })}\n`, + ); + fs.writeFileSync( + path.join(root, "package-lock.json"), + `${JSON.stringify({ + lockfileVersion: 3, + packages: { + "node_modules/fast-uri": { version: "3.1.5" }, + "node_modules/dev-only": { version: "1.0.0", dev: true }, + }, + })}\n`, + ); + fs.writeFileSync( + path.join(root, "runtime", "package-lock.json"), + `${JSON.stringify({ + lockfileVersion: 3, + packages: { "node_modules/tar": { version: "7.5.20" } }, + })}\n`, + ); + + expect(loadReviewedInventory(root)).toEqual([ + { name: "openclaw", version: "2026.7.1", origin: "ci/reviewed-npm-audit.json" }, + { name: "mcporter", version: "0.7.3", origin: "ci/reviewed-npm-audit.json" }, + { name: "fast-uri", version: "3.1.5", origin: "package-lock.json" }, + { name: "tar", version: "7.5.20", origin: "runtime/package-lock.json" }, + ]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it("inventories aliased lock entries under their real package name", () => { const lock = { lockfileVersion: 3, diff --git a/test/reviewed-npm-audit-workflow.test.ts b/test/reviewed-npm-audit-workflow.test.ts index f3036a1b3b..3cbb715bf1 100644 --- a/test/reviewed-npm-audit-workflow.test.ts +++ b/test/reviewed-npm-audit-workflow.test.ts @@ -6,7 +6,10 @@ import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { normalizeOpenClawSignatureAlias } from "../scripts/audit-reviewed-npm-graph.mts"; +import { + materializeSourceGraph, + normalizeOpenClawSignatureAlias, +} from "../scripts/audit-reviewed-npm-graph.mts"; import { readYaml } from "./helpers/e2e-workflow-contract"; type WorkflowStep = { @@ -148,6 +151,39 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { expect(driver).not.toContain('resolveTargetPath(\n "ci/reviewed-npm-audit.json"'); }); + 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"); + const destination = path.join(root, "materialized"); + const manifest = { name: "source-graph-fixture", private: true, version: "1.0.0" }; + const lock = { + name: manifest.name, + version: manifest.version, + lockfileVersion: 3, + requires: true, + packages: { "": manifest }, + }; + const lockSource = `${JSON.stringify(lock, null, 2)}\n`; + try { + fs.mkdirSync(source); + fs.writeFileSync(path.join(source, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`); + fs.writeFileSync(path.join(source, "package-lock.json"), lockSource); + + expect( + materializeSourceGraph( + path.join(source, "package.json"), + path.join(source, "package-lock.json"), + destination, + ), + ).toBe(destination); + expect(fs.readFileSync(path.join(destination, "package-lock.json"), "utf-8")).toBe( + lockSource, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it("normalizes only the reviewed OpenClaw npm alias for registry signature verification", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-signature-alias-")); const aliasPath = path.join("node_modules", "openclaw", "node_modules", "node-domexception"); From 3ab72f3ddabb9806f9e76b77f8db261753cf0f84 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 3 Aug 2026 18:39:17 +0700 Subject: [PATCH 02/15] test(security): cover root audit enforcement --- scripts/audit-reviewed-npm-graph.mts | 68 ++++++++++++++++++------ test/reviewed-npm-audit-workflow.test.ts | 67 +++++++++++++++++++++++ 2 files changed, 118 insertions(+), 17 deletions(-) diff --git a/scripts/audit-reviewed-npm-graph.mts b/scripts/audit-reviewed-npm-graph.mts index cb4548deda..c4af0a7b9c 100755 --- a/scripts/audit-reviewed-npm-graph.mts +++ b/scripts/audit-reviewed-npm-graph.mts @@ -18,6 +18,7 @@ import { assertExceptionGraphs, readAuditExceptionRegistry, runReviewedNpmAudit, + type AuditPolicyResult, type Severity, } from "./lib/reviewed-npm-audit.mts"; @@ -40,6 +41,7 @@ type AuditConfig = Readonly<{ schemaVersion: 2; severityThreshold: Severity; }>; +type ReviewedAuditReport = Readonly<{ label: string; result: AuditPolicyResult }>; const TRUSTED_REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const TARGET_REPO_ROOT = fs.realpathSync( @@ -377,25 +379,66 @@ function auditSourceGraph( sourceLock, path.join(tempRoot, "source-graph"), ); - const result = runReviewedNpmAudit({ + return auditMaterializedSourceGraph({ directory, exceptionFile, + artifactDirectory, + npmVersion, + packageSpec: `${sourceManifest.name}@${sourceManifest.version}`, + threshold: config.severityThreshold, + }); +} + +export function auditMaterializedSourceGraph( + options: Readonly<{ + artifactDirectory: string; + directory: string; + exceptionFile: string; + npmVersion: string; + packageSpec: string; + threshold: Severity; + }>, + dependencies: Readonly<{ + runAudit?: typeof runReviewedNpmAudit; + verifySignatures?: (directory: string) => void; + }> = {}, +): AuditPolicyResult { + const result = (dependencies.runAudit ?? runReviewedNpmAudit)({ + directory: options.directory, + exceptionFile: options.exceptionFile, graph: SOURCE_GRAPH.id, provenance: { label: SOURCE_GRAPH.label, nodeVersion: process.version, - npmVersion, - packageSpecs: [`${sourceManifest.name}@${sourceManifest.version}`], + npmVersion: options.npmVersion, + packageSpecs: [options.packageSpec], }, - reportFile: path.join(artifactDirectory, "source-graph.json"), - resultFile: path.join(artifactDirectory, "source-graph-policy.json"), - threshold: config.severityThreshold, + reportFile: path.join(options.artifactDirectory, "source-graph.json"), + resultFile: path.join(options.artifactDirectory, "source-graph-policy.json"), + threshold: options.threshold, throwOnBlock: false, }); - run("npm", ["audit", "signatures", "--omit=dev"], directory); + ( + dependencies.verifySignatures ?? + ((directory) => run("npm", ["audit", "signatures", "--omit=dev"], directory)) + )(options.directory); return result; } +export function assertReviewedAuditReportsPass( + reports: readonly ReviewedAuditReport[], + threshold: Severity, +): void { + const failures = reports + .filter(({ result }) => result.unacceptedBlockingAdvisories.length > 0) + .map( + ({ label, result }) => + `${label}: ${result.unacceptedBlockingAdvisories.length} unaccepted at or above ${threshold}`, + ); + if (failures.length > 0) + throw new Error(`reviewed npm audit threshold failed\n${failures.join("\n")}`); +} + function main(): void { const config = readConfig(); const expectedNode = `v${config.nodeVersion}`; @@ -457,16 +500,7 @@ function main(): void { ), })), ]; - const failures: string[] = []; - for (const { label, result } of reports) { - if (result.unacceptedBlockingAdvisories.length > 0) { - failures.push( - `${label}: ${result.unacceptedBlockingAdvisories.length} unaccepted at or above ${config.severityThreshold}`, - ); - } - } - if (failures.length > 0) - throw new Error(`reviewed npm audit threshold failed\n${failures.join("\n")}`); + assertReviewedAuditReportsPass(reports, config.severityThreshold); } finally { fs.rmSync(tempRoot, { recursive: true, force: true }); } diff --git a/test/reviewed-npm-audit-workflow.test.ts b/test/reviewed-npm-audit-workflow.test.ts index 3cbb715bf1..cf8db2f1e4 100644 --- a/test/reviewed-npm-audit-workflow.test.ts +++ b/test/reviewed-npm-audit-workflow.test.ts @@ -7,9 +7,12 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { + assertReviewedAuditReportsPass, + auditMaterializedSourceGraph, materializeSourceGraph, normalizeOpenClawSignatureAlias, } from "../scripts/audit-reviewed-npm-graph.mts"; +import type { AuditPolicyResult } from "../scripts/lib/reviewed-npm-audit.mts"; import { readYaml } from "./helpers/e2e-workflow-contract"; type WorkflowStep = { @@ -184,6 +187,70 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { } }); + it("audits the NemoClaw production graph, verifies signatures, and rejects blocking advisories (#8116)", () => { + const events: string[] = []; + const blockedResult = { + acceptedAdvisories: [], + blockingThreshold: "high", + exceptionPolicySha256: "fixture-policy", + graph: "nemoclaw-cli", + reported: { info: 0, low: 0, moderate: 0, high: 1, critical: 0 }, + schemaVersion: 1, + status: "blocked", + unacceptedBlockingAdvisories: [ + { + advisory: "GHSA-aaaa-bbbb-cccc", + installedVersion: "1.0.0", + package: "fixture-package", + severity: "high", + }, + ], + } satisfies AuditPolicyResult; + const result = auditMaterializedSourceGraph( + { + artifactDirectory: "/artifacts", + directory: "/materialized", + exceptionFile: "/exceptions.json", + npmVersion: "10.9.4", + packageSpec: "nemoclaw@0.0.0", + threshold: "high", + }, + { + runAudit: (options) => { + events.push("policy-audit"); + expect(options).toMatchObject({ + directory: "/materialized", + exceptionFile: "/exceptions.json", + graph: "nemoclaw-cli", + provenance: { + label: "NemoClaw CLI locked production graph", + npmVersion: "10.9.4", + packageSpecs: ["nemoclaw@0.0.0"], + }, + reportFile: path.join("/artifacts", "source-graph.json"), + resultFile: path.join("/artifacts", "source-graph-policy.json"), + threshold: "high", + throwOnBlock: false, + }); + return blockedResult; + }, + verifySignatures: (directory) => { + events.push(`signatures:${directory}`); + }, + }, + ); + + expect(events).toEqual(["policy-audit", "signatures:/materialized"]); + expect(() => + assertReviewedAuditReportsPass( + [{ label: "NemoClaw CLI locked production graph", result }], + "high", + ), + ).toThrow( + "reviewed npm audit threshold failed\nNemoClaw CLI locked production graph: 1 unaccepted at or above high", + ); + }); + it("normalizes only the reviewed OpenClaw npm alias for registry signature verification", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-signature-alias-")); const aliasPath = path.join("node_modules", "openclaw", "node_modules", "node-domexception"); From fb42b51f3f177a0a4bdd35793af050f2fe97dc01 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 3 Aug 2026 18:56:26 +0700 Subject: [PATCH 03/15] fix(security): validate root audit registry --- scripts/audit-reviewed-npm-graph.mts | 8 +++- scripts/lib/reviewed-npm-archive.mts | 18 +++++++- test/reviewed-npm-audit-workflow.test.ts | 54 ++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/scripts/audit-reviewed-npm-graph.mts b/scripts/audit-reviewed-npm-graph.mts index c4af0a7b9c..39623d719d 100755 --- a/scripts/audit-reviewed-npm-graph.mts +++ b/scripts/audit-reviewed-npm-graph.mts @@ -13,6 +13,7 @@ import { packReviewedNpmArchive, verifyInstalledNpmLock, verifyReviewedNpmLock, + verifyReviewedNpmLockPackages, } from "./lib/reviewed-npm-archive.mts"; import { assertExceptionGraphs, @@ -241,15 +242,19 @@ export function materializeSourceGraph( sourcePackage: string, sourceLock: string, destination: string, + registryOrigin: string, + installProductionDependencies: (directory: string) => void = (directory) => + void run("npm", ["ci", "--ignore-scripts", "--omit=dev", "--no-audit", "--no-fund"], directory), ): string { assertRegularFile(sourcePackage, "NemoClaw CLI package manifest"); assertRegularFile(sourceLock, "NemoClaw CLI lockfile"); + verifyReviewedNpmLockPackages({ lockfilePath: sourceLock, omitDev: true, registryOrigin }); const lockSha256 = createHash("sha256").update(fs.readFileSync(sourceLock)).digest("hex"); fs.mkdirSync(destination); fs.copyFileSync(sourcePackage, path.join(destination, "package.json")); const installedLock = path.join(destination, "package-lock.json"); fs.copyFileSync(sourceLock, installedLock); - run("npm", ["ci", "--ignore-scripts", "--omit=dev", "--no-audit", "--no-fund"], destination); + installProductionDependencies(destination); const installedLockSha256 = createHash("sha256") .update(fs.readFileSync(installedLock)) .digest("hex"); @@ -378,6 +383,7 @@ function auditSourceGraph( sourcePackage, sourceLock, path.join(tempRoot, "source-graph"), + config.registryOrigin, ); return auditMaterializedSourceGraph({ directory, diff --git a/scripts/lib/reviewed-npm-archive.mts b/scripts/lib/reviewed-npm-archive.mts index 6cdb829162..6c96320c4d 100755 --- a/scripts/lib/reviewed-npm-archive.mts +++ b/scripts/lib/reviewed-npm-archive.mts @@ -281,6 +281,8 @@ function readReviewedLockPackages( packages: Readonly>>, lockfilePath: string, registryOrigin: string, + omitDev = false, + allowEmpty = false, ): readonly ReviewedNpmArchiveRequest[] { const reviewed: ReviewedNpmArchiveRequest[] = []; const identities = new Map(); @@ -290,6 +292,7 @@ function readReviewedLockPackages( throw new Error(`reviewed npm lock has an invalid package record: ${location}`); } const record = value as Record; + if (omitDev && record.dev === true) continue; const locationName = packageNameFromLockLocation(location); const packageName = typeof record.name === "string" ? record.name : locationName; const version = typeof record.version === "string" ? record.version : ""; @@ -331,12 +334,25 @@ function readReviewedLockPackages( identities.set(packageSpec, request); reviewed.push(request); } - if (reviewed.length === 0) { + if (!allowEmpty && reviewed.length === 0) { throw new Error(`reviewed npm lock contains no packages: ${lockfilePath}`); } return reviewed; } +export function verifyReviewedNpmLockPackages( + request: Readonly<{ lockfilePath: string; omitDev?: boolean; registryOrigin: string }>, +): readonly string[] { + const registryOrigin = normalizeRegistryOrigin(request.registryOrigin); + return readReviewedLockPackages( + readReviewedLock(request.lockfilePath), + request.lockfilePath, + registryOrigin, + request.omitDev, + true, + ).map(({ packageSpec }) => packageSpec); +} + export function verifyReviewedNpmLock( request: ReviewedNpmLockRequest, npmRunner: NpmRunner = runNpm, diff --git a/test/reviewed-npm-audit-workflow.test.ts b/test/reviewed-npm-audit-workflow.test.ts index cf8db2f1e4..659c421336 100644 --- a/test/reviewed-npm-audit-workflow.test.ts +++ b/test/reviewed-npm-audit-workflow.test.ts @@ -177,6 +177,7 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { path.join(source, "package.json"), path.join(source, "package-lock.json"), destination, + "https://registry.npmjs.org", ), ).toBe(destination); expect(fs.readFileSync(path.join(destination, "package-lock.json"), "utf-8")).toBe( @@ -187,6 +188,59 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { } }); + it("rejects an unreviewed registry package before npm ci installs the root production graph (#8116)", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-source-graph-registry-")); + const source = path.join(root, "source"); + const destination = path.join(root, "materialized"); + const manifest = { + dependencies: { "fixture-package": "1.0.0" }, + name: "source-graph-fixture", + private: true, + version: "1.0.0", + }; + const lock = { + name: manifest.name, + version: manifest.version, + lockfileVersion: 3, + requires: true, + packages: { + "": manifest, + "node_modules/fixture-package": { + integrity: "sha512-fixture", + resolved: "https://example.com/fixture-package-1.0.0.tgz", + version: "1.0.0", + }, + }, + }; + let installCalled = false; + try { + fs.mkdirSync(source); + fs.writeFileSync(path.join(source, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`); + fs.writeFileSync( + path.join(source, "package-lock.json"), + `${JSON.stringify(lock, null, 2)}\n`, + ); + + expect(() => + materializeSourceGraph( + path.join(source, "package.json"), + path.join(source, "package-lock.json"), + destination, + "https://registry.npmjs.org", + () => { + installCalled = true; + }, + ), + ).toThrow( + "reviewed npm lock package must use the reviewed registry: node_modules/fixture-package", + ); + expect(installCalled).toBe(false); + expect(fs.existsSync(destination)).toBe(false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it("audits the NemoClaw production graph, verifies signatures, and rejects blocking advisories (#8116)", () => { const events: string[] = []; const blockedResult = { From 0e191e731818d8b6817ae859db3d14a57c9bc732 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 3 Aug 2026 14:16:40 -0400 Subject: [PATCH 04/15] fix(security): upgrade brace-expansion remediation Signed-off-by: Julie Yaunches --- Dockerfile | 2 +- Dockerfile.base | 4 ++-- agents/hermes/Dockerfile.base | 2 +- .../langchain-deepagents-code/Dockerfile.base | 2 +- .../openclaw-runtime/package-lock.json | 6 ++--- ci/reviewed-npm-audit.json | 2 +- .../openclaw-2026.7.1-dependency-review.md | 17 +++++++++----- ...ndbox-base-2026-07-25-dependency-review.md | 22 +++++++++++-------- scripts/lib/openclaw-npm-remediation.mts | 8 +++---- scripts/patch-bundled-npm-brace-expansion.mts | 6 ++--- ...race-expansion-dockerfile-contract.test.ts | 4 ++-- test/openclaw-locked-install.test.ts | 4 ++-- test/openclaw-npm-remediation.test.ts | 6 ++--- 13 files changed, 47 insertions(+), 38 deletions(-) diff --git a/Dockerfile b/Dockerfile index d78fbe5352..870c88e7a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -378,7 +378,7 @@ RUN set -eu; \ OPENCLAW_LOCK_SHA256=none-legacy-fixture; \ OPENCLAW_RECIPE='ignore-scripts+reviewed-lifecycle-v1'; \ if [ "$OPENCLAW_VERSION" = "2026.7.1" ]; then \ - OPENCLAW_LOCK_SHA256=82489f62febb12da52833c0b1f7f6969f7e21a098c565ef1f91342b1e5e32d88; \ + OPENCLAW_LOCK_SHA256=fdbee91a3f3a0222ec2e8e34864d09dfcff4711e86ac6bfa4d475af0593acd51; \ ACTUAL_OPENCLAW_LOCK_SHA256="$(sha256sum /usr/local/lib/nemoclaw/openclaw-runtime/package-lock.json | awk '{print $1}')"; \ [ "$ACTUAL_OPENCLAW_LOCK_SHA256" = "$OPENCLAW_LOCK_SHA256" ] \ || { echo "ERROR: OpenClaw lock SHA-256 mismatch (expected $OPENCLAW_LOCK_SHA256, found $ACTUAL_OPENCLAW_LOCK_SHA256)" >&2; exit 1; }; \ diff --git a/Dockerfile.base b/Dockerfile.base index 1607c5a9e0..9ba1ae53e6 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -434,7 +434,7 @@ RUN node --experimental-strip-types /scripts/upgrade-bundled-npm.mts \ --npm-root /usr/local/lib/node_modules/npm # npm 11.18.0 contains brace-expansion 5.0.7. Replace only that private -# package from the reviewed 5.0.8 archive after the complete npm upgrade. +# package from the reviewed 5.0.9 archive after the complete npm upgrade. # hadolint ignore=DL3059 RUN node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts \ --npm-root /usr/local/lib/node_modules/npm @@ -481,7 +481,7 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep OPENCLAW_LOCK_SHA256=none-legacy-fixture; \ OPENCLAW_RECIPE='ignore-scripts+reviewed-lifecycle-v1'; \ if [ "$OPENCLAW_VERSION" = "2026.7.1" ]; then \ - OPENCLAW_LOCK_SHA256=82489f62febb12da52833c0b1f7f6969f7e21a098c565ef1f91342b1e5e32d88; \ + OPENCLAW_LOCK_SHA256=fdbee91a3f3a0222ec2e8e34864d09dfcff4711e86ac6bfa4d475af0593acd51; \ ACTUAL_OPENCLAW_LOCK_SHA256="$(sha256sum /usr/local/lib/nemoclaw/openclaw-runtime/package-lock.json | awk '{print $1}')"; \ [ "$ACTUAL_OPENCLAW_LOCK_SHA256" = "$OPENCLAW_LOCK_SHA256" ] \ || { echo "Error: OpenClaw lock SHA-256 mismatch (expected $OPENCLAW_LOCK_SHA256, found $ACTUAL_OPENCLAW_LOCK_SHA256)"; exit 1; }; \ diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index 023c808ace..397a6e237d 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -268,7 +268,7 @@ RUN node --experimental-strip-types /scripts/upgrade-bundled-npm.mts \ --npm-root /usr/local/lib/node_modules/npm # Replace npm 11.18.0's private brace-expansion 5.0.7 package with the -# registry- and SRI-pinned 5.0.8 release. +# registry- and SRI-pinned 5.0.9 release. # hadolint ignore=DL3059 RUN node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts \ --npm-root /usr/local/lib/node_modules/npm diff --git a/agents/langchain-deepagents-code/Dockerfile.base b/agents/langchain-deepagents-code/Dockerfile.base index 0f88ce7647..c439605420 100644 --- a/agents/langchain-deepagents-code/Dockerfile.base +++ b/agents/langchain-deepagents-code/Dockerfile.base @@ -208,7 +208,7 @@ RUN node --experimental-strip-types /scripts/upgrade-bundled-npm.mts \ --npm-root /usr/local/lib/node_modules/npm # Replace npm 11.18.0's private brace-expansion 5.0.7 package with the -# registry- and SRI-pinned 5.0.8 release. +# registry- and SRI-pinned 5.0.9 release. # hadolint ignore=DL3059 RUN node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts \ --npm-root /usr/local/lib/node_modules/npm diff --git a/agents/openclaw/openclaw-runtime/package-lock.json b/agents/openclaw/openclaw-runtime/package-lock.json index 7cb2f69b89..943e2eae39 100644 --- a/agents/openclaw/openclaw-runtime/package-lock.json +++ b/agents/openclaw/openclaw-runtime/package-lock.json @@ -794,9 +794,9 @@ "license": "MIT" }, "node_modules/openclaw/node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" diff --git a/ci/reviewed-npm-audit.json b/ci/reviewed-npm-audit.json index d57d87bc81..96eb9cc65d 100644 --- a/ci/reviewed-npm-audit.json +++ b/ci/reviewed-npm-audit.json @@ -70,7 +70,7 @@ "integrity": "sha512-ge/Xss99CHAjPL/ikmH/UFoiOrjcxDB4sW3y9mhyCD+dYW3wzV7TKbAVdkrXFgAG2d2BjpJofP97zUZ+umxo8g==", "tarballUrl": "https://registry.npmjs.org/openclaw/-/openclaw-2026.7.1.tgz", "directory": "agents/openclaw/openclaw-runtime", - "lockSha256": "82489f62febb12da52833c0b1f7f6969f7e21a098c565ef1f91342b1e5e32d88" + "lockSha256": "fdbee91a3f3a0222ec2e8e34864d09dfcff4711e86ac6bfa4d475af0593acd51" }, { "id": "mcporter-runtime", diff --git a/docs/security/openclaw-2026.7.1-dependency-review.md b/docs/security/openclaw-2026.7.1-dependency-review.md index 3054ad1727..b28b208af5 100644 --- a/docs/security/openclaw-2026.7.1-dependency-review.md +++ b/docs/security/openclaw-2026.7.1-dependency-review.md @@ -5,6 +5,8 @@ Review date: 2026-07-21 +Last updated: 2026-08-03 + ## Decision Pin the production OpenClaw runtime and matching official plugins to the @@ -17,10 +19,10 @@ the exact plugin graph reports no vulnerabilities after that update. The production OpenClaw install uses the authoritative committed lock at `agents/openclaw/openclaw-runtime/package-lock.json`, with SHA-256 -`82489f62febb12da52833c0b1f7f6969f7e21a098c565ef1f91342b1e5e32d88`. +`fdbee91a3f3a0222ec2e8e34864d09dfcff4711e86ac6bfa4d475af0593acd51`. NemoClaw derives that lock from the SRI-verified `openclaw@2026.7.1` archive after replacing the reviewed affected `brace-expansion@5.0.7` and -`fast-uri@3.1.2` resolutions with `5.0.8` and `3.1.4`. +`fast-uri@3.1.2` resolutions with `5.0.9` and `3.1.4`. Image builds verify the lock digest and installed production graph before they expose the OpenClaw binary. @@ -61,9 +63,9 @@ whose amd64 config reports Node `22.23.1`. - `tar@7.5.20` (NemoClaw plugin direct dependency) - `sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==` - `https://registry.npmjs.org/tar/-/tar-7.5.20.tgz` -- `brace-expansion@5.0.8` (OpenClaw locked-runtime remediation) - - `sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==` - - `https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz` +- `brace-expansion@5.0.9` (OpenClaw locked-runtime remediation) + - `sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==` + - `https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz` - `fast-uri@3.1.4` (OpenClaw and mcporter locked-runtime remediation) - `sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==` - `https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz` @@ -94,7 +96,10 @@ major-version compatibility boundary. OpenClaw's `minimatch@10.2.5` edge originally resolved `brace-expansion@5.0.7`, which is affected by `GHSA-mh99-v99m-4gvg`. -The reviewed OpenClaw remediation selects `5.0.8`, retains the declared +The initial remediation selected `5.0.8` for that advisory. +The high-severity `GHSA-rgw5-rvv9-x895`, also tracked as `CVE-2026-69152`, +affects `brace-expansion` versions `>=4.0.0 <5.0.9`. +The current OpenClaw remediation selects `5.0.9`, retains the declared `balanced-match@^4.0.2` dependency shape, and fails if the upstream shrinkwrap or replacement archive identity changes. diff --git a/docs/security/sandbox-base-2026-07-25-dependency-review.md b/docs/security/sandbox-base-2026-07-25-dependency-review.md index 1a0337ffcd..10616ba78e 100644 --- a/docs/security/sandbox-base-2026-07-25-dependency-review.md +++ b/docs/security/sandbox-base-2026-07-25-dependency-review.md @@ -5,12 +5,14 @@ Date: 2026-07-25 +Last updated: 2026-08-03 + ## Scope This review covers the sandbox dependency changes that: - standardize the OpenClaw, Hermes, and Deep Agents Code base images on fixed Debian `libexpat1=2.8.2-1`, `libjq1=1.8.2-1`, `jq=1.8.2-1`, `vim-common=2:9.2.0782-1`, and `vim-tiny=2:9.2.0782-1` packages, with the reviewed `libonig5=6.9.9-1+b1` jq runtime dependency; -- replace the `brace-expansion@5.0.7` copy inside the reviewed `npm@11.18.0` package with `brace-expansion@5.0.8`; and +- replace the `brace-expansion@5.0.7` copy inside the reviewed `npm@11.18.0` package with `brace-expansion@5.0.9`; and - verify the security-relevant dual-life module versions shipped by the checksum-pinned Perl 5.44.0 build. The OpenClaw base image retains its existing reviewed jq and Expat identities, while the Hermes and Deep Agents Code base images move to the same package boundary. @@ -25,7 +27,7 @@ These changes preserve the existing supported image behavior and do not create a | Oniguruma | Distro-selected jq runtime dependency | All managed images: `libonig5=6.9.9-1+b1` | Debian Snapshot `20260724T000000Z` and architecture-specific SHA-256 values below | | Expat | OpenClaw: `2.8.2-1`; Hermes and Deep Agents Code: distro-selected package | All managed images: `libexpat1=2.8.2-1` | Debian Snapshot `20260724T000000Z` and architecture-specific SHA-256 values below | | npm | `npm@11.18.0` | unchanged | Existing reviewed npm archive and integrity | -| npm private `brace-expansion` | `5.0.7` | `5.0.8` | Registry tarball and SHA-512 integrity below | +| npm private `brace-expansion` | `5.0.7` | `5.0.9` | Registry tarball and SHA-512 integrity below | | Perl | `5.44.0-1nemoclaw1` | unchanged | Existing CPAN archive SHA-256 and complete upstream test suite | The fixed Vim source package is later than every reviewed 9.2 patch boundary. @@ -44,9 +46,9 @@ The immutable Debian package SHA-256 values are: The reviewed npm replacement is: -- version: `brace-expansion@5.0.8`; -- integrity: `sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==`; and -- tarball: `https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz`. +- version: `brace-expansion@5.0.9`; +- integrity: `sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==`; and +- tarball: `https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz`. ## Contract audit @@ -79,14 +81,16 @@ The final reviewed dependency set and range evidence is: | `libjq1` | `1.7.1-6+deb13u2..1.8.2-1` | `1.8.2-1` | exact dpkg identity and matching `jq` runtime | | `jq` | `1.7.1-6+deb13u2..1.8.2-1` | `1.8.2-1` | exact dpkg identity, `jq-1.8.2`, and a JSON expression probe | | `vim-common` and `vim-tiny` | `2:9.1.1230-2..2:9.2.0782-1` | `2:9.2.0782-1` | exact dpkg identities and Vim 9.2 runtime probe | -| npm private `brace-expansion` | `5.0.7..5.0.8` | `5.0.8` | complete-tree identity, dependency-shape, SRI, rollback, and npm/npx ordering guards | +| npm private `brace-expansion` | `>=4.0.0 <5.0.9` | `5.0.9` | complete-tree identity, dependency-shape, SRI, rollback, and npm/npx ordering guards | | Perl and reviewed dual-life modules | Perl `5.43.10..5.44.0`; `HTTP::Tiny < 0.095`; `IO::Compress < 2.223` | Perl `5.44.0`; `HTTP::Tiny 0.096`; `IO::Compress 2.223`; component identities listed below | native package identity and direct interpreter/module version probes | ### Bundled npm package compatibility The npm release remains `11.18.0`. Its private dependency tree contains one top-level `brace-expansion@5.0.7` package with the existing `balanced-match@^4.0.2` contract. -The replacement `5.0.8` package preserves that dependency contract, and its Node engine floor is compatible with the Node 22 and Node 24 base images. +The initial replacement selected `5.0.8` to address `GHSA-mh99-v99m-4gvg`. +The high-severity `GHSA-rgw5-rvv9-x895`, also tracked as `CVE-2026-69152`, affects versions `>=4.0.0 <5.0.9`. +The current `5.0.9` replacement preserves the dependency contract, and its Node engine floor is compatible with the Node 22 and Node 24 base images. The replacement helper: @@ -138,7 +142,7 @@ The core interpreter version check also remains the binding for core-language fi ### DEP-2 affected package inside npm's private tree -- Range: `brace-expansion 5.0.7..5.0.8` +- Range: `brace-expansion >=4.0.0 <5.0.9` - Surface: transitive bundled npm dependency - Severity: high - Confidence: high @@ -176,7 +180,7 @@ The core interpreter version check also remains the binding for core-language fi Remove the Debian snapshot override only when the supported Debian suite publishes packages at or beyond every reviewed fix boundary and the replacements pass the same amd64 and arm64 package and runtime checks for all managed base images. -Remove the private brace-expansion helper only when every pinned Node base installs a reviewed npm release whose complete private tree contains no brace-expansion version below 5.0.8. +Remove the private brace-expansion helper only when every pinned Node base installs a reviewed npm release whose complete private tree contains no brace-expansion version below 5.0.9. Updating the npm archive without revisiting this helper must fail the image contract. ## Verification diff --git a/scripts/lib/openclaw-npm-remediation.mts b/scripts/lib/openclaw-npm-remediation.mts index 4561999fb1..42a23aa375 100755 --- a/scripts/lib/openclaw-npm-remediation.mts +++ b/scripts/lib/openclaw-npm-remediation.mts @@ -85,11 +85,11 @@ const BRACE_EXPANSION_INTEGRITY = "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="; const BRACE_EXPANSION_TARBALL = "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz"; -const CURRENT_BRACE_EXPANSION_VERSION = "5.0.8"; +const CURRENT_BRACE_EXPANSION_VERSION = "5.0.9"; const CURRENT_BRACE_EXPANSION_INTEGRITY = - "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="; + "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="; const CURRENT_BRACE_EXPANSION_TARBALL = - "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz"; + "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz"; const CURRENT_FAST_URI_VERSION = "3.1.4"; const CURRENT_FAST_URI_INTEGRITY = "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw=="; @@ -152,7 +152,7 @@ const REMEDIATIONS: Readonly> = Object.freeze({ // publishes both corrected transitive resolutions in its shrinkwrap. "openclaw@2026.7.1": { expectedPatchedTreeIntegrity: - "sha512-/D/vAtyFEvd0TvYxFBfBN/chwW8WGaAy8bH40ojjJSRuN+P461qnbfiMTHydA7asXN/69y2NnQHs7b3pDh8+0g==", + "sha512-13Ou0RhcOY850Feq/gu6MiCrRQPUlnfkVxwH6o2KCe1G460lfFBJ+lkGBjedUhek1nyEOSotcD0BYsizX7TI+w==", kind: "current-core", version: "2026.7.1", }, diff --git a/scripts/patch-bundled-npm-brace-expansion.mts b/scripts/patch-bundled-npm-brace-expansion.mts index 28d281d04b..a8fc528496 100755 --- a/scripts/patch-bundled-npm-brace-expansion.mts +++ b/scripts/patch-bundled-npm-brace-expansion.mts @@ -25,11 +25,11 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "nod import { fileURLToPath } from "node:url"; export const AFFECTED_BRACE_EXPANSION_VERSION = "5.0.7"; -export const FIXED_BRACE_EXPANSION_VERSION = "5.0.8"; +export const FIXED_BRACE_EXPANSION_VERSION = "5.0.9"; export const FIXED_BRACE_EXPANSION_INTEGRITY = - "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="; + "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="; export const FIXED_BRACE_EXPANSION_TARBALL = - "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz"; + "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz"; export const REVIEWED_NPM_VERSION = "11.18.0"; type JsonRecord = Record; diff --git a/test/bundled-npm-brace-expansion-dockerfile-contract.test.ts b/test/bundled-npm-brace-expansion-dockerfile-contract.test.ts index 36ee1e03b8..5cb814cc12 100644 --- a/test/bundled-npm-brace-expansion-dockerfile-contract.test.ts +++ b/test/bundled-npm-brace-expansion-dockerfile-contract.test.ts @@ -33,10 +33,10 @@ describe("bundled npm brace-expansion image remediation contract", () => { it("binds the replacement to the reviewed npm and registry artifact", () => { expect(REVIEWED_NPM_VERSION).toBe(UPGRADED_NPM_VERSION); expect(REVIEWED_NPM_VERSION).toBe("11.18.0"); - expect(FIXED_BRACE_EXPANSION_VERSION).toBe("5.0.8"); + expect(FIXED_BRACE_EXPANSION_VERSION).toBe("5.0.9"); expect(FIXED_BRACE_EXPANSION_INTEGRITY).toMatch(/^sha512-[A-Za-z0-9+/]+=*$/u); expect(FIXED_BRACE_EXPANSION_TARBALL).toBe( - "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", ); }); diff --git a/test/openclaw-locked-install.test.ts b/test/openclaw-locked-install.test.ts index e6adcd104b..c5088e8fc2 100644 --- a/test/openclaw-locked-install.test.ts +++ b/test/openclaw-locked-install.test.ts @@ -19,7 +19,7 @@ const PACKAGE_SPEC = "openclaw@2026.7.1"; const INTEGRITY = "sha512-ge/Xss99CHAjPL/ikmH/UFoiOrjcxDB4sW3y9mhyCD+dYW3wzV7TKbAVdkrXFgAG2d2BjpJofP97zUZ+umxo8g=="; const TARBALL = "https://registry.npmjs.org/openclaw/-/openclaw-2026.7.1.tgz"; -const LOCK_SHA256 = "82489f62febb12da52833c0b1f7f6969f7e21a098c565ef1f91342b1e5e32d88"; +const LOCK_SHA256 = "fdbee91a3f3a0222ec2e8e34864d09dfcff4711e86ac6bfa4d475af0593acd51"; const roots: string[] = []; function sha256(file: string): string { @@ -172,7 +172,7 @@ describe("locked OpenClaw production installation (#5896)", () => { const verified = verifyReviewedNpmLock(lockRequest(), reviewedMetadata); expect(verified).toHaveLength(307); expect(verified).toContain(PACKAGE_SPEC); - expect(verified).toContain("brace-expansion@5.0.8"); + expect(verified).toContain("brace-expansion@5.0.9"); expect(verified).toContain("fast-uri@3.1.4"); expect(sha256(LOCKFILE)).toBe(LOCK_SHA256); }); diff --git a/test/openclaw-npm-remediation.test.ts b/test/openclaw-npm-remediation.test.ts index a1cfbb3ea5..522c6f21ba 100644 --- a/test/openclaw-npm-remediation.test.ts +++ b/test/openclaw-npm-remediation.test.ts @@ -428,10 +428,10 @@ describe("OpenClaw npm remediation", () => { packages: Record; }>(path.join(directory, "npm-shrinkwrap.json")); expect(shrinkwrap.packages["node_modules/brace-expansion"]).toMatchObject({ - version: "5.0.8", - resolved: "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + version: "5.0.9", + resolved: "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", integrity: - "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", }); expect(shrinkwrap.packages["node_modules/fast-uri"]).toMatchObject({ version: "3.1.4", From 13c44101a56728f0d08d3e75f1610fdf6997c67c Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 3 Aug 2026 14:31:12 -0400 Subject: [PATCH 05/15] fix(security): accept previous brace remediation Signed-off-by: Julie Yaunches --- scripts/patch-bundled-npm-brace-expansion.mts | 9 ++++++++- test/patch-bundled-npm-brace-expansion.test.ts | 12 ++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/scripts/patch-bundled-npm-brace-expansion.mts b/scripts/patch-bundled-npm-brace-expansion.mts index a8fc528496..b4361682d7 100755 --- a/scripts/patch-bundled-npm-brace-expansion.mts +++ b/scripts/patch-bundled-npm-brace-expansion.mts @@ -32,6 +32,12 @@ export const FIXED_BRACE_EXPANSION_TARBALL = "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz"; export const REVIEWED_NPM_VERSION = "11.18.0"; +const REVIEWED_BRACE_EXPANSION_VERSIONS = new Set([ + AFFECTED_BRACE_EXPANSION_VERSION, + "5.0.8", + FIXED_BRACE_EXPANSION_VERSION, +]); + type JsonRecord = Record; function record(value: unknown, label: string): JsonRecord { @@ -156,7 +162,8 @@ export function inspectBundledNpmBraceExpansion(npmRoot: string): BundledNpmBrac const version = braceManifest.version; if ( braceManifest.name !== "brace-expansion" || - (version !== AFFECTED_BRACE_EXPANSION_VERSION && version !== FIXED_BRACE_EXPANSION_VERSION) || + typeof version !== "string" || + !REVIEWED_BRACE_EXPANSION_VERSIONS.has(version) || dependencies["balanced-match"] !== "^4.0.2" ) { throw new Error( diff --git a/test/patch-bundled-npm-brace-expansion.test.ts b/test/patch-bundled-npm-brace-expansion.test.ts index 3ba2b02d04..c7894403d0 100644 --- a/test/patch-bundled-npm-brace-expansion.test.ts +++ b/test/patch-bundled-npm-brace-expansion.test.ts @@ -91,6 +91,18 @@ describe("npm bundled brace-expansion remediation", () => { ).toBe("fixed\n"); }); + it("upgrades the previous remediation release", () => { + const target = fixture("5.0.8"); + + expect(patchBundledNpmBraceExpansion(target)).toMatchObject({ + braceExpansionVersion: FIXED_BRACE_EXPANSION_VERSION, + state: "fixed", + }); + expect( + fs.existsSync(path.join(target.npmRoot, "node_modules", "brace-expansion", "old.js")), + ).toBe(false); + }); + it("does not invoke npm or npx until the private package is replaced and verified", () => { const target = fixture(AFFECTED_BRACE_EXPANSION_VERSION); const commands: string[] = []; From 1e30bc1a4caa1c0cbbbb9c4c1fc4d210782248e0 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 3 Aug 2026 15:20:29 -0400 Subject: [PATCH 06/15] fix(security): update root brace-expansion locks Signed-off-by: Julie Yaunches --- ci/source-shape-test-budget.json | 5 +++ package-lock.json | 22 +++++----- test/root-package-lock-security.test.ts | 54 +++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 11 deletions(-) create mode 100644 test/root-package-lock-security.test.ts diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index 8e473b6d06..201c241919 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -431,6 +431,11 @@ "test": "runs PR audits from trusted code and keeps the main audit on the checked-in action", "category": "security" }, + { + "file": "test/root-package-lock-security.test.ts", + "test": "pins every brace-expansion copy past the affected ranges", + "category": "security" + }, { "file": "test/platform-vitest-main-workflow.test.ts", "test": "pins and verifies the Node.js archive in the trusted WSL helper", diff --git a/package-lock.json b/package-lock.json index a3bf6c6e29..adda5d2fb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2208,16 +2208,16 @@ "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/buffer-equal-constant-time": { @@ -4504,15 +4504,15 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/call-bind-apply-helpers": { @@ -5220,9 +5220,9 @@ "license": "MIT" }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" diff --git a/test/root-package-lock-security.test.ts b/test/root-package-lock-security.test.ts new file mode 100644 index 0000000000..1c2882d6cc --- /dev/null +++ b/test/root-package-lock-security.test.ts @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +type LockedPackage = { + integrity?: string; + resolved?: string; + version?: string; +}; + +const ROOT = resolve(import.meta.dirname, ".."); +const lock = JSON.parse(readFileSync(resolve(ROOT, "package-lock.json"), "utf8")) as { + packages: Record; +}; + +const reviewedBraceExpansionCopies = { + "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { + version: "5.0.9", + resolved: "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + integrity: + "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + }, + "node_modules/brace-expansion": { + version: "5.0.9", + resolved: "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + integrity: + "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + }, + "node_modules/filelist/node_modules/brace-expansion": { + version: "2.1.4", + resolved: "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + integrity: + "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + }, +}; + +describe("root dependency lock security", () => { + // source-shape-contract: security -- Exact root lock identities keep every brace-expansion copy outside the affected advisory ranges + it("pins every brace-expansion copy past the affected ranges", () => { + const braceExpansionCopies = Object.fromEntries( + Object.entries(lock.packages).filter(([packagePath]) => + packagePath.endsWith("node_modules/brace-expansion"), + ), + ); + + expect(braceExpansionCopies).toMatchObject(reviewedBraceExpansionCopies); + expect(Object.keys(braceExpansionCopies).sort()).toEqual( + Object.keys(reviewedBraceExpansionCopies).sort(), + ); + }); +}); From 06e4ae15591995c904dd5b93e2bed104ec6636a4 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 3 Aug 2026 12:29:09 -0700 Subject: [PATCH 07/15] fix(security): keep root remediation dependency-only Signed-off-by: Apurv Kumaria --- package-lock.json | 2 +- scripts/advisory-early-warning-scan.mts | 29 ++--- scripts/audit-reviewed-npm-graph.mts | 134 ++----------------- scripts/lib/advisory-early-warning.mts | 7 +- scripts/lib/reviewed-npm-archive.mts | 18 +-- test/advisory-early-warning.test.ts | 58 --------- test/reviewed-npm-audit-workflow.test.ts | 159 +---------------------- 7 files changed, 26 insertions(+), 381 deletions(-) diff --git a/package-lock.json b/package-lock.json index 208fb908f2..6a7649d91d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1634,7 +1634,7 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "dist/cli.js" + "pi-ai": "./dist/cli.js" }, "engines": { "node": ">=22.19.0" diff --git a/scripts/advisory-early-warning-scan.mts b/scripts/advisory-early-warning-scan.mts index 3da7c82d55..27109f7b09 100755 --- a/scripts/advisory-early-warning-scan.mts +++ b/scripts/advisory-early-warning-scan.mts @@ -4,8 +4,8 @@ // // CLI entry for the advisory early-warning path (#7338). Correlates public // GitHub Security Advisory JSON with the reviewed npm inventory derived from -// ci/reviewed-npm-audit.json (committed package specs plus the root and -// locked-graph production package-locks) and prints structured, NON-blocking signals. Signals never +// 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. // // Usage: @@ -17,7 +17,7 @@ // --inventory replaces the repo-derived reviewed inventory with an explicit // JSON array of {name, version[, origin]} entries, so offline callers and // tests can run hermetically; without it (the workflow default) the inventory -// is built from ci/reviewed-npm-audit.json and the production package-locks. +// is built from ci/reviewed-npm-audit.json and the locked-graph package-locks. // // --nvd-records attaches supplementary NVD reconciliations from a file of // previously fetched NVD 2.0 API responses. The CLI itself never performs @@ -36,28 +36,21 @@ import { attachNvdReconciliations, type NvdAnnotatedSignal } from "./lib/nvd-rec const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const CONFIG_RELATIVE_PATH = path.join("ci", "reviewed-npm-audit.json"); -const ROOT_LOCK_RELATIVE_PATH = "package-lock.json"; -export function loadReviewedInventory(repoRoot = REPO_ROOT): InventoryEntry[] { +function loadReviewedInventory(): InventoryEntry[] { const config = JSON.parse( - fs.readFileSync(path.join(repoRoot, CONFIG_RELATIVE_PATH), "utf-8"), + fs.readFileSync(path.join(REPO_ROOT, CONFIG_RELATIVE_PATH), "utf-8"), ) as Record; const inventory = parseInventoryFromAuditConfig(config, CONFIG_RELATIVE_PATH); const lockedGraphs = Array.isArray(config.lockedGraphs) ? config.lockedGraphs : []; - const lockRelativePaths = [ - ROOT_LOCK_RELATIVE_PATH, - ...lockedGraphs.flatMap((graph) => { - const directory = (graph as Record | null)?.directory; - return typeof directory === "string" && directory.length > 0 - ? [path.join(directory, "package-lock.json")] - : []; - }), - ]; - for (const lockRelativePath of lockRelativePaths) { - const lockPath = path.join(repoRoot, lockRelativePath); + for (const graph of lockedGraphs) { + const directory = (graph as Record | null)?.directory; + if (typeof directory !== "string" || directory.length === 0) continue; + const lockRelativePath = path.join(directory, "package-lock.json"); + const lockPath = path.join(REPO_ROOT, lockRelativePath); if (!fs.existsSync(lockPath)) continue; const lock = JSON.parse(fs.readFileSync(lockPath, "utf-8")) as unknown; - inventory.push(...parseInventoryFromPackageLock(lock, lockRelativePath, { omitDev: true })); + inventory.push(...parseInventoryFromPackageLock(lock, lockRelativePath)); } return inventory; } diff --git a/scripts/audit-reviewed-npm-graph.mts b/scripts/audit-reviewed-npm-graph.mts index 39623d719d..e2e46dbc04 100755 --- a/scripts/audit-reviewed-npm-graph.mts +++ b/scripts/audit-reviewed-npm-graph.mts @@ -3,7 +3,6 @@ // 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"; @@ -13,13 +12,11 @@ import { packReviewedNpmArchive, verifyInstalledNpmLock, verifyReviewedNpmLock, - verifyReviewedNpmLockPackages, } from "./lib/reviewed-npm-archive.mts"; import { assertExceptionGraphs, readAuditExceptionRegistry, runReviewedNpmAudit, - type AuditPolicyResult, type Severity, } from "./lib/reviewed-npm-audit.mts"; @@ -42,7 +39,6 @@ type AuditConfig = Readonly<{ schemaVersion: 2; severityThreshold: Severity; }>; -type ReviewedAuditReport = Readonly<{ label: string; result: AuditPolicyResult }>; const TRUSTED_REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const TARGET_REPO_ROOT = fs.realpathSync( @@ -50,10 +46,6 @@ const TARGET_REPO_ROOT = fs.realpathSync( ); const CONFIG_PATH = resolveTrustedAuditConfigPath(TRUSTED_REPO_ROOT); const SEVERITIES: readonly Severity[] = ["info", "low", "moderate", "high", "critical"]; -const SOURCE_GRAPH = { - id: "nemoclaw-cli", - label: "NemoClaw CLI locked production graph", -} as const; const OPENCLAW_DOMEXCEPTION_ALIAS = { actualName: "@nolyfill/domexception", aliasPackagePath: "node_modules/openclaw/node_modules/node-domexception", @@ -238,32 +230,6 @@ function assertRegularFile(file: string, label: string): void { } } -export function materializeSourceGraph( - sourcePackage: string, - sourceLock: string, - destination: string, - registryOrigin: string, - installProductionDependencies: (directory: string) => void = (directory) => - void run("npm", ["ci", "--ignore-scripts", "--omit=dev", "--no-audit", "--no-fund"], directory), -): string { - assertRegularFile(sourcePackage, "NemoClaw CLI package manifest"); - assertRegularFile(sourceLock, "NemoClaw CLI lockfile"); - verifyReviewedNpmLockPackages({ lockfilePath: sourceLock, omitDev: true, registryOrigin }); - const lockSha256 = createHash("sha256").update(fs.readFileSync(sourceLock)).digest("hex"); - fs.mkdirSync(destination); - fs.copyFileSync(sourcePackage, path.join(destination, "package.json")); - const installedLock = path.join(destination, "package-lock.json"); - fs.copyFileSync(sourceLock, installedLock); - installProductionDependencies(destination); - const installedLockSha256 = createHash("sha256") - .update(fs.readFileSync(installedLock)) - .digest("hex"); - if (installedLockSha256 !== lockSha256) { - throw new Error("NemoClaw CLI lockfile changed while materializing its production graph"); - } - return destination; -} - export function normalizeOpenClawSignatureAlias(directory: string): void { const { actualName, @@ -366,85 +332,6 @@ function auditLockedGraph( return result; } -function auditSourceGraph( - config: AuditConfig, - 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"); - const sourceManifest = readJsonObject(sourcePackage, "NemoClaw CLI package manifest"); - if (typeof sourceManifest.name !== "string" || typeof sourceManifest.version !== "string") { - throw new Error("NemoClaw CLI package manifest must declare its name and version"); - } - const directory = materializeSourceGraph( - sourcePackage, - sourceLock, - path.join(tempRoot, "source-graph"), - config.registryOrigin, - ); - return auditMaterializedSourceGraph({ - directory, - exceptionFile, - artifactDirectory, - npmVersion, - packageSpec: `${sourceManifest.name}@${sourceManifest.version}`, - threshold: config.severityThreshold, - }); -} - -export function auditMaterializedSourceGraph( - options: Readonly<{ - artifactDirectory: string; - directory: string; - exceptionFile: string; - npmVersion: string; - packageSpec: string; - threshold: Severity; - }>, - dependencies: Readonly<{ - runAudit?: typeof runReviewedNpmAudit; - verifySignatures?: (directory: string) => void; - }> = {}, -): AuditPolicyResult { - const result = (dependencies.runAudit ?? runReviewedNpmAudit)({ - directory: options.directory, - exceptionFile: options.exceptionFile, - graph: SOURCE_GRAPH.id, - provenance: { - label: SOURCE_GRAPH.label, - nodeVersion: process.version, - npmVersion: options.npmVersion, - packageSpecs: [options.packageSpec], - }, - reportFile: path.join(options.artifactDirectory, "source-graph.json"), - resultFile: path.join(options.artifactDirectory, "source-graph-policy.json"), - threshold: options.threshold, - throwOnBlock: false, - }); - ( - dependencies.verifySignatures ?? - ((directory) => run("npm", ["audit", "signatures", "--omit=dev"], directory)) - )(options.directory); - return result; -} - -export function assertReviewedAuditReportsPass( - reports: readonly ReviewedAuditReport[], - threshold: Severity, -): void { - const failures = reports - .filter(({ result }) => result.unacceptedBlockingAdvisories.length > 0) - .map( - ({ label, result }) => - `${label}: ${result.unacceptedBlockingAdvisories.length} unaccepted at or above ${threshold}`, - ); - if (failures.length > 0) - throw new Error(`reviewed npm audit threshold failed\n${failures.join("\n")}`); -} - function main(): void { const config = readConfig(); const expectedNode = `v${config.nodeVersion}`; @@ -459,11 +346,7 @@ function main(): void { const exceptionRegistry = readAuditExceptionRegistry(exceptionFile); assertExceptionGraphs( exceptionRegistry.policy, - new Set([ - SOURCE_GRAPH.id, - config.archiveGraphId, - ...config.lockedGraphs.map((graph) => graph.id), - ]), + new Set([config.archiveGraphId, ...config.lockedGraphs.map((graph) => graph.id)]), ); fs.rmSync(artifactDirectory, { recursive: true, force: true }); fs.mkdirSync(artifactDirectory, { recursive: true }); @@ -471,10 +354,6 @@ function main(): void { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-npm-audit-")); try { const reports = [ - { - label: SOURCE_GRAPH.label, - result: auditSourceGraph(config, tempRoot, exceptionFile, artifactDirectory, npmVersion), - }, { label: "reviewed archive graph", result: runReviewedNpmAudit({ @@ -506,7 +385,16 @@ function main(): void { ), })), ]; - assertReviewedAuditReportsPass(reports, config.severityThreshold); + const failures: string[] = []; + for (const { label, result } of reports) { + if (result.unacceptedBlockingAdvisories.length > 0) { + failures.push( + `${label}: ${result.unacceptedBlockingAdvisories.length} unaccepted at or above ${config.severityThreshold}`, + ); + } + } + if (failures.length > 0) + throw new Error(`reviewed npm audit threshold failed\n${failures.join("\n")}`); } finally { fs.rmSync(tempRoot, { recursive: true, force: true }); } diff --git a/scripts/lib/advisory-early-warning.mts b/scripts/lib/advisory-early-warning.mts index ff9fb42390..fc554284af 100644 --- a/scripts/lib/advisory-early-warning.mts +++ b/scripts/lib/advisory-early-warning.mts @@ -199,11 +199,7 @@ export function parseInventoryFromAuditConfig(config: unknown, origin: string): * Build an installed-package inventory from a lockfile-version-3 package-lock * subset: every `node_modules/...` entry that records an installed version. */ -export function parseInventoryFromPackageLock( - lock: unknown, - origin: string, - options: Readonly<{ omitDev?: boolean }> = {}, -): InventoryEntry[] { +export function parseInventoryFromPackageLock(lock: unknown, origin: string): InventoryEntry[] { if (typeof lock !== "object" || lock === null) return []; const packages = (lock as Record).packages; if (typeof packages !== "object" || packages === null || Array.isArray(packages)) return []; @@ -214,7 +210,6 @@ export function parseInventoryFromPackageLock( const pathName = location.slice(marker + "node_modules/".length); if (pathName.length === 0) continue; if (typeof entry !== "object" || entry === null) continue; - if (options.omitDev && (entry as Record).dev === true) continue; const version = (entry as Record).version; if (typeof version !== "string" || version.length === 0) continue; // Aliased installs (`npm install alias@npm:real-name`) live under the alias diff --git a/scripts/lib/reviewed-npm-archive.mts b/scripts/lib/reviewed-npm-archive.mts index 6c96320c4d..6cdb829162 100755 --- a/scripts/lib/reviewed-npm-archive.mts +++ b/scripts/lib/reviewed-npm-archive.mts @@ -281,8 +281,6 @@ function readReviewedLockPackages( packages: Readonly>>, lockfilePath: string, registryOrigin: string, - omitDev = false, - allowEmpty = false, ): readonly ReviewedNpmArchiveRequest[] { const reviewed: ReviewedNpmArchiveRequest[] = []; const identities = new Map(); @@ -292,7 +290,6 @@ function readReviewedLockPackages( throw new Error(`reviewed npm lock has an invalid package record: ${location}`); } const record = value as Record; - if (omitDev && record.dev === true) continue; const locationName = packageNameFromLockLocation(location); const packageName = typeof record.name === "string" ? record.name : locationName; const version = typeof record.version === "string" ? record.version : ""; @@ -334,25 +331,12 @@ function readReviewedLockPackages( identities.set(packageSpec, request); reviewed.push(request); } - if (!allowEmpty && reviewed.length === 0) { + if (reviewed.length === 0) { throw new Error(`reviewed npm lock contains no packages: ${lockfilePath}`); } return reviewed; } -export function verifyReviewedNpmLockPackages( - request: Readonly<{ lockfilePath: string; omitDev?: boolean; registryOrigin: string }>, -): readonly string[] { - const registryOrigin = normalizeRegistryOrigin(request.registryOrigin); - return readReviewedLockPackages( - readReviewedLock(request.lockfilePath), - request.lockfilePath, - registryOrigin, - request.omitDev, - true, - ).map(({ packageSpec }) => packageSpec); -} - export function verifyReviewedNpmLock( request: ReviewedNpmLockRequest, npmRunner: NpmRunner = runNpm, diff --git a/test/advisory-early-warning.test.ts b/test/advisory-early-warning.test.ts index a14f9f42a1..0b01b31146 100644 --- a/test/advisory-early-warning.test.ts +++ b/test/advisory-early-warning.test.ts @@ -1,11 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; import { describe, expect, it } from "vitest"; -import { loadReviewedInventory } from "../scripts/advisory-early-warning-scan.mts"; import { compareSemver, correlateAdvisories, @@ -267,60 +263,6 @@ describe("advisory early warning inventory parsing", () => { ]); }); - it("omits development packages from a production lock inventory", () => { - const lock = { - lockfileVersion: 3, - packages: { - "node_modules/fast-uri": { version: "3.1.5" }, - "node_modules/dev-only": { version: "1.0.0", dev: true }, - }, - }; - expect(parseInventoryFromPackageLock(lock, "fixture-lock.json", { omitDev: true })).toEqual([ - { name: "fast-uri", version: "3.1.5", origin: "fixture-lock.json" }, - ]); - }); - - it("includes the root production lock in the reviewed inventory (#8116)", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-advisory-inventory-")); - try { - fs.mkdirSync(path.join(root, "ci")); - fs.mkdirSync(path.join(root, "runtime")); - fs.writeFileSync( - path.join(root, "ci", "reviewed-npm-audit.json"), - `${JSON.stringify({ - archivePackages: [{ packageSpec: "openclaw@2026.7.1" }], - lockedGraphs: [{ directory: "runtime", packageSpec: "mcporter@0.7.3" }], - })}\n`, - ); - fs.writeFileSync( - path.join(root, "package-lock.json"), - `${JSON.stringify({ - lockfileVersion: 3, - packages: { - "node_modules/fast-uri": { version: "3.1.5" }, - "node_modules/dev-only": { version: "1.0.0", dev: true }, - }, - })}\n`, - ); - fs.writeFileSync( - path.join(root, "runtime", "package-lock.json"), - `${JSON.stringify({ - lockfileVersion: 3, - packages: { "node_modules/tar": { version: "7.5.20" } }, - })}\n`, - ); - - expect(loadReviewedInventory(root)).toEqual([ - { name: "openclaw", version: "2026.7.1", origin: "ci/reviewed-npm-audit.json" }, - { name: "mcporter", version: "0.7.3", origin: "ci/reviewed-npm-audit.json" }, - { name: "fast-uri", version: "3.1.5", origin: "package-lock.json" }, - { name: "tar", version: "7.5.20", origin: "runtime/package-lock.json" }, - ]); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } - }); - it("inventories aliased lock entries under their real package name", () => { const lock = { lockfileVersion: 3, diff --git a/test/reviewed-npm-audit-workflow.test.ts b/test/reviewed-npm-audit-workflow.test.ts index 659c421336..f3036a1b3b 100644 --- a/test/reviewed-npm-audit-workflow.test.ts +++ b/test/reviewed-npm-audit-workflow.test.ts @@ -6,13 +6,7 @@ import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { - assertReviewedAuditReportsPass, - auditMaterializedSourceGraph, - materializeSourceGraph, - normalizeOpenClawSignatureAlias, -} from "../scripts/audit-reviewed-npm-graph.mts"; -import type { AuditPolicyResult } from "../scripts/lib/reviewed-npm-audit.mts"; +import { normalizeOpenClawSignatureAlias } from "../scripts/audit-reviewed-npm-graph.mts"; import { readYaml } from "./helpers/e2e-workflow-contract"; type WorkflowStep = { @@ -154,157 +148,6 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { expect(driver).not.toContain('resolveTargetPath(\n "ci/reviewed-npm-audit.json"'); }); - 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"); - const destination = path.join(root, "materialized"); - const manifest = { name: "source-graph-fixture", private: true, version: "1.0.0" }; - const lock = { - name: manifest.name, - version: manifest.version, - lockfileVersion: 3, - requires: true, - packages: { "": manifest }, - }; - const lockSource = `${JSON.stringify(lock, null, 2)}\n`; - try { - fs.mkdirSync(source); - fs.writeFileSync(path.join(source, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`); - fs.writeFileSync(path.join(source, "package-lock.json"), lockSource); - - expect( - materializeSourceGraph( - path.join(source, "package.json"), - path.join(source, "package-lock.json"), - destination, - "https://registry.npmjs.org", - ), - ).toBe(destination); - expect(fs.readFileSync(path.join(destination, "package-lock.json"), "utf-8")).toBe( - lockSource, - ); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } - }); - - it("rejects an unreviewed registry package before npm ci installs the root production graph (#8116)", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-source-graph-registry-")); - const source = path.join(root, "source"); - const destination = path.join(root, "materialized"); - const manifest = { - dependencies: { "fixture-package": "1.0.0" }, - name: "source-graph-fixture", - private: true, - version: "1.0.0", - }; - const lock = { - name: manifest.name, - version: manifest.version, - lockfileVersion: 3, - requires: true, - packages: { - "": manifest, - "node_modules/fixture-package": { - integrity: "sha512-fixture", - resolved: "https://example.com/fixture-package-1.0.0.tgz", - version: "1.0.0", - }, - }, - }; - let installCalled = false; - try { - fs.mkdirSync(source); - fs.writeFileSync(path.join(source, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`); - fs.writeFileSync( - path.join(source, "package-lock.json"), - `${JSON.stringify(lock, null, 2)}\n`, - ); - - expect(() => - materializeSourceGraph( - path.join(source, "package.json"), - path.join(source, "package-lock.json"), - destination, - "https://registry.npmjs.org", - () => { - installCalled = true; - }, - ), - ).toThrow( - "reviewed npm lock package must use the reviewed registry: node_modules/fixture-package", - ); - expect(installCalled).toBe(false); - expect(fs.existsSync(destination)).toBe(false); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } - }); - - it("audits the NemoClaw production graph, verifies signatures, and rejects blocking advisories (#8116)", () => { - const events: string[] = []; - const blockedResult = { - acceptedAdvisories: [], - blockingThreshold: "high", - exceptionPolicySha256: "fixture-policy", - graph: "nemoclaw-cli", - reported: { info: 0, low: 0, moderate: 0, high: 1, critical: 0 }, - schemaVersion: 1, - status: "blocked", - unacceptedBlockingAdvisories: [ - { - advisory: "GHSA-aaaa-bbbb-cccc", - installedVersion: "1.0.0", - package: "fixture-package", - severity: "high", - }, - ], - } satisfies AuditPolicyResult; - const result = auditMaterializedSourceGraph( - { - artifactDirectory: "/artifacts", - directory: "/materialized", - exceptionFile: "/exceptions.json", - npmVersion: "10.9.4", - packageSpec: "nemoclaw@0.0.0", - threshold: "high", - }, - { - runAudit: (options) => { - events.push("policy-audit"); - expect(options).toMatchObject({ - directory: "/materialized", - exceptionFile: "/exceptions.json", - graph: "nemoclaw-cli", - provenance: { - label: "NemoClaw CLI locked production graph", - npmVersion: "10.9.4", - packageSpecs: ["nemoclaw@0.0.0"], - }, - reportFile: path.join("/artifacts", "source-graph.json"), - resultFile: path.join("/artifacts", "source-graph-policy.json"), - threshold: "high", - throwOnBlock: false, - }); - return blockedResult; - }, - verifySignatures: (directory) => { - events.push(`signatures:${directory}`); - }, - }, - ); - - expect(events).toEqual(["policy-audit", "signatures:/materialized"]); - expect(() => - assertReviewedAuditReportsPass( - [{ label: "NemoClaw CLI locked production graph", result }], - "high", - ), - ).toThrow( - "reviewed npm audit threshold failed\nNemoClaw CLI locked production graph: 1 unaccepted at or above high", - ); - }); - it("normalizes only the reviewed OpenClaw npm alias for registry signature verification", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-signature-alias-")); const aliasPath = path.join("node_modules", "openclaw", "node_modules", "node-domexception"); From b853d68b544317dbc94fa35151667354aba618cf Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 3 Aug 2026 12:58:06 -0700 Subject: [PATCH 08/15] fix(security): upgrade vulnerable undici runtime Signed-off-by: Apurv Kumaria --- .github/workflows/pr-review-advisor.yaml | 2 +- package-lock.json | 8 ++++---- package.json | 2 +- .../managed-image-registry-transport.test.ts | 4 ++-- test/pr-review-advisor-workflow-boundary.test.ts | 6 +++--- tools/pr-review-advisor/workflow-boundary.mts | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/pr-review-advisor.yaml b/.github/workflows/pr-review-advisor.yaml index fd94afac02..92ca3aa33c 100644 --- a/.github/workflows/pr-review-advisor.yaml +++ b/.github/workflows/pr-review-advisor.yaml @@ -88,7 +88,7 @@ jobs: # Workflow-boundary modules parse YAML before the advisor session starts. YAML_VERSION: "2.8.3" # Embedded Pi SDK sessions use Pi's proxy-aware Undici transport. - UNDICI_VERSION: "8.5.0" + UNDICI_VERSION: "8.10.0" # Credential-free inventory discovery executes the trusted Vitest entrypoint. VITEST_VERSION: "4.1.9" FD_FIND_VERSION: "9.0.0-1" diff --git a/package-lock.json b/package-lock.json index 6a7649d91d..de8f78a507 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "p-retry": "^4.6.2", "qrcode-terminal": "^0.12.0", "smol-toml": "1.7.0", - "undici": "8.5.0", + "undici": "8.10.0", "yaml": "2.8.3" }, "bin": { @@ -6818,9 +6818,9 @@ } }, "node_modules/undici": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", - "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", "license": "MIT", "engines": { "node": ">=22.19.0" diff --git a/package.json b/package.json index 92b617dee5..4b7156b716 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,7 @@ "p-retry": "^4.6.2", "qrcode-terminal": "^0.12.0", "smol-toml": "1.7.0", - "undici": "8.5.0", + "undici": "8.10.0", "yaml": "2.8.3" }, "bundleDependencies": [ diff --git a/test/package-contract/managed-image-registry-transport.test.ts b/test/package-contract/managed-image-registry-transport.test.ts index a34067bb1c..63a7af8ba9 100644 --- a/test/package-contract/managed-image-registry-transport.test.ts +++ b/test/package-contract/managed-image-registry-transport.test.ts @@ -33,7 +33,7 @@ describe("managed image registry transport package contract", () => { const productionDependencies = JSON.parse(productionTree.stdout) as { dependencies?: { undici?: { version?: string } }; }; - expect(productionDependencies.dependencies?.undici?.version).toBe("8.5.0"); + expect(productionDependencies.dependencies?.undici?.version).toBe("8.10.0"); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-managed-registry-package-")); try { @@ -80,7 +80,7 @@ describe("managed image registry transport package contract", () => { const installedProductionDependencies = JSON.parse(installedProductionTree.stdout) as { dependencies?: { undici?: { version?: string } }; }; - expect(installedProductionDependencies.dependencies?.undici?.version).toBe("8.5.0"); + expect(installedProductionDependencies.dependencies?.undici?.version).toBe("8.10.0"); const probe = spawnSync( process.execPath, diff --git a/test/pr-review-advisor-workflow-boundary.test.ts b/test/pr-review-advisor-workflow-boundary.test.ts index 03c2cd53d8..59a9904561 100644 --- a/test/pr-review-advisor-workflow-boundary.test.ts +++ b/test/pr-review-advisor-workflow-boundary.test.ts @@ -1310,7 +1310,7 @@ process.exitCode = valid ? 0 : 1;`, const errors = validateMutation((source) => source .replace(' FD_FIND_VERSION: "9.0.0-1"', ' FD_FIND_VERSION: "latest"') - .replace(' UNDICI_VERSION: "8.5.0"', ' UNDICI_VERSION: "latest"') + .replace(' UNDICI_VERSION: "8.10.0"', ' UNDICI_VERSION: "latest"') .replace(' VITEST_VERSION: "4.1.9"', ' VITEST_VERSION: "latest"') .replace(' YAML_VERSION: "2.8.3"', ' YAML_VERSION: "latest"') .replace( @@ -1322,7 +1322,7 @@ process.exitCode = valid ? 0 : 1;`, expect(errors).toEqual( expect.arrayContaining([ "review job env.FD_FIND_VERSION must be 9.0.0-1", - "review job env.UNDICI_VERSION must be 8.5.0", + "review job env.UNDICI_VERSION must be 8.10.0", "review job env.VITEST_VERSION must be 4.1.9", "review job env.YAML_VERSION must be 2.8.3", "review job env.PR_REVIEW_ADVISOR_LOAD_PREVIOUS_REVIEW must be false", @@ -1380,7 +1380,7 @@ process.exitCode = valid ? 0 : 1;`, expect.arrayContaining([ "advisor package lock must pin @earendil-works/pi-coding-agent@0.80.6", "advisor package lock must pin typebox@1.1.38", - "advisor package lock must pin undici@8.5.0", + "advisor package lock must pin undici@8.10.0", "advisor package lock must pin yaml@2.8.3", "advisor package lock must pin vitest@4.1.9", ]), diff --git a/tools/pr-review-advisor/workflow-boundary.mts b/tools/pr-review-advisor/workflow-boundary.mts index 50a9e75a94..2a384a9157 100644 --- a/tools/pr-review-advisor/workflow-boundary.mts +++ b/tools/pr-review-advisor/workflow-boundary.mts @@ -53,7 +53,7 @@ const FORBIDDEN_ARTIFACT_DOWNLOAD_WITH_KEYS = [ const ADVISOR_RUNTIME_PACKAGE_PINS = [ { packageName: "@earendil-works/pi-coding-agent", envName: "PI_SDK_VERSION", version: "0.80.6" }, { packageName: "typebox", envName: "TYPEBOX_VERSION", version: "1.1.38" }, - { packageName: "undici", envName: "UNDICI_VERSION", version: "8.5.0" }, + { packageName: "undici", envName: "UNDICI_VERSION", version: "8.10.0" }, { packageName: "yaml", envName: "YAML_VERSION", version: "2.8.3" }, { packageName: "vitest", envName: "VITEST_VERSION", version: "4.1.9" }, ] as const; From f500da266a3473d95c3db3e2cbd446209455dbab Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 3 Aug 2026 13:02:54 -0700 Subject: [PATCH 09/15] fix(security): bind brace remediation metadata Signed-off-by: Prekshi Vyas --- Dockerfile | 2 +- Dockerfile.base | 2 +- .../openclaw-runtime/package-lock.json | 2 +- ci/reviewed-npm-audit.json | 3 +- ci/source-shape-test-budget.json | 5 -- .../openclaw-2026.7.1-dependency-review.md | 6 +-- ...ndbox-base-2026-07-25-dependency-review.md | 5 +- package-lock.json | 22 ++++---- test/openclaw-locked-install.test.ts | 2 +- test/root-package-lock-security.test.ts | 54 ------------------- 10 files changed, 22 insertions(+), 81 deletions(-) delete mode 100644 test/root-package-lock-security.test.ts diff --git a/Dockerfile b/Dockerfile index 870c88e7a4..41169bc17d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -378,7 +378,7 @@ RUN set -eu; \ OPENCLAW_LOCK_SHA256=none-legacy-fixture; \ OPENCLAW_RECIPE='ignore-scripts+reviewed-lifecycle-v1'; \ if [ "$OPENCLAW_VERSION" = "2026.7.1" ]; then \ - OPENCLAW_LOCK_SHA256=fdbee91a3f3a0222ec2e8e34864d09dfcff4711e86ac6bfa4d475af0593acd51; \ + OPENCLAW_LOCK_SHA256=847f68ac46f18d17efcd47dd3d8a5944b9dbcd205bc4d1e821ff82aa543eec20; \ ACTUAL_OPENCLAW_LOCK_SHA256="$(sha256sum /usr/local/lib/nemoclaw/openclaw-runtime/package-lock.json | awk '{print $1}')"; \ [ "$ACTUAL_OPENCLAW_LOCK_SHA256" = "$OPENCLAW_LOCK_SHA256" ] \ || { echo "ERROR: OpenClaw lock SHA-256 mismatch (expected $OPENCLAW_LOCK_SHA256, found $ACTUAL_OPENCLAW_LOCK_SHA256)" >&2; exit 1; }; \ diff --git a/Dockerfile.base b/Dockerfile.base index 9ba1ae53e6..a44caf42e1 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -481,7 +481,7 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep OPENCLAW_LOCK_SHA256=none-legacy-fixture; \ OPENCLAW_RECIPE='ignore-scripts+reviewed-lifecycle-v1'; \ if [ "$OPENCLAW_VERSION" = "2026.7.1" ]; then \ - OPENCLAW_LOCK_SHA256=fdbee91a3f3a0222ec2e8e34864d09dfcff4711e86ac6bfa4d475af0593acd51; \ + OPENCLAW_LOCK_SHA256=847f68ac46f18d17efcd47dd3d8a5944b9dbcd205bc4d1e821ff82aa543eec20; \ ACTUAL_OPENCLAW_LOCK_SHA256="$(sha256sum /usr/local/lib/nemoclaw/openclaw-runtime/package-lock.json | awk '{print $1}')"; \ [ "$ACTUAL_OPENCLAW_LOCK_SHA256" = "$OPENCLAW_LOCK_SHA256" ] \ || { echo "Error: OpenClaw lock SHA-256 mismatch (expected $OPENCLAW_LOCK_SHA256, found $ACTUAL_OPENCLAW_LOCK_SHA256)"; exit 1; }; \ diff --git a/agents/openclaw/openclaw-runtime/package-lock.json b/agents/openclaw/openclaw-runtime/package-lock.json index 943e2eae39..1025e33f92 100644 --- a/agents/openclaw/openclaw-runtime/package-lock.json +++ b/agents/openclaw/openclaw-runtime/package-lock.json @@ -802,7 +802,7 @@ "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/openclaw/node_modules/buffer-equal-constant-time": { diff --git a/ci/reviewed-npm-audit.json b/ci/reviewed-npm-audit.json index 0f90938993..29cad85c5c 100644 --- a/ci/reviewed-npm-audit.json +++ b/ci/reviewed-npm-audit.json @@ -71,8 +71,7 @@ "tarballUrl": "https://registry.npmjs.org/openclaw/-/openclaw-2026.7.1.tgz", "directory": "agents/openclaw/openclaw-runtime", "reviewedLockSha256": [ - "82489f62febb12da52833c0b1f7f6969f7e21a098c565ef1f91342b1e5e32d88", - "fdbee91a3f3a0222ec2e8e34864d09dfcff4711e86ac6bfa4d475af0593acd51" + "847f68ac46f18d17efcd47dd3d8a5944b9dbcd205bc4d1e821ff82aa543eec20" ] }, { diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index f8b615988c..c5f7af36e7 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -431,11 +431,6 @@ "test": "runs PR audits from trusted code and keeps the main audit on the checked-in action", "category": "security" }, - { - "file": "test/root-package-lock-security.test.ts", - "test": "pins every brace-expansion copy past the affected ranges", - "category": "security" - }, { "file": "test/platform-vitest-main-workflow.test.ts", "test": "pins and verifies the Node.js archive in the trusted WSL helper", diff --git a/docs/security/openclaw-2026.7.1-dependency-review.md b/docs/security/openclaw-2026.7.1-dependency-review.md index b28b208af5..7e3dceb3d9 100644 --- a/docs/security/openclaw-2026.7.1-dependency-review.md +++ b/docs/security/openclaw-2026.7.1-dependency-review.md @@ -19,7 +19,7 @@ the exact plugin graph reports no vulnerabilities after that update. The production OpenClaw install uses the authoritative committed lock at `agents/openclaw/openclaw-runtime/package-lock.json`, with SHA-256 -`fdbee91a3f3a0222ec2e8e34864d09dfcff4711e86ac6bfa4d475af0593acd51`. +`847f68ac46f18d17efcd47dd3d8a5944b9dbcd205bc4d1e821ff82aa543eec20`. NemoClaw derives that lock from the SRI-verified `openclaw@2026.7.1` archive after replacing the reviewed affected `brace-expansion@5.0.7` and `fast-uri@3.1.2` resolutions with `5.0.9` and `3.1.4`. @@ -97,8 +97,8 @@ major-version compatibility boundary. OpenClaw's `minimatch@10.2.5` edge originally resolved `brace-expansion@5.0.7`, which is affected by `GHSA-mh99-v99m-4gvg`. The initial remediation selected `5.0.8` for that advisory. -The high-severity `GHSA-rgw5-rvv9-x895`, also tracked as `CVE-2026-69152`, -affects `brace-expansion` versions `>=4.0.0 <5.0.9`. +`GHSA-rgw5-rvv9-x895` affects `brace-expansion` versions `>=4.0.0 <5.0.9`, +and `5.0.9` fixes the advisory. The current OpenClaw remediation selects `5.0.9`, retains the declared `balanced-match@^4.0.2` dependency shape, and fails if the upstream shrinkwrap or replacement archive identity changes. diff --git a/docs/security/sandbox-base-2026-07-25-dependency-review.md b/docs/security/sandbox-base-2026-07-25-dependency-review.md index 10616ba78e..a7ef9f9e08 100644 --- a/docs/security/sandbox-base-2026-07-25-dependency-review.md +++ b/docs/security/sandbox-base-2026-07-25-dependency-review.md @@ -89,8 +89,9 @@ The final reviewed dependency set and range evidence is: The npm release remains `11.18.0`. Its private dependency tree contains one top-level `brace-expansion@5.0.7` package with the existing `balanced-match@^4.0.2` contract. The initial replacement selected `5.0.8` to address `GHSA-mh99-v99m-4gvg`. -The high-severity `GHSA-rgw5-rvv9-x895`, also tracked as `CVE-2026-69152`, affects versions `>=4.0.0 <5.0.9`. -The current `5.0.9` replacement preserves the dependency contract, and its Node engine floor is compatible with the Node 22 and Node 24 base images. +`GHSA-rgw5-rvv9-x895` affects versions `>=4.0.0 <5.0.9`, and `5.0.9` fixes the advisory. +The current `5.0.9` replacement preserves the dependency contract and declares `engines.node` as `20 || >=22`. +This engine range includes the Node 22 and Node 24 base images. The replacement helper: diff --git a/package-lock.json b/package-lock.json index adda5d2fb7..a3bf6c6e29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2208,16 +2208,16 @@ "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/buffer-equal-constant-time": { @@ -4504,15 +4504,15 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" } }, "node_modules/call-bind-apply-helpers": { @@ -5220,9 +5220,9 @@ "license": "MIT" }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" diff --git a/test/openclaw-locked-install.test.ts b/test/openclaw-locked-install.test.ts index 11c1dd4514..f14da2b273 100644 --- a/test/openclaw-locked-install.test.ts +++ b/test/openclaw-locked-install.test.ts @@ -19,7 +19,7 @@ const PACKAGE_SPEC = "openclaw@2026.7.1"; const INTEGRITY = "sha512-ge/Xss99CHAjPL/ikmH/UFoiOrjcxDB4sW3y9mhyCD+dYW3wzV7TKbAVdkrXFgAG2d2BjpJofP97zUZ+umxo8g=="; const TARBALL = "https://registry.npmjs.org/openclaw/-/openclaw-2026.7.1.tgz"; -const LOCK_SHA256 = "fdbee91a3f3a0222ec2e8e34864d09dfcff4711e86ac6bfa4d475af0593acd51"; +const LOCK_SHA256 = "847f68ac46f18d17efcd47dd3d8a5944b9dbcd205bc4d1e821ff82aa543eec20"; const roots: string[] = []; function sha256(file: string): string { diff --git a/test/root-package-lock-security.test.ts b/test/root-package-lock-security.test.ts deleted file mode 100644 index 1c2882d6cc..0000000000 --- a/test/root-package-lock-security.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { describe, expect, it } from "vitest"; - -type LockedPackage = { - integrity?: string; - resolved?: string; - version?: string; -}; - -const ROOT = resolve(import.meta.dirname, ".."); -const lock = JSON.parse(readFileSync(resolve(ROOT, "package-lock.json"), "utf8")) as { - packages: Record; -}; - -const reviewedBraceExpansionCopies = { - "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { - version: "5.0.9", - resolved: "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - integrity: - "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - }, - "node_modules/brace-expansion": { - version: "5.0.9", - resolved: "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - integrity: - "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - }, - "node_modules/filelist/node_modules/brace-expansion": { - version: "2.1.4", - resolved: "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - integrity: - "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", - }, -}; - -describe("root dependency lock security", () => { - // source-shape-contract: security -- Exact root lock identities keep every brace-expansion copy outside the affected advisory ranges - it("pins every brace-expansion copy past the affected ranges", () => { - const braceExpansionCopies = Object.fromEntries( - Object.entries(lock.packages).filter(([packagePath]) => - packagePath.endsWith("node_modules/brace-expansion"), - ), - ); - - expect(braceExpansionCopies).toMatchObject(reviewedBraceExpansionCopies); - expect(Object.keys(braceExpansionCopies).sort()).toEqual( - Object.keys(reviewedBraceExpansionCopies).sort(), - ); - }); -}); From 162af6b43bb1bb3ea06df236525ff4396b7dfe9e Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 3 Aug 2026 14:21:23 -0700 Subject: [PATCH 10/15] fix(security): close managed runtime audit gaps Signed-off-by: Prekshi Vyas --- Dockerfile | 2 +- Dockerfile.base | 2 +- agents/openclaw/dependency-review.md | 9 +- .../mcporter-runtime/package-lock.json | 12 +- agents/openclaw/mcporter-runtime/package.json | 3 +- .../openclaw-runtime/package-lock.json | 20 +- ci/reviewed-npm-audit.json | 4 +- .../openclaw-2026.7.1-dependency-review.md | 145 ++++++++++--- scripts/lib/openclaw-npm-remediation.mts | 204 +++++++++++++++++- test/mcporter-supply-chain.test.ts | 21 +- test/openclaw-dependency-review.test.ts | 8 +- test/openclaw-locked-install.test.ts | 6 +- test/openclaw-npm-remediation.test.ts | 138 +++++++++++- 13 files changed, 505 insertions(+), 69 deletions(-) diff --git a/Dockerfile b/Dockerfile index 41169bc17d..52b033646e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -378,7 +378,7 @@ RUN set -eu; \ OPENCLAW_LOCK_SHA256=none-legacy-fixture; \ OPENCLAW_RECIPE='ignore-scripts+reviewed-lifecycle-v1'; \ if [ "$OPENCLAW_VERSION" = "2026.7.1" ]; then \ - OPENCLAW_LOCK_SHA256=847f68ac46f18d17efcd47dd3d8a5944b9dbcd205bc4d1e821ff82aa543eec20; \ + OPENCLAW_LOCK_SHA256=759b31779f40867f35f15065b582eb1d3efb8fddb1fe43c207507c905fa2a421; \ ACTUAL_OPENCLAW_LOCK_SHA256="$(sha256sum /usr/local/lib/nemoclaw/openclaw-runtime/package-lock.json | awk '{print $1}')"; \ [ "$ACTUAL_OPENCLAW_LOCK_SHA256" = "$OPENCLAW_LOCK_SHA256" ] \ || { echo "ERROR: OpenClaw lock SHA-256 mismatch (expected $OPENCLAW_LOCK_SHA256, found $ACTUAL_OPENCLAW_LOCK_SHA256)" >&2; exit 1; }; \ diff --git a/Dockerfile.base b/Dockerfile.base index a44caf42e1..4a2b4177f1 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -481,7 +481,7 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep OPENCLAW_LOCK_SHA256=none-legacy-fixture; \ OPENCLAW_RECIPE='ignore-scripts+reviewed-lifecycle-v1'; \ if [ "$OPENCLAW_VERSION" = "2026.7.1" ]; then \ - OPENCLAW_LOCK_SHA256=847f68ac46f18d17efcd47dd3d8a5944b9dbcd205bc4d1e821ff82aa543eec20; \ + OPENCLAW_LOCK_SHA256=759b31779f40867f35f15065b582eb1d3efb8fddb1fe43c207507c905fa2a421; \ ACTUAL_OPENCLAW_LOCK_SHA256="$(sha256sum /usr/local/lib/nemoclaw/openclaw-runtime/package-lock.json | awk '{print $1}')"; \ [ "$ACTUAL_OPENCLAW_LOCK_SHA256" = "$OPENCLAW_LOCK_SHA256" ] \ || { echo "Error: OpenClaw lock SHA-256 mismatch (expected $OPENCLAW_LOCK_SHA256, found $ACTUAL_OPENCLAW_LOCK_SHA256)"; exit 1; }; \ diff --git a/agents/openclaw/dependency-review.md b/agents/openclaw/dependency-review.md index 35790a3006..2a41254e82 100644 --- a/agents/openclaw/dependency-review.md +++ b/agents/openclaw/dependency-review.md @@ -16,10 +16,11 @@ Update it and `agents/openclaw/mcporter-runtime/package*.json` together whenever - Locked graph: `agents/openclaw/mcporter-runtime/package-lock.json` (npm lockfile version 3). - Lock regeneration command: `npm --prefix agents/openclaw/mcporter-runtime install --package-lock-only --ignore-scripts --omit=dev` - Advisory command: `npm --prefix agents/openclaw/mcporter-runtime ci --ignore-scripts --omit=dev && node --experimental-strip-types scripts/lib/reviewed-npm-audit.mts --directory agents/openclaw/mcporter-runtime --exceptions ci/npm-audit-exceptions.json --graph mcporter-runtime --threshold high && npm --prefix agents/openclaw/mcporter-runtime audit signatures` -- Advisory review date: 2026-07-21. -- Advisory result: `0` known vulnerabilities across the resolved production dependency graph; npm verified registry signatures for all `120` resolved packages and attestations for `13` packages. +- Advisory review date: 2026-08-03. +- Advisory result: `0` high and `0` critical vulnerabilities across the resolved production dependency graph; the current report keeps `GHSA-8j4g-w8fx-2239` in `hono@4.12.27` visible at moderate severity below the configured threshold. npm verified registry signatures for all `120` resolved packages and attestations for `14` packages. - Security override: `@hono/node-server@2.0.11` (`sha512-bjD221KPLoJTWUwso1J6fGKiTXEUFedG/s0visavY4zakFPkeGURMRNly+FhBHs7T8Dz4qHaZIMX9ZoJHSJtKA==`) replaces the SDK's vulnerable `1.19.14` resolution for `GHSA-frvp-7c67-39w9` and the previously reviewed `2.0.5` resolution affected by `GHSA-9mqv-5hh9-4cgg`. `2.0.5` is the first patched release for `GHSA-frvp-7c67-39w9`. The reviewed v2 range retains the `getRequestListener` API used by `@modelcontextprotocol/sdk`; its Node.js 20 floor is below NemoClaw's Node.js 22.19 floor, and the `/vercel` adapter is not consumed. Mcporter's production path imports the SDK's client transport, not the server adapter, and the image build still exercises the installed CLI after the locked install. Remove the override when the SDK's declared range resolves to a reviewed release outside both affected ranges. -- Security override: `fast-uri@3.1.4` (`sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==`) replaces Ajv's vulnerable `3.1.3` resolution for `GHSA-v2hh-gcrm-f6hx`. It remains within Ajv's declared `^3.0.1` range and preserves the reviewed v3 API boundary. Remove the override when the declared graph resolves to a reviewed patched release. +- Security override: `fast-uri@3.1.5` (`sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==`) replaces Ajv's vulnerable `3.1.3` resolution and the initially reviewed `3.1.4` remediation. `GHSA-v2hh-gcrm-f6hx` affects releases through `3.1.3`, and `GHSA-7p8r-x3mc-p8w7` affects releases from `3.0.0` through `3.1.4`. The replacement remains within Ajv's declared `^3.0.1` range and preserves the reviewed v3 API boundary. Remove the override when the declared graph resolves to a reviewed release outside both affected ranges. +- Security override: `ip-address@10.3.1` (`sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==`) replaces `express-rate-limit`'s vulnerable `10.2.0` resolution. `GHSA-mwp4-54f8-5fhr` affects releases through `10.3.0`; the replacement remains within the declared `^10.2.0` range, adds the leading-zero IPv4 rejection and host-only subnet classification required for trust-boundary checks, and preserves Node `>= 12`. Remove the override when the declared graph resolves to a reviewed release outside the affected range. Both image paths install the committed graph with `npm ci --ignore-scripts --omit=dev` because the published package declares no install-time lifecycle script and NemoClaw needs only its already-built CLI. The reviewed audit wrapper reports lower-severity production findings and blocks unaccepted high or critical advisories. The default `ci/npm-audit-exceptions.json` registry is empty. Any future exception must match one advisory, graph, package, installed version, and severity; identify an owner and NemoClaw tracking issue; state a decision, rationale, and expiry no more than 30 days away; and include compensating controls for temporary risk acceptance. Missing, malformed, expired, overlong, mismatched, or unused exceptions fail closed. The repository-wide audit also rejects exceptions for unknown graph IDs. Registry signature verification remains a separate control. @@ -41,7 +42,7 @@ The lock records the exact version, registry URL, and integrity for every transi ## Source-of-Truth Boundary -- `invalidState`: the image installs a package graph, tarball, license, or advisory state that differs from the independently queried npm registry records for `mcporter@0.7.3`, resolves `@hono/node-server` to any version other than exact `2.0.11`, or resolves `fast-uri` to any version other than exact `3.1.4`. +- `invalidState`: the image installs a package graph, tarball, license, or advisory state that differs from the independently queried npm registry records for `mcporter@0.7.3`, resolves `@hono/node-server` to any version other than exact `2.0.11`, resolves `fast-uri` to any version other than exact `3.1.5`, or resolves `ip-address` to any version other than exact `10.3.1`. - `sourceBoundary`: npm owns registry metadata, tarball integrity, provenance signatures, and advisory responses; NemoClaw owns the exact lock, script-disabled install, Docker integrity assertion, empty-by-default audit exception registry, and review record. - `whyNotSourceFix`: a repository note cannot make external registry state trustworthy, so image builds execute `npm audit` and `npm audit signatures` against the locked production graph and reviewers compare the lock with the registry response. - `regressionTest`: `test/mcporter-supply-chain.test.ts` keeps the version, integrity, lock metadata, Docker install flags, audit commands, and this review synchronized; `test/reviewed-npm-audit.test.ts` proves exact matching and fail-closed exception validation. diff --git a/agents/openclaw/mcporter-runtime/package-lock.json b/agents/openclaw/mcporter-runtime/package-lock.json index 3f174e5555..f42f8845d9 100644 --- a/agents/openclaw/mcporter-runtime/package-lock.json +++ b/agents/openclaw/mcporter-runtime/package-lock.json @@ -821,9 +821,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -1021,9 +1021,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", + "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", "license": "MIT", "engines": { "node": ">= 12" diff --git a/agents/openclaw/mcporter-runtime/package.json b/agents/openclaw/mcporter-runtime/package.json index eebe373e18..ad6c2fb3b8 100644 --- a/agents/openclaw/mcporter-runtime/package.json +++ b/agents/openclaw/mcporter-runtime/package.json @@ -10,7 +10,8 @@ }, "overrides": { "@hono/node-server": "2.0.11", - "fast-uri": "3.1.4" + "fast-uri": "3.1.5", + "ip-address": "10.3.1" }, "engines": { "node": ">=22.19.0" diff --git a/agents/openclaw/openclaw-runtime/package-lock.json b/agents/openclaw/openclaw-runtime/package-lock.json index 1025e33f92..dc69ae4b3c 100644 --- a/agents/openclaw/openclaw-runtime/package-lock.json +++ b/agents/openclaw/openclaw-runtime/package-lock.json @@ -72,7 +72,7 @@ "tslog": "4.10.2", "typebox": "1.3.3", "typescript": "6.0.3", - "undici": "8.5.0", + "undici": "8.10.0", "web-push": "3.6.7", "web-tree-sitter": "0.26.10", "ws": "8.21.0", @@ -1444,9 +1444,9 @@ } }, "node_modules/openclaw/node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -1955,9 +1955,9 @@ "license": "ISC" }, "node_modules/openclaw/node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", + "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", "license": "MIT", "engines": { "node": ">= 12" @@ -3413,9 +3413,9 @@ } }, "node_modules/openclaw/node_modules/undici": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", - "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", "license": "MIT", "engines": { "node": ">=22.19.0" diff --git a/ci/reviewed-npm-audit.json b/ci/reviewed-npm-audit.json index 29cad85c5c..39ab963224 100644 --- a/ci/reviewed-npm-audit.json +++ b/ci/reviewed-npm-audit.json @@ -71,7 +71,7 @@ "tarballUrl": "https://registry.npmjs.org/openclaw/-/openclaw-2026.7.1.tgz", "directory": "agents/openclaw/openclaw-runtime", "reviewedLockSha256": [ - "847f68ac46f18d17efcd47dd3d8a5944b9dbcd205bc4d1e821ff82aa543eec20" + "759b31779f40867f35f15065b582eb1d3efb8fddb1fe43c207507c905fa2a421" ] }, { @@ -82,7 +82,7 @@ "tarballUrl": "https://registry.npmjs.org/mcporter/-/mcporter-0.7.3.tgz", "directory": "agents/openclaw/mcporter-runtime", "reviewedLockSha256": [ - "c31959d7950903f7477ca2e143b3f1f4adfd10f1961fe97db40cd72f62b84830" + "962dee34f6b0a493521d1619d1cf030e2630cbdfce8bf0598217202f57078793" ] } ] diff --git a/docs/security/openclaw-2026.7.1-dependency-review.md b/docs/security/openclaw-2026.7.1-dependency-review.md index 7e3dceb3d9..65328b3b29 100644 --- a/docs/security/openclaw-2026.7.1-dependency-review.md +++ b/docs/security/openclaw-2026.7.1-dependency-review.md @@ -19,10 +19,16 @@ the exact plugin graph reports no vulnerabilities after that update. The production OpenClaw install uses the authoritative committed lock at `agents/openclaw/openclaw-runtime/package-lock.json`, with SHA-256 -`847f68ac46f18d17efcd47dd3d8a5944b9dbcd205bc4d1e821ff82aa543eec20`. +`759b31779f40867f35f15065b582eb1d3efb8fddb1fe43c207507c905fa2a421`. NemoClaw derives that lock from the SRI-verified `openclaw@2026.7.1` archive -after replacing the reviewed affected `brace-expansion@5.0.7` and -`fast-uri@3.1.2` resolutions with `5.0.9` and `3.1.4`. +after applying the reviewed dependency remediation. +The remediation replaces `brace-expansion@5.0.7` with `5.0.9`. +It also replaces `fast-uri@3.1.2` with `3.1.5` and `ip-address@10.2.0` with +`10.3.1` in the OpenClaw core graph. +The same reviewed `undici@8.10.0` replacement applies to the OpenClaw core +dependency and the Discord manifest, shrinkwrap, and bundled package tree. +The committed mcporter lock also selects `fast-uri@3.1.5` and +`ip-address@10.3.1`. Image builds verify the lock digest and installed production graph before they expose the OpenClaw binary. @@ -41,12 +47,14 @@ whose amd64 config reports Node `22.23.1`. - `openclaw@2026.7.1` - `sha512-ge/Xss99CHAjPL/ikmH/UFoiOrjcxDB4sW3y9mhyCD+dYW3wzV7TKbAVdkrXFgAG2d2BjpJofP97zUZ+umxo8g==` - `https://registry.npmjs.org/openclaw/-/openclaw-2026.7.1.tgz` + - remediated package tree: `sha512-ugtX/U1jNS+ZlZqEXa+Y9nN+wlhPxeZJrx6tJZFLcGspWPFhsC5qOjTkzBbOda9lEZF6TWKt6wU9m9p2tidqdQ==` - `@openclaw/diagnostics-otel@2026.7.1` - `sha512-XXhMifYWTgoR6yFN4T3JkHxdPvQCe8k1cNZjVIgXNmk1svCdBWuALfQQicmpemlmWwauIQuHYgBURY6k63e+rw==` - `@openclaw/brave-plugin@2026.7.1` - `sha512-7Z+GZ/6K6a8LlkTsWVnAZ1hv8EarORzHQvFHD7ekcg033FGJOXYPEZSbvvE3qR9vM+vnoZplNjMZ7vFMRcvQgw==` - `@openclaw/discord@2026.7.1` - `sha512-tZfdC1YA8oVLvc2BK1w0F6rUljS5ugCOp2uWe0vPsbG1fbzVVIO4V32RoqZznGHe5u2R9u4n1aV5Z/qa1m2oFg==` + - remediated package tree: `sha512-w+F8FrRl0wPd0EN2RnLyu6yfixel7BT8Iex4wLLQDvfIac8rLhuksNpFU4uZa8W9wXgh47hguq0F9NSN0BZfOQ==` - `@openclaw/slack@2026.7.1` - `sha512-dwVGEVCmoTQrOIeZaSCIOPg8pT7hB883QQEXdp9EZUDzTGuvSc+KxH2iERSOV/59hROQctYdcobGn/vdB1H4XA==` - remediated package tree: `sha512-4ThnsNS+yBlFSkTaQn2xosxrDu1s0vrxcqka5QqFj+8dCEaTa9JVLRgNniYV/QNhO53wc7a2R5oQFElzYspT2w==` @@ -66,20 +74,46 @@ whose amd64 config reports Node `22.23.1`. - `brace-expansion@5.0.9` (OpenClaw locked-runtime remediation) - `sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==` - `https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz` -- `fast-uri@3.1.4` (OpenClaw and mcporter locked-runtime remediation) - - `sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==` - - `https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz` +- `fast-uri@3.1.5` (OpenClaw and mcporter locked-runtime remediation) + - `sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==` + - `https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz` + - `BSD-3-Clause`; no dependencies +- `undici@8.10.0` (OpenClaw core and Discord remediation) + - `sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==` + - `https://registry.npmjs.org/undici/-/undici-8.10.0.tgz` + - `MIT`; no dependencies; Node `>=22.19.0` +- `ip-address@10.3.1` (OpenClaw and mcporter locked-runtime remediation) + - `sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==` + - `https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz` + - `MIT`; no dependencies; Node `>= 12` + - annotated source tag commit: `be7e626c0d49fccb518899f520a3fb64ee189741`; unsigned tag +- `mcporter@0.7.3` committed runtime lock + - SHA-256: `962dee34f6b0a493521d1619d1cf030e2630cbdfce8bf0598217202f57078793` + +`fast-uri@3.1.5` has a valid npm registry signature and no registry attestation. +`undici@8.10.0` has a valid npm registry signature and SLSA provenance. +`ip-address@10.3.1` has two valid npm registry signatures and SLSA provenance. ## Audit result and temporary dependency remediations The reviewed audit materializes three production-compatible boundaries: the remediated reviewed-archive graph, the committed OpenClaw runtime lock, and the -committed mcporter runtime lock. A real registry-backed run reports `0` high -and `0` critical vulnerabilities for all three boundaries, and npm registry -signature verification passes for both locked graphs. The critical `tar` -finding that blocked the previous pin and the high Jaeger, -`brace-expansion`, and `fast-uri` findings are gone. Lower-severity findings -remain visible below the configured `high` threshold. +committed mcporter runtime lock. +The final registry-backed audit completed successfully under Node `22.23.1`. +Its exact results are: + +- Reviewed archive graph: `info=0`, `low=0`, `moderate=6`, `high=0`, + `critical=0`, `clean`. +- OpenClaw locked runtime: `info=0`, `low=0`, `moderate=7`, `high=0`, + `critical=0`, `clean`. +- mcporter runtime: `info=0`, `low=0`, `moderate=1`, `high=0`, `critical=0`, + `clean`. + +Registry signature checks completed within the successful audit. +The critical `tar` finding that blocked the previous pin and the high Jaeger, +`brace-expansion`, `fast-uri`, `undici`, and `ip-address` findings are gone. +The accepted lower-severity findings remain visible below the configured +`high` threshold. The independently installed `nemoclaw/` plugin graph reports `0` vulnerabilities after resolving its direct `tar` dependency to `7.5.20`. @@ -106,21 +140,75 @@ or replacement archive identity changes. The AJV graphs in OpenClaw and mcporter request `fast-uri@^3.0.1`. The reviewed upstream OpenClaw shrinkwrap resolves `3.1.2`, and `GHSA-v2hh-gcrm-f6hx` affects releases through `3.1.3`. -Both committed runtime locks select the first compatible release outside that -range, `3.1.4`. Image assembly reports lower-severity findings and blocks +The initial remediation selected `3.1.4` for that advisory. +The high-severity `GHSA-7p8r-x3mc-p8w7` later affected that release. +Both committed runtime locks now select reviewed `3.1.5`. + +The OpenClaw core package manifest and shrinkwrap directly pin `undici@8.5.0`. +The published `@openclaw/discord@2026.7.1` archive also pins that version in +its package manifest and shrinkwrap. +Discord lists `undici` in `bundledDependencies` and contains bundled +`node_modules/undici@8.5.0`. +`GHSA-8xcm-r25x-g524` and `GHSA-4cwx-7wf7-3272` affect releases +`>=8.0.0 <8.9.0`. +The current remediation selects the same reviewed `8.10.0` archive for both +packages. +The replacement retains the no-dependency package shape and Node `>=22.19.0` +engine requirement. +For Discord, the remediation fails closed unless the manifest, shrinkwrap, and +bundled package identities match the reviewed `undici@8.5.0` boundary. +It updates the manifest and shrinkwrap and replaces the complete bundled +package tree with the verified `undici@8.10.0` archive. + +The upstream `undici@8.10.0` release CI reported one flaky Node 24 failure in +`test/http2-request-never-settles.js`: 1,502 tests passed, and one failed. +The Node 22 `no-intl` and `no-ssl` jobs, type checks, lint, fuzz, release, and +Scorecard jobs passed. +NemoClaw's focused remediation and locked-install tests, audit validation, +registry-signature verification, and provenance verification provide +compensating evidence for the reviewed archive. + +The OpenClaw core and mcporter graphs resolve `express-rate-limit@8.5.2` and +its declared `ip-address@^10.2.0` dependency to affected `10.2.0`. +The high-severity `GHSA-mwp4-54f8-5fhr` affects releases through `10.3.0` +because leading-zero IPv4 inputs can cross SSRF and trust boundaries through +decimal or octal interpretation differences. +Version `10.3.1` also contains the fixes for `GHSA-4xrf-jv44-h6hh` and +`GHSA-22jq-vg5j-6vgg`. +Both committed locks select the first release outside all three affected +boundaries, `10.3.1`, while preserving the declared `^10.2.0` consumer range. + +The full non-shallow `v10.2.0..v10.3.1` source range was reviewed. +The change adds correct host-only classification and rejects leading-zero IPv4 +inputs. +The annotated `v10.3.1` tag resolves to commit +`be7e626c0d49fccb518899f520a3fb64ee189741` and is unsigned. +Release check `30150298200` succeeded. +Its trusted OpenID Connect (OIDC) publish workflow ran `npm ci` and `npm test` +before publication. +The focused repository tests pass 103/103, and the bundle-specific unit suite +passes 16/16. +Both committed locks report no problems through `npm ls`. +`npm run build:cli`, `npm run typecheck:cli`, and +`npm run checks:repository` pass. + +Image assembly reports the accepted lower-severity findings and blocks unaccepted high or critical findings through the empty-by-default audit -exception registry. Signature verification and the exact committed locks -remain mandatory. +exception registry. +Signature verification and the exact committed locks remain mandatory. The OpenClaw audit first applies the same fail-closed remediation to the SRI-verified reviewed archive, then independently installs and verifies the committed lock. The audit configuration pins the official npm registry origin, package identity, tarball URL, SRI, and lock SHA-256. It rejects repository path escapes, lock drift, registry-origin drift, or an installed graph that -does not match the lock before evaluating advisories. Remove the two OpenClaw -core replacements only after a supported OpenClaw archive publishes both -corrected transitive resolutions and the regenerated lock, installed-graph -verification, audit, and signature checks all pass. +does not match the lock before evaluating advisories. Remove the OpenClaw core +replacements only after a supported OpenClaw archive publishes every corrected +dependency identity and the regenerated lock, installed-graph verification, +audit, and signature checks all pass. +Remove the Discord `undici` replacement only after a supported Discord plugin +archive publishes the corrected identity in its manifest, shrinkwrap, and +bundled package tree and passes the same audit, signature, and runtime checks. The published Slack and Microsoft Teams plugin archives bundle `axios@1.16.0`. That version is in the affected range for the newly disclosed Axios @@ -167,12 +255,17 @@ The child-process probe confirms these results: `scripts/lib/openclaw-npm-remediation.mts` verifies each original package and replacement package identities before it writes the archive. It rejects an upstream graph that no longer resolves the reviewed Axios, Jaeger, -`brace-expansion`, or `fast-uri` source versions and dependency shapes. It then -verifies the deterministic remediated package-tree integrity before -installation or lock generation. This canonical tree digest is independent of -npm-generated tar metadata, which can vary between npm patch releases without -changing package contents. The production plugin installer, OpenClaw lock -workflow, and `reviewed-npm-audit` use this same function. +`brace-expansion`, `fast-uri`, `undici`, or `ip-address` source versions and +dependency shapes. +For Discord, it requires the package manifest dependency and +`bundledDependencies`, shrinkwrap, and bundled package tree to retain the +reviewed `undici@8.5.0` identities before replacement. +It then verifies the deterministic remediated package-tree integrity before +installation or lock generation. +This canonical tree digest is independent of npm-generated tar metadata, which +can vary between npm patch releases without changing package contents. +The production plugin installer, OpenClaw lock workflow, and +`reviewed-npm-audit` use this same function. The tree hash opens each regular file without following symbolic links and validates the opened descriptor before it reads the content. This keeps the metadata and content checks bound to the same file. diff --git a/scripts/lib/openclaw-npm-remediation.mts b/scripts/lib/openclaw-npm-remediation.mts index 42a23aa375..ddb9bcae0b 100755 --- a/scripts/lib/openclaw-npm-remediation.mts +++ b/scripts/lib/openclaw-npm-remediation.mts @@ -27,7 +27,7 @@ type JsonObject = Record; type Remediation = Readonly<{ expectedPatchedMetadataIntegrity?: string; expectedPatchedTreeIntegrity?: string; - kind: "axios" | "core" | "current-core" | "jaeger" | "legacy-core"; + kind: "axios" | "core" | "current-core" | "jaeger" | "legacy-core" | "undici"; version: "2026.3.11" | "2026.6.10" | "2026.7.1"; }>; @@ -90,10 +90,18 @@ const CURRENT_BRACE_EXPANSION_INTEGRITY = "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="; const CURRENT_BRACE_EXPANSION_TARBALL = "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz"; -const CURRENT_FAST_URI_VERSION = "3.1.4"; +const CURRENT_FAST_URI_VERSION = "3.1.5"; const CURRENT_FAST_URI_INTEGRITY = - "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw=="; -const CURRENT_FAST_URI_TARBALL = "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz"; + "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="; +const CURRENT_FAST_URI_TARBALL = "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz"; +const CURRENT_UNDICI_VERSION = "8.10.0"; +const CURRENT_UNDICI_INTEGRITY = + "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ=="; +const CURRENT_UNDICI_TARBALL = "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz"; +const CURRENT_IP_ADDRESS_VERSION = "10.3.1"; +const CURRENT_IP_ADDRESS_INTEGRITY = + "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g=="; +const CURRENT_IP_ADDRESS_TARBALL = "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz"; const JAEGER_PROPAGATOR_VERSION = "2.9.0"; const JAEGER_PROPAGATOR_INTEGRITY = "sha512-4mYGty27rYvSM0jtp1ZUOqd3LfVRCYg9H5G9OFzSx5HViYToU21MFhWfco7x1HwXr7ER8yGOiCIHZUwjPksc0Q=="; @@ -130,6 +138,12 @@ const REMEDIATIONS: Readonly> = Object.freeze({ kind: "jaeger", version: "2026.7.1", }, + "@openclaw/discord@2026.7.1": { + expectedPatchedTreeIntegrity: + "sha512-w+F8FrRl0wPd0EN2RnLyu6yfixel7BT8Iex4wLLQDvfIac8rLhuksNpFU4uZa8W9wXgh47hguq0F9NSN0BZfOQ==", + kind: "undici", + version: "2026.7.1", + }, "@openclaw/msteams@2026.7.1": { expectedPatchedTreeIntegrity: "sha512-FL4l65gEbbwtDd9Ogr69+xBNzIfE4YS8Hib36G+kcmX+T0oB1zL+/qs6b4bJc+ygTsh60H3yqpFbXoQeN05JYQ==", @@ -149,10 +163,10 @@ const REMEDIATIONS: Readonly> = Object.freeze({ version: "2026.6.10", }, // openclaw/openclaw#113584: remove after a supported OpenClaw archive - // publishes both corrected transitive resolutions in its shrinkwrap. + // publishes the corrected transitive resolutions in its shrinkwrap. "openclaw@2026.7.1": { expectedPatchedTreeIntegrity: - "sha512-13Ou0RhcOY850Feq/gu6MiCrRQPUlnfkVxwH6o2KCe1G460lfFBJ+lkGBjedUhek1nyEOSotcD0BYsizX7TI+w==", + "sha512-ugtX/U1jNS+ZlZqEXa+Y9nN+wlhPxeZJrx6tJZFLcGspWPFhsC5qOjTkzBbOda9lEZF6TWKt6wU9m9p2tidqdQ==", kind: "current-core", version: "2026.7.1", }, @@ -368,6 +382,37 @@ function requireDependencyShape( } } +function requireCurrentUndiciReplacement(packageJson: JsonObject, label: string): void { + requirePackageIdentity(packageJson, "undici", CURRENT_UNDICI_VERSION, label); + if ( + (packageJson.dependencies !== undefined && + Object.keys(packageJson.dependencies).length !== 0) || + packageJson.engines?.node !== ">=22.19.0" + ) { + throw new Error( + `undici@${CURRENT_UNDICI_VERSION} package contract changed; review the remediation before updating it`, + ); + } +} + +function requireCurrentIpAddressReplacement(packageJson: JsonObject): void { + requirePackageIdentity( + packageJson, + "ip-address", + CURRENT_IP_ADDRESS_VERSION, + "OpenClaw ip-address remediation package", + ); + if ( + (packageJson.dependencies !== undefined && + Object.keys(packageJson.dependencies).length !== 0) || + packageJson.engines?.node !== ">= 12" + ) { + throw new Error( + `ip-address@${CURRENT_IP_ADDRESS_VERSION} package contract changed; review the remediation before updating it`, + ); + } +} + export function patchOpenClawPluginPackageGraph( packageDirectory: string, packageSpec: string, @@ -522,8 +567,10 @@ export function patchCurrentOpenClawCorePackageGraph(packageDirectory: string): if ( packageJson.dependencies?.minimatch !== "10.2.5" || packageJson.dependencies?.["@modelcontextprotocol/sdk"] !== "1.29.0" || + packageJson.dependencies?.undici !== "8.5.0" || packageJson.dependencies?.["brace-expansion"] !== undefined || - packageJson.dependencies?.["fast-uri"] !== undefined + packageJson.dependencies?.["fast-uri"] !== undefined || + packageJson.dependencies?.["ip-address"] !== undefined ) { throw new Error("openclaw@2026.7.1 dependency boundary changed after review"); } @@ -539,6 +586,9 @@ export function patchCurrentOpenClawCorePackageGraph(packageDirectory: string): const fastUri = packages["node_modules/fast-uri"] as JsonObject | undefined; const minimatch = packages["node_modules/minimatch"] as JsonObject | undefined; const ajv = packages["node_modules/ajv"] as JsonObject | undefined; + const undici = packages["node_modules/undici"] as JsonObject | undefined; + const ipAddress = packages["node_modules/ip-address"] as JsonObject | undefined; + const expressRateLimit = packages["node_modules/express-rate-limit"] as JsonObject | undefined; if ( braceExpansion?.version !== "5.0.7" || braceExpansion.resolved !== @@ -560,13 +610,104 @@ export function patchCurrentOpenClawCorePackageGraph(packageDirectory: string): ) { throw new Error("openclaw@2026.7.1 fast-uri layout changed after review"); } + if ( + root.dependencies?.undici !== "8.5.0" || + undici?.version !== "8.5.0" || + undici.resolved !== "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz" || + undici.integrity !== + "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==" || + undici.engines?.node !== ">=22.19.0" + ) { + throw new Error("openclaw@2026.7.1 undici layout changed after review"); + } + if ( + ipAddress?.version !== "10.2.0" || + ipAddress.resolved !== "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz" || + ipAddress.integrity !== + "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==" || + ipAddress.engines?.node !== ">= 12" || + expressRateLimit?.version !== "8.5.2" || + expressRateLimit.dependencies?.["ip-address"] !== "^10.2.0" + ) { + throw new Error("openclaw@2026.7.1 ip-address layout changed after review"); + } + packageJson.dependencies.undici = CURRENT_UNDICI_VERSION; + root.dependencies.undici = CURRENT_UNDICI_VERSION; braceExpansion.version = CURRENT_BRACE_EXPANSION_VERSION; braceExpansion.resolved = CURRENT_BRACE_EXPANSION_TARBALL; braceExpansion.integrity = CURRENT_BRACE_EXPANSION_INTEGRITY; fastUri.version = CURRENT_FAST_URI_VERSION; fastUri.resolved = CURRENT_FAST_URI_TARBALL; fastUri.integrity = CURRENT_FAST_URI_INTEGRITY; + undici.version = CURRENT_UNDICI_VERSION; + undici.resolved = CURRENT_UNDICI_TARBALL; + undici.integrity = CURRENT_UNDICI_INTEGRITY; + ipAddress.version = CURRENT_IP_ADDRESS_VERSION; + ipAddress.resolved = CURRENT_IP_ADDRESS_TARBALL; + ipAddress.integrity = CURRENT_IP_ADDRESS_INTEGRITY; + writeJson(packageJsonPath, packageJson); + writeJson(shrinkwrapPath, shrinkwrap); +} + +export function patchOpenClawDiscordPackageGraph(packageDirectory: string): void { + const packageJsonPath = join(packageDirectory, "package.json"); + const shrinkwrapPath = join(packageDirectory, "npm-shrinkwrap.json"); + const bundledUndiciPackageJsonPath = join( + packageDirectory, + "node_modules", + "undici", + "package.json", + ); + const packageJson = readJson(packageJsonPath); + requirePackageIdentity(packageJson, "@openclaw/discord", "2026.7.1", "OpenClaw Discord plugin"); + if ( + packageJson.dependencies?.undici !== "8.5.0" || + !Array.isArray(packageJson.bundledDependencies) || + !packageJson.bundledDependencies.includes("undici") + ) { + throw new Error("@openclaw/discord@2026.7.1 undici dependency changed after review"); + } + const bundledUndiciPackageJson = readJson(bundledUndiciPackageJsonPath); + requirePackageIdentity( + bundledUndiciPackageJson, + "undici", + "8.5.0", + "OpenClaw Discord bundled undici package", + ); + if ( + (bundledUndiciPackageJson.dependencies !== undefined && + Object.keys(bundledUndiciPackageJson.dependencies).length !== 0) || + bundledUndiciPackageJson.engines?.node !== ">=22.19.0" + ) { + throw new Error("@openclaw/discord@2026.7.1 bundled undici layout changed after review"); + } + + const shrinkwrap = readJson(shrinkwrapPath); + if (shrinkwrap.lockfileVersion !== 3 || !shrinkwrap.packages?.[""]) { + throw new Error("@openclaw/discord@2026.7.1 must ship an npm lockfileVersion 3 shrinkwrap"); + } + const packages = shrinkwrap.packages as JsonObject; + const root = packages[""] as JsonObject; + const undici = packages["node_modules/undici"] as JsonObject | undefined; + requirePackageIdentity(root, "@openclaw/discord", "2026.7.1", "OpenClaw Discord shrinkwrap root"); + if ( + root.dependencies?.undici !== "8.5.0" || + undici?.version !== "8.5.0" || + undici.resolved !== "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz" || + undici.integrity !== + "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==" || + undici.engines?.node !== ">=22.19.0" + ) { + throw new Error("@openclaw/discord@2026.7.1 undici layout changed after review"); + } + + packageJson.dependencies.undici = CURRENT_UNDICI_VERSION; + root.dependencies.undici = CURRENT_UNDICI_VERSION; + undici.version = CURRENT_UNDICI_VERSION; + undici.resolved = CURRENT_UNDICI_TARBALL; + undici.integrity = CURRENT_UNDICI_INTEGRITY; + writeJson(packageJsonPath, packageJson); writeJson(shrinkwrapPath, shrinkwrap); } @@ -856,6 +997,20 @@ export function buildRemediatedOpenClawPluginArchive( remediationRoot, env, ); + const undiciArchive = packReplacement( + `undici@${CURRENT_UNDICI_VERSION}`, + CURRENT_UNDICI_INTEGRITY, + CURRENT_UNDICI_TARBALL, + remediationRoot, + env, + ); + const ipAddressArchive = packReplacement( + `ip-address@${CURRENT_IP_ADDRESS_VERSION}`, + CURRENT_IP_ADDRESS_INTEGRITY, + CURRENT_IP_ADDRESS_TARBALL, + remediationRoot, + env, + ); const braceExpansionPackage = extractArchive( braceExpansionArchive.archivePath, join(remediationRoot, "brace-expansion"), @@ -868,8 +1023,21 @@ export function buildRemediatedOpenClawPluginArchive( remediationRoot, env, ); + const undiciPackage = extractArchive( + undiciArchive.archivePath, + join(remediationRoot, "undici"), + remediationRoot, + env, + ); + const ipAddressPackage = extractArchive( + ipAddressArchive.archivePath, + join(remediationRoot, "ip-address"), + remediationRoot, + env, + ); const braceExpansionPackageJson = readJson(join(braceExpansionPackage, "package.json")); const fastUriPackageJson = readJson(join(fastUriPackage, "package.json")); + const undiciPackageJson = readJson(join(undiciPackage, "package.json")); requirePackageIdentity( braceExpansionPackageJson, "brace-expansion", @@ -895,7 +1063,29 @@ export function buildRemediatedOpenClawPluginArchive( `fast-uri@${CURRENT_FAST_URI_VERSION} dependency graph changed; review the remediation before updating it`, ); } + requireCurrentUndiciReplacement(undiciPackageJson, "OpenClaw undici remediation package"); + requireCurrentIpAddressReplacement(readJson(join(ipAddressPackage, "package.json"))); patchCurrentOpenClawCorePackageGraph(sourcePackage); + } else if (remediation.kind === "undici") { + const undiciArchive = packReplacement( + `undici@${CURRENT_UNDICI_VERSION}`, + CURRENT_UNDICI_INTEGRITY, + CURRENT_UNDICI_TARBALL, + remediationRoot, + env, + ); + const undiciPackage = extractArchive( + undiciArchive.archivePath, + join(remediationRoot, "undici"), + remediationRoot, + env, + ); + requireCurrentUndiciReplacement( + readJson(join(undiciPackage, "package.json")), + "OpenClaw Discord undici remediation package", + ); + patchOpenClawDiscordPackageGraph(sourcePackage); + copyReplacementPackage(undiciPackage, join(sourcePackage, "node_modules", "undici")); } else if (remediation.kind === "legacy-core") { const bundledTarPath = join(sourcePackage, "node_modules", "tar"); if (existsSync(bundledTarPath)) { diff --git a/test/mcporter-supply-chain.test.ts b/test/mcporter-supply-chain.test.ts index 4d4294cae4..516825e9e6 100644 --- a/test/mcporter-supply-chain.test.ts +++ b/test/mcporter-supply-chain.test.ts @@ -25,8 +25,10 @@ const expectedTarball = "https://registry.npmjs.org/mcporter/-/mcporter-0.7.3.tg const expectedHonoNodeServerVersion = "2.0.11"; const expectedHonoNodeServerTarball = "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.11.tgz"; -const expectedFastUriVersion = "3.1.4"; -const expectedFastUriTarball = "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz"; +const expectedFastUriVersion = "3.1.5"; +const expectedFastUriTarball = "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz"; +const expectedIpAddressVersion = "10.3.1"; +const expectedIpAddressTarball = "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz"; const runtimePrefix = "npm --prefix /usr/local/lib/nemoclaw/mcporter-runtime"; function extractIntegrityGate(contents: string): string { @@ -80,9 +82,13 @@ describe("mcporter image supply-chain controls", () => { ); expect(dependencyReview).toContain("any version other than exact `2.0.11`"); expect(dependencyReview).toContain("the `/vercel` adapter"); - expect(dependencyReview).toContain("`fast-uri@3.1.4`"); + expect(dependencyReview).toContain("`fast-uri@3.1.5`"); expect(dependencyReview).toContain("`GHSA-v2hh-gcrm-f6hx`"); - expect(dependencyReview).toContain("exact `3.1.4`"); + expect(dependencyReview).toContain("`GHSA-7p8r-x3mc-p8w7`"); + expect(dependencyReview).toContain("exact `3.1.5`"); + expect(dependencyReview).toContain("`ip-address@10.3.1`"); + expect(dependencyReview).toContain("`GHSA-mwp4-54f8-5fhr`"); + expect(dependencyReview).toContain("exact `10.3.1`"); }); it("resolves the committed production graph through npm's lockfile boundary", () => { @@ -109,6 +115,13 @@ describe("mcporter image supply-chain controls", () => { version: expectedFastUriVersion, }), ); + expect(findDependency(graph, "ip-address")).toEqual( + expect.objectContaining({ + overridden: true, + resolved: expectedIpAddressTarball, + version: expectedIpAddressVersion, + }), + ); }); it.each(dockerfiles)("pins and verifies the package in $name", ({ contents }) => { diff --git a/test/openclaw-dependency-review.test.ts b/test/openclaw-dependency-review.test.ts index 4db26e5e4c..8905e44494 100644 --- a/test/openclaw-dependency-review.test.ts +++ b/test/openclaw-dependency-review.test.ts @@ -162,8 +162,14 @@ describe("OpenClaw 2026.6.10 dependency review contract", () => { expect(review).toContain("@hono/node-server@^1.19.9"); expect(review).toContain("`2.0.11`"); expect(review).toContain("GHSA-v2hh-gcrm-f6hx"); + expect(review).toContain("GHSA-7p8r-x3mc-p8w7"); expect(review).toContain("fast-uri@^3.0.1"); - expect(review).toContain("`3.1.4`"); + expect(review).toContain("`3.1.5`"); + expect(review).toContain("GHSA-8xcm-r25x-g524"); + expect(review).toContain("GHSA-4cwx-7wf7-3272"); + expect(review).toContain("undici@8.10.0"); + expect(review).toContain("GHSA-mwp4-54f8-5fhr"); + expect(review).toContain("ip-address@10.3.1"); }); it("keeps advisor disposition evidence in the dependency review note", () => { diff --git a/test/openclaw-locked-install.test.ts b/test/openclaw-locked-install.test.ts index f14da2b273..e0aa96d48a 100644 --- a/test/openclaw-locked-install.test.ts +++ b/test/openclaw-locked-install.test.ts @@ -19,7 +19,7 @@ const PACKAGE_SPEC = "openclaw@2026.7.1"; const INTEGRITY = "sha512-ge/Xss99CHAjPL/ikmH/UFoiOrjcxDB4sW3y9mhyCD+dYW3wzV7TKbAVdkrXFgAG2d2BjpJofP97zUZ+umxo8g=="; const TARBALL = "https://registry.npmjs.org/openclaw/-/openclaw-2026.7.1.tgz"; -const LOCK_SHA256 = "847f68ac46f18d17efcd47dd3d8a5944b9dbcd205bc4d1e821ff82aa543eec20"; +const LOCK_SHA256 = "759b31779f40867f35f15065b582eb1d3efb8fddb1fe43c207507c905fa2a421"; const roots: string[] = []; function sha256(file: string): string { @@ -173,7 +173,9 @@ describe("locked OpenClaw production installation (#5896)", () => { expect(verified).toHaveLength(307); expect(verified).toContain(PACKAGE_SPEC); expect(verified).toContain("brace-expansion@5.0.9"); - expect(verified).toContain("fast-uri@3.1.4"); + expect(verified).toContain("fast-uri@3.1.5"); + expect(verified).toContain("ip-address@10.3.1"); + expect(verified).toContain("undici@8.10.0"); expect(sha256(LOCKFILE)).toBe(LOCK_SHA256); }); diff --git a/test/openclaw-npm-remediation.test.ts b/test/openclaw-npm-remediation.test.ts index 522c6f21ba..1696c9cb5e 100644 --- a/test/openclaw-npm-remediation.test.ts +++ b/test/openclaw-npm-remediation.test.ts @@ -22,6 +22,7 @@ import { patchCurrentOpenClawCorePackageGraph, patchLegacyOpenClawCorePackageGraph, patchOpenClawDiagnosticsOtelPackageGraph, + patchOpenClawDiscordPackageGraph, patchOpenClawPluginPackageGraph, } from "../scripts/lib/openclaw-npm-remediation.mts"; @@ -144,6 +145,8 @@ function writeLegacyCoreFixture(tarVersion = "7.5.11"): string { function writeCurrentCoreFixture( braceExpansionVersion = "5.0.7", fastUriVersion = "3.1.2", + undiciVersion = "8.5.0", + ipAddressVersion = "10.2.0", ): string { const directory = mkdtempSync(path.join(tmpdir(), "nemoclaw-current-openclaw-core-remediation-")); temporaryDirectories.push(directory); @@ -156,6 +159,7 @@ function writeCurrentCoreFixture( dependencies: { "@modelcontextprotocol/sdk": "1.29.0", minimatch: "10.2.5", + undici: undiciVersion, }, }, null, @@ -176,6 +180,7 @@ function writeCurrentCoreFixture( dependencies: { "@modelcontextprotocol/sdk": "1.29.0", minimatch: "10.2.5", + undici: undiciVersion, }, }, "node_modules/ajv": { @@ -199,6 +204,76 @@ function writeCurrentCoreFixture( version: "10.2.5", dependencies: { "brace-expansion": "^5.0.5" }, }, + "node_modules/express-rate-limit": { + version: "8.5.2", + dependencies: { "ip-address": "^10.2.0" }, + }, + "node_modules/ip-address": { + version: ipAddressVersion, + resolved: `https://registry.npmjs.org/ip-address/-/ip-address-${ipAddressVersion}.tgz`, + integrity: + "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + engines: { node: ">= 12" }, + }, + "node_modules/undici": { + version: undiciVersion, + resolved: `https://registry.npmjs.org/undici/-/undici-${undiciVersion}.tgz`, + integrity: + "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", + engines: { node: ">=22.19.0" }, + }, + }, + }, + null, + 2, + )}\n`, + ); + return directory; +} + +function writeDiscordFixture(undiciVersion = "8.5.0"): string { + const directory = mkdtempSync(path.join(tmpdir(), "nemoclaw-openclaw-discord-remediation-")); + temporaryDirectories.push(directory); + const dependencies = { undici: undiciVersion, ws: "8.21.0" }; + const bundledUndiciDirectory = path.join(directory, "node_modules", "undici"); + mkdirSync(bundledUndiciDirectory, { recursive: true }); + writeFileSync( + path.join(directory, "package.json"), + `${JSON.stringify( + { + name: "@openclaw/discord", + version: "2026.7.1", + dependencies, + bundledDependencies: ["undici"], + }, + null, + 2, + )}\n`, + ); + writeFileSync( + path.join(bundledUndiciDirectory, "package.json"), + `${JSON.stringify( + { name: "undici", version: undiciVersion, engines: { node: ">=22.19.0" } }, + null, + 2, + )}\n`, + ); + writeFileSync( + path.join(directory, "npm-shrinkwrap.json"), + `${JSON.stringify( + { + name: "@openclaw/discord", + version: "2026.7.1", + lockfileVersion: 3, + packages: { + "": { name: "@openclaw/discord", version: "2026.7.1", dependencies }, + "node_modules/undici": { + version: undiciVersion, + resolved: `https://registry.npmjs.org/undici/-/undici-${undiciVersion}.tgz`, + integrity: + "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", + engines: { node: ">=22.19.0" }, + }, }, }, null, @@ -434,25 +509,80 @@ describe("OpenClaw npm remediation", () => { "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", }); expect(shrinkwrap.packages["node_modules/fast-uri"]).toMatchObject({ - version: "3.1.4", - resolved: "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + version: "3.1.5", + resolved: "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + integrity: + "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + }); + expect(shrinkwrap.packages["node_modules/undici"]).toMatchObject({ + version: "8.10.0", + resolved: "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + integrity: + "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + }); + expect(shrinkwrap.packages["node_modules/ip-address"]).toMatchObject({ + version: "10.3.1", + resolved: "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", integrity: - "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", }); + expect( + readJson<{ dependencies: Record }>(path.join(directory, "package.json")), + ).toMatchObject({ dependencies: { undici: "8.10.0" } }); }); it.each([ ["brace-expansion", "5.0.6", "brace-expansion layout changed after review"], ["fast-uri", "3.1.1", "fast-uri layout changed after review"], + ["undici", "8.4.0", "dependency boundary changed after review"], + ["ip-address", "10.1.1", "ip-address layout changed after review"], ])("rejects a current OpenClaw %s graph that changed after review", (dependency, version, error) => { const directory = dependency === "brace-expansion" ? writeCurrentCoreFixture(version) - : writeCurrentCoreFixture("5.0.7", version); + : dependency === "fast-uri" + ? writeCurrentCoreFixture("5.0.7", version) + : dependency === "undici" + ? writeCurrentCoreFixture("5.0.7", "3.1.2", version) + : writeCurrentCoreFixture("5.0.7", "3.1.2", "8.5.0", version); expect(() => patchCurrentOpenClawCorePackageGraph(directory)).toThrow(error); }); + it("replaces the reviewed OpenClaw Discord undici dependency", () => { + const directory = writeDiscordFixture(); + + patchOpenClawDiscordPackageGraph(directory); + + expect(readPackageField(directory, "dependencies.undici")).toBe("8.10.0"); + const shrinkwrap = readJson<{ + packages: Record< + string, + { + dependencies?: Record; + integrity?: string; + resolved?: string; + version?: string; + } + >; + }>(path.join(directory, "npm-shrinkwrap.json")); + expect(shrinkwrap.packages[""].dependencies).toMatchObject({ undici: "8.10.0" }); + expect(shrinkwrap.packages["node_modules/undici"]).toMatchObject({ + version: "8.10.0", + resolved: "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + integrity: + "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + }); + }); + + it("rejects an OpenClaw Discord undici graph that changed after review", () => { + const directory = writeDiscordFixture("8.4.0"); + + expect(() => patchOpenClawDiscordPackageGraph(directory)).toThrow( + "undici dependency changed after review", + ); + }); + it("rebuilds the legacy fixture archive with the reviewed tar package bundled", () => { const fixture = writeLegacyCoreArchiveFixtures(); const request = { From 914d3471b561a5226cdf3b2bd8c776865ba9438d Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 3 Aug 2026 14:31:33 -0700 Subject: [PATCH 11/15] docs(security): clarify audit threshold semantics Signed-off-by: Prekshi Vyas --- docs/security/openclaw-2026.7.1-dependency-review.md | 9 ++++----- scripts/lib/openclaw-npm-remediation.mts | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/security/openclaw-2026.7.1-dependency-review.md b/docs/security/openclaw-2026.7.1-dependency-review.md index 65328b3b29..14b50f52ee 100644 --- a/docs/security/openclaw-2026.7.1-dependency-review.md +++ b/docs/security/openclaw-2026.7.1-dependency-review.md @@ -112,8 +112,8 @@ Its exact results are: Registry signature checks completed within the successful audit. The critical `tar` finding that blocked the previous pin and the high Jaeger, `brace-expansion`, `fast-uri`, `undici`, and `ip-address` findings are gone. -The accepted lower-severity findings remain visible below the configured -`high` threshold. +The lower-severity findings remain visible below the configured `high` +threshold. The independently installed `nemoclaw/` plugin graph reports `0` vulnerabilities after resolving its direct `tar` dependency to `7.5.20`. @@ -192,9 +192,8 @@ Both committed locks report no problems through `npm ls`. `npm run build:cli`, `npm run typecheck:cli`, and `npm run checks:repository` pass. -Image assembly reports the accepted lower-severity findings and blocks -unaccepted high or critical findings through the empty-by-default audit -exception registry. +Image assembly reports lower-severity findings and blocks high or critical +findings unless they match the empty-by-default audit exception registry. Signature verification and the exact committed locks remain mandatory. The OpenClaw audit first applies the same fail-closed remediation to the diff --git a/scripts/lib/openclaw-npm-remediation.mts b/scripts/lib/openclaw-npm-remediation.mts index ddb9bcae0b..e54bc8717c 100755 --- a/scripts/lib/openclaw-npm-remediation.mts +++ b/scripts/lib/openclaw-npm-remediation.mts @@ -163,7 +163,7 @@ const REMEDIATIONS: Readonly> = Object.freeze({ version: "2026.6.10", }, // openclaw/openclaw#113584: remove after a supported OpenClaw archive - // publishes the corrected transitive resolutions in its shrinkwrap. + // publishes every corrected dependency identity in its manifest and shrinkwrap. "openclaw@2026.7.1": { expectedPatchedTreeIntegrity: "sha512-ugtX/U1jNS+ZlZqEXa+Y9nN+wlhPxeZJrx6tJZFLcGspWPFhsC5qOjTkzBbOda9lEZF6TWKt6wU9m9p2tidqdQ==", From e9b4f9196b31bc81e5ef1d34d58b6587f2d40476 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 3 Aug 2026 18:48:55 -0400 Subject: [PATCH 12/15] docs(security): restore audit evidence contract Signed-off-by: Julie Yaunches --- docs/security/openclaw-2026.7.1-dependency-review.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/security/openclaw-2026.7.1-dependency-review.md b/docs/security/openclaw-2026.7.1-dependency-review.md index 14b50f52ee..a33bfd49c5 100644 --- a/docs/security/openclaw-2026.7.1-dependency-review.md +++ b/docs/security/openclaw-2026.7.1-dependency-review.md @@ -112,7 +112,9 @@ Its exact results are: Registry signature checks completed within the successful audit. The critical `tar` finding that blocked the previous pin and the high Jaeger, `brace-expansion`, `fast-uri`, `undici`, and `ip-address` findings are gone. -The lower-severity findings remain visible below the configured `high` +All three post-remediation boundaries report `0` high and `0` critical +findings. +Lower-severity findings remain visible below the configured `high` threshold. The independently installed `nemoclaw/` plugin graph reports `0` From ced321f6f2da3fc14d021a9eafe9cb9219520cd3 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Mon, 3 Aug 2026 16:07:07 -0700 Subject: [PATCH 13/15] test(security): harden npm remediation contracts Signed-off-by: Senthil Ravichandran --- test/openclaw-locked-install.test.ts | 20 ++++----- test/openclaw-npm-remediation.test.ts | 64 +++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/test/openclaw-locked-install.test.ts b/test/openclaw-locked-install.test.ts index e6d0d5e394..ba46d6a94a 100644 --- a/test/openclaw-locked-install.test.ts +++ b/test/openclaw-locked-install.test.ts @@ -384,18 +384,14 @@ describe("locked OpenClaw production installation (#5896)", () => { }), ]), ); - const openclawGraph = audit.lockedGraphs.find( - ({ packageSpec }: { packageSpec?: string }) => packageSpec === PACKAGE_SPEC, - ); - expect(openclawGraph).toBeDefined(); - expect(openclawGraph).not.toHaveProperty("replacementLockSha256"); - expect(openclawGraph).not.toHaveProperty("reviewedLockSha256"); - const mcporterGraph = audit.lockedGraphs.find( - ({ packageSpec }: { packageSpec?: string }) => packageSpec === MCPORTER_PACKAGE_SPEC, - ); - expect(mcporterGraph).toBeDefined(); - expect(mcporterGraph).not.toHaveProperty("replacementLockSha256"); - expect(mcporterGraph).not.toHaveProperty("reviewedLockSha256"); + expect(audit.lockedGraphs).toHaveLength(2); + expect( + audit.lockedGraphs.map(({ packageSpec }: { packageSpec?: string }) => packageSpec).sort(), + ).toEqual([MCPORTER_PACKAGE_SPEC, PACKAGE_SPEC].sort()); + for (const graph of audit.lockedGraphs) { + expect(graph).not.toHaveProperty("replacementLockSha256"); + expect(graph).not.toHaveProperty("reviewedLockSha256"); + } const baseWorkflow = fs.readFileSync( path.join(REPO_ROOT, ".github", "workflows", "base-image.yaml"), diff --git a/test/openclaw-npm-remediation.test.ts b/test/openclaw-npm-remediation.test.ts index 3303fb1aa2..f03140d9cb 100644 --- a/test/openclaw-npm-remediation.test.ts +++ b/test/openclaw-npm-remediation.test.ts @@ -287,6 +287,10 @@ function readJson(file: string): T { return JSON.parse(readFileSync(file, "utf-8")) as T; } +function writeJson(file: string, value: unknown): void { + writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + function packFixture(packageDirectory: string, archivePath: string): void { const root = mkdtempSync(path.join(tmpdir(), "nemoclaw-openclaw-archive-fixture-")); temporaryDirectories.push(root); @@ -549,6 +553,24 @@ describe("OpenClaw npm remediation", () => { expect(() => patchCurrentOpenClawCorePackageGraph(directory)).toThrow(error); }); + it.each([ + ["resolved", "https://registry.npmjs.org/undici/-/undici-8.4.0.tgz"], + ["integrity", "sha512-deliberate-mismatch"], + ["engines", { node: ">=22.20.0" }], + ])("rejects a current OpenClaw undici shrinkwrap with changed %s", (field, value) => { + const directory = writeCurrentCoreFixture(); + const shrinkwrapPath = path.join(directory, "npm-shrinkwrap.json"); + const shrinkwrap = readJson<{ + packages: Record>; + }>(shrinkwrapPath); + shrinkwrap.packages["node_modules/undici"][field] = value; + writeJson(shrinkwrapPath, shrinkwrap); + + expect(() => patchCurrentOpenClawCorePackageGraph(directory)).toThrow( + "undici layout changed after review", + ); + }); + it("replaces the reviewed OpenClaw Discord undici dependency", () => { const directory = writeDiscordFixture(); @@ -583,6 +605,48 @@ describe("OpenClaw npm remediation", () => { ); }); + it.each([ + ["resolved", "https://registry.npmjs.org/undici/-/undici-8.4.0.tgz"], + ["integrity", "sha512-deliberate-mismatch"], + ["engines", { node: ">=22.20.0" }], + ])("rejects an OpenClaw Discord undici shrinkwrap with changed %s", (field, value) => { + const directory = writeDiscordFixture(); + const shrinkwrapPath = path.join(directory, "npm-shrinkwrap.json"); + const shrinkwrap = readJson<{ + packages: Record>; + }>(shrinkwrapPath); + shrinkwrap.packages["node_modules/undici"][field] = value; + writeJson(shrinkwrapPath, shrinkwrap); + + expect(() => patchOpenClawDiscordPackageGraph(directory)).toThrow( + "undici layout changed after review", + ); + }); + + it("rejects a changed OpenClaw Discord bundled undici identity", () => { + const directory = writeDiscordFixture(); + writeJson(path.join(directory, "node_modules", "undici", "package.json"), { + name: "undici", + version: "8.4.0", + engines: { node: ">=22.19.0" }, + }); + + expect(() => patchOpenClawDiscordPackageGraph(directory)).toThrow("must be undici@8.5.0"); + }); + + it("rejects a changed OpenClaw Discord bundled undici layout", () => { + const directory = writeDiscordFixture(); + writeJson(path.join(directory, "node_modules", "undici", "package.json"), { + name: "undici", + version: "8.5.0", + engines: { node: ">=22.20.0" }, + }); + + expect(() => patchOpenClawDiscordPackageGraph(directory)).toThrow( + "bundled undici layout changed after review", + ); + }); + it("rebuilds the legacy fixture archive with the reviewed tar package bundled", () => { const fixture = writeLegacyCoreArchiveFixtures(); const request = { From e8fd11c940e74ae77a4c8517ea06eb46948c613b Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 3 Aug 2026 19:15:34 -0400 Subject: [PATCH 14/15] test(messaging): isolate doctor boundary Signed-off-by: Julie Yaunches --- test/messaging-build-applier.test.ts | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/test/messaging-build-applier.test.ts b/test/messaging-build-applier.test.ts index d56a0dddff..235690ca9b 100644 --- a/test/messaging-build-applier.test.ts +++ b/test/messaging-build-applier.test.ts @@ -1183,7 +1183,6 @@ describe("messaging-build-applier.mts: agent-install", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-discord-runtime-contract-")); const tracePath = path.join(tmp, "openclaw.trace"); const fakeOpenclaw = path.join(tmp, "openclaw"); - const fakeNpm = path.join(tmp, "npm"); const discordChannels = channelsB64(["discord"]); fs.writeFileSync( fakeOpenclaw, @@ -1210,19 +1209,6 @@ describe("messaging-build-applier.mts: agent-install", () => { ].join("\n"), { mode: 0o755 }, ); - fs.writeFileSync( - fakeNpm, - [ - "#!/bin/sh", - 'printf \'npm|%s|%s|%s||\\n\' "$1" "$2" "$3" >> "$OPENCLAW_TRACE"', - ...fakeOpenClawPluginNpmPackScriptLines(), - 'if [ "${1:-}" = "view" ] && [ "${2:-}" = "@openclaw/discord@2026.7.1" ] && [ "${3:-}" = "dist.integrity" ]; then printf "%s\\n" "$OPENCLAW_DISCORD_2026_7_1_INTEGRITY"; exit 0; fi', - "exit 1", - "", - ].join("\n"), - { mode: 0o755 }, - ); - try { const generatorEnv = await withLegacyMessagingPlanEnvDirect( { @@ -1252,19 +1238,10 @@ describe("messaging-build-applier.mts: agent-install", () => { NEMOCLAW_MESSAGING_PLAN_B64: generatorEnv.NEMOCLAW_MESSAGING_PLAN_B64, NEMOCLAW_WEB_SEARCH_ENABLED: "1", }; - const pluginResult = runApplierProcess(applierEnv, "openclaw", "agent-install"); - expect(pluginResult.status, pluginResult.stderr).toBe(0); - const postInstallResult = runApplierProcess(applierEnv, "openclaw", "post-agent-install"); expect(postInstallResult.status, postInstallResult.stderr).toBe(0); const trace = fs.readFileSync(tracePath, "utf-8"); - expect(trace).toContain("npm|view|@openclaw/discord@2026.7.1|dist.integrity||"); - expect(trace).toContain( - "npm|pack|https://registry.npmjs.org/@openclaw/discord/-/discord-2026.7.1.tgz|--pack-destination||", - ); - expect(trace).toContain("plugins|install|npm-pack:"); - expect(trace).toContain("discord-2026.7.1.tgz||"); expect(trace).toContain( "doctor|--fix|--non-interactive|openshell:resolve:env:DISCORD_BOT_TOKEN|openshell:resolve:env:BRAVE_API_KEY", ); From 1d28516eac51effbd690f8bf1847e59571ac5118 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Mon, 3 Aug 2026 16:53:32 -0700 Subject: [PATCH 15/15] test(security): allow reviewed archive rebuild latency Signed-off-by: Aaron Erickson --- test/openclaw-npm-remediation.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/openclaw-npm-remediation.test.ts b/test/openclaw-npm-remediation.test.ts index f03140d9cb..ba424ef2d3 100644 --- a/test/openclaw-npm-remediation.test.ts +++ b/test/openclaw-npm-remediation.test.ts @@ -681,5 +681,5 @@ describe("OpenClaw npm remediation", () => { path.join(extracted, "package", "node_modules", "tar", "package.json"), ), ).toMatchObject({ name: "tar", version: "7.5.19" }); - }); + }, 60_000); });