Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/security/openclaw-2026.6.10-dependency-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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.
Expand Down
114 changes: 93 additions & 21 deletions scripts/lib/openclaw-npm-remediation.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -108,7 +110,7 @@ const REMEDIATIONS: Readonly<Record<string, Remediation>> = Object.freeze({
"openclaw@2026.3.11": {
kind: "legacy-core",
expectedPatchedMetadataIntegrity:
"sha512-c+3QxBJidAFb8xZSmz4azC7KHFvXUAY9vN1AlXJ243LwMCFN5it5MW0r6FBuxIFvlBCnGlzcqRCvU5ghUec/ng==",
"sha512-1i30XSb/2NEcuTcuhXfR/x3YKaXVhWq6ttecFBSD9nrCKrzjNxSNMfK1y3qRcnblNOzRWmHtJZwZKeej02s/EQ==",
},
});

Expand Down Expand Up @@ -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");
Expand All @@ -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 {
Expand Down Expand Up @@ -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") {
Expand All @@ -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);
}

Expand Down Expand Up @@ -548,7 +594,9 @@ function packReplacement(
});
}

export function buildRemediatedOpenClawArchive(request: BuildRequest): RemediatedArchive {
export function buildRemediatedOpenClawArchive(
request: BuildRequest,
): Extract<RemediatedArchive, { remediated: true }> {
const remediation = REMEDIATIONS[request.packageSpec];
if (!remediation) {
throw new Error(`No OpenClaw npm remediation is defined for ${request.packageSpec}`);
Expand Down Expand Up @@ -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(
Expand Down
15 changes: 10 additions & 5 deletions test/e2e/live/openshell-gateway-upgrade-old-installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ const REVIEWED_OLD_OPENCLAW_ARCHIVES: Readonly<Record<string, ReviewedOldOpenCla
},
});

export const OLD_INSTALLER_BOOTSTRAP_NEEDLE = ' legacy_script="${source_root}/install.sh"\n';
export const OLD_INSTALLER_CLONE_NEEDLE =
' spin "Cloning ${_CLI_DISPLAY} source" clone_nemoclaw_ref "$release_ref" "$nemoclaw_src"\n';
export const OLD_INSTALLER_ADVISORY_AUDIT =
" npm --prefix /usr/local/lib/nemoclaw/mcporter-runtime audit --omit=dev --audit-level=low; \\\n";

export function reviewedOldOpenClawArchive(version: string): ReviewedOldOpenClawArchive {
const reviewedArchive = REVIEWED_OLD_OPENCLAW_ARCHIVES[version];
if (!reviewedArchive) {
Expand All @@ -48,7 +54,6 @@ export function reviewedOldOpenClawArchive(version: string): ReviewedOldOpenClaw
// Keep this adapter scoped to the frozen historical lanes and retire it with
// them; changing the tagged release payloads is not viable.
export function patchOldInstallerFixture(installer: string): void {
const needle = ' legacy_script="${source_root}/install.sh"\n';
const hook =
String.raw` if [[ -n "\${NEMOCLAW_OLD_OPENCLAW_VERSION:-}" && -f "$payload_script" ]]; then
python3 - "$payload_script" <<'NEMOCLAW_OLD_PAYLOAD_PIN_PY'
Expand All @@ -57,7 +62,7 @@ import sys

path = Path(sys.argv[1])
text = path.read_text(encoding="utf-8")
needle = ' spin "Cloning \${_CLI_DISPLAY} source" clone_nemoclaw_ref "$release_ref" "$nemoclaw_src"\n'
needle = ${JSON.stringify(OLD_INSTALLER_CLONE_NEEDLE)}
hook = r''' if [[ -n "\${NEMOCLAW_OLD_OPENCLAW_VERSION:-}" ]]; then
if [[ -z "\${NEMOCLAW_OLD_OPENCLAW_ARCHIVE:-}" || ! -f "$NEMOCLAW_OLD_OPENCLAW_ARCHIVE" ]]; then
echo "ERROR: reviewed historical OpenClaw archive is missing" >&2
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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`);
})();
Expand Down
15 changes: 10 additions & 5 deletions test/e2e/support/openshell-gateway-upgrade-old-installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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",
"",
Expand All @@ -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 },
Expand All @@ -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"),
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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([
Expand Down
15 changes: 12 additions & 3 deletions test/node-tar-dockerfile-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading
Loading