Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
93 changes: 93 additions & 0 deletions Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,83 @@
# by OpenClaw CLI version bumps or the weekly docker-pin-check.
# ────────────────────────────────────────────────────────────────────────

ARG PERL_VERSION=5.44.0
ARG PERL_SHA256=505cf43912e9480495c344c70260452e32aa2a73c546a026b3f100053b23ce91
ARG PERL_PACKAGE_REVISION=1nemoclaw1

# Debian trixie has not published a Perl package containing the upstream
# fixes for CVE-2026-12087, CVE-2026-13221, and CVE-2026-57433. Build the
# fixed upstream release as native Debian packages so dpkg dependencies and
# vulnerability inventory both describe the runtime that is actually used.
FROM node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba AS perl-builder

ARG PERL_VERSION
ARG PERL_SHA256
ARG PERL_PACKAGE_REVISION

RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential=12.12 \
ca-certificates=20250419 \
curl=8.14.1-2+deb13u4 \
netbase=6.5 \
xz-utils=5.8.1-1+deb13u1 \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /tmp/perl-source

# Buildx emulates one production architecture. Declare trixie's libc syscall
# prototype so a failed QEMU feature probe cannot synthesize a conflicting one.
RUN curl --proto '=https' --tlsv1.2 -fsSL \
--retry 5 --retry-all-errors --retry-delay 2 --connect-timeout 15 --max-time 120 \
-o /tmp/perl.tar.xz "https://www.cpan.org/src/5.0/perl-${PERL_VERSION}.tar.xz" \
&& printf '%s %s\n' "${PERL_SHA256}" /tmp/perl.tar.xz > /tmp/perl.sha256 \
&& sha256sum -c /tmp/perl.sha256 \
&& tar -xJf /tmp/perl.tar.xz -C /tmp/perl-source --strip-components=1 \
&& ./Configure -des \
-Dprefix=/usr \
-Dvendorprefix=/usr \
-Dsiteprefix=/usr/local \
-Dusethreads \
-Duse64bitall \
-Dd_syscallproto=define \
-Dman1dir=none \
-Dman3dir=none \
&& make -j"$(nproc)" \
&& make test \
&& make install DESTDIR=/tmp/perl-root

RUN package_version="${PERL_VERSION}-${PERL_PACKAGE_REVISION}" \
&& architecture="$(dpkg --print-architecture)" \
&& mkdir -p /tmp/perl-root/DEBIAN /tmp/perl-meta/DEBIAN \
&& printf '%s\n' \
'Package: perl-base' \
"Version: ${package_version}" \
"Architecture: ${architecture}" \
'Essential: yes' \
'Priority: required' \
'Section: perl' \
'Multi-Arch: allowed' \
'Maintainer: NVIDIA NemoClaw Maintainers' \
"Provides: libperl5.40 (= ${package_version}), perl-modules-5.40 (= ${package_version})" \
'Conflicts: libperl5.40, perl-modules-5.40' \
"Breaks: perl (<< ${package_version})" \
"Replaces: libperl5.40, perl-modules-5.40, perl (<< ${package_version})" \
'Description: Perl 5 language interpreter built for the NemoClaw sandbox' \
> /tmp/perl-root/DEBIAN/control \
&& printf '%s\n' \
'Package: perl' \
"Version: ${package_version}" \
"Architecture: ${architecture}" \
'Priority: standard' \
'Section: perl' \
'Multi-Arch: allowed' \
"Depends: perl-base (= ${package_version})" \
'Maintainer: NVIDIA NemoClaw Maintainers' \
'Description: Perl 5 language interpreter metapackage for the NemoClaw sandbox' \
> /tmp/perl-meta/DEBIAN/control \
&& dpkg-deb --build --root-owner-group /tmp/perl-root /tmp/perl-base.deb \
&& dpkg-deb --build --root-owner-group /tmp/perl-meta /tmp/perl.deb

FROM node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba

# OpenShell blocks the link-local EC2 Instance Metadata Service. Keep AWS SDK
Expand Down Expand Up @@ -80,6 +157,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/* \
&& ln -s /usr/bin/python3 /usr/local/bin/python

COPY --from=perl-builder /tmp/perl-base.deb /tmp/perl.deb /tmp/

RUN apt-get update \
&& apt-get install -y --no-install-recommends /tmp/perl-base.deb /tmp/perl.deb \
&& rm -f /tmp/perl-base.deb /tmp/perl.deb \
&& rm -rf /var/lib/apt/lists/* \
&& test "$(perl -e 'print $^V')" = "v5.44.0" \
&& test "$(perl -MSocket -e 'print Socket->VERSION')" = "2.041" \
&& test "$(perl -MStorable -e 'print Storable->VERSION')" = "3.41" \
&& perl -MSocket=pack_ip_mreq_source -e \
'eval { pack_ip_mreq_source("\0" x 4, "\0" x 3) }; die "short source accepted" unless $@ =~ /Bad arg length/' \
&& perl -e \
'my $x = join "|", "aaa".."mzz"; my $y = join "|", "naa".."zzz"; use re "Debug"; "fnord" =~ m/(?:$x)|(?:$y)/' \
&& git --version \
&& test -z "$(dpkg --audit)"

# gosu for privilege separation (gateway vs sandbox user).
# Install from GitHub release with checksum verification instead of
# Debian's packaged gosu can lag upstream. Pinned to 1.19 (2025-09).
Expand Down
5 changes: 5 additions & 0 deletions ci/source-shape-test-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,11 @@
"test": "cross-checks the allowlist against every production archive install boundary",
"category": "security"
},
{
"file": "test/perl-critical-cve-remediation.test.ts",
"test": "builds the fixed upstream release from a checksum-pinned source archive (#7338)",
"category": "security"
},
{
"file": "test/platform-vitest-main-workflow.test.ts",
"test": "keeps the WSL suite unprivileged with explicit root-only contracts",
Expand Down
89 changes: 89 additions & 0 deletions test/perl-critical-cve-remediation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import fs from "node:fs";
import path from "node:path";

import { describe, expect, it } from "vitest";

const dockerfile = fs.readFileSync(path.join(import.meta.dirname, "..", "Dockerfile.base"), "utf8");
const fixedPerlVersion = "5.44.0";
const fixedPerlSha256 = "505cf43912e9480495c344c70260452e32aa2a73c546a026b3f100053b23ce91";

function stageNamed(name: string): string {
const start = dockerfile.indexOf(` AS ${name}`);
expect(start, `missing ${name} stage`).toBeGreaterThanOrEqual(0);
const next = dockerfile.indexOf("\nFROM ", start);
return dockerfile.slice(start, next >= 0 ? next : undefined);
}

function completedStage(): string {
const start = dockerfile.lastIndexOf("\nFROM ");
expect(start, "missing completed image stage").toBeGreaterThanOrEqual(0);
return dockerfile.slice(start);
}

function argumentDefault(name: string): string | undefined {
return dockerfile.match(new RegExp(`^ARG ${name}=([^\\s]+)$`, "mu"))?.[1];
}

describe("sandbox base critical Perl CVE remediation", () => {
// source-shape-contract: security -- Exact upstream version and checksum bind the replacement runtime to the reviewed CVE fixes
it("builds the fixed upstream release from a checksum-pinned source archive (#7338)", () => {
const builder = stageNamed("perl-builder");
const download = builder.indexOf(
'-o /tmp/perl.tar.xz "https://www.cpan.org/src/5.0/perl-${PERL_VERSION}.tar.xz"',
);
const checksum = builder.indexOf("sha256sum -c /tmp/perl.sha256");
const extract = builder.indexOf("tar -xJf /tmp/perl.tar.xz");

expect(argumentDefault("PERL_VERSION")).toBe(fixedPerlVersion);
expect(argumentDefault("PERL_SHA256")).toBe(fixedPerlSha256);
expect(download).toBeGreaterThanOrEqual(0);
expect(checksum).toBeGreaterThan(download);
expect(extract).toBeGreaterThan(checksum);
expect(builder).toContain("-Dd_syscallproto=define");
});

it("runs the upstream test suite before packaging the replacement runtime (#7338)", () => {
const builder = stageNamed("perl-builder");
const compile = builder.indexOf('make -j"$(nproc)"');
const test = builder.indexOf("make test");
const install = builder.indexOf("make install DESTDIR=/tmp/perl-root");
const packageBuild = builder.indexOf("dpkg-deb --build --root-owner-group");

expect(compile).toBeGreaterThanOrEqual(0);
expect(test).toBeGreaterThan(compile);
expect(install).toBeGreaterThan(test);
expect(packageBuild).toBeGreaterThan(install);
});

it("replaces the vulnerable distro packages without breaking dpkg ownership (#7338)", () => {
const builder = stageNamed("perl-builder");
const runtime = completedStage();

expect(builder).toContain(
'"Provides: libperl5.40 (= ${package_version}), perl-modules-5.40 (= ${package_version})"',
);
expect(builder).toContain("'Conflicts: libperl5.40, perl-modules-5.40'");
expect(builder).toContain(
'"Replaces: libperl5.40, perl-modules-5.40, perl (<< ${package_version})"',
);
expect(runtime).toContain(
"apt-get install -y --no-install-recommends /tmp/perl-base.deb /tmp/perl.deb",
);
expect(runtime).toContain('test -z "$(dpkg --audit)"');
});

it("fails the image build unless all three critical fixes are active (#7338)", () => {
const runtime = completedStage();

expect(runtime).toContain(`test "$(perl -e 'print $^V')" = "v${fixedPerlVersion}"`);
expect(runtime).toContain(`test "$(perl -MSocket -e 'print Socket->VERSION')" = "2.041"`);
expect(runtime).toContain(`test "$(perl -MStorable -e 'print Storable->VERSION')" = "3.41"`);
expect(runtime).toContain("pack_ip_mreq_source");
expect(runtime).toContain('die "short source accepted"');
expect(runtime).toContain('use re "Debug"');
expect(runtime).toContain('"fnord" =~ m/(?:$x)|(?:$y)/');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
});
4 changes: 3 additions & 1 deletion test/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1028,10 +1028,12 @@ describe("regression guards", () => {

expect(baseSrc).toContain("ENV AWS_EC2_METADATA_DISABLED=true");
expect(runtimeSrc).toContain("ENV AWS_EC2_METADATA_DISABLED=true");
const baseRuntimeStageStart = baseSrc.lastIndexOf("\nFROM ");
expect(baseRuntimeStageStart).toBeGreaterThan(-1);
const runtimeStageStart = runtimeSrc.indexOf("# Stage 3: Runtime image");
expect(runtimeStageStart).toBeGreaterThan(-1);
for (const [source, stageStart] of [
[baseSrc, 0],
[baseSrc, baseRuntimeStageStart],
[runtimeSrc, runtimeStageStart],
] as const) {
const fromIndex = source.indexOf("\nFROM ", stageStart);
Expand Down
11 changes: 8 additions & 3 deletions test/sandbox-provisioning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ const DEEPAGENTS_DOCKERFILE_BASE = path.join(
"Dockerfile.base",
);

function completedDockerStage(dockerfile: string): string {
const start = dockerfile.lastIndexOf("\nFROM ");
return start >= 0 ? dockerfile.slice(start) : dockerfile;
}

function dockerRunCommandBetween(
dockerfile: string,
startMarker: string,
Expand Down Expand Up @@ -1038,7 +1043,7 @@ describe("sandbox provisioning: base runtime tools", () => {
["Hermes", HERMES_DOCKERFILE_BASE],
["Deep Agents Code", DEEPAGENTS_DOCKERFILE_BASE],
])("installs pinned nftables for OpenShell bypass enforcement in %s", (_agent, file) => {
const dockerfile = fs.readFileSync(file, "utf-8");
const dockerfile = completedDockerStage(fs.readFileSync(file, "utf-8"));
const aptInstall = dockerfile.match(
/^RUN apt-get update && apt-get install -y --no-install-recommends \\\n(?:.*\\\n)*.*$/m,
)?.[0];
Expand All @@ -1048,7 +1053,7 @@ describe("sandbox provisioning: base runtime tools", () => {
});

it("base apt layer requests procps, e2fsprogs, and the SFTP server", () => {
const dockerfile = fs.readFileSync(DOCKERFILE_BASE, "utf-8");
const dockerfile = completedDockerStage(fs.readFileSync(DOCKERFILE_BASE, "utf-8"));
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-base-apt-"));
const lists = path.join(tmp, "apt-lists");
const fakePy3 = path.join(tmp, "usr-bin", "python3");
Expand Down Expand Up @@ -1081,7 +1086,7 @@ describe("sandbox provisioning: base runtime tools", () => {
});

it("symlinks bare `python` to python3 so agent tool calls don't fail with command-not-found (#1452)", () => {
const dockerfile = fs.readFileSync(DOCKERFILE_BASE, "utf-8");
const dockerfile = completedDockerStage(fs.readFileSync(DOCKERFILE_BASE, "utf-8"));
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-base-pysymlink-"));
const lists = path.join(tmp, "apt-lists");
const fakePy3 = path.join(tmp, "usr-bin", "python3");
Expand Down
Loading