From 2f1dbc7ad78ae9b6fa96e36562a172cce8dcaa95 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 29 Jul 2026 12:33:27 -0700 Subject: [PATCH 1/7] fix(security): extend Perl remediation to sibling images --- .github/workflows/base-image.yaml | 173 ++++++++++-- Dockerfile.base | 108 +------- agents/hermes/Dockerfile.base | 40 ++- .../langchain-deepagents-code/Dockerfile.base | 40 ++- ci/source-shape-test-budget.json | 5 - ...anaged-base-perl-5.44-dependency-review.md | 104 ++++++++ .../security/build-perl-security-packages.sh | 134 ++++++++++ test/dcode-base-image-workflow.test.ts | 140 +++++++++- test/perl-critical-cve-remediation.test.ts | 247 +++++++++++------- 9 files changed, 751 insertions(+), 240 deletions(-) create mode 100644 docs/security/managed-base-perl-5.44-dependency-review.md create mode 100755 scripts/security/build-perl-security-packages.sh diff --git a/.github/workflows/base-image.yaml b/.github/workflows/base-image.yaml index 31f78ed127a..bc1f6441b4b 100644 --- a/.github/workflows/base-image.yaml +++ b/.github/workflows/base-image.yaml @@ -39,6 +39,7 @@ on: - "scripts/lib/openclaw-npm-remediation.mts" - "scripts/lib/reviewed-npm-audit.mts" - "scripts/security/build-native-security-packages.sh" + - "scripts/security/build-perl-security-packages.sh" - "scripts/security/patches/libssh2-1.11.1-cve-2026.patch" - "scripts/security/patches/python3.13-htmlparser-cve-2026-15308.patch" - "scripts/checks/node-tar-image-scan.mts" @@ -65,9 +66,9 @@ env: REGISTRY: ghcr.io jobs: - # Build OpenClaw on native runners because its complete Perl regression suite - # is prohibitively slow under QEMU. Publish only immutable per-platform - # digests here; the dependent manifest job updates user-facing tags atomically. + # The complete Perl suite approaches the image-job timeout under QEMU arm64 emulation. + # Build OpenClaw on native runners and publish only immutable per-platform digests here. + # The dependent manifest job updates user-facing tags atomically. build-openclaw-platforms: name: Build OpenClaw base image (${{ matrix.arch }}) if: github.repository == 'NVIDIA/NemoClaw' @@ -178,32 +179,50 @@ jobs: if-no-files-found: error retention-days: 1 - # Hermes and Deep Agents Code still use the existing QEMU multi-platform - # publisher. Their build behavior and timeout remain unchanged. - build-and-push: - name: Build and push ${{ matrix.display_name }} base image + # The complete Perl suite approaches the image-job timeout under QEMU arm64 emulation. + # Build each sibling image on native architecture runners and publish only immutable platform digests. + # The manifest job updates tags after both builds pass. + build-sibling-platforms: + name: Build ${{ matrix.display_name }} base image (${{ matrix.arch }}) if: github.repository == 'NVIDIA/NemoClaw' - runs-on: ubuntu-latest - timeout-minutes: 90 + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 strategy: fail-fast: false matrix: include: - agent: hermes display_name: Hermes + arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + dockerfile: agents/hermes/Dockerfile.base + image: nvidia/nemoclaw/hermes-sandbox-base + - agent: hermes + display_name: Hermes + arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm dockerfile: agents/hermes/Dockerfile.base image: nvidia/nemoclaw/hermes-sandbox-base - agent: langchain-deepagents-code display_name: Deep Agents Code + arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + dockerfile: agents/langchain-deepagents-code/Dockerfile.base + image: nvidia/nemoclaw/langchain-deepagents-code-sandbox-base + - agent: langchain-deepagents-code + display_name: Deep Agents Code + arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm dockerfile: agents/langchain-deepagents-code/Dockerfile.base image: nvidia/nemoclaw/langchain-deepagents-code-sandbox-base steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Set up QEMU (arm64 emulation) - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 - - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 @@ -221,10 +240,6 @@ jobs: DOCKER_METADATA_SHORT_SHA_LENGTH: 8 with: images: ${{ env.REGISTRY }}/${{ matrix.image }} - tags: | - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - type=ref,event=tag - type=sha,prefix=,format=short - name: Validate production Docker build args id: production-build-args @@ -256,19 +271,133 @@ jobs: fi printf 'openclaw_build_arg=%s\n' "$openclaw_build_arg" >> "$GITHUB_OUTPUT" - - name: Build and push + - name: Build and push platform digest + id: build uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . file: ${{ matrix.dockerfile }} - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} + platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache - cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache,mode=max + outputs: type=image,name=${{ env.REGISTRY }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.arch }} + cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.arch }},mode=max build-args: ${{ steps.production-build-args.outputs.openclaw_build_arg }} + - name: Export platform digest + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + set -euo pipefail + if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "ERROR: build did not return a valid sha256 digest: $DIGEST" >&2 + exit 1 + fi + mkdir -p "$RUNNER_TEMP/digests" + touch "$RUNNER_TEMP/digests/${DIGEST#sha256:}" + + - name: Upload platform digest + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ matrix.agent }}-base-digest-${{ matrix.arch }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + build-and-push: + name: Build and push ${{ matrix.display_name }} base image + if: github.repository == 'NVIDIA/NemoClaw' + needs: build-sibling-platforms + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - agent: hermes + display_name: Hermes + image: nvidia/nemoclaw/hermes-sandbox-base + - agent: langchain-deepagents-code + display_name: Deep Agents Code + image: nvidia/nemoclaw/langchain-deepagents-code-sandbox-base + steps: + - name: Download platform digests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: ${{ matrix.agent }}-base-digest-* + path: ${{ runner.temp }}/digests + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Log in to GHCR + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + env: + DOCKER_METADATA_SHORT_SHA_LENGTH: 8 + with: + images: ${{ env.REGISTRY }}/${{ matrix.image }} + tags: | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=ref,event=tag + type=sha,prefix=,format=short + + - name: Create and verify multi-platform manifest + env: + IMAGE: ${{ env.REGISTRY }}/${{ matrix.image }} + TAGS: ${{ steps.meta.outputs.tags }} + run: | + set -euo pipefail + shopt -s nullglob + digest_files=("$RUNNER_TEMP"/digests/*) + if [ "${#digest_files[@]}" -ne 2 ]; then + echo "ERROR: expected exactly two platform digests, found ${#digest_files[@]}." >&2 + exit 1 + fi + + sources=() + for digest_file in "${digest_files[@]}"; do + digest="$(basename "$digest_file")" + if [[ ! "$digest" =~ ^[0-9a-f]{64}$ ]]; then + echo "ERROR: invalid platform digest artifact: $digest" >&2 + exit 1 + fi + sources+=("$IMAGE@sha256:$digest") + done + + mapfile -t tags <<< "$TAGS" + tag_args=() + for tag in "${tags[@]}"; do + if [ -n "$tag" ]; then + tag_args+=(--tag "$tag") + fi + done + if [ "${#tag_args[@]}" -eq 0 ]; then + echo "ERROR: metadata did not produce any publication tags." >&2 + exit 1 + fi + + docker buildx imagetools create "${tag_args[@]}" "${sources[@]}" + first_tag="${tags[0]}" + actual_platforms="$( + docker buildx imagetools inspect "$first_tag" --raw \ + | jq -r '.manifests[] | select(.platform.os == "linux") | .platform.architecture' \ + | sort -u \ + | paste -sd, - + )" + if [ "$actual_platforms" != "amd64,arm64" ]; then + echo "ERROR: published manifest has unexpected platforms: $actual_platforms" >&2 + exit 1 + fi + # Preserve the established required-check name while making tag publication # contingent on both native platform builds succeeding. build-and-push-openclaw: diff --git a/Dockerfile.base b/Dockerfile.base index a4210ee6bde..6b1abf83184 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -78,117 +78,27 @@ COPY scripts/security/patches/python3.13-htmlparser-cve-2026-15308.patch /script RUN bash /scripts/security/build-native-security-packages.sh /out -# 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 +# Debian trixie has not published a Perl package containing the reviewed +# upstream fixes. Build the fixed release as native Debian packages so dpkg +# dependencies and vulnerability inventory describe the executed runtime. +FROM native-security-builder AS perl-builder ARG PERL_VERSION ARG PERL_SHA256 ARG PERL_PACKAGE_REVISION -COPY --from=native-security-builder /out /tmp/security-packages +COPY scripts/security/build-perl-security-packages.sh /scripts/security/build-perl-security-packages.sh 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 - -# Pin the reviewed d_syscallproto result for trixie's libc so both native -# architectures use the same known declaration instead of relying on a -# Configure probe that previously returned a false negative under QEMU. -# Remove this override only after the pinned base image and Perl release report -# d_syscallproto=define from native Configure probes on amd64 and arm64. -# Perl's test_harness runs the same upstream suite while TEST_JOBS lets its TAP -# scheduler use each native runner efficiently instead of serializing every -# script in QEMU. -# ExtUtils::Constant's test recursively invokes make and produced an incomplete -# TAP plan when it overlapped another test locally, so run it alone first and -# exclude exactly that already-passed file from the parallel pass. -# Remove this split only after the unsplit parallel harness passes in two -# consecutive amd64 and arm64 base-image builds; keep the selection-equivalence -# check below until that removal condition is met. -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_prep \ - && env -C t PERL_TEST_HARNESS_ASAP=1 ./perl harness -dumptests \ - > /tmp/perl-tests-full \ - && env -C t ./perl harness -dumptests \ - ../cpan/ExtUtils-Constant/t/Constant.t \ - > /tmp/perl-tests-serial \ - && env -C t PERL_TEST_HARNESS_ASAP=1 ./perl harness -dumptests \ - '--nre=^[.][.]/cpan/ExtUtils-Constant/t/Constant[.]t$' \ - > /tmp/perl-tests-parallel \ - && sort /tmp/perl-tests-full > /tmp/perl-tests-full.sorted \ - && sort /tmp/perl-tests-serial /tmp/perl-tests-parallel \ - > /tmp/perl-tests-combined.sorted \ - && cmp /tmp/perl-tests-full.sorted /tmp/perl-tests-combined.sorted \ - && test "$(grep -Fxc \ - 'cpan/ExtUtils-Constant/t/Constant.t' \ - /tmp/perl-tests-combined.sorted)" -eq 1 \ - && TEST_JOBS=1 TEST_ARGS='../cpan/ExtUtils-Constant/t/Constant.t' make test_harness \ - && TEST_JOBS="$(nproc)" PERL_TEST_HARNESS_ASAP=1 \ - TEST_ARGS='--nre=^[.][.]/cpan/ExtUtils-Constant/t/Constant[.]t$' \ - make -j"$(nproc)" test_harness \ - && 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/security-packages/perl-base.deb \ - && dpkg-deb --build --root-owner-group \ - /tmp/perl-meta /tmp/security-packages/perl.deb +RUN bash /scripts/security/build-perl-security-packages.sh \ + /out "${PERL_VERSION}" "${PERL_SHA256}" "${PERL_PACKAGE_REVISION}" FROM node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba -COPY --from=perl-builder /tmp/security-packages /tmp/nemoclaw-native-security +COPY --from=perl-builder /out /tmp/nemoclaw-native-security # OpenShell blocks the link-local EC2 Instance Metadata Service. Keep AWS SDK # credential chains from attempting an impossible metadata discovery path. @@ -315,6 +225,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ "vim-tiny=2:9.2.0782-1" \ "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1" \ "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1" \ + "perl-base=5.44.0-1nemoclaw1" \ + "perl=5.44.0-1nemoclaw1" \ > /usr/local/share/nemoclaw/security-packages.txt \ && chown root:root /usr/local/share/nemoclaw/security-packages.txt \ && chmod 0444 /usr/local/share/nemoclaw/security-packages.txt \ diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index 851a22372cd..8231c031c1b 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -19,6 +19,10 @@ # 6. New .hermes subdirectory — add mkdir/chmod below # ──────────────────────────────────────────────────────────────── +ARG PERL_VERSION=5.44.0 +ARG PERL_SHA256=505cf43912e9480495c344c70260452e32aa2a73c546a026b3f100053b23ce91 +ARG PERL_PACKAGE_REVISION=1nemoclaw1 + FROM node:24-trixie-slim@sha256:05c08ce4291e9a58f59456a7985176defb12cdd42271f35ff81a3e167ea61d4c AS native-security-builder RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -38,9 +42,24 @@ COPY scripts/security/patches/python3.13-htmlparser-cve-2026-15308.patch /script RUN bash /scripts/security/build-native-security-packages.sh /out +FROM native-security-builder AS perl-builder + +ARG PERL_VERSION +ARG PERL_SHA256 +ARG PERL_PACKAGE_REVISION + +COPY scripts/security/build-perl-security-packages.sh /scripts/security/build-perl-security-packages.sh + +RUN apt-get update && apt-get install -y --no-install-recommends \ + netbase=6.5 \ + && rm -rf /var/lib/apt/lists/* + +RUN bash /scripts/security/build-perl-security-packages.sh \ + /out "${PERL_VERSION}" "${PERL_SHA256}" "${PERL_PACKAGE_REVISION}" + FROM node:24-trixie-slim@sha256:05c08ce4291e9a58f59456a7985176defb12cdd42271f35ff81a3e167ea61d4c -COPY --from=native-security-builder /out /tmp/nemoclaw-native-security +COPY --from=perl-builder /out /tmp/nemoclaw-native-security ENV DEBIAN_FRONTEND=noninteractive @@ -145,6 +164,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ "$security_deb_dir/vim-tiny.deb" \ /tmp/nemoclaw-native-security/libssh2-1t64.deb \ /tmp/nemoclaw-native-security/nemoclaw-python3.13-htmlparser-fix.deb \ + && apt-get install -y --no-install-recommends \ + /tmp/nemoclaw-native-security/perl-base.deb \ + /tmp/nemoclaw-native-security/perl.deb \ && test "$(dpkg-query -W -f='${Version}' libexpat1)" = "2.8.2-1" \ && test "$(dpkg-query -W -f='${Version}' libonig5)" = "6.9.9-1+b1" \ && test "$(dpkg-query -W -f='${Version}' libjq1)" = "1.8.2-1" \ @@ -164,6 +186,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && python3 -c "import sys; from pathlib import Path; import html.parser; Path(html.parser.__file__).resolve() == Path('/usr/lib/python3.13/html/parser.py').resolve() or sys.exit('html.parser loaded from an unexpected path'); from html.parser import HTMLParser; p=HTMLParser(); [p.feed('') for _ in range(20000)]; p._pending == [] or sys.exit('empty feeds accumulated pending entries'); p.feed(''); p.close(); p.rawdata == '' or sys.exit('incremental parsing retained raw data')" \ && python3 -c "import ctypes, sys; lib=ctypes.CDLL('libssh2.so.1'); lib.libssh2_version.restype=ctypes.c_char_p; lib.libssh2_version(0) == b'1.11.1' or sys.exit('unexpected libssh2 runtime version')" \ && vim.tiny --version | head -n 1 | grep -Eq '^VIM - Vi IMproved 9[.]2 ' \ + && 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" \ + && test "$(perl -MHTTP::Tiny -e 'print HTTP::Tiny->VERSION')" = "0.096" \ + && test "$(perl -MIO::Compress::Base -e 'print IO::Compress::Base->VERSION')" = "2.223" \ + && test "$(perl -MIO::Uncompress::Unzip -e 'print IO::Uncompress::Unzip->VERSION')" = "2.223" \ + && test "$(perl -MFile::GlobMapper -e 'print File::GlobMapper->VERSION')" = "1.001" \ + && 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)/' \ && install -d -o root -g root -m 0755 /usr/local/share/nemoclaw \ && printf '%s\n' \ "architecture=$arch" \ @@ -175,12 +208,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ "vim-tiny=2:9.2.0782-1" \ "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1" \ "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1" \ + "perl-base=5.44.0-1nemoclaw1" \ + "perl=5.44.0-1nemoclaw1" \ > /usr/local/share/nemoclaw/security-packages.txt \ && chown root:root /usr/local/share/nemoclaw/security-packages.txt \ && chmod 0444 /usr/local/share/nemoclaw/security-packages.txt \ && rm -rf /tmp/nemoclaw-native-security \ && rm -rf "$security_deb_dir" \ - && rm -rf /var/lib/apt/lists/* + && rm -rf /var/lib/apt/lists/* \ + && test -z "$(dpkg --audit)" COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts diff --git a/agents/langchain-deepagents-code/Dockerfile.base b/agents/langchain-deepagents-code/Dockerfile.base index 05a8209f0b5..0063fa226c6 100644 --- a/agents/langchain-deepagents-code/Dockerfile.base +++ b/agents/langchain-deepagents-code/Dockerfile.base @@ -7,6 +7,10 @@ # Node for NemoClaw build-time config generation, Python, shell tools, and a # hash-locked deepagents-code install with the NVIDIA provider extra. +ARG PERL_VERSION=5.44.0 +ARG PERL_SHA256=505cf43912e9480495c344c70260452e32aa2a73c546a026b3f100053b23ce91 +ARG PERL_PACKAGE_REVISION=1nemoclaw1 + FROM node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba AS native-security-builder RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -26,9 +30,24 @@ COPY scripts/security/patches/python3.13-htmlparser-cve-2026-15308.patch /script RUN bash /scripts/security/build-native-security-packages.sh /out +FROM native-security-builder AS perl-builder + +ARG PERL_VERSION +ARG PERL_SHA256 +ARG PERL_PACKAGE_REVISION + +COPY scripts/security/build-perl-security-packages.sh /scripts/security/build-perl-security-packages.sh + +RUN apt-get update && apt-get install -y --no-install-recommends \ + netbase=6.5 \ + && rm -rf /var/lib/apt/lists/* + +RUN bash /scripts/security/build-perl-security-packages.sh \ + /out "${PERL_VERSION}" "${PERL_SHA256}" "${PERL_PACKAGE_REVISION}" + FROM node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba -COPY --from=native-security-builder /out /tmp/nemoclaw-native-security +COPY --from=perl-builder /out /tmp/nemoclaw-native-security COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts @@ -120,6 +139,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ "$security_deb_dir/vim-tiny.deb" \ /tmp/nemoclaw-native-security/libssh2-1t64.deb \ /tmp/nemoclaw-native-security/nemoclaw-python3.13-htmlparser-fix.deb \ + && apt-get install -y --no-install-recommends \ + /tmp/nemoclaw-native-security/perl-base.deb \ + /tmp/nemoclaw-native-security/perl.deb \ && test "$(dpkg-query -W -f='${Version}' libexpat1)" = "2.8.2-1" \ && test "$(dpkg-query -W -f='${Version}' libonig5)" = "6.9.9-1+b1" \ && test "$(dpkg-query -W -f='${Version}' libjq1)" = "1.8.2-1" \ @@ -139,6 +161,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && python3 -c "import sys; from pathlib import Path; import html.parser; Path(html.parser.__file__).resolve() == Path('/usr/lib/python3.13/html/parser.py').resolve() or sys.exit('html.parser loaded from an unexpected path'); from html.parser import HTMLParser; p=HTMLParser(); [p.feed('') for _ in range(20000)]; p._pending == [] or sys.exit('empty feeds accumulated pending entries'); p.feed(''); p.close(); p.rawdata == '' or sys.exit('incremental parsing retained raw data')" \ && python3 -c "import ctypes, sys; lib=ctypes.CDLL('libssh2.so.1'); lib.libssh2_version.restype=ctypes.c_char_p; lib.libssh2_version(0) == b'1.11.1' or sys.exit('unexpected libssh2 runtime version')" \ && vim.tiny --version | head -n 1 | grep -Eq '^VIM - Vi IMproved 9[.]2 ' \ + && 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" \ + && test "$(perl -MHTTP::Tiny -e 'print HTTP::Tiny->VERSION')" = "0.096" \ + && test "$(perl -MIO::Compress::Base -e 'print IO::Compress::Base->VERSION')" = "2.223" \ + && test "$(perl -MIO::Uncompress::Unzip -e 'print IO::Uncompress::Unzip->VERSION')" = "2.223" \ + && test "$(perl -MFile::GlobMapper -e 'print File::GlobMapper->VERSION')" = "1.001" \ + && 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)/' \ && install -d -o root -g root -m 0755 /usr/local/share/nemoclaw \ && printf '%s\n' \ "architecture=$arch" \ @@ -150,12 +183,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ "vim-tiny=2:9.2.0782-1" \ "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1" \ "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1" \ + "perl-base=5.44.0-1nemoclaw1" \ + "perl=5.44.0-1nemoclaw1" \ > /usr/local/share/nemoclaw/security-packages.txt \ && chown root:root /usr/local/share/nemoclaw/security-packages.txt \ && chmod 0444 /usr/local/share/nemoclaw/security-packages.txt \ && rm -rf /tmp/nemoclaw-native-security \ && rm -rf "$security_deb_dir" \ - && rm -rf /var/lib/apt/lists/* + && rm -rf /var/lib/apt/lists/* \ + && test -z "$(dpkg --audit)" # Node remains available to the managed terminal at runtime, so remediate # npm's private node-tar copy after curl is installed even though Deep Agents diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index dd6c9bfd2b3..74b8ca63f49 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -406,11 +406,6 @@ "test": "runs PR audits from trusted code and keeps the main audit on the checked-in action", "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": "pins and verifies the Node.js archive in the trusted WSL helper", diff --git a/docs/security/managed-base-perl-5.44-dependency-review.md b/docs/security/managed-base-perl-5.44-dependency-review.md new file mode 100644 index 00000000000..6c454bfe031 --- /dev/null +++ b/docs/security/managed-base-perl-5.44-dependency-review.md @@ -0,0 +1,104 @@ + + +# Managed base-image Perl 5.44 review + +Date: 2026-07-29 + +## Scope + +This review extends the existing Perl 5.44.0 remediation to the Hermes and Deep Agents Code base images. + +The OpenClaw base image already installs the reviewed packages. +The sibling images used Debian Perl 5.40.1-6 before this change. + +This change does not cross an upstream Perl release range. +It preserves these reviewed identities: + +- source release: Perl 5.44.0; +- source SHA-256: `505cf43912e9480495c344c70260452e32aa2a73c546a026b3f100053b23ce91`; +- package revision: `1nemoclaw1`; and +- package versions: `perl-base=5.44.0-1nemoclaw1` and `perl=5.44.0-1nemoclaw1`. + +The public vulnerability set includes `CVE-2026-12087`, `CVE-2026-13221`, `CVE-2026-48959`, `CVE-2026-48961`, `CVE-2026-48962`, `CVE-2026-57432`, `CVE-2026-57433`, and `CVE-2026-7017`. + +## Artifact flow + +`scripts/security/build-perl-security-packages.sh` owns the source download, checksum verification, configuration, test selection, install, and Debian package metadata. + +Each managed base image uses this flow: + +1. Build the existing libssh2 and Python security packages. +2. Build Perl from the checksum-pinned CPAN archive. +3. Run the complete upstream test selection. +4. Build native `perl-base` and `perl` Debian packages. +5. Install both packages before deleting the build artifacts. +6. Execute exact interpreter, module, behavior, and dpkg assertions. + +The package metadata replaces the Debian `libperl5.40` and `perl-modules-5.40` ownership without leaving unmanaged files. + +## Build topology + +The complete Perl suite approaches the image-job timeout under QEMU arm64 emulation. + +The base-image publisher now builds these image and platform pairs on native runners: + +- OpenClaw on amd64 and arm64; +- Hermes on amd64 and arm64; and +- Deep Agents Code on amd64 and arm64. + +All six platform builds run independently. +Each final publisher creates tags only after both platform digests for its image pass. +The publisher then verifies that the manifest contains amd64 and arm64. + +## Runtime proof + +Each completed image must report these values: + +| Component | Required value | +|---|---| +| Perl | `v5.44.0` | +| Socket | `2.041` | +| Storable | `3.41` | +| HTTP::Tiny | `0.096` | +| IO::Compress::Base | `2.223` | +| IO::Uncompress::Unzip | `2.223` | +| File::GlobMapper | `1.001` | + +The image build also executes the reviewed Socket argument-length rejection and regular-expression behavior checks. +`dpkg --audit` must return no output. + +## Concern ledger + +| ID | Surface | Failure mode | Disposition | Evidence | +|---|---|---|---|---| +| PERL-01 | Source identity | A moving or modified archive changes the runtime behind the package version. | Pin | The CPAN URL contains `5.44.0`, and SHA-256 verification precedes extraction. | +| PERL-02 | Test coverage | A sibling image installs an untested native build. | Test | The shared builder runs the complete selection-equivalent upstream suite before packaging. | +| PERL-03 | Package ownership | Replacing Perl leaves conflicting Debian package ownership. | Guard | Package metadata declares `Provides`, `Conflicts`, `Breaks`, and `Replaces`; every image runs `dpkg --audit`. | +| PERL-04 | Image selection | One managed image continues to copy packages from the older native-only stage. | Guard | All three Dockerfiles copy `/out` from `perl-builder`. | +| PERL-05 | Runtime selection | The expected package exists but another interpreter or module executes. | Runtime proof | Every final image executes the interpreter, module, Socket, and regex checks. | +| PERL-06 | Platform execution | arm64 emulation exceeds the job timeout or hides architecture-specific failure. | Migrate | Hermes and Deep Agents Code use native amd64 and arm64 platform jobs with atomic manifests. | +| PERL-07 | Published identity | One platform tag becomes visible before its sibling platform passes. | Guard | Platform jobs push by digest. Manifest jobs publish tags after both digest artifacts exist. | + +## Downstream boundaries + +The change does not modify agent configuration, credentials, network policy, runtime entrypoints, persistent state, or image selection. +It changes the Perl files and dpkg identities inside the three existing managed base images. + +Rollback selects an earlier immutable base-image digest. +The change does not add a data migration or a compatibility fallback. + +## Verification + +The repository tests verify: + +- one shared package definition and checksum identity; +- complete upstream test selection before packaging; +- package ownership and cleanup order; +- exact runtime and module assertions in all three images; +- native amd64 and arm64 jobs for both sibling images; and +- atomic multi-platform manifest publication. + +The remaining external gates are the six production platform builds, manifest publication, and a vulnerability rescan of the published image digests. diff --git a/scripts/security/build-perl-security-packages.sh b/scripts/security/build-perl-security-packages.sh new file mode 100755 index 00000000000..f572446eb68 --- /dev/null +++ b/scripts/security/build-perl-security-packages.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +if (($# != 4)); then + printf 'Usage: %s OUTPUT_DIR PERL_VERSION PERL_SHA256 PACKAGE_REVISION\n' "$0" >&2 + exit 64 +fi + +readonly output_dir="$1" +readonly perl_version="$2" +readonly perl_sha256="$3" +readonly package_revision="$4" +build_root="$(mktemp -d /tmp/nemoclaw-perl-security.XXXXXX)" +readonly build_root +readonly source_archive="${build_root}/perl.tar.xz" +readonly source_dir="${build_root}/perl-source" +readonly perl_root="${build_root}/perl-root" +readonly perl_meta="${build_root}/perl-meta" + +cleanup() { + rm -rf "${build_root}" +} +trap cleanup EXIT + +mkdir -p "${output_dir}" "${source_dir}" + +# Pin the reviewed d_syscallproto result for trixie's libc so both native +# architectures use the same known declaration instead of relying on a +# Configure probe that previously returned a false negative under QEMU. +# Remove this override only after the pinned base image and Perl release report +# d_syscallproto=define from native Configure probes on amd64 and arm64. +# Perl's test_harness runs the same upstream suite while TEST_JOBS lets its TAP +# scheduler use each native runner efficiently instead of serializing every +# script in QEMU. +# ExtUtils::Constant's test recursively invokes make and produced an incomplete +# TAP plan when it overlapped another test locally, so run it alone first and +# exclude exactly that already-passed file from the parallel pass. +# Remove this split only after the unsplit parallel harness passes in two +# consecutive amd64 and arm64 base-image builds; keep the selection-equivalence +# check below until that removal condition is met. +curl --proto '=https' --tlsv1.2 -fsSL \ + --retry 5 --retry-all-errors --retry-delay 2 \ + --connect-timeout 15 --max-time 120 \ + -o "${source_archive}" \ + "https://www.cpan.org/src/5.0/perl-${perl_version}.tar.xz" +printf '%s %s\n' "${perl_sha256}" "${source_archive}" >"${build_root}/perl.sha256" +sha256sum -c "${build_root}/perl.sha256" +tar -xJf "${source_archive}" -C "${source_dir}" --strip-components=1 + +( + cd "${source_dir}" + ./Configure -des \ + -Dprefix=/usr \ + -Dvendorprefix=/usr \ + -Dsiteprefix=/usr/local \ + -Dusethreads \ + -Duse64bitall \ + -Dd_syscallproto=define \ + -Dman1dir=none \ + -Dman3dir=none + make -j"$(nproc)" + make test_prep + env -C t PERL_TEST_HARNESS_ASAP=1 ./perl harness -dumptests \ + >"${build_root}/perl-tests-full" + env -C t ./perl harness -dumptests \ + ../cpan/ExtUtils-Constant/t/Constant.t \ + >"${build_root}/perl-tests-serial" + env -C t PERL_TEST_HARNESS_ASAP=1 ./perl harness -dumptests \ + '--nre=^[.][.]/cpan/ExtUtils-Constant/t/Constant[.]t$' \ + >"${build_root}/perl-tests-parallel" + sort "${build_root}/perl-tests-full" \ + >"${build_root}/perl-tests-full.sorted" + sort \ + "${build_root}/perl-tests-serial" \ + "${build_root}/perl-tests-parallel" \ + >"${build_root}/perl-tests-combined.sorted" + cmp \ + "${build_root}/perl-tests-full.sorted" \ + "${build_root}/perl-tests-combined.sorted" + test "$( + grep -Fxc \ + 'cpan/ExtUtils-Constant/t/Constant.t' \ + "${build_root}/perl-tests-combined.sorted" + )" -eq 1 + TEST_JOBS=1 \ + TEST_ARGS='../cpan/ExtUtils-Constant/t/Constant.t' \ + make test_harness + TEST_JOBS="$(nproc)" \ + PERL_TEST_HARNESS_ASAP=1 \ + TEST_ARGS='--nre=^[.][.]/cpan/ExtUtils-Constant/t/Constant[.]t$' \ + make -j"$(nproc)" test_harness + make install DESTDIR="${perl_root}" +) + +package_version="${perl_version}-${package_revision}" +readonly package_version +architecture="$(dpkg --print-architecture)" +readonly architecture +mkdir -p "${perl_root}/DEBIAN" "${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' \ + >"${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' \ + >"${perl_meta}/DEBIAN/control" +dpkg-deb --build --root-owner-group \ + "${perl_root}" "${output_dir}/perl-base.deb" +dpkg-deb --build --root-owner-group \ + "${perl_meta}" "${output_dir}/perl.deb" +test "$(dpkg-deb -f "${output_dir}/perl-base.deb" Version)" = "${package_version}" +test "$(dpkg-deb -f "${output_dir}/perl.deb" Version)" = "${package_version}" diff --git a/test/dcode-base-image-workflow.test.ts b/test/dcode-base-image-workflow.test.ts index a691017019c..619aa0b606c 100644 --- a/test/dcode-base-image-workflow.test.ts +++ b/test/dcode-base-image-workflow.test.ts @@ -193,11 +193,13 @@ function validatePublishers(candidate: Workflow): string[] { const dockerActions = steps.filter((step) => step.uses?.startsWith("docker/")); const tags = String(metadata?.with?.tags ?? ""); const metadataImage = renderMatrixValue(metadata?.with?.images, matrix); - const expectedCacheRef = `${metadataImage}:buildcache`; + const expectedCacheRef = `${metadataImage}:buildcache-${matrix.arch}`; const cacheFrom = registryCacheEntries(renderMatrixValue(build.with?.["cache-from"], matrix)); const cacheTo = registryCacheEntries(renderMatrixValue(build.with?.["cache-to"], matrix)); const importedCacheRef = cacheFrom[0]?.ref; const exportedCacheRef = cacheTo[0]?.ref; + const expectedOutput = + "type=image,name=${{ env.REGISTRY }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true"; return [ ...(guardIndex < 0 || guardIndex >= buildIndex @@ -210,11 +212,7 @@ function validatePublishers(candidate: Workflow): string[] { ? [`${jobName} must derive publication metadata with docker/metadata-action`] : []), ...(metadataImage.length === 0 ? [`${jobName} must declare a publication image`] : []), - ...(!tags.includes("type=ref,event=tag") || - !tags.includes("type=raw,value=latest") || - !tags.includes("type=sha,prefix=,format=short") - ? [`${jobName} must publish release, latest, and commit tags`] - : []), + ...(tags.length > 0 ? [`${jobName} platform build must not publish mutable tags`] : []), ...dockerActions .filter((step) => !FULL_SHA_ACTION.test(step.uses ?? "")) .map((step) => `${jobName} Docker action must use a full commit SHA: ${step.uses}`), @@ -222,13 +220,17 @@ function validatePublishers(candidate: Workflow): string[] { ? [`${jobName} build-push action must use a full commit SHA`] : []), ...(build.with?.context !== "." ? [`${jobName} must publish from repository context`] : []), - ...(build.with?.platforms !== "linux/amd64,linux/arm64" - ? [`${jobName} must publish both supported architectures`] + ...(build.with?.platforms !== "${{ matrix.platform }}" + ? [`${jobName} must build its selected native platform`] + : []), + ...(build.with?.outputs !== expectedOutput + ? [`${jobName} must push an immutable platform digest`] : []), - ...(build.with?.push !== true ? [`${jobName} must push the built image`] : []), - ...(build.with?.tags !== "${{ steps.meta.outputs.tags }}" || - build.with?.labels !== "${{ steps.meta.outputs.labels }}" - ? [`${jobName} must publish the reviewed metadata outputs`] + ...(build.with?.tags !== undefined || build.with?.push !== undefined + ? [`${jobName} platform build must not publish tags directly`] + : []), + ...(build.with?.labels !== "${{ steps.meta.outputs.labels }}" + ? [`${jobName} must use the reviewed metadata labels`] : []), ...(cacheFrom.length !== 1 || !importedCacheRef ? [`${jobName} cache-from must declare exactly one registry cache ref`] @@ -268,17 +270,32 @@ describe("base-image publication behavior", () => { expect( publishers.map(({ dockerfile, matrix }) => ({ agent: matrix.agent, + arch: matrix.arch, dockerfile, image: matrix.image, })), ).toEqual([ { agent: "hermes", + arch: "amd64", dockerfile: "agents/hermes/Dockerfile.base", image: "nvidia/nemoclaw/hermes-sandbox-base", }, + { + agent: "hermes", + arch: "arm64", + dockerfile: "agents/hermes/Dockerfile.base", + image: "nvidia/nemoclaw/hermes-sandbox-base", + }, + { + agent: "langchain-deepagents-code", + arch: "amd64", + dockerfile: "agents/langchain-deepagents-code/Dockerfile.base", + image: "nvidia/nemoclaw/langchain-deepagents-code-sandbox-base", + }, { agent: "langchain-deepagents-code", + arch: "arm64", dockerfile: "agents/langchain-deepagents-code/Dockerfile.base", image: "nvidia/nemoclaw/langchain-deepagents-code-sandbox-base", }, @@ -311,7 +328,7 @@ describe("base-image publication behavior", () => { `${mutatedPublisher.jobName} must validate production build args before publishing`, `${mutatedPublisher.jobName} Docker action must use a full commit SHA: docker/build-push-action@v7`, `${mutatedPublisher.jobName} build-push action must use a full commit SHA`, - `${mutatedPublisher.jobName} must push the built image`, + `${mutatedPublisher.jobName} platform build must not publish tags directly`, `${mutatedPublisher.jobName} cache-from must declare exactly one registry cache ref`, `${mutatedPublisher.jobName} must import and export the same registry cache ref`, `${mutatedPublisher.jobName} must export its registry cache in max mode`, @@ -438,6 +455,103 @@ describe("base-image publication behavior", () => { } }); + it("publishes sibling images atomically from native architecture runners", () => { + const publishers = publisherJobs(workflow); + const platformJob = workflow.jobs?.["build-sibling-platforms"]; + const manifestJob = workflow.jobs?.["build-and-push"]; + + expect(platformJob?.["timeout-minutes"]).toBe(60); + expect(platformJob?.["runs-on"]).toBe("${{ matrix.runner }}"); + expect(platformJob?.strategy?.["fail-fast"]).toBe(false); + expect( + publishers.map(({ matrix }) => ({ + agent: matrix.agent, + arch: matrix.arch, + platform: matrix.platform, + runner: matrix.runner, + })), + ).toEqual([ + { + agent: "hermes", + arch: "amd64", + platform: "linux/amd64", + runner: "ubuntu-24.04", + }, + { + agent: "hermes", + arch: "arm64", + platform: "linux/arm64", + runner: "ubuntu-24.04-arm", + }, + { + agent: "langchain-deepagents-code", + arch: "amd64", + platform: "linux/amd64", + runner: "ubuntu-24.04", + }, + { + agent: "langchain-deepagents-code", + arch: "arm64", + platform: "linux/arm64", + runner: "ubuntu-24.04-arm", + }, + ]); + + for (const { job, build, matrix } of publishers) { + const steps = job.steps ?? []; + const digestUpload = steps.find((step) => step.name === "Upload platform digest"); + + expect(steps.some((step) => step.uses?.startsWith("docker/setup-qemu-action@"))).toBe(false); + expect(build.with?.platforms).toBe("${{ matrix.platform }}"); + expect(build.with?.outputs).toBe( + "type=image,name=${{ env.REGISTRY }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true", + ); + expect(digestUpload?.with?.name).toBe("${{ matrix.agent }}-base-digest-${{ matrix.arch }}"); + expect(renderMatrixValue(digestUpload?.with?.name, matrix)).toBe( + `${matrix.agent}-base-digest-${matrix.arch}`, + ); + } + + expect(manifestJob?.needs).toBe("build-sibling-platforms"); + expect(manifestJob?.["timeout-minutes"]).toBe(10); + expect( + manifestJob?.steps?.some((step) => step.uses?.startsWith("docker/build-push-action@")), + ).toBe(false); + expect(manifestJob?.strategy?.matrix?.include).toEqual([ + { + agent: "hermes", + display_name: "Hermes", + image: "nvidia/nemoclaw/hermes-sandbox-base", + }, + { + agent: "langchain-deepagents-code", + display_name: "Deep Agents Code", + image: "nvidia/nemoclaw/langchain-deepagents-code-sandbox-base", + }, + ]); + const download = manifestJob?.steps?.find((step) => step.name === "Download platform digests"); + const metadata = manifestJob?.steps?.find((step) => step.id === "meta"); + const createManifest = manifestJob?.steps?.find( + (step) => step.name === "Create and verify multi-platform manifest", + ); + expect(download?.with).toMatchObject({ + pattern: "${{ matrix.agent }}-base-digest-*", + "merge-multiple": true, + }); + expect(metadata?.with?.images).toBe("${{ env.REGISTRY }}/${{ matrix.image }}"); + expect(metadata?.with?.tags).toContain("type=raw,value=latest"); + expect(metadata?.with?.tags).toContain("type=ref,event=tag"); + expect(metadata?.with?.tags).toContain("type=sha,prefix=,format=short"); + expect(createManifest?.run).toContain('"${#digest_files[@]}" -ne 2'); + expect(createManifest?.run).toContain( + 'docker buildx imagetools create "${tag_args[@]}" "${sources[@]}"', + ); + expect(createManifest?.run).toContain('"amd64,arm64"'); + for (const step of (manifestJob?.steps ?? []).filter((step) => step.uses)) { + expect(step.uses, step.name).toMatch(FULL_SHA_ACTION); + } + }); + it("keeps shared apt dependencies pinned and aligned across discovered base images (#6679)", () => { const dockerfiles = [ ...openClawPlatformPublishers(workflow).map(({ dockerfile }) => dockerfile), diff --git a/test/perl-critical-cve-remediation.test.ts b/test/perl-critical-cve-remediation.test.ts index 466c1a4215a..be6d849a426 100644 --- a/test/perl-critical-cve-remediation.test.ts +++ b/test/perl-critical-cve-remediation.test.ts @@ -6,22 +6,48 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -const dockerfile = fs.readFileSync(path.join(import.meta.dirname, "..", "Dockerfile.base"), "utf8"); +const repoRoot = path.join(import.meta.dirname, ".."); const baseImageWorkflow = fs.readFileSync( - path.join(import.meta.dirname, "..", ".github", "workflows", "base-image.yaml"), + path.join(repoRoot, ".github", "workflows", "base-image.yaml"), "utf8", ); +const packageBuilder = fs.readFileSync( + path.join(repoRoot, "scripts", "security", "build-perl-security-packages.sh"), + "utf8", +); +const dependencyReview = fs.readFileSync( + path.join(repoRoot, "docs", "security", "managed-base-perl-5.44-dependency-review.md"), + "utf8", +); +const managedImages = [ + { + name: "OpenClaw", + dockerfile: "Dockerfile.base", + }, + { + name: "Hermes", + dockerfile: "agents/hermes/Dockerfile.base", + }, + { + name: "Deep Agents Code", + dockerfile: "agents/langchain-deepagents-code/Dockerfile.base", + }, +].map((image) => ({ + ...image, + source: fs.readFileSync(path.join(repoRoot, image.dockerfile), "utf8"), +})); const fixedPerlVersion = "5.44.0"; const fixedPerlSha256 = "505cf43912e9480495c344c70260452e32aa2a73c546a026b3f100053b23ce91"; +const fixedPackageVersion = "5.44.0-1nemoclaw1"; -function stageNamed(name: string): string { +function stageNamed(dockerfile: string, 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 { +function completedStage(dockerfile: string): string { const start = dockerfile.lastIndexOf("\nFROM "); expect(start, "missing completed image stage").toBeGreaterThanOrEqual(0); return dockerfile.slice(start); @@ -43,80 +69,71 @@ function runInstructionContaining(stage: string, needle: string): string { return lines.slice(start, end + 1).join("\n"); } -function argumentDefault(name: string): string | undefined { +function argumentDefault(dockerfile: string, 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"', +describe("managed base-image Perl CVE remediation", () => { + it("builds one reviewed Perl package definition for every managed image (#7338)", () => { + const download = packageBuilder.indexOf( + '"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"); + const checksum = packageBuilder.indexOf('sha256sum -c "${build_root}/perl.sha256"'); + const extract = packageBuilder.indexOf('tar -xJf "${source_archive}"'); - 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"); - expect(builder).toContain("Pin the reviewed d_syscallproto result"); - expect(builder).toContain("Remove this override only after the pinned base image"); - expect(builder).toContain("native Configure probes on amd64 and arm64"); - expect(builder).toContain("each native runner efficiently"); - expect(baseImageWorkflow).toContain("runner: ubuntu-24.04"); - expect(baseImageWorkflow).toContain("runner: ubuntu-24.04-arm"); - expect(baseImageWorkflow).toContain("platform: linux/amd64"); - expect(baseImageWorkflow).toContain("platform: linux/arm64"); - expect(baseImageWorkflow).toContain("Build and push platform digest"); - expect(baseImageWorkflow).toContain("Create and verify multi-platform manifest"); - expect(baseImageWorkflow).toContain("docker/setup-qemu-action"); - expect(baseImageWorkflow).toContain("platforms: linux/amd64,linux/arm64"); - expect(baseImageWorkflow).toContain("dockerfile: Dockerfile.base"); + expect(packageBuilder).toContain("-Dd_syscallproto=define"); + expect(packageBuilder).toContain("Pin the reviewed d_syscallproto result"); + expect(packageBuilder).toContain("Remove this override only after the pinned base image"); + expect(packageBuilder).toContain("native Configure probes on amd64 and arm64"); + + for (const image of managedImages) { + const builder = stageNamed(image.source, "perl-builder"); + expect(argumentDefault(image.source, "PERL_VERSION"), image.name).toBe(fixedPerlVersion); + expect(argumentDefault(image.source, "PERL_SHA256"), image.name).toBe(fixedPerlSha256); + expect(image.source, image.name).toContain("FROM native-security-builder AS perl-builder"); + expect(builder, image.name).toContain( + "COPY scripts/security/build-perl-security-packages.sh", + ); + expect(builder, image.name).toContain("netbase=6.5"); + expect(builder, image.name).toContain( + '/out "${PERL_VERSION}" "${PERL_SHA256}" "${PERL_PACKAGE_REVISION}"', + ); + expect(image.source, image.name).toContain( + "COPY --from=perl-builder /out /tmp/nemoclaw-native-security", + ); + } }); - 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 prepare = builder.indexOf("make test_prep", compile); - const fullSelection = builder.indexOf( + it("runs the complete upstream test selection before packaging (#7338)", () => { + const compile = packageBuilder.indexOf('make -j"$(nproc)"'); + const prepare = packageBuilder.indexOf("make test_prep", compile); + const fullSelection = packageBuilder.indexOf( "env -C t PERL_TEST_HARNESS_ASAP=1 ./perl harness -dumptests", prepare, ); - const serialSelection = builder.indexOf( + const serialSelection = packageBuilder.indexOf( "../cpan/ExtUtils-Constant/t/Constant.t", fullSelection, ); - const parallelSelection = builder.indexOf( + const parallelSelection = packageBuilder.indexOf( "'--nre=^[.][.]/cpan/ExtUtils-Constant/t/Constant[.]t$'", serialSelection, ); - const compareSelections = builder.indexOf( - "cmp /tmp/perl-tests-full.sorted /tmp/perl-tests-combined.sorted", + const compareSelections = packageBuilder.indexOf( + '"${build_root}/perl-tests-combined.sorted"', parallelSelection, ); - const constantExactlyOnce = builder.indexOf( - "'cpan/ExtUtils-Constant/t/Constant.t'", + const serialTest = packageBuilder.indexOf( + "TEST_ARGS='../cpan/ExtUtils-Constant/t/Constant.t'", compareSelections, ); - const serialTest = builder.indexOf( - "TEST_JOBS=1 TEST_ARGS='../cpan/ExtUtils-Constant/t/Constant.t' make test_harness", - constantExactlyOnce, - ); - const parallelTest = builder.indexOf( - 'TEST_JOBS="$(nproc)" PERL_TEST_HARNESS_ASAP=1', - serialTest, - ); - const parallelFilter = builder.indexOf( - "TEST_ARGS='--nre=^[.][.]/cpan/ExtUtils-Constant/t/Constant[.]t$'", - parallelTest, - ); - const parallelHarness = builder.indexOf('make -j"$(nproc)" test_harness', parallelFilter); - const install = builder.indexOf("make install DESTDIR=/tmp/perl-root"); - const packageBuild = builder.indexOf("dpkg-deb --build --root-owner-group"); + const parallelTest = packageBuilder.indexOf('TEST_JOBS="$(nproc)"', serialTest); + const parallelHarness = packageBuilder.indexOf('make -j"$(nproc)" test_harness', parallelTest); + const install = packageBuilder.indexOf('make install DESTDIR="${perl_root}"'); + const packageBuild = packageBuilder.indexOf("dpkg-deb --build --root-owner-group"); expect(compile).toBeGreaterThanOrEqual(0); expect(prepare).toBeGreaterThan(compile); @@ -124,60 +141,94 @@ describe("sandbox base critical Perl CVE remediation", () => { expect(serialSelection).toBeGreaterThan(fullSelection); expect(parallelSelection).toBeGreaterThan(serialSelection); expect(compareSelections).toBeGreaterThan(parallelSelection); - expect(constantExactlyOnce).toBeGreaterThan(compareSelections); - expect(serialTest).toBeGreaterThan(constantExactlyOnce); + expect(serialTest).toBeGreaterThan(compareSelections); expect(parallelTest).toBeGreaterThan(serialTest); - expect(parallelFilter).toBeGreaterThan(parallelTest); - expect(parallelHarness).toBeGreaterThan(parallelFilter); + expect(parallelHarness).toBeGreaterThan(parallelTest); expect(install).toBeGreaterThan(parallelHarness); expect(packageBuild).toBeGreaterThan(install); - expect(builder).toContain("Remove this split only after the unsplit parallel harness passes"); - expect(builder).toContain("consecutive amd64 and arm64 base-image builds"); - expect(builder).not.toMatch(/\bmake\s+(?:-j[^\n]+\s+)?test(?:\s|\\|$)/m); - }); - - it("replaces the vulnerable distro packages without breaking dpkg ownership (#7338)", () => { - const builder = stageNamed("perl-builder"); - const runtime = completedStage(); - const perlInstall = runInstructionContaining( - runtime, - "/tmp/nemoclaw-native-security/perl-base.deb", + expect(packageBuilder).toContain( + "Remove this split only after the unsplit parallel harness passes", ); + expect(packageBuilder).toContain("consecutive amd64 and arm64 base-image builds"); + expect(packageBuilder).not.toMatch(/\bmake\s+(?:-j[^\n]+\s+)?test(?:\s|\\|$)/m); + }); - expect(builder).toContain( + it("preserves dpkg ownership and records the installed package identity (#7338)", () => { + expect(packageBuilder).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( + expect(packageBuilder).toContain("'Conflicts: libperl5.40, perl-modules-5.40'"); + expect(packageBuilder).toContain( '"Replaces: libperl5.40, perl-modules-5.40, perl (<< ${package_version})"', ); - expect(perlInstall.match(/apt-get install -y --no-install-recommends/g)).toHaveLength(1); - expect(perlInstall).toContain("/tmp/nemoclaw-native-security/perl-base.deb"); - expect(perlInstall).toContain("/tmp/nemoclaw-native-security/perl.deb"); - expect(runtime).toContain('test -z "$(dpkg --audit)"'); + expect(packageBuilder).toContain( + 'test "$(dpkg-deb -f "${output_dir}/perl-base.deb" Version)" = "${package_version}"', + ); + + for (const image of managedImages) { + const runtime = completedStage(image.source); + const perlInstall = runInstructionContaining( + runtime, + "/tmp/nemoclaw-native-security/perl-base.deb", + ); + const install = perlInstall.indexOf("/tmp/nemoclaw-native-security/perl-base.deb"); + const cleanup = perlInstall.indexOf("rm -rf /tmp/nemoclaw-native-security"); + + expect(perlInstall, image.name).toContain("/tmp/nemoclaw-native-security/perl.deb"); + expect(cleanup, image.name).toBeGreaterThan(install); + expect(runtime, image.name).toContain(`"perl-base=${fixedPackageVersion}"`); + expect(runtime, image.name).toContain(`"perl=${fixedPackageVersion}"`); + expect(runtime, image.name).toContain('test -z "$(dpkg --audit)"'); + } }); - it("fails the image build unless the reviewed critical and high fixes are active (#7338)", () => { - const runtime = completedStage(); + it("fails each image build unless the reviewed fixes execute (#7338)", () => { + for (const image of managedImages) { + const runtime = completedStage(image.source); - 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( - `test "$(perl -MHTTP::Tiny -e 'print HTTP::Tiny->VERSION')" = "0.096"`, - ); - expect(runtime).toContain( - `test "$(perl -MIO::Compress::Base -e 'print IO::Compress::Base->VERSION')" = "2.223"`, - ); - expect(runtime).toContain( - `test "$(perl -MIO::Uncompress::Unzip -e 'print IO::Uncompress::Unzip->VERSION')" = "2.223"`, - ); - expect(runtime).toContain( - `test "$(perl -MFile::GlobMapper -e 'print File::GlobMapper->VERSION')" = "1.001"`, - ); - 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)/'); + expect(runtime, image.name).toContain( + `test "$(perl -e 'print $^V')" = "v${fixedPerlVersion}"`, + ); + expect(runtime, image.name).toContain( + `test "$(perl -MSocket -e 'print Socket->VERSION')" = "2.041"`, + ); + expect(runtime, image.name).toContain( + `test "$(perl -MStorable -e 'print Storable->VERSION')" = "3.41"`, + ); + expect(runtime, image.name).toContain( + `test "$(perl -MHTTP::Tiny -e 'print HTTP::Tiny->VERSION')" = "0.096"`, + ); + expect(runtime, image.name).toContain( + `test "$(perl -MIO::Compress::Base -e 'print IO::Compress::Base->VERSION')" = "2.223"`, + ); + expect(runtime, image.name).toContain( + `test "$(perl -MIO::Uncompress::Unzip -e 'print IO::Uncompress::Unzip->VERSION')" = "2.223"`, + ); + expect(runtime, image.name).toContain( + `test "$(perl -MFile::GlobMapper -e 'print File::GlobMapper->VERSION')" = "1.001"`, + ); + expect(runtime, image.name).toContain("pack_ip_mreq_source"); + expect(runtime, image.name).toContain('die "short source accepted"'); + expect(runtime, image.name).toContain('use re "Debug"'); + expect(runtime, image.name).toContain('"fnord" =~ m/(?:$x)|(?:$y)/'); + } + }); + + it("builds both architectures and every managed image from the PR head (#7338)", () => { + expect(baseImageWorkflow).toContain("runner: ubuntu-24.04"); + expect(baseImageWorkflow).toContain("runner: ubuntu-24.04-arm"); + expect(baseImageWorkflow).toContain("platform: linux/amd64"); + expect(baseImageWorkflow).toContain("platform: linux/arm64"); + for (const image of managedImages) { + expect(baseImageWorkflow, image.name).toContain(`dockerfile: ${image.dockerfile}`); + } + }); + + it("records the migration boundary and external proof gates (#7338)", () => { + expect(dependencyReview).toContain("This change does not cross an upstream Perl release range"); + expect(dependencyReview).toContain("PERL-01"); + expect(dependencyReview).toContain("PERL-07"); + expect(dependencyReview).toContain("native amd64 and arm64"); + expect(dependencyReview).toContain("vulnerability rescan"); }); }); From afee28367e258d76d567d6fb84166f3576f922a1 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 29 Jul 2026 13:18:56 -0700 Subject: [PATCH 2/7] fix(security): address Perl publication review --- .github/workflows/base-image.yaml | 204 ++++++++++++++++-- Dockerfile.base | 11 +- agents/hermes/Dockerfile.base | 2 + .../langchain-deepagents-code/Dockerfile.base | 2 + .../security/build-perl-security-packages.sh | 30 +-- test/dcode-base-image-workflow.test.ts | 117 +++++----- test/perl-critical-cve-remediation.test.ts | 13 +- 7 files changed, 287 insertions(+), 92 deletions(-) diff --git a/.github/workflows/base-image.yaml b/.github/workflows/base-image.yaml index bc1f6441b4b..3c4798f36e1 100644 --- a/.github/workflows/base-image.yaml +++ b/.github/workflows/base-image.yaml @@ -181,8 +181,8 @@ jobs: # The complete Perl suite approaches the image-job timeout under QEMU arm64 emulation. # Build each sibling image on native architecture runners and publish only immutable platform digests. - # The manifest job updates tags after both builds pass. - build-sibling-platforms: + # Each manifest job updates its image tags after both platform builds pass. + build-hermes-platforms: name: Build ${{ matrix.display_name }} base image (${{ matrix.arch }}) if: github.repository == 'NVIDIA/NemoClaw' runs-on: ${{ matrix.runner }} @@ -205,6 +205,100 @@ jobs: runner: ubuntu-24.04-arm dockerfile: agents/hermes/Dockerfile.base image: nvidia/nemoclaw/hermes-sandbox-base + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Log in to GHCR + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + env: + DOCKER_METADATA_SHORT_SHA_LENGTH: 8 + with: + images: ${{ env.REGISTRY }}/${{ matrix.image }} + + - name: Validate production Docker build args + id: production-build-args + env: + AGENT: ${{ matrix.agent }} + OPENCLAW_VERSION_INPUT: ${{ inputs.openclaw_version }} + run: | + set -euo pipefail + build_args=() + openclaw_build_arg="" + if [ "$AGENT" = "openclaw" ] && [ -n "${OPENCLAW_VERSION_INPUT}" ]; then + openclaw_build_arg="OPENCLAW_VERSION=${OPENCLAW_VERSION_INPUT}" + build_args+=(--build-arg "$openclaw_build_arg") + fi + if [ "${#build_args[@]}" -gt 0 ]; then + scripts/check-production-build-args.sh "${build_args[@]}" + else + scripts/check-production-build-args.sh + fi + if [ "$AGENT" = "openclaw" ] && [ -n "${OPENCLAW_VERSION_INPUT}" ]; then + if [[ "$OPENCLAW_VERSION_INPUT" == *$'\r'* || "$OPENCLAW_VERSION_INPUT" == *$'\n'* ]]; then + echo "ERROR: OpenClaw version must not contain CR or LF characters." >&2 + exit 1 + fi + if [[ ! "$OPENCLAW_VERSION_INPUT" =~ ^[0-9]+([.][0-9]+)*$ ]]; then + echo "ERROR: OpenClaw version must be a whole decimal dotted version (for example, 2026.6.10)." >&2 + exit 1 + fi + fi + printf 'openclaw_build_arg=%s\n' "$openclaw_build_arg" >> "$GITHUB_OUTPUT" + + - name: Build and push platform digest + id: build + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: ${{ matrix.dockerfile }} + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.REGISTRY }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.arch }} + cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.arch }},mode=max + build-args: ${{ steps.production-build-args.outputs.openclaw_build_arg }} + + - name: Export platform digest + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + set -euo pipefail + if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "ERROR: build did not return a valid sha256 digest: $DIGEST" >&2 + exit 1 + fi + mkdir -p "$RUNNER_TEMP/digests" + touch "$RUNNER_TEMP/digests/${DIGEST#sha256:}" + + - name: Upload platform digest + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ matrix.agent }}-base-digest-${{ matrix.arch }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + build-dcode-platforms: + name: Build ${{ matrix.display_name }} base image (${{ matrix.arch }}) + if: github.repository == 'NVIDIA/NemoClaw' + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: - agent: langchain-deepagents-code display_name: Deep Agents Code arch: amd64 @@ -304,27 +398,17 @@ jobs: if-no-files-found: error retention-days: 1 - build-and-push: - name: Build and push ${{ matrix.display_name }} base image + build-and-push-hermes: + name: Build and push Hermes base image if: github.repository == 'NVIDIA/NemoClaw' - needs: build-sibling-platforms + needs: build-hermes-platforms runs-on: ubuntu-latest timeout-minutes: 10 - strategy: - fail-fast: false - matrix: - include: - - agent: hermes - display_name: Hermes - image: nvidia/nemoclaw/hermes-sandbox-base - - agent: langchain-deepagents-code - display_name: Deep Agents Code - image: nvidia/nemoclaw/langchain-deepagents-code-sandbox-base steps: - name: Download platform digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - pattern: ${{ matrix.agent }}-base-digest-* + pattern: hermes-base-digest-* path: ${{ runner.temp }}/digests merge-multiple: true @@ -344,7 +428,91 @@ jobs: env: DOCKER_METADATA_SHORT_SHA_LENGTH: 8 with: - images: ${{ env.REGISTRY }}/${{ matrix.image }} + images: ${{ env.REGISTRY }}/nvidia/nemoclaw/hermes-sandbox-base + tags: | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=ref,event=tag + type=sha,prefix=,format=short + + - name: Create and verify multi-platform manifest + env: + IMAGE: ${{ env.REGISTRY }}/nvidia/nemoclaw/hermes-sandbox-base + TAGS: ${{ steps.meta.outputs.tags }} + run: | + set -euo pipefail + shopt -s nullglob + digest_files=("$RUNNER_TEMP"/digests/*) + if [ "${#digest_files[@]}" -ne 2 ]; then + echo "ERROR: expected exactly two platform digests, found ${#digest_files[@]}." >&2 + exit 1 + fi + + sources=() + for digest_file in "${digest_files[@]}"; do + digest="$(basename "$digest_file")" + if [[ ! "$digest" =~ ^[0-9a-f]{64}$ ]]; then + echo "ERROR: invalid platform digest artifact: $digest" >&2 + exit 1 + fi + sources+=("$IMAGE@sha256:$digest") + done + + mapfile -t tags <<< "$TAGS" + tag_args=() + for tag in "${tags[@]}"; do + if [ -n "$tag" ]; then + tag_args+=(--tag "$tag") + fi + done + if [ "${#tag_args[@]}" -eq 0 ]; then + echo "ERROR: metadata did not produce any publication tags." >&2 + exit 1 + fi + + docker buildx imagetools create "${tag_args[@]}" "${sources[@]}" + first_tag="${tags[0]}" + actual_platforms="$( + docker buildx imagetools inspect "$first_tag" --raw \ + | jq -r '.manifests[] | select(.platform.os == "linux") | .platform.architecture' \ + | sort -u \ + | paste -sd, - + )" + if [ "$actual_platforms" != "amd64,arm64" ]; then + echo "ERROR: published manifest has unexpected platforms: $actual_platforms" >&2 + exit 1 + fi + + build-and-push-dcode: + name: Build and push Deep Agents Code base image + if: github.repository == 'NVIDIA/NemoClaw' + needs: build-dcode-platforms + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Download platform digests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: langchain-deepagents-code-base-digest-* + path: ${{ runner.temp }}/digests + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Log in to GHCR + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + env: + DOCKER_METADATA_SHORT_SHA_LENGTH: 8 + with: + images: ${{ env.REGISTRY }}/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base tags: | type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} type=ref,event=tag @@ -352,7 +520,7 @@ jobs: - name: Create and verify multi-platform manifest env: - IMAGE: ${{ env.REGISTRY }}/${{ matrix.image }} + IMAGE: ${{ env.REGISTRY }}/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base TAGS: ${{ steps.meta.outputs.tags }} run: | set -euo pipefail diff --git a/Dockerfile.base b/Dockerfile.base index 6b1abf83184..1607c5a9e01 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -225,11 +225,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ "vim-tiny=2:9.2.0782-1" \ "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1" \ "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1" \ - "perl-base=5.44.0-1nemoclaw1" \ - "perl=5.44.0-1nemoclaw1" \ > /usr/local/share/nemoclaw/security-packages.txt \ && chown root:root /usr/local/share/nemoclaw/security-packages.txt \ - && chmod 0444 /usr/local/share/nemoclaw/security-packages.txt \ + && chmod 0644 /usr/local/share/nemoclaw/security-packages.txt \ && rm -rf "$security_deb_dir" \ && rm -rf /var/lib/apt/lists/* \ && ln -s /usr/bin/python3 /usr/local/bin/python @@ -240,6 +238,8 @@ RUN apt-get update \ /tmp/nemoclaw-native-security/perl.deb \ && rm -rf /tmp/nemoclaw-native-security \ && rm -rf /var/lib/apt/lists/* \ + && test "$(dpkg-query -W -f='${Version}' perl-base)" = "5.44.0-1nemoclaw1" \ + && test "$(dpkg-query -W -f='${Version}' perl)" = "5.44.0-1nemoclaw1" \ && 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" \ @@ -251,6 +251,11 @@ RUN apt-get update \ '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)/' \ + && printf '%s\n' \ + "perl-base=5.44.0-1nemoclaw1" \ + "perl=5.44.0-1nemoclaw1" \ + >> /usr/local/share/nemoclaw/security-packages.txt \ + && chmod 0444 /usr/local/share/nemoclaw/security-packages.txt \ && git --version \ && test -z "$(dpkg --audit)" diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index 0daaa2d6064..200037063bc 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -167,6 +167,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \ /tmp/nemoclaw-native-security/perl-base.deb \ /tmp/nemoclaw-native-security/perl.deb \ + && test "$(dpkg-query -W -f='${Version}' perl-base)" = "5.44.0-1nemoclaw1" \ + && test "$(dpkg-query -W -f='${Version}' perl)" = "5.44.0-1nemoclaw1" \ && test "$(dpkg-query -W -f='${Version}' libexpat1)" = "2.8.2-1" \ && test "$(dpkg-query -W -f='${Version}' libonig5)" = "6.9.9-1+b1" \ && test "$(dpkg-query -W -f='${Version}' libjq1)" = "1.8.2-1" \ diff --git a/agents/langchain-deepagents-code/Dockerfile.base b/agents/langchain-deepagents-code/Dockerfile.base index 0063fa226c6..e78788980c7 100644 --- a/agents/langchain-deepagents-code/Dockerfile.base +++ b/agents/langchain-deepagents-code/Dockerfile.base @@ -142,6 +142,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \ /tmp/nemoclaw-native-security/perl-base.deb \ /tmp/nemoclaw-native-security/perl.deb \ + && test "$(dpkg-query -W -f='${Version}' perl-base)" = "5.44.0-1nemoclaw1" \ + && test "$(dpkg-query -W -f='${Version}' perl)" = "5.44.0-1nemoclaw1" \ && test "$(dpkg-query -W -f='${Version}' libexpat1)" = "2.8.2-1" \ && test "$(dpkg-query -W -f='${Version}' libonig5)" = "6.9.9-1+b1" \ && test "$(dpkg-query -W -f='${Version}' libjq1)" = "1.8.2-1" \ diff --git a/scripts/security/build-perl-security-packages.sh b/scripts/security/build-perl-security-packages.sh index f572446eb68..ae479b92fee 100755 --- a/scripts/security/build-perl-security-packages.sh +++ b/scripts/security/build-perl-security-packages.sh @@ -27,20 +27,6 @@ trap cleanup EXIT mkdir -p "${output_dir}" "${source_dir}" -# Pin the reviewed d_syscallproto result for trixie's libc so both native -# architectures use the same known declaration instead of relying on a -# Configure probe that previously returned a false negative under QEMU. -# Remove this override only after the pinned base image and Perl release report -# d_syscallproto=define from native Configure probes on amd64 and arm64. -# Perl's test_harness runs the same upstream suite while TEST_JOBS lets its TAP -# scheduler use each native runner efficiently instead of serializing every -# script in QEMU. -# ExtUtils::Constant's test recursively invokes make and produced an incomplete -# TAP plan when it overlapped another test locally, so run it alone first and -# exclude exactly that already-passed file from the parallel pass. -# Remove this split only after the unsplit parallel harness passes in two -# consecutive amd64 and arm64 base-image builds; keep the selection-equivalence -# check below until that removal condition is met. curl --proto '=https' --tlsv1.2 -fsSL \ --retry 5 --retry-all-errors --retry-delay 2 \ --connect-timeout 15 --max-time 120 \ @@ -52,6 +38,11 @@ tar -xJf "${source_archive}" -C "${source_dir}" --strip-components=1 ( cd "${source_dir}" + # Pin the reviewed d_syscallproto result for trixie's libc so both native + # architectures use the same known declaration instead of relying on a + # Configure probe that previously returned a false negative under QEMU. + # Remove this override only after the pinned base image and Perl release report + # d_syscallproto=define from native Configure probes on amd64 and arm64. ./Configure -des \ -Dprefix=/usr \ -Dvendorprefix=/usr \ @@ -63,6 +54,12 @@ tar -xJf "${source_archive}" -C "${source_dir}" --strip-components=1 -Dman3dir=none make -j"$(nproc)" make test_prep + # ExtUtils::Constant's test recursively invokes make and produced an incomplete + # TAP plan when it overlapped another test locally, so run it alone first and + # exclude exactly that already-passed file from the parallel pass. + # Remove this split only after the unsplit parallel harness passes in two + # consecutive amd64 and arm64 base-image builds; keep the selection-equivalence + # check below until that removal condition is met. env -C t PERL_TEST_HARNESS_ASAP=1 ./perl harness -dumptests \ >"${build_root}/perl-tests-full" env -C t ./perl harness -dumptests \ @@ -82,9 +79,12 @@ tar -xJf "${source_archive}" -C "${source_dir}" --strip-components=1 "${build_root}/perl-tests-combined.sorted" test "$( grep -Fxc \ - 'cpan/ExtUtils-Constant/t/Constant.t' \ + '../cpan/ExtUtils-Constant/t/Constant.t' \ "${build_root}/perl-tests-combined.sorted" )" -eq 1 + # Perl's test_harness runs the same upstream suite while TEST_JOBS lets its TAP + # scheduler use each native runner efficiently instead of serializing every + # script in QEMU. TEST_JOBS=1 \ TEST_ARGS='../cpan/ExtUtils-Constant/t/Constant.t' \ make test_harness diff --git a/test/dcode-base-image-workflow.test.ts b/test/dcode-base-image-workflow.test.ts index 619aa0b606c..890daaca832 100644 --- a/test/dcode-base-image-workflow.test.ts +++ b/test/dcode-base-image-workflow.test.ts @@ -67,6 +67,8 @@ const workflow = YAML.parse( const FULL_SHA_ACTION = /^[^@]+@[0-9a-f]{40}$/i; const OPENCLAW_AGENT_GATE = 'if [ "$AGENT" = "openclaw" ] && [ -n "${OPENCLAW_VERSION_INPUT}" ]; then'; +const PLATFORM_DIGEST_OUTPUT = + "type=image,name=${{ env.REGISTRY }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true"; function renderMatrixValue(value: unknown, matrix: PublisherMatrixEntry): string { return String(value ?? "").replace( @@ -198,9 +200,6 @@ function validatePublishers(candidate: Workflow): string[] { const cacheTo = registryCacheEntries(renderMatrixValue(build.with?.["cache-to"], matrix)); const importedCacheRef = cacheFrom[0]?.ref; const exportedCacheRef = cacheTo[0]?.ref; - const expectedOutput = - "type=image,name=${{ env.REGISTRY }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true"; - return [ ...(guardIndex < 0 || guardIndex >= buildIndex ? [`${jobName} must validate production build args before publishing`] @@ -223,7 +222,7 @@ function validatePublishers(candidate: Workflow): string[] { ...(build.with?.platforms !== "${{ matrix.platform }}" ? [`${jobName} must build its selected native platform`] : []), - ...(build.with?.outputs !== expectedOutput + ...(build.with?.outputs !== PLATFORM_DIGEST_OUTPUT ? [`${jobName} must push an immutable platform digest`] : []), ...(build.with?.tags !== undefined || build.with?.push !== undefined @@ -266,7 +265,7 @@ describe("base-image publication behavior", () => { // source-shape-contract: security -- Publisher mutations must preserve immutable actions, guarded arguments, and trusted registry cache ownership it("accepts every discovered publisher and rejects supply-chain mutations", () => { const publishers = publisherJobs(workflow); - expect(publisherBuildSteps(workflow)).toHaveLength(2); + expect(publisherBuildSteps(workflow)).toHaveLength(3); expect( publishers.map(({ dockerfile, matrix }) => ({ agent: matrix.agent, @@ -300,7 +299,9 @@ describe("base-image publication behavior", () => { image: "nvidia/nemoclaw/langchain-deepagents-code-sandbox-base", }, ]); - expect(publishers[0].job.strategy?.["fail-fast"]).toBe(false); + for (const publisher of publishers) { + expect(publisher.job.strategy?.["fail-fast"]).toBe(false); + } expect(validatePublishers(workflow)).toEqual([]); expect(validatePublisherInputs(workflow, openClawPlatformPublishers(workflow))).toEqual([]); @@ -411,9 +412,7 @@ describe("base-image publication behavior", () => { expect(guardIndex).toBeLessThan(buildIndex); expect(hasAgentScopedOpenClawVersion(steps[guardIndex])).toBe(true); expect(build.with?.platforms).toBe("${{ matrix.platform }}"); - expect(build.with?.outputs).toBe( - "type=image,name=${{ env.REGISTRY }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true", - ); + expect(build.with?.outputs).toBe(PLATFORM_DIGEST_OUTPUT); expect(build.with?.tags).toBeUndefined(); expect(renderMatrixValue(build.with?.["cache-from"], matrix)).toContain(cacheSuffix); expect(renderMatrixValue(build.with?.["cache-to"], matrix)).toContain( @@ -457,12 +456,29 @@ describe("base-image publication behavior", () => { it("publishes sibling images atomically from native architecture runners", () => { const publishers = publisherJobs(workflow); - const platformJob = workflow.jobs?.["build-sibling-platforms"]; - const manifestJob = workflow.jobs?.["build-and-push"]; + const imagePublishers = [ + { + platformJobName: "build-hermes-platforms", + manifestJobName: "build-and-push-hermes", + manifestName: "Build and push Hermes base image", + artifactPattern: "hermes-base-digest-*", + image: "${{ env.REGISTRY }}/nvidia/nemoclaw/hermes-sandbox-base", + }, + { + platformJobName: "build-dcode-platforms", + manifestJobName: "build-and-push-dcode", + manifestName: "Build and push Deep Agents Code base image", + artifactPattern: "langchain-deepagents-code-base-digest-*", + image: "${{ env.REGISTRY }}/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base", + }, + ]; - expect(platformJob?.["timeout-minutes"]).toBe(60); - expect(platformJob?.["runs-on"]).toBe("${{ matrix.runner }}"); - expect(platformJob?.strategy?.["fail-fast"]).toBe(false); + for (const { platformJobName } of imagePublishers) { + const platformJob = workflow.jobs?.[platformJobName]; + expect(platformJob?.["timeout-minutes"]).toBe(60); + expect(platformJob?.["runs-on"]).toBe("${{ matrix.runner }}"); + expect(platformJob?.strategy?.["fail-fast"]).toBe(false); + } expect( publishers.map(({ matrix }) => ({ agent: matrix.agent, @@ -503,52 +519,45 @@ describe("base-image publication behavior", () => { expect(steps.some((step) => step.uses?.startsWith("docker/setup-qemu-action@"))).toBe(false); expect(build.with?.platforms).toBe("${{ matrix.platform }}"); - expect(build.with?.outputs).toBe( - "type=image,name=${{ env.REGISTRY }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true", - ); + expect(build.with?.outputs).toBe(PLATFORM_DIGEST_OUTPUT); expect(digestUpload?.with?.name).toBe("${{ matrix.agent }}-base-digest-${{ matrix.arch }}"); expect(renderMatrixValue(digestUpload?.with?.name, matrix)).toBe( `${matrix.agent}-base-digest-${matrix.arch}`, ); } - expect(manifestJob?.needs).toBe("build-sibling-platforms"); - expect(manifestJob?.["timeout-minutes"]).toBe(10); - expect( - manifestJob?.steps?.some((step) => step.uses?.startsWith("docker/build-push-action@")), - ).toBe(false); - expect(manifestJob?.strategy?.matrix?.include).toEqual([ - { - agent: "hermes", - display_name: "Hermes", - image: "nvidia/nemoclaw/hermes-sandbox-base", - }, - { - agent: "langchain-deepagents-code", - display_name: "Deep Agents Code", - image: "nvidia/nemoclaw/langchain-deepagents-code-sandbox-base", - }, - ]); - const download = manifestJob?.steps?.find((step) => step.name === "Download platform digests"); - const metadata = manifestJob?.steps?.find((step) => step.id === "meta"); - const createManifest = manifestJob?.steps?.find( - (step) => step.name === "Create and verify multi-platform manifest", - ); - expect(download?.with).toMatchObject({ - pattern: "${{ matrix.agent }}-base-digest-*", - "merge-multiple": true, - }); - expect(metadata?.with?.images).toBe("${{ env.REGISTRY }}/${{ matrix.image }}"); - expect(metadata?.with?.tags).toContain("type=raw,value=latest"); - expect(metadata?.with?.tags).toContain("type=ref,event=tag"); - expect(metadata?.with?.tags).toContain("type=sha,prefix=,format=short"); - expect(createManifest?.run).toContain('"${#digest_files[@]}" -ne 2'); - expect(createManifest?.run).toContain( - 'docker buildx imagetools create "${tag_args[@]}" "${sources[@]}"', - ); - expect(createManifest?.run).toContain('"amd64,arm64"'); - for (const step of (manifestJob?.steps ?? []).filter((step) => step.uses)) { - expect(step.uses, step.name).toMatch(FULL_SHA_ACTION); + for (const imagePublisher of imagePublishers) { + const manifestJob = workflow.jobs?.[imagePublisher.manifestJobName]; + expect(manifestJob?.name).toBe(imagePublisher.manifestName); + expect(manifestJob?.needs).toBe(imagePublisher.platformJobName); + expect(manifestJob?.["timeout-minutes"]).toBe(10); + expect( + manifestJob?.steps?.some((step) => step.uses?.startsWith("docker/build-push-action@")), + ).toBe(false); + const download = manifestJob?.steps?.find( + (step) => step.name === "Download platform digests", + ); + const metadata = manifestJob?.steps?.find((step) => step.id === "meta"); + const createManifest = manifestJob?.steps?.find( + (step) => step.name === "Create and verify multi-platform manifest", + ); + expect(download?.with).toMatchObject({ + pattern: imagePublisher.artifactPattern, + "merge-multiple": true, + }); + expect(metadata?.with?.images).toBe(imagePublisher.image); + expect(metadata?.with?.tags).toContain("type=raw,value=latest"); + expect(metadata?.with?.tags).toContain("type=ref,event=tag"); + expect(metadata?.with?.tags).toContain("type=sha,prefix=,format=short"); + expect(createManifest?.env?.IMAGE).toBe(imagePublisher.image); + expect(createManifest?.run).toContain('"${#digest_files[@]}" -ne 2'); + expect(createManifest?.run).toContain( + 'docker buildx imagetools create "${tag_args[@]}" "${sources[@]}"', + ); + expect(createManifest?.run).toContain('"amd64,arm64"'); + for (const step of (manifestJob?.steps ?? []).filter((step) => step.uses)) { + expect(step.uses, step.name).toMatch(FULL_SHA_ACTION); + } } }); diff --git a/test/perl-critical-cve-remediation.test.ts b/test/perl-critical-cve-remediation.test.ts index be6d849a426..e2ea56f96aa 100644 --- a/test/perl-critical-cve-remediation.test.ts +++ b/test/perl-critical-cve-remediation.test.ts @@ -38,6 +38,7 @@ const managedImages = [ })); const fixedPerlVersion = "5.44.0"; const fixedPerlSha256 = "505cf43912e9480495c344c70260452e32aa2a73c546a026b3f100053b23ce91"; +const fixedPackageRevision = "1nemoclaw1"; const fixedPackageVersion = "5.44.0-1nemoclaw1"; function stageNamed(dockerfile: string, name: string): string { @@ -93,6 +94,9 @@ describe("managed base-image Perl CVE remediation", () => { const builder = stageNamed(image.source, "perl-builder"); expect(argumentDefault(image.source, "PERL_VERSION"), image.name).toBe(fixedPerlVersion); expect(argumentDefault(image.source, "PERL_SHA256"), image.name).toBe(fixedPerlSha256); + expect(argumentDefault(image.source, "PERL_PACKAGE_REVISION"), image.name).toBe( + fixedPackageRevision, + ); expect(image.source, image.name).toContain("FROM native-security-builder AS perl-builder"); expect(builder, image.name).toContain( "COPY scripts/security/build-perl-security-packages.sh", @@ -150,6 +154,7 @@ describe("managed base-image Perl CVE remediation", () => { "Remove this split only after the unsplit parallel harness passes", ); expect(packageBuilder).toContain("consecutive amd64 and arm64 base-image builds"); + expect(packageBuilder).toContain("'../cpan/ExtUtils-Constant/t/Constant.t'"); expect(packageBuilder).not.toMatch(/\bmake\s+(?:-j[^\n]+\s+)?test(?:\s|\\|$)/m); }); @@ -176,6 +181,12 @@ describe("managed base-image Perl CVE remediation", () => { expect(perlInstall, image.name).toContain("/tmp/nemoclaw-native-security/perl.deb"); expect(cleanup, image.name).toBeGreaterThan(install); + expect(runtime, image.name).toContain( + `test "$(dpkg-query -W -f='\${Version}' perl-base)" = "${fixedPackageVersion}"`, + ); + expect(runtime, image.name).toContain( + `test "$(dpkg-query -W -f='\${Version}' perl)" = "${fixedPackageVersion}"`, + ); expect(runtime, image.name).toContain(`"perl-base=${fixedPackageVersion}"`); expect(runtime, image.name).toContain(`"perl=${fixedPackageVersion}"`); expect(runtime, image.name).toContain('test -z "$(dpkg --audit)"'); @@ -225,10 +236,8 @@ describe("managed base-image Perl CVE remediation", () => { }); it("records the migration boundary and external proof gates (#7338)", () => { - expect(dependencyReview).toContain("This change does not cross an upstream Perl release range"); expect(dependencyReview).toContain("PERL-01"); expect(dependencyReview).toContain("PERL-07"); expect(dependencyReview).toContain("native amd64 and arm64"); - expect(dependencyReview).toContain("vulnerability rescan"); }); }); From f2ff362e71b762d7547f458c23a307d51f6114c7 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 29 Jul 2026 14:24:27 -0700 Subject: [PATCH 3/7] fix(ci): validate image platforms before publish Signed-off-by: Senthil Ravichandran --- .github/workflows/base-image.yaml | 108 +++++++++++++++++++++---- test/dcode-base-image-workflow.test.ts | 35 ++++++-- 2 files changed, 122 insertions(+), 21 deletions(-) diff --git a/.github/workflows/base-image.yaml b/.github/workflows/base-image.yaml index 3c4798f36e1..3cd10c3fda6 100644 --- a/.github/workflows/base-image.yaml +++ b/.github/workflows/base-image.yaml @@ -161,15 +161,20 @@ jobs: - name: Export platform digest env: + ARCH: ${{ matrix.arch }} DIGEST: ${{ steps.build.outputs.digest }} run: | set -euo pipefail + if [[ ! "$ARCH" =~ ^(amd64|arm64)$ ]]; then + echo "ERROR: unsupported platform architecture: $ARCH" >&2 + exit 1 + fi if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then echo "ERROR: build did not return a valid sha256 digest: $DIGEST" >&2 exit 1 fi mkdir -p "$RUNNER_TEMP/digests" - touch "$RUNNER_TEMP/digests/${DIGEST#sha256:}" + touch "$RUNNER_TEMP/digests/${ARCH}-${DIGEST#sha256:}" - name: Upload platform digest uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -272,15 +277,20 @@ jobs: - name: Export platform digest env: + ARCH: ${{ matrix.arch }} DIGEST: ${{ steps.build.outputs.digest }} run: | set -euo pipefail + if [[ ! "$ARCH" =~ ^(amd64|arm64)$ ]]; then + echo "ERROR: unsupported platform architecture: $ARCH" >&2 + exit 1 + fi if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then echo "ERROR: build did not return a valid sha256 digest: $DIGEST" >&2 exit 1 fi mkdir -p "$RUNNER_TEMP/digests" - touch "$RUNNER_TEMP/digests/${DIGEST#sha256:}" + touch "$RUNNER_TEMP/digests/${ARCH}-${DIGEST#sha256:}" - name: Upload platform digest uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -380,15 +390,20 @@ jobs: - name: Export platform digest env: + ARCH: ${{ matrix.arch }} DIGEST: ${{ steps.build.outputs.digest }} run: | set -euo pipefail + if [[ ! "$ARCH" =~ ^(amd64|arm64)$ ]]; then + echo "ERROR: unsupported platform architecture: $ARCH" >&2 + exit 1 + fi if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then echo "ERROR: build did not return a valid sha256 digest: $DIGEST" >&2 exit 1 fi mkdir -p "$RUNNER_TEMP/digests" - touch "$RUNNER_TEMP/digests/${DIGEST#sha256:}" + touch "$RUNNER_TEMP/digests/${ARCH}-${DIGEST#sha256:}" - name: Upload platform digest uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -447,15 +462,36 @@ jobs: exit 1 fi + declare -A seen_arches=() sources=() for digest_file in "${digest_files[@]}"; do - digest="$(basename "$digest_file")" - if [[ ! "$digest" =~ ^[0-9a-f]{64}$ ]]; then - echo "ERROR: invalid platform digest artifact: $digest" >&2 + digest_artifact="$(basename "$digest_file")" + if [[ ! "$digest_artifact" =~ ^(amd64|arm64)-([0-9a-f]{64})$ ]]; then + echo "ERROR: invalid platform digest artifact: $digest_artifact" >&2 exit 1 fi - sources+=("$IMAGE@sha256:$digest") + expected_arch="${BASH_REMATCH[1]}" + digest="${BASH_REMATCH[2]}" + if [ -n "${seen_arches[$expected_arch]:-}" ]; then + echo "ERROR: duplicate platform digest for linux/$expected_arch." >&2 + exit 1 + fi + source="$IMAGE@sha256:$digest" + source_platform="$( + docker buildx imagetools inspect "$source" \ + --format '{{.Image.OS}}/{{.Image.Architecture}}' + )" + if [ "$source_platform" != "linux/$expected_arch" ]; then + echo "ERROR: digest for linux/$expected_arch resolves to $source_platform." >&2 + exit 1 + fi + seen_arches["$expected_arch"]=1 + sources+=("$source") done + if [ "${seen_arches[amd64]:-0}" -ne 1 ] || [ "${seen_arches[arm64]:-0}" -ne 1 ]; then + echo "ERROR: expected one validated digest for linux/amd64 and linux/arm64." >&2 + exit 1 + fi mapfile -t tags <<< "$TAGS" tag_args=() @@ -531,15 +567,36 @@ jobs: exit 1 fi + declare -A seen_arches=() sources=() for digest_file in "${digest_files[@]}"; do - digest="$(basename "$digest_file")" - if [[ ! "$digest" =~ ^[0-9a-f]{64}$ ]]; then - echo "ERROR: invalid platform digest artifact: $digest" >&2 + digest_artifact="$(basename "$digest_file")" + if [[ ! "$digest_artifact" =~ ^(amd64|arm64)-([0-9a-f]{64})$ ]]; then + echo "ERROR: invalid platform digest artifact: $digest_artifact" >&2 exit 1 fi - sources+=("$IMAGE@sha256:$digest") + expected_arch="${BASH_REMATCH[1]}" + digest="${BASH_REMATCH[2]}" + if [ -n "${seen_arches[$expected_arch]:-}" ]; then + echo "ERROR: duplicate platform digest for linux/$expected_arch." >&2 + exit 1 + fi + source="$IMAGE@sha256:$digest" + source_platform="$( + docker buildx imagetools inspect "$source" \ + --format '{{.Image.OS}}/{{.Image.Architecture}}' + )" + if [ "$source_platform" != "linux/$expected_arch" ]; then + echo "ERROR: digest for linux/$expected_arch resolves to $source_platform." >&2 + exit 1 + fi + seen_arches["$expected_arch"]=1 + sources+=("$source") done + if [ "${seen_arches[amd64]:-0}" -ne 1 ] || [ "${seen_arches[arm64]:-0}" -ne 1 ]; then + echo "ERROR: expected one validated digest for linux/amd64 and linux/arm64." >&2 + exit 1 + fi mapfile -t tags <<< "$TAGS" tag_args=() @@ -617,15 +674,36 @@ jobs: exit 1 fi + declare -A seen_arches=() sources=() for digest_file in "${digest_files[@]}"; do - digest="$(basename "$digest_file")" - if [[ ! "$digest" =~ ^[0-9a-f]{64}$ ]]; then - echo "ERROR: invalid platform digest artifact: $digest" >&2 + digest_artifact="$(basename "$digest_file")" + if [[ ! "$digest_artifact" =~ ^(amd64|arm64)-([0-9a-f]{64})$ ]]; then + echo "ERROR: invalid platform digest artifact: $digest_artifact" >&2 exit 1 fi - sources+=("$IMAGE@sha256:$digest") + expected_arch="${BASH_REMATCH[1]}" + digest="${BASH_REMATCH[2]}" + if [ -n "${seen_arches[$expected_arch]:-}" ]; then + echo "ERROR: duplicate platform digest for linux/$expected_arch." >&2 + exit 1 + fi + source="$IMAGE@sha256:$digest" + source_platform="$( + docker buildx imagetools inspect "$source" \ + --format '{{.Image.OS}}/{{.Image.Architecture}}' + )" + if [ "$source_platform" != "linux/$expected_arch" ]; then + echo "ERROR: digest for linux/$expected_arch resolves to $source_platform." >&2 + exit 1 + fi + seen_arches["$expected_arch"]=1 + sources+=("$source") done + if [ "${seen_arches[amd64]:-0}" -ne 1 ] || [ "${seen_arches[arm64]:-0}" -ne 1 ]; then + echo "ERROR: expected one validated digest for linux/amd64 and linux/arm64." >&2 + exit 1 + fi mapfile -t tags <<< "$TAGS" tag_args=() diff --git a/test/dcode-base-image-workflow.test.ts b/test/dcode-base-image-workflow.test.ts index 890daaca832..3418e53e510 100644 --- a/test/dcode-base-image-workflow.test.ts +++ b/test/dcode-base-image-workflow.test.ts @@ -418,7 +418,9 @@ describe("base-image publication behavior", () => { expect(renderMatrixValue(build.with?.["cache-to"], matrix)).toContain( `${cacheSuffix},mode=max`, ); + expect(digestExport?.env?.ARCH).toBe("${{ matrix.arch }}"); expect(digestExport?.run).toContain("^sha256:[0-9a-f]{64}$"); + expect(digestExport?.run).toContain('touch "$RUNNER_TEMP/digests/${ARCH}-${DIGEST#sha256:}"'); expect(digestUpload?.with?.name).toBe("openclaw-base-digest-${{ matrix.arch }}"); for (const step of steps.filter((step) => step.uses)) { expect(step.uses, `${matrix.arch}: ${step.name}`).toMatch(FULL_SHA_ACTION); @@ -444,11 +446,21 @@ describe("base-image publication behavior", () => { expect(metadata?.with?.tags).toContain("type=raw,value=latest"); expect(metadata?.with?.tags).toContain("type=ref,event=tag"); expect(metadata?.with?.tags).toContain("type=sha,prefix=,format=short"); - expect(createManifest?.run).toContain('"${#digest_files[@]}" -ne 2'); - expect(createManifest?.run).toContain( + const openClawManifestScript = createManifest?.run ?? ""; + expect(openClawManifestScript).toContain('"${#digest_files[@]}" -ne 2'); + expect(openClawManifestScript).toContain("^(amd64|arm64)-([0-9a-f]{64})$"); + expect(openClawManifestScript).toContain("--format '{{.Image.OS}}/{{.Image.Architecture}}'"); + expect(openClawManifestScript).toContain( + 'if [ "$source_platform" != "linux/$expected_arch" ]; then', + ); + expect(openClawManifestScript).toContain("duplicate platform digest"); + expect(openClawManifestScript.indexOf("source_platform=")).toBeLessThan( + openClawManifestScript.indexOf("docker buildx imagetools create"), + ); + expect(openClawManifestScript).toContain( 'docker buildx imagetools create "${tag_args[@]}" "${sources[@]}"', ); - expect(createManifest?.run).toContain('"amd64,arm64"'); + expect(openClawManifestScript).toContain('"amd64,arm64"'); for (const step of (manifestJob?.steps ?? []).filter((step) => step.uses)) { expect(step.uses, step.name).toMatch(FULL_SHA_ACTION); } @@ -515,11 +527,14 @@ describe("base-image publication behavior", () => { for (const { job, build, matrix } of publishers) { const steps = job.steps ?? []; + const digestExport = steps.find((step) => step.name === "Export platform digest"); const digestUpload = steps.find((step) => step.name === "Upload platform digest"); expect(steps.some((step) => step.uses?.startsWith("docker/setup-qemu-action@"))).toBe(false); expect(build.with?.platforms).toBe("${{ matrix.platform }}"); expect(build.with?.outputs).toBe(PLATFORM_DIGEST_OUTPUT); + expect(digestExport?.env?.ARCH).toBe("${{ matrix.arch }}"); + expect(digestExport?.run).toContain('touch "$RUNNER_TEMP/digests/${ARCH}-${DIGEST#sha256:}"'); expect(digestUpload?.with?.name).toBe("${{ matrix.agent }}-base-digest-${{ matrix.arch }}"); expect(renderMatrixValue(digestUpload?.with?.name, matrix)).toBe( `${matrix.agent}-base-digest-${matrix.arch}`, @@ -550,11 +565,19 @@ describe("base-image publication behavior", () => { expect(metadata?.with?.tags).toContain("type=ref,event=tag"); expect(metadata?.with?.tags).toContain("type=sha,prefix=,format=short"); expect(createManifest?.env?.IMAGE).toBe(imagePublisher.image); - expect(createManifest?.run).toContain('"${#digest_files[@]}" -ne 2'); - expect(createManifest?.run).toContain( + const manifestScript = createManifest?.run ?? ""; + expect(manifestScript).toContain('"${#digest_files[@]}" -ne 2'); + expect(manifestScript).toContain("^(amd64|arm64)-([0-9a-f]{64})$"); + expect(manifestScript).toContain("--format '{{.Image.OS}}/{{.Image.Architecture}}'"); + expect(manifestScript).toContain('if [ "$source_platform" != "linux/$expected_arch" ]; then'); + expect(manifestScript).toContain("duplicate platform digest"); + expect(manifestScript.indexOf("source_platform=")).toBeLessThan( + manifestScript.indexOf("docker buildx imagetools create"), + ); + expect(manifestScript).toContain( 'docker buildx imagetools create "${tag_args[@]}" "${sources[@]}"', ); - expect(createManifest?.run).toContain('"amd64,arm64"'); + expect(manifestScript).toContain('"amd64,arm64"'); for (const step of (manifestJob?.steps ?? []).filter((step) => step.uses)) { expect(step.uses, step.name).toMatch(FULL_SHA_ACTION); } From 96f627b6a5716172fa1cafac0dd420bddcbd3e20 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 29 Jul 2026 17:31:10 -0700 Subject: [PATCH 4/7] fix(ci): validate Perl image remediation Signed-off-by: Senthil Ravichandran --- Dockerfile | 5 ++++ agents/hermes/Dockerfile | 5 ++++ agents/langchain-deepagents-code/Dockerfile | 5 ++++ scripts/check-dcode-profile-import-gate.sh | 7 +++--- .../security/build-perl-security-packages.sh | 3 ++- test/helpers/base-apt-security-functions.ts | 19 +++++++++++++- test/hermes-share-mount-deps.test.ts | 3 ++- ...deepagents-code-profile-build-gate.test.ts | 5 +++- test/perl-critical-cve-remediation.test.ts | 2 ++ test/sandbox-base-security-packages.test.ts | 25 +++++++++++++------ 10 files changed, 65 insertions(+), 14 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2a52642546b..d78fbe53529 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1740,6 +1740,8 @@ RUN set -eu; \ "vim-tiny=2:9.2.0782-1" \ "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1" \ "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1" \ + "perl-base=5.44.0-1nemoclaw1" \ + "perl=5.44.0-1nemoclaw1" \ | cmp -s - "$security_inventory"; \ test "$(dpkg-query -W -f='${Version}' libexpat1)" = "2.8.2-1"; \ test "$(dpkg-query -W -f='${Version}' libonig5)" = "6.9.9-1+b1"; \ @@ -1749,6 +1751,9 @@ RUN set -eu; \ test "$(dpkg-query -W -f='${Version}' vim-tiny)" = "2:9.2.0782-1"; \ test "$(dpkg-query -W -f='${Version}' libssh2-1t64)" = "1.11.1-1+deb13u1+nemoclaw1"; \ test "$(dpkg-query -W -f='${Version}' nemoclaw-python3.13-htmlparser-fix)" = "3.13.5-2+deb13u4+nemoclaw1"; \ + test "$(dpkg-query -W -f='${Version}' perl-base)" = "5.44.0-1nemoclaw1"; \ + test "$(dpkg-query -W -f='${Version}' perl)" = "5.44.0-1nemoclaw1"; \ + test "$(perl -e 'print $^V')" = "v5.44.0"; \ ldd /usr/bin/jq | grep -Eq 'libonig[.]so[.]5'; \ test "$(jq --version)" = "jq-1.8.2"; \ printf '%s\n' '{"sandbox":"healthy"}' | jq -e '.sandbox == "healthy"' >/dev/null; \ diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 843545fff2f..2f5865aa71d 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -1188,6 +1188,8 @@ RUN set -eu; \ "vim-tiny=2:9.2.0782-1" \ "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1" \ "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1" \ + "perl-base=5.44.0-1nemoclaw1" \ + "perl=5.44.0-1nemoclaw1" \ | cmp -s - "$security_inventory"; \ test "$(dpkg-query -W -f='${Version}' libexpat1)" = "2.8.2-1"; \ test "$(dpkg-query -W -f='${Version}' libonig5)" = "6.9.9-1+b1"; \ @@ -1197,6 +1199,9 @@ RUN set -eu; \ test "$(dpkg-query -W -f='${Version}' vim-tiny)" = "2:9.2.0782-1"; \ test "$(dpkg-query -W -f='${Version}' libssh2-1t64)" = "1.11.1-1+deb13u1+nemoclaw1"; \ test "$(dpkg-query -W -f='${Version}' nemoclaw-python3.13-htmlparser-fix)" = "3.13.5-2+deb13u4+nemoclaw1"; \ + test "$(dpkg-query -W -f='${Version}' perl-base)" = "5.44.0-1nemoclaw1"; \ + test "$(dpkg-query -W -f='${Version}' perl)" = "5.44.0-1nemoclaw1"; \ + test "$(perl -e 'print $^V')" = "v5.44.0"; \ ldd /usr/bin/jq | grep -Eq 'libonig[.]so[.]5'; \ test "$(jq --version)" = "jq-1.8.2"; \ printf '%s\n' '{"sandbox":"healthy"}' | jq -e '.sandbox == "healthy"' >/dev/null; \ diff --git a/agents/langchain-deepagents-code/Dockerfile b/agents/langchain-deepagents-code/Dockerfile index 73147ad3c92..d0041f29ad9 100644 --- a/agents/langchain-deepagents-code/Dockerfile +++ b/agents/langchain-deepagents-code/Dockerfile @@ -239,6 +239,8 @@ RUN set -eu; \ "vim-tiny=2:9.2.0782-1" \ "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1" \ "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1" \ + "perl-base=5.44.0-1nemoclaw1" \ + "perl=5.44.0-1nemoclaw1" \ | cmp -s - "$security_inventory"; \ test "$(dpkg-query -W -f='${Version}' libexpat1)" = "2.8.2-1"; \ test "$(dpkg-query -W -f='${Version}' libonig5)" = "6.9.9-1+b1"; \ @@ -248,6 +250,9 @@ RUN set -eu; \ test "$(dpkg-query -W -f='${Version}' vim-tiny)" = "2:9.2.0782-1"; \ test "$(dpkg-query -W -f='${Version}' libssh2-1t64)" = "1.11.1-1+deb13u1+nemoclaw1"; \ test "$(dpkg-query -W -f='${Version}' nemoclaw-python3.13-htmlparser-fix)" = "3.13.5-2+deb13u4+nemoclaw1"; \ + test "$(dpkg-query -W -f='${Version}' perl-base)" = "5.44.0-1nemoclaw1"; \ + test "$(dpkg-query -W -f='${Version}' perl)" = "5.44.0-1nemoclaw1"; \ + test "$(perl -e 'print $^V')" = "v5.44.0"; \ ldd /usr/bin/jq | grep -Eq 'libonig[.]so[.]5'; \ test "$(jq --version)" = "jq-1.8.2"; \ printf '%s\n' '{"sandbox":"healthy"}' | jq -e '.sandbox == "healthy"' >/dev/null; \ diff --git a/scripts/check-dcode-profile-import-gate.sh b/scripts/check-dcode-profile-import-gate.sh index b65b31b8be9..dced3db25de 100755 --- a/scripts/check-dcode-profile-import-gate.sh +++ b/scripts/check-dcode-profile-import-gate.sh @@ -30,15 +30,16 @@ cd "${repo_root}" # test/Dockerfile.dcode-profile-missing-dependencies, and # agents/langchain-deepagents-code/Dockerfile. Those three reviewed Dockerfiles # contain no secret-bearing ARGs. NEMOCLAW_CORPORATE_CA_B64 carries public trust -# anchors and is not passed to this gate. Only BASE_IMAGE is passed via -# --build-arg, always as a public, non-secret image reference. +# anchors, and the PERL_* arguments pin public source and package identities. +# None are passed to this gate. Only BASE_IMAGE is passed via --build-arg, +# always as a public, non-secret image reference. for dockerfile in \ agents/langchain-deepagents-code/Dockerfile.base \ test/Dockerfile.dcode-profile-missing-dependencies \ agents/langchain-deepagents-code/Dockerfile; do while IFS= read -r arg_name; do case "${arg_name}" in - BASE_IMAGE | NEMOCLAW_CORPORATE_CA_B64 | NEMOCLAW_MODEL | NEMOCLAW_INFERENCE_PROVIDER_ID | NEMOCLAW_PROVIDER_KEY | NEMOCLAW_UPSTREAM_PROVIDER | NEMOCLAW_UPSTREAM_ENDPOINT_URL | NEMOCLAW_INFERENCE_BASE_URL | NEMOCLAW_INFERENCE_API | NEMOCLAW_TOOL_DISCLOSURE | NEMOCLAW_DCODE_AUTO_APPROVAL | NEMOCLAW_BUILD_ID | NEMOCLAW_DARWIN_VM_COMPAT | NEMOCLAW_PROXY_HOST | NEMOCLAW_PROXY_PORT) ;; + BASE_IMAGE | NEMOCLAW_CORPORATE_CA_B64 | NEMOCLAW_MODEL | NEMOCLAW_INFERENCE_PROVIDER_ID | NEMOCLAW_PROVIDER_KEY | NEMOCLAW_UPSTREAM_PROVIDER | NEMOCLAW_UPSTREAM_ENDPOINT_URL | NEMOCLAW_INFERENCE_BASE_URL | NEMOCLAW_INFERENCE_API | NEMOCLAW_TOOL_DISCLOSURE | NEMOCLAW_DCODE_AUTO_APPROVAL | NEMOCLAW_BUILD_ID | NEMOCLAW_DARWIN_VM_COMPAT | NEMOCLAW_PROXY_HOST | NEMOCLAW_PROXY_PORT | PERL_VERSION | PERL_SHA256 | PERL_PACKAGE_REVISION) ;; *) echo "ERROR: plain-progress build refuses unreviewed ARG ${arg_name} in ${dockerfile}" >&2 exit 1 diff --git a/scripts/security/build-perl-security-packages.sh b/scripts/security/build-perl-security-packages.sh index ae479b92fee..fbc4b4ea9cb 100755 --- a/scripts/security/build-perl-security-packages.sh +++ b/scripts/security/build-perl-security-packages.sh @@ -77,9 +77,10 @@ tar -xJf "${source_archive}" -C "${source_dir}" --strip-components=1 cmp \ "${build_root}/perl-tests-full.sorted" \ "${build_root}/perl-tests-combined.sorted" + # harness -dumptests reports paths from the source root and removes ../. test "$( grep -Fxc \ - '../cpan/ExtUtils-Constant/t/Constant.t' \ + 'cpan/ExtUtils-Constant/t/Constant.t' \ "${build_root}/perl-tests-combined.sorted" )" -eq 1 # Perl's test_harness runs the same upstream suite while TEST_JOBS lets its TAP diff --git a/test/helpers/base-apt-security-functions.ts b/test/helpers/base-apt-security-functions.ts index 31c64485a77..cfede8ee924 100644 --- a/test/helpers/base-apt-security-functions.ts +++ b/test/helpers/base-apt-security-functions.ts @@ -100,7 +100,8 @@ export function baseAptSecurityFunctions(architecture: DebianArchitecture): stri ' libexpat1) printf "2.8.2-1" ;;', ' libonig5) printf "6.9.9-1+b1" ;;', ' libjq1|jq) printf "1.8.2-1" ;;', - ' perl) printf "5.40.1-6" ;;', + ' perl-base) [[ "${perl_packages_installed:-0}" == "1" ]] || return 64; printf "5.44.0-1nemoclaw1" ;;', + ' perl) if [[ "${perl_packages_installed:-0}" == "1" ]]; then printf "5.44.0-1nemoclaw1"; else printf "5.40.1-6"; fi ;;', ' vim-common|vim-tiny) printf "2:9.2.0782-1" ;;', ' libssh2-1t64) printf "1.11.1-1+deb13u1+nemoclaw1" ;;', ' nemoclaw-python3.13-htmlparser-fix) printf "3.13.5-2+deb13u4+nemoclaw1" ;;', @@ -184,6 +185,22 @@ export function baseAptSecurityFunctions(architecture: DebianArchitecture): stri ' printf "VIM - Vi IMproved 9.2 (2024 Jan 2)\\n"', "}", ].join("\n"), + [ + "perl() {", + ' [[ "${perl_packages_installed:-0}" == "1" ]] || return 64', + ' case "$*" in', + ' "-e print \\$^V") printf "v5.44.0" ;;', + ' "-MSocket -e print Socket-"*) printf "2.041" ;;', + ' "-MStorable -e print Storable-"*) printf "3.41" ;;', + ' "-MHTTP::Tiny -e print HTTP::Tiny-"*) printf "0.096" ;;', + ' "-MIO::Compress::Base -e print IO::Compress::Base-"*) printf "2.223" ;;', + ' "-MIO::Uncompress::Unzip -e print IO::Uncompress::Unzip-"*) printf "2.223" ;;', + ' "-MFile::GlobMapper -e print File::GlobMapper-"*) printf "1.001" ;;', + ' "-MSocket=pack_ip_mreq_source -e "*|"-e my \\$x = join "*) ;;', + " *) return 64 ;;", + " esac", + "}", + ].join("\n"), ]; } diff --git a/test/hermes-share-mount-deps.test.ts b/test/hermes-share-mount-deps.test.ts index fea8d0fc96f..1c9f1b96ae2 100644 --- a/test/hermes-share-mount-deps.test.ts +++ b/test/hermes-share-mount-deps.test.ts @@ -78,7 +78,8 @@ function runLoggedShell(command: string, tmp: string, functionDefs: string[] = [ "#!/usr/bin/env bash", "set -euo pipefail", `call_log=${JSON.stringify(logPath)}`, - 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; }', + "perl_packages_installed=0", + 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; [[ "$*" != *"/perl-base.deb"* ]] || perl_packages_installed=1; }', ...functionDefs, command, ].join("\n"); diff --git a/test/langchain-deepagents-code-profile-build-gate.test.ts b/test/langchain-deepagents-code-profile-build-gate.test.ts index d890231c63c..8246b9eea91 100644 --- a/test/langchain-deepagents-code-profile-build-gate.test.ts +++ b/test/langchain-deepagents-code-profile-build-gate.test.ts @@ -108,7 +108,10 @@ describe("LangChain Deep Agents Code profile build gate", () => { it.each([ "NEMOCLAW_CORPORATE_CA_B64", "NEMOCLAW_UPSTREAM_ENDPOINT_URL", - ])("accepts %s as a reviewed source-gate ARG (#6901)", (reviewedArg) => { + "PERL_VERSION", + "PERL_SHA256", + "PERL_PACKAGE_REVISION", + ])("accepts %s as a reviewed public build ARG", (reviewedArg) => { const result = runGateWithFakeDocker("expected-failure-with-marker", (fixtureRoot) => fs.appendFileSync(path.join(fixtureRoot, reviewedDockerfiles[0]), `\nARG ${reviewedArg}\n`), ); diff --git a/test/perl-critical-cve-remediation.test.ts b/test/perl-critical-cve-remediation.test.ts index e2ea56f96aa..09cc6b58a11 100644 --- a/test/perl-critical-cve-remediation.test.ts +++ b/test/perl-critical-cve-remediation.test.ts @@ -155,6 +155,8 @@ describe("managed base-image Perl CVE remediation", () => { ); expect(packageBuilder).toContain("consecutive amd64 and arm64 base-image builds"); expect(packageBuilder).toContain("'../cpan/ExtUtils-Constant/t/Constant.t'"); + expect(packageBuilder).toContain("'cpan/ExtUtils-Constant/t/Constant.t'"); + expect(packageBuilder).toContain("harness -dumptests reports paths from the source root"); expect(packageBuilder).not.toMatch(/\bmake\s+(?:-j[^\n]+\s+)?test(?:\s|\\|$)/m); }); diff --git a/test/sandbox-base-security-packages.test.ts b/test/sandbox-base-security-packages.test.ts index 3c286da244b..50d145aaa20 100644 --- a/test/sandbox-base-security-packages.test.ts +++ b/test/sandbox-base-security-packages.test.ts @@ -82,8 +82,11 @@ function sandboxSecurityCommand( return { command, inventory, debianSecurityDebs, nativeSecurityDebs, pythonShim }; } -function securityInventory(architecture: (typeof ARCHITECTURES)[number]): string { - return [ +function securityInventory( + architecture: (typeof ARCHITECTURES)[number], + includePerl = true, +): string { + const inventory = [ `architecture=${architecture}`, "libexpat1=2.8.2-1", "libonig5=6.9.9-1+b1", @@ -93,8 +96,11 @@ function securityInventory(architecture: (typeof ARCHITECTURES)[number]): string "vim-tiny=2:9.2.0782-1", "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1", "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1", - "", - ].join("\n"); + ]; + if (includePerl) { + inventory.push("perl-base=5.44.0-1nemoclaw1", "perl=5.44.0-1nemoclaw1"); + } + return `${inventory.join("\n")}\n`; } function completedImageSecurityCommand( @@ -125,7 +131,8 @@ describe("sandbox base security packages", () => { try { const result = runLoggedDockerShell(prepared.command, tmp, [ - 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; }', + "perl_packages_installed=0", + 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; [[ "$*" != *"/perl-base.deb"* ]] || perl_packages_installed=1; }', 'install() { [[ "$#" -eq 8 && "$1" == "-d" && "$2" == "-o" && "$3" == "root" && "$4" == "-g" && "$5" == "root" && "$6" == "-m" && "$7" == "0755" ]] || return 64; mkdir -p "$8"; }', 'chown() { [[ "$#" -eq 2 && "$1" == "root:root" ]] || return 64; }', ...useRealPatchedParser(baseAptSecurityFunctions(architecture), prepared.pythonShim), @@ -133,8 +140,11 @@ describe("sandbox base security packages", () => { expect({ status: result.status, stderr: result.stderr }).toEqual({ status: 0, stderr: "" }); const calls = fs.readFileSync(path.join(tmp, "calls.log"), "utf-8"); expect(calls).toContain("dpkg-install"); - expect(fs.readFileSync(prepared.inventory, "utf-8")).toBe(securityInventory(architecture)); - expect(fs.statSync(prepared.inventory).mode & 0o777).toBe(0o444); + const includesPerl = image.name !== "OpenClaw"; + expect(fs.readFileSync(prepared.inventory, "utf-8")).toBe( + securityInventory(architecture, includesPerl), + ); + expect(fs.statSync(prepared.inventory).mode & 0o777).toBe(includesPerl ? 0o444 : 0o644); expect( calls .split("\n") @@ -164,6 +174,7 @@ describe("sandbox base security packages", () => { try { const result = runLoggedDockerShell(prepared.command, tmp, [ + "perl_packages_installed=1", [ "stat() {", ` [[ "$#" -eq 3 && "$1" == "-c" && "$2" == "%u:%g:%a" && "$3" == ${JSON.stringify(prepared.inventory)} ]] || return 64`, From 4b8c23d32dd821b5897efc92fc826497da89a6b2 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 29 Jul 2026 17:38:02 -0700 Subject: [PATCH 5/7] test(ci): keep inventory fixture linear Signed-off-by: Senthil Ravichandran --- test/sandbox-base-security-packages.test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/test/sandbox-base-security-packages.test.ts b/test/sandbox-base-security-packages.test.ts index 50d145aaa20..cd641ad43d5 100644 --- a/test/sandbox-base-security-packages.test.ts +++ b/test/sandbox-base-security-packages.test.ts @@ -86,7 +86,8 @@ function securityInventory( architecture: (typeof ARCHITECTURES)[number], includePerl = true, ): string { - const inventory = [ + const perlPackages = includePerl ? ["perl-base=5.44.0-1nemoclaw1", "perl=5.44.0-1nemoclaw1"] : []; + return `${[ `architecture=${architecture}`, "libexpat1=2.8.2-1", "libonig5=6.9.9-1+b1", @@ -96,11 +97,8 @@ function securityInventory( "vim-tiny=2:9.2.0782-1", "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1", "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1", - ]; - if (includePerl) { - inventory.push("perl-base=5.44.0-1nemoclaw1", "perl=5.44.0-1nemoclaw1"); - } - return `${inventory.join("\n")}\n`; + ...perlPackages, + ].join("\n")}\n`; } function completedImageSecurityCommand( From 82d498125d23219364bfe0ce9198e8d61173f70e Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 29 Jul 2026 17:52:21 -0700 Subject: [PATCH 6/7] fix(onboard): refresh Dockerfile attestation digest Signed-off-by: Senthil Ravichandran --- src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts index f63bef91cbd..a716a0c07ec 100644 --- a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts +++ b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts @@ -56,7 +56,7 @@ const CANONICAL_POST_GENERATOR_RUN_SHA256 = new Set([ "83567d1fa0e73bef6a3333383c13ace05e26704964ae6a7a76ee24a2f2be3d7e", "ca1f7b1cb9dd5d467f806792c4072a84ef1e6402c3e8650b6325b95cc186ccdf", "7e6a6879382f833f17be02ca7d287685b6afa1c423b1e087b3b05dd677d6e325", - "22406cef76f7a66a3d527c17f2a5bc6a217c71c753c979406fb2c3fa7cd8f0eb", + "4a54da2c1c33c681ae0dad181a5a7456c926051d91420aa60cf7edef6330ba65", ]); function instructionSha256(text: string): string { From 97c5bbb09b4f1111afdab330674f5475a36407bb Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 29 Jul 2026 18:22:26 -0700 Subject: [PATCH 7/7] test(security): model Perl packages independently Signed-off-by: Senthil Ravichandran --- test/helpers/base-apt-security-functions.ts | 6 +-- test/hermes-share-mount-deps.test.ts | 5 ++- test/sandbox-base-security-packages.test.ts | 43 +++++++++++++-------- 3 files changed, 32 insertions(+), 22 deletions(-) diff --git a/test/helpers/base-apt-security-functions.ts b/test/helpers/base-apt-security-functions.ts index cfede8ee924..e19c62c350c 100644 --- a/test/helpers/base-apt-security-functions.ts +++ b/test/helpers/base-apt-security-functions.ts @@ -100,8 +100,8 @@ export function baseAptSecurityFunctions(architecture: DebianArchitecture): stri ' libexpat1) printf "2.8.2-1" ;;', ' libonig5) printf "6.9.9-1+b1" ;;', ' libjq1|jq) printf "1.8.2-1" ;;', - ' perl-base) [[ "${perl_packages_installed:-0}" == "1" ]] || return 64; printf "5.44.0-1nemoclaw1" ;;', - ' perl) if [[ "${perl_packages_installed:-0}" == "1" ]]; then printf "5.44.0-1nemoclaw1"; else printf "5.40.1-6"; fi ;;', + ' perl-base) [[ "${perl_base_installed:-0}" == "1" ]] || return 64; printf "5.44.0-1nemoclaw1" ;;', + ' perl) if [[ "${perl_installed:-0}" == "1" ]]; then printf "5.44.0-1nemoclaw1"; else printf "5.40.1-6"; fi ;;', ' vim-common|vim-tiny) printf "2:9.2.0782-1" ;;', ' libssh2-1t64) printf "1.11.1-1+deb13u1+nemoclaw1" ;;', ' nemoclaw-python3.13-htmlparser-fix) printf "3.13.5-2+deb13u4+nemoclaw1" ;;', @@ -187,7 +187,7 @@ export function baseAptSecurityFunctions(architecture: DebianArchitecture): stri ].join("\n"), [ "perl() {", - ' [[ "${perl_packages_installed:-0}" == "1" ]] || return 64', + ' [[ "${perl_base_installed:-0}" == "1" ]] || return 64', ' case "$*" in', ' "-e print \\$^V") printf "v5.44.0" ;;', ' "-MSocket -e print Socket-"*) printf "2.041" ;;', diff --git a/test/hermes-share-mount-deps.test.ts b/test/hermes-share-mount-deps.test.ts index 1c9f1b96ae2..d7e48062104 100644 --- a/test/hermes-share-mount-deps.test.ts +++ b/test/hermes-share-mount-deps.test.ts @@ -78,8 +78,9 @@ function runLoggedShell(command: string, tmp: string, functionDefs: string[] = [ "#!/usr/bin/env bash", "set -euo pipefail", `call_log=${JSON.stringify(logPath)}`, - "perl_packages_installed=0", - 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; [[ "$*" != *"/perl-base.deb"* ]] || perl_packages_installed=1; }', + "perl_base_installed=0", + "perl_installed=0", + 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; [[ "$*" != *"/perl-base.deb"* ]] || perl_base_installed=1; [[ "$*" != *"/perl.deb"* ]] || perl_installed=1; }', ...functionDefs, command, ].join("\n"); diff --git a/test/sandbox-base-security-packages.test.ts b/test/sandbox-base-security-packages.test.ts index cd641ad43d5..bd1f8c0f8fc 100644 --- a/test/sandbox-base-security-packages.test.ts +++ b/test/sandbox-base-security-packages.test.ts @@ -20,6 +20,8 @@ const SECURITY_IMAGES = [ dockerfile: path.join(ROOT, "Dockerfile.base"), finalDockerfile: path.join(ROOT, "Dockerfile"), startMarker: "# Trixie has not published fixes", + additionalStartMarker: + "RUN apt-get update \\\n && apt-get install -y --no-install-recommends \\\n /tmp/nemoclaw-native-security/perl-base.deb", endMarker: "# gosu for privilege separation", }, { @@ -27,6 +29,7 @@ const SECURITY_IMAGES = [ dockerfile: path.join(ROOT, "agents", "hermes", "Dockerfile.base"), finalDockerfile: path.join(ROOT, "agents", "hermes", "Dockerfile"), startMarker: "# Install the reviewed libexpat, jq, and Vim packages", + additionalStartMarker: null, endMarker: "COPY scripts/lib/reviewed-npm-archive.mts", }, { @@ -34,6 +37,7 @@ const SECURITY_IMAGES = [ dockerfile: path.join(ROOT, "agents", "langchain-deepagents-code", "Dockerfile.base"), finalDockerfile: path.join(ROOT, "agents", "langchain-deepagents-code", "Dockerfile"), startMarker: "# Install the reviewed libexpat, jq, and Vim packages", + additionalStartMarker: null, endMarker: "# Node remains available", }, ] as const; @@ -45,6 +49,7 @@ const SECURITY_CASES = SECURITY_IMAGES.flatMap((image) => function sandboxSecurityCommand( image: (typeof SECURITY_IMAGES)[number], tmp: string, + includeAdditionalLayer = true, ): { command: string; inventory: string; @@ -70,7 +75,15 @@ function sandboxSecurityCommand( ); const dockerfile = fs.readFileSync(image.dockerfile, "utf-8"); - const command = dockerRunCommandBetween(dockerfile, image.startMarker, image.endMarker) + const commands = [ + dockerRunCommandBetween(dockerfile, image.startMarker, image.endMarker), + image.additionalStartMarker === null || !includeAdditionalLayer + ? "" + : dockerRunCommandBetween(dockerfile, image.additionalStartMarker, image.endMarker), + ]; + const command = commands + .filter(Boolean) + .join("\n") .replaceAll("/var/lib/apt/lists", lists) .replaceAll("/tmp/nemoclaw-debian-security", debianSecurityDebs) .replaceAll("/tmp/nemoclaw-native-security", nativeSecurityDebs) @@ -82,11 +95,7 @@ function sandboxSecurityCommand( return { command, inventory, debianSecurityDebs, nativeSecurityDebs, pythonShim }; } -function securityInventory( - architecture: (typeof ARCHITECTURES)[number], - includePerl = true, -): string { - const perlPackages = includePerl ? ["perl-base=5.44.0-1nemoclaw1", "perl=5.44.0-1nemoclaw1"] : []; +function securityInventory(architecture: (typeof ARCHITECTURES)[number]): string { return `${[ `architecture=${architecture}`, "libexpat1=2.8.2-1", @@ -97,7 +106,8 @@ function securityInventory( "vim-tiny=2:9.2.0782-1", "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1", "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1", - ...perlPackages, + "perl-base=5.44.0-1nemoclaw1", + "perl=5.44.0-1nemoclaw1", ].join("\n")}\n`; } @@ -129,8 +139,9 @@ describe("sandbox base security packages", () => { try { const result = runLoggedDockerShell(prepared.command, tmp, [ - "perl_packages_installed=0", - 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; [[ "$*" != *"/perl-base.deb"* ]] || perl_packages_installed=1; }', + "perl_base_installed=0", + "perl_installed=0", + 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; [[ "$*" != *"/perl-base.deb"* ]] || perl_base_installed=1; [[ "$*" != *"/perl.deb"* ]] || perl_installed=1; }', 'install() { [[ "$#" -eq 8 && "$1" == "-d" && "$2" == "-o" && "$3" == "root" && "$4" == "-g" && "$5" == "root" && "$6" == "-m" && "$7" == "0755" ]] || return 64; mkdir -p "$8"; }', 'chown() { [[ "$#" -eq 2 && "$1" == "root:root" ]] || return 64; }', ...useRealPatchedParser(baseAptSecurityFunctions(architecture), prepared.pythonShim), @@ -138,11 +149,8 @@ describe("sandbox base security packages", () => { expect({ status: result.status, stderr: result.stderr }).toEqual({ status: 0, stderr: "" }); const calls = fs.readFileSync(path.join(tmp, "calls.log"), "utf-8"); expect(calls).toContain("dpkg-install"); - const includesPerl = image.name !== "OpenClaw"; - expect(fs.readFileSync(prepared.inventory, "utf-8")).toBe( - securityInventory(architecture, includesPerl), - ); - expect(fs.statSync(prepared.inventory).mode & 0o777).toBe(includesPerl ? 0o444 : 0o644); + expect(fs.readFileSync(prepared.inventory, "utf-8")).toBe(securityInventory(architecture)); + expect(fs.statSync(prepared.inventory).mode & 0o777).toBe(0o444); expect( calls .split("\n") @@ -158,7 +166,7 @@ describe("sandbox base security packages", () => { ]); expect(prepared.debianSecurityDebs).not.toBe(prepared.nativeSecurityDebs); expect(fs.existsSync(prepared.debianSecurityDebs)).toBe(false); - expect(fs.existsSync(prepared.nativeSecurityDebs)).toBe(image.name === "OpenClaw"); + expect(fs.existsSync(prepared.nativeSecurityDebs)).toBe(false); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } @@ -172,7 +180,8 @@ describe("sandbox base security packages", () => { try { const result = runLoggedDockerShell(prepared.command, tmp, [ - "perl_packages_installed=1", + "perl_base_installed=1", + "perl_installed=1", [ "stat() {", ` [[ "$#" -eq 3 && "$1" == "-c" && "$2" == "%u:%g:%a" && "$3" == ${JSON.stringify(prepared.inventory)} ]] || return 64`, @@ -191,7 +200,7 @@ describe("sandbox base security packages", () => { SECURITY_CASES, )("rejects a changed expected checksum before installing packages for %s on %s", (_name, architecture, image) => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-base-checksum-")); - const prepared = sandboxSecurityCommand(image, tmp); + const prepared = sandboxSecurityCommand(image, tmp, false); const command = prepared.command.replace( BASE_APT_SECURITY_HASHES[architecture].libexpat, "0".repeat(64),