diff --git a/docs/security/openclaw-2026.6.10-dependency-review.md b/docs/security/openclaw-2026.6.10-dependency-review.md index 9ca1cb9f3c2..f9d9dd6e880 100644 --- a/docs/security/openclaw-2026.6.10-dependency-review.md +++ b/docs/security/openclaw-2026.6.10-dependency-review.md @@ -89,8 +89,10 @@ For `openclaw@2026.6.10`, the helper makes these changes: - Bundles the reviewed `@openclaw/fs-safe@0.3.0` package and removes its duplicate optional `tar` and `jszip` declarations. The bundled package resolves OpenClaw's reviewed direct `tar@7.5.19` and `jszip@3.10.1` dependencies instead, including during a global npm install. - Verifies the installed global dependency tree before either the reviewed base image or production image can complete. -For the E2E-only `openclaw@2026.3.11` identity, the helper replaces the exact `tar@7.5.11` declaration with reviewed `tar@7.5.19`. -It rejects a source archive that has different dependency metadata or an unexpected npm shrinkwrap. +For the E2E-only `openclaw@2026.3.11` identity, the helper requires the exact `tar@7.5.11` declaration, no bundled dependencies, no bundled tar package, and no npm shrinkwrap. +The reviewed source archive SRI binds the remainder of the source manifest and package bytes. +The helper then verifies the exact `tar@7.5.19` registry SRI and tarball URL, copies that reviewed package into the remediated archive, and declares it as a bundled dependency so the later global install cannot resolve the replacement tar package from mutable registry state. +The committed patched-metadata hash binds the OpenClaw identity, replacement declaration, bundled-dependency marker, and bundled tar identity. For `@openclaw/slack@2026.6.10` and `@openclaw/msteams@2026.6.10`, the helper makes these changes: @@ -126,7 +128,7 @@ It binds each patched package manifest and shrinkwrap to a committed SHA-512 met The core value also covers the bundled `@openclaw/fs-safe` package manifest. The diagnostics value also covers the bundled SDK, Jaeger propagator, and nested core package manifests. The expected values are `sha512-B5O6Gu3YGY52w+Px8diL5zBtk8mj0u7E1ZvVK7KOLWX9H+S3B7kYUxnGfyB239mVYSluecfiWGvFFMk5eFhwKg==` for OpenClaw core, `sha512-ByLYBs3KXz3u0mPuj9DcP/xPTJNgQaLTPxazybhyIC1VjyftEmKQuoZufPZ8z8CjwBsOPm6NbjMQB2BfX36TTg==` for diagnostics OTEL, `sha512-AXllGzI+m33jUq3w1nCVXngLA1m9kH8c9XryHSoPzuVhGP6xwWpzgKl3yyfOMoIykN0GKcka59ZZbjEwkxFudQ==` for Slack, and `sha512-eTTIpA8HzcBwXBLt6UZDoFgOUmkRgIhcZFBOwg+5Jfgt8HDwtfPnqKo6vm2DdDdPMPhu08FbEzU5Gt3RoL5fIw==` for Microsoft Teams. -The E2E-only `openclaw@2026.3.11` value is `sha512-c+3QxBJidAFb8xZSmz4azC7KHFvXUAY9vN1AlXJ243LwMCFN5it5MW0r6FBuxIFvlBCnGlzcqRCvU5ghUec/ng==`. +The E2E-only `openclaw@2026.3.11` value is `sha512-1i30XSb/2NEcuTcuhXfR/x3YKaXVhWq6ttecFBSD9nrCKrzjNxSNMfK1y3qRcnblNOzRWmHtJZwZKeej02s/EQ==`. Both the library and command-line entry points enforce the same committed values. `Dockerfile.base` records `ignore-scripts+reviewed-lifecycle+transitive-remediation-v1` in its protected provenance marker. The production Dockerfile rejects stale base provenance and repeats the remediation when the marker does not match. diff --git a/scripts/lib/openclaw-npm-remediation.mts b/scripts/lib/openclaw-npm-remediation.mts index 5a593ef1a12..5ac6a4afa26 100755 --- a/scripts/lib/openclaw-npm-remediation.mts +++ b/scripts/lib/openclaw-npm-remediation.mts @@ -36,17 +36,19 @@ type BuildRequest = RemediationRequest & expectedPatchedMetadataIntegrity?: string; }>; -export type RemediatedArchive = Readonly<{ - archivePath: string; - integrity: string; -}> & - Readonly< - | { remediated: false } - | { - metadataIntegrity: string; - remediated: true; - } - >; +export type RemediatedArchive = Readonly< + | { + archivePath: string; + integrity: string; + remediated: false; + } + | { + archivePath: string; + integrity: string; + metadataIntegrity: string; + remediated: true; + } +>; const AXIOS_VERSION = "1.18.0"; const AXIOS_INTEGRITY = @@ -108,7 +110,7 @@ const REMEDIATIONS: Readonly> = Object.freeze({ "openclaw@2026.3.11": { kind: "legacy-core", expectedPatchedMetadataIntegrity: - "sha512-c+3QxBJidAFb8xZSmz4azC7KHFvXUAY9vN1AlXJ243LwMCFN5it5MW0r6FBuxIFvlBCnGlzcqRCvU5ghUec/ng==", + "sha512-1i30XSb/2NEcuTcuhXfR/x3YKaXVhWq6ttecFBSD9nrCKrzjNxSNMfK1y3qRcnblNOzRWmHtJZwZKeej02s/EQ==", }, }); @@ -186,8 +188,45 @@ function writeJson(path: string, value: JsonObject): void { writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); } -function hashPatchedMetadata(packageDirectory: string): string { +function hashMetadataEntries(entries: readonly (readonly [string, Buffer])[]): string { const hash = createHash("sha512"); + for (const [name, contents] of entries) { + hash.update(`${name}\0${contents.length}\0`); + hash.update(contents); + hash.update("\0"); + } + return `sha512-${hash.digest("base64")}`; +} + +function hashPatchedMetadata(packageDirectory: string): string { + const packageJson = readJson(join(packageDirectory, "package.json")); + if (packageJson.name === "openclaw" && packageJson.version === "2026.3.11") { + const bundledTarPackageJson = readJson( + join(packageDirectory, "node_modules", "tar", "package.json"), + ); + return hashMetadataEntries([ + [ + "legacy-openclaw-remediation.json", + Buffer.from( + `${JSON.stringify( + { + bundledDependencies: packageJson.bundledDependencies, + bundledTar: { + name: bundledTarPackageJson.name, + version: bundledTarPackageJson.version, + }, + name: packageJson.name, + tarDependency: packageJson.dependencies?.tar, + version: packageJson.version, + }, + null, + 2, + )}\n`, + ), + ], + ]); + } + const names = ["package.json"]; if (existsSync(join(packageDirectory, "npm-shrinkwrap.json"))) { names.push("npm-shrinkwrap.json"); @@ -204,13 +243,9 @@ function hashPatchedMetadata(packageDirectory: string): string { if (diagnosticsMetadata.every((name) => existsSync(join(packageDirectory, name)))) { names.push(...diagnosticsMetadata); } - for (const name of names) { - const contents = readFileSync(join(packageDirectory, name)); - hash.update(`${name}\0${contents.length}\0`); - hash.update(contents); - hash.update("\0"); - } - return `sha512-${hash.digest("base64")}`; + return hashMetadataEntries( + names.map((name) => [name, readFileSync(join(packageDirectory, name))] as const), + ); } function sortedObject(value: JsonObject): JsonObject { @@ -394,6 +429,7 @@ export function patchOpenClawCorePackageGraph(packageDirectory: string): void { export function patchLegacyOpenClawCorePackageGraph(packageDirectory: string): void { const packageJsonPath = join(packageDirectory, "package.json"); + const bundledTarPackageJsonPath = join(packageDirectory, "node_modules", "tar", "package.json"); const packageJson = readJson(packageJsonPath); requirePackageIdentity(packageJson, "openclaw", "2026.3.11", "Legacy OpenClaw core"); if (packageJson.dependencies?.tar !== "7.5.11") { @@ -405,8 +441,18 @@ export function patchLegacyOpenClawCorePackageGraph(packageDirectory: string): v if (existsSync(join(packageDirectory, "npm-shrinkwrap.json"))) { throw new Error("openclaw@2026.3.11 unexpectedly ships an npm shrinkwrap"); } + if (!existsSync(bundledTarPackageJsonPath)) { + throw new Error("openclaw@2026.3.11 remediation requires the reviewed bundled tar package"); + } + requirePackageIdentity( + readJson(bundledTarPackageJsonPath), + "tar", + TAR_VERSION, + "Legacy OpenClaw bundled tar remediation", + ); packageJson.dependencies.tar = TAR_VERSION; + packageJson.bundledDependencies = ["tar"]; writeJson(packageJsonPath, packageJson); } @@ -548,7 +594,9 @@ function packReplacement( }); } -export function buildRemediatedOpenClawArchive(request: BuildRequest): RemediatedArchive { +export function buildRemediatedOpenClawArchive( + request: BuildRequest, +): Extract { const remediation = REMEDIATIONS[request.packageSpec]; if (!remediation) { throw new Error(`No OpenClaw npm remediation is defined for ${request.packageSpec}`); @@ -592,6 +640,30 @@ export function buildRemediatedOpenClawArchive(request: BuildRequest): Remediate ); patchOpenClawCorePackageGraph(sourcePackage); } else if (remediation.kind === "legacy-core") { + const bundledTarPath = join(sourcePackage, "node_modules", "tar"); + if (existsSync(bundledTarPath)) { + throw new Error("openclaw@2026.3.11 unexpectedly bundles tar before remediation"); + } + const tarArchive = packReplacement( + `tar@${TAR_VERSION}`, + TAR_INTEGRITY, + TAR_TARBALL, + remediationRoot, + env, + ); + const tarPackage = extractArchive( + tarArchive.archivePath, + join(remediationRoot, "tar"), + remediationRoot, + env, + ); + requirePackageIdentity( + readJson(join(tarPackage, "package.json")), + "tar", + TAR_VERSION, + "Legacy OpenClaw tar remediation package", + ); + copyReplacementPackage(tarPackage, bundledTarPath); patchLegacyOpenClawCorePackageGraph(sourcePackage); } else if (remediation.kind === "diagnostics-otel") { const jaegerArchive = packReplacement( diff --git a/test/e2e/live/openshell-gateway-upgrade-old-installer.ts b/test/e2e/live/openshell-gateway-upgrade-old-installer.ts index 31e4a7932a8..0171690dc50 100644 --- a/test/e2e/live/openshell-gateway-upgrade-old-installer.ts +++ b/test/e2e/live/openshell-gateway-upgrade-old-installer.ts @@ -35,6 +35,12 @@ const REVIEWED_OLD_OPENCLAW_ARCHIVES: Readonly&2 @@ -98,7 +103,7 @@ if injection not in text: raise SystemExit(f"{path}: old OpenClaw version gate not found") text = text.replace(marker, injection + marker, 1) -advisory_audit = ' npm --prefix /usr/local/lib/nemoclaw/mcporter-runtime audit --omit=dev --audit-level=low; \\\n' +advisory_audit = ${JSON.stringify(OLD_INSTALLER_ADVISORY_AUDIT)} advisory_audit_count = text.count(advisory_audit) if advisory_audit_count != 1: raise SystemExit( @@ -126,8 +131,8 @@ NEMOCLAW_OLD_PAYLOAD_PIN_PY const text = fs.readFileSync(installer, "utf8"); const patchedText = text.includes(hook) ? text - : text.includes(needle) - ? text.replace(needle, needle + hook) + : text.includes(OLD_INSTALLER_BOOTSTRAP_NEEDLE) + ? text.replace(OLD_INSTALLER_BOOTSTRAP_NEEDLE, OLD_INSTALLER_BOOTSTRAP_NEEDLE + hook) : (() => { throw new Error(`${installer}: old bootstrap payload hook not found`); })(); diff --git a/test/e2e/support/openshell-gateway-upgrade-old-installer.test.ts b/test/e2e/support/openshell-gateway-upgrade-old-installer.test.ts index df3567bb5b6..f159fe4689f 100644 --- a/test/e2e/support/openshell-gateway-upgrade-old-installer.test.ts +++ b/test/e2e/support/openshell-gateway-upgrade-old-installer.test.ts @@ -7,6 +7,9 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { + OLD_INSTALLER_ADVISORY_AUDIT, + OLD_INSTALLER_BOOTSTRAP_NEEDLE, + OLD_INSTALLER_CLONE_NEEDLE, patchOldInstallerFixture, reviewedOldOpenClawArchive, } from "../live/openshell-gateway-upgrade-old-installer.ts"; @@ -28,14 +31,12 @@ function writeHistoricalFixture(advisoryAuditCount = 1): { fs.mkdirSync(sourceRoot); fs.writeFileSync(archive, "reviewed fixture archive"); - const advisoryAudit = - " npm --prefix /usr/local/lib/nemoclaw/mcporter-runtime audit --omit=dev --audit-level=low; \\\n"; fs.writeFileSync( dockerfile, [ "FROM fixture", "ARG OPENCLAW_VERSION=2026.5.27", - ...Array.from({ length: advisoryAuditCount }, () => advisoryAudit.trimEnd()), + ...Array.from({ length: advisoryAuditCount }, () => OLD_INSTALLER_ADVISORY_AUDIT.trimEnd()), " npm --prefix /usr/local/lib/nemoclaw/mcporter-runtime audit signatures; \\", " true", "", @@ -51,7 +52,7 @@ function writeHistoricalFixture(advisoryAuditCount = 1): { "release_ref=fixture", 'spin() { shift; "$@"; }', "clone_nemoclaw_ref() { :; }", - ' spin "Cloning ${_CLI_DISPLAY} source" clone_nemoclaw_ref "$release_ref" "$nemoclaw_src"', + OLD_INSTALLER_CLONE_NEEDLE.trimEnd(), "", ].join("\n"), { mode: 0o700 }, @@ -63,7 +64,7 @@ function writeHistoricalFixture(advisoryAuditCount = 1): { "set -euo pipefail", `payload_script=${JSON.stringify(payload)}`, `source_root=${JSON.stringify(sourceRoot)}`, - ' legacy_script="${source_root}/install.sh"', + OLD_INSTALLER_BOOTSTRAP_NEEDLE.trimEnd(), '"$payload_script"', "", ].join("\n"), @@ -117,6 +118,7 @@ describe("historical OpenShell gateway upgrade installer adapter", () => { it("rejects an ambiguous historical advisory boundary", () => { const fixture = writeHistoricalFixture(2); patchOldInstallerFixture(fixture.installer); + const originalDockerfile = fs.readFileSync(fixture.dockerfile, "utf8"); const result = spawnSync("bash", [fixture.installer], { encoding: "utf8", @@ -128,11 +130,13 @@ describe("historical OpenShell gateway upgrade installer adapter", () => { }); expect(result.status).not.toBe(0); expect(result.stderr).toContain("historical mcporter advisory audits; expected exactly one"); + expect(fs.readFileSync(fixture.dockerfile, "utf8")).toBe(originalDockerfile); }); it("rejects a missing historical advisory boundary", () => { const fixture = writeHistoricalFixture(0); patchOldInstallerFixture(fixture.installer); + const originalDockerfile = fs.readFileSync(fixture.dockerfile, "utf8"); const result = spawnSync("bash", [fixture.installer], { encoding: "utf8", @@ -146,6 +150,7 @@ describe("historical OpenShell gateway upgrade installer adapter", () => { expect(result.stderr).toContain( "found 0 historical mcporter advisory audits; expected exactly one", ); + expect(fs.readFileSync(fixture.dockerfile, "utf8")).toBe(originalDockerfile); }); it.each([ diff --git a/test/node-tar-dockerfile-contract.test.ts b/test/node-tar-dockerfile-contract.test.ts index 2d7161ecd0d..e882cdb1eb5 100644 --- a/test/node-tar-dockerfile-contract.test.ts +++ b/test/node-tar-dockerfile-contract.test.ts @@ -71,10 +71,19 @@ describe("node-tar image remediation contract", () => { expect(reviewedCopy, file).toBeGreaterThanOrEqual(0); expect(patchCopy, file).toBeGreaterThan(reviewedCopy); expect(patchRun, file).toBeGreaterThan(patchCopy); - const patchDownloader = source.indexOf("curl="); - expect(patchDownloader > patchCopy && patchDownloader < patchRun, file).toBe( - installsPatchDownloader, + const aptInstall = source.indexOf( + "RUN apt-get update && apt-get install -y --no-install-recommends", + patchCopy, ); + const curlPackage = source.indexOf("curl=8.14.1-2+deb13u4", aptInstall); + const aptInstallCleanup = source.indexOf("&& rm -rf /var/lib/apt/lists/*", curlPackage); + expect( + aptInstall > patchCopy && + curlPackage > aptInstall && + aptInstallCleanup > curlPackage && + aptInstallCleanup < patchRun, + file, + ).toBe(installsPatchDownloader); expect(scanCopy, file).toBeGreaterThan(patchRun); expect(scanRun, file).toBeGreaterThan(scanCopy); expect(source, file).toContain("> /usr/local/share/nemoclaw/node-tar-inventory.json"); diff --git a/test/openclaw-npm-remediation.test.ts b/test/openclaw-npm-remediation.test.ts index 2504887cf7c..ff5a35d0813 100644 --- a/test/openclaw-npm-remediation.test.ts +++ b/test/openclaw-npm-remediation.test.ts @@ -237,6 +237,54 @@ function writeCoreArchiveFixtures(): { return { archivePath, npmExecutable, workingDirectory: path.join(root, "work") }; } +function writeLegacyCoreArchiveFixtures(): { + archivePath: string; + npmExecutable: string; + workingDirectory: string; +} { + const root = mkdtempSync(path.join(tmpdir(), "nemoclaw-legacy-openclaw-build-remediation-")); + temporaryDirectories.push(root); + const archivePath = path.join(root, "openclaw-2026.3.11.tgz"); + packFixture(writeLegacyCoreFixture(), archivePath); + + const tarDirectory = path.join(root, "tar-package"); + mkdirSync(tarDirectory, { recursive: true }); + writeFileSync( + path.join(tarDirectory, "package.json"), + `${JSON.stringify({ name: "tar", version: "7.5.19" }, null, 2)}\n`, + ); + const tarArchive = path.join(root, "tar-7.5.19-source.tgz"); + packFixture(tarDirectory, tarArchive); + + const npmExecutable = path.join(root, "npm-fixture.sh"); + writeFileSync( + npmExecutable, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `tar_archive=${JSON.stringify(tarArchive)}`, + 'case "$1:$2:${3:-}" in', + ' "view:tar@7.5.19:dist.integrity") value="sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==" ;;', + ' "view:tar@7.5.19:dist.tarball") value="https://registry.npmjs.org/tar/-/tar-7.5.19.tgz" ;;', + ' "pack:https://registry.npmjs.org/tar/-/tar-7.5.19.tgz:--pack-destination") ;;', + ' *) echo "unexpected npm fixture invocation: $*" >&2; exit 1 ;;', + "esac", + 'if [ "$1" = "view" ]; then printf "%s\\n" "$value"; exit 0; fi', + 'destination=""', + 'while [ "$#" -gt 0 ]; do', + ' if [ "$1" = "--pack-destination" ]; then destination="$2"; shift 2; continue; fi', + " shift", + "done", + 'cp "$tar_archive" "$destination/tar-7.5.19.tgz"', + 'printf \'[{"filename":"tar-7.5.19.tgz","integrity":"sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw=="}]\\n\'', + "", + ].join("\n"), + { mode: 0o700 }, + ); + chmodSync(npmExecutable, 0o700); + return { archivePath, npmExecutable, workingDirectory: path.join(root, "work") }; +} + function writePluginArchiveFixtures(): { archivePath: string; npmExecutable: string; @@ -440,33 +488,23 @@ describe("OpenClaw npm remediation", () => { ); }); - it("rebuilds the legacy fixture archive without adding mutable lock metadata", () => { - const directory = writeLegacyCoreFixture(); - const root = mkdtempSync(path.join(tmpdir(), "nemoclaw-legacy-openclaw-build-remediation-")); - temporaryDirectories.push(root); - const archivePath = path.join(root, "openclaw-2026.3.11.tgz"); - packFixture(directory, archivePath); + it("rebuilds the legacy fixture archive with the reviewed tar package bundled", () => { + const fixture = writeLegacyCoreArchiveFixtures(); const request = { - archivePath, + archivePath: fixture.archivePath, + env: { NEMOCLAW_REVIEWED_NPM_EXECUTABLE: fixture.npmExecutable }, packageSpec: "openclaw@2026.3.11", - workingDirectory: path.join(root, "work"), + workingDirectory: fixture.workingDirectory, }; - let metadataIntegrity = ""; - try { + const remediated = buildRemediatedOpenClawArchive(request); + expect(() => buildRemediatedOpenClawArchive({ ...request, expectedPatchedMetadataIntegrity: "sha512-deliberate-mismatch", - }); - } catch (error) { - metadataIntegrity = String(error).match(/got (sha512-\S+)/u)?.[1] ?? ""; - } - expect(metadataIntegrity).toMatch(/^sha512-/u); + }), + ).toThrow(`got ${remediated.metadataIntegrity}`); - const remediated = buildRemediatedOpenClawArchive({ - ...request, - expectedPatchedMetadataIntegrity: metadataIntegrity, - }); - const extracted = path.join(root, "asserted"); + const extracted = path.join(path.dirname(fixture.archivePath), "asserted"); mkdirSync(extracted, { recursive: true }); const extraction = spawnSync("tar", ["-xzf", remediated.archivePath, "-C", extracted], { encoding: "utf8", @@ -474,10 +512,16 @@ describe("OpenClaw npm remediation", () => { expect(extraction.status, extraction.stderr).toBe(0); expect(existsSync(path.join(extracted, "package", "npm-shrinkwrap.json"))).toBe(false); expect( - readJson<{ dependencies?: Record }>( - path.join(extracted, "package", "package.json"), - ).dependencies?.tar, - ).toBe("7.5.19"); + readJson<{ + bundledDependencies?: string[]; + dependencies?: Record; + }>(path.join(extracted, "package", "package.json")), + ).toMatchObject({ bundledDependencies: ["tar"], dependencies: { tar: "7.5.19" } }); + expect( + readJson<{ name?: string; version?: string }>( + path.join(extracted, "package", "node_modules", "tar", "package.json"), + ), + ).toMatchObject({ name: "tar", version: "7.5.19" }); }); // source-shape-contract: security -- Archive metadata proves the rebuilt package carries the reviewed bundled fs-safe remediation