diff --git a/.github/workflows/base-image.yaml b/.github/workflows/base-image.yaml index 241c0b3fe8..5c9c915e7d 100644 --- a/.github/workflows/base-image.yaml +++ b/.github/workflows/base-image.yaml @@ -38,6 +38,7 @@ on: - "scripts/lib/openclaw-npm-remediation.mts" - "scripts/lib/reviewed-npm-audit.mts" - "scripts/checks/node-tar-image-scan.mts" + - "scripts/patch-bundled-npm-brace-expansion.mts" - "scripts/patch-bundled-npm-tar.mts" - "scripts/upgrade-bundled-npm.mts" - "scripts/lib/sandbox-rlimits.sh" diff --git a/Dockerfile b/Dockerfile index b7b66580c3..9762b73ef6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,6 +52,7 @@ COPY ci/npm-audit-exceptions.json /scripts/npm-audit-exceptions.json COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts COPY scripts/lib/reviewed-npm-audit.mts /scripts/lib/reviewed-npm-audit.mts COPY scripts/lib/openclaw-npm-remediation.mts /scripts/lib/openclaw-npm-remediation.mts +COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts FROM scratch AS openclaw-plugin-payload @@ -147,6 +148,11 @@ RUN --mount=type=bind,from=openclaw-dependency-payload,source=/,target=/run/nemo RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ --npm-root /usr/local/lib/node_modules/npm +# Reassert the npm-private brace-expansion fix for the exact final filesystem. +# hadolint ignore=DL3059 +RUN node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts \ + --npm-root /usr/local/lib/node_modules/npm + # OpenClaw 2026.7.1 loads some generated source through jiti. Disable its # filesystem transform cache so source fragments that mention provider marker # names do not persist under /tmp/jiti inside the sandbox. @@ -1651,6 +1657,7 @@ RUN check_metadata() { \ exit 1; \ fi; \ } \ + && check_metadata /scripts/patch-bundled-npm-brace-expansion.mts 'root:root:755' \ && check_metadata /scripts/patch-bundled-npm-tar.mts 'root:root:755' \ && check_metadata /opt/nemoclaw/openclaw.plugin.json 'root:root:644' \ && check_metadata /usr/local/lib/nemoclaw/patch-openclaw-tool-catalog.mts 'root:root:755' \ @@ -1727,6 +1734,37 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \ python3 -c 'import pathlib, sys; proc = pathlib.Path(sys.argv[1]); expected = sys.argv[2].encode("ascii"); port = sys.argv[3].encode(); parse = lambda data: (lambda fields: (fields[0], fields[19]))(data.rsplit(b") ", 1)[1].split()); before = parse((proc / "stat").read_bytes()); raw = (proc / "cmdline").read_bytes(); after = parse((proc / "stat").read_bytes()); trimmed = raw.rstrip(b"\0"); padding = len(raw) - len(trimmed); title = padding >= 1 and trimmed in (b"openclaw", b"openclaw-gateway"); argv = raw[:-1].split(b"\0") if padding == 1 else []; interpreters = (b"node", b"nodejs", b"/usr/local/bin/node", b"/usr/local/bin/nodejs", b"/usr/bin/node", b"/usr/bin/nodejs"); launchers = (b"/usr/local/bin/openclaw", b"/usr/local/lib/node_modules/openclaw/openclaw.mjs"); index = 1 if argv and argv[0] in interpreters else 0; command = index < len(argv) and argv[index] in launchers and argv[index + 1:] in ([b"gateway", b"run", b"--port", port], [b"gateway", b"run", b"--port=" + port]); identity = before[1] == expected == after[1] and before[0] != b"Z" and after[0] != b"Z"; raise SystemExit(not (identity and (title or command)))' "/proc/$gwpid" "$gwstart" "$port" 2>/dev/null || exit 1; \ [ -s /tmp/gateway.log ] +# Verify the immutable security package inventory in the completed image. +# hadolint ignore=DL4006 +RUN set -eu; \ + security_inventory=/usr/local/share/nemoclaw/security-packages.txt; \ + arch="$(dpkg --print-architecture)"; \ + test -f "$security_inventory"; \ + test ! -L "$security_inventory"; \ + test "$(stat -c '%u:%g:%a' "$security_inventory")" = "0:0:444"; \ + printf '%s\n' \ + "architecture=$arch" \ + "libexpat1=2.8.2-1" \ + "libonig5=6.9.9-1+b1" \ + "libjq1=1.8.2-1" \ + "jq=1.8.2-1" \ + "vim-common=2:9.2.0782-1" \ + "vim-tiny=2:9.2.0782-1" \ + | 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"; \ + test "$(dpkg-query -W -f='${Version}' libjq1)" = "1.8.2-1"; \ + test "$(dpkg-query -W -f='${Version}' jq)" = "1.8.2-1"; \ + test "$(dpkg-query -W -f='${Version}' vim-common)" = "2:9.2.0782-1"; \ + test "$(dpkg-query -W -f='${Version}' vim-tiny)" = "2:9.2.0782-1"; \ + 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; \ + python3 -c "import pyexpat; assert pyexpat.EXPAT_VERSION == 'expat_2.8.2', pyexpat.EXPAT_VERSION"; \ + vim.tiny --version | head -n 1 | grep -Eq '^VIM - Vi IMproved 9[.]2 '; \ + test -z "$(dpkg --audit)" +# End completed-image security package verification. + # Entrypoint runs as root to start the gateway as the gateway user, # then drops to sandbox for agent commands. See nemoclaw-start.sh. ENTRYPOINT ["/usr/local/bin/nemoclaw-start"] diff --git a/Dockerfile.base b/Dockerfile.base index a4bf30c5e7..93e9f313f9 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -168,11 +168,12 @@ ENV AWS_EC2_METADATA_DISABLED=true ENV DEBIAN_FRONTEND=noninteractive -# Trixie has not published fixes for CVE-2026-45186 (libexpat) or -# CVE-2026-49839 (jq). Install the fixed Debian packages from an immutable -# snapshot, with architecture-specific hashes, until Trixie carries them. +# Trixie has not published fixes for the reviewed libexpat, jq, or Vim +# advisories. Install fixed Debian packages from an immutable snapshot, with +# architecture-specific hashes, until Trixie carries them. # The checksum and runtime probes put the validating command last in each pipe. -# hadolint ignore=DL4006 +# The runtime Vim probe verifies the package that this layer installs. +# hadolint ignore=DL3001,DL4006 RUN apt-get update && apt-get install -y --no-install-recommends \ python3=3.13.5-1 \ python3-pip=25.1.1+dfsg-1 \ @@ -189,31 +190,39 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ e2fsprogs=1.47.2-3+b11 \ "dos2unix=7.5.2-1*" \ libonig5=6.9.9-1+b1 \ - vim-tiny=2:9.1.1230-2 \ openssh-sftp-server=1:10.0p1-7+deb13u4 \ tmux=3.5a-3 \ && arch="$(dpkg --print-architecture)" \ && case "$arch" in \ amd64) \ libexpat_sha256="37d24b40a745107941f823d1f22c38f197f01981f7f0783777fe0026af016463"; \ + libonig_sha256="3abee130696244050500bcc7870e3b4cb82ddd87149ece3fd55010c3d4e1d18c"; \ libjq_sha256="9a5bf964cef39ed8f0f162e20d856e31961d28a57772b5313989b42a8be7e941"; \ - jq_sha256="b973a5d304f666845e8ccefab492e3850d4bc2e7aa2a1e7450862095125f2cc0" \ + jq_sha256="b973a5d304f666845e8ccefab492e3850d4bc2e7aa2a1e7450862095125f2cc0"; \ + vim_tiny_sha256="0e6e231d6d2430a92cf76f8a78506090418fa37758c33b31ed50dfbfc76e22ed" \ ;; \ arm64) \ libexpat_sha256="df928e3a8e4da79408d4b18e8cd80a03dffa90130d0698e50041aab5e14f9397"; \ + libonig_sha256="137e708575c0622d347815d19cb471a107546b16e9602805ee27afad7bba107f"; \ libjq_sha256="eae4a828df2eb53d728f88109d9f9549e0983a90b573cf0c7fa1e4bbc7533a7e"; \ - jq_sha256="c25086443abd04d1457cbb322a0837f9ba986f82b28f44670467c8dc9be1f696" \ + jq_sha256="c25086443abd04d1457cbb322a0837f9ba986f82b28f44670467c8dc9be1f696"; \ + vim_tiny_sha256="be30f7e9de0b872bec0128ccd890452c0e0e29d99017d16c0f3aa74164f6700d" \ ;; \ *) echo "Unsupported architecture for Debian security packages: $arch" >&2; exit 1 ;; \ esac \ && perl_version_before="$(dpkg-query -W -f='${Version}' perl)" \ && security_deb_dir="/tmp/nemoclaw-debian-security" \ && snapshot_url="https://snapshot.debian.org/archive/debian/20260724T000000Z/pool/main" \ + && vim_common_sha256="6b063038246492c4a20e0a212c896dde4d5aa9f59d6fb43ff33d10080bc53a39" \ && mkdir -p "$security_deb_dir" \ && curl --proto '=https' --tlsv1.2 -fsSL \ --retry 5 --retry-all-errors --retry-delay 2 --connect-timeout 15 --max-time 120 \ -o "$security_deb_dir/libexpat1.deb" \ "$snapshot_url/e/expat/libexpat1_2.8.2-1_${arch}.deb" \ + && curl --proto '=https' --tlsv1.2 -fsSL \ + --retry 5 --retry-all-errors --retry-delay 2 --connect-timeout 15 --max-time 120 \ + -o "$security_deb_dir/libonig5.deb" \ + "$snapshot_url/libo/libonig/libonig5_6.9.9-1+b1_${arch}.deb" \ && curl --proto '=https' --tlsv1.2 -fsSL \ --retry 5 --retry-all-errors --retry-delay 2 --connect-timeout 15 --max-time 120 \ -o "$security_deb_dir/libjq1.deb" \ @@ -222,22 +231,53 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ --retry 5 --retry-all-errors --retry-delay 2 --connect-timeout 15 --max-time 120 \ -o "$security_deb_dir/jq.deb" \ "$snapshot_url/j/jq/jq_1.8.2-1_${arch}.deb" \ + && curl --proto '=https' --tlsv1.2 -fsSL \ + --retry 5 --retry-all-errors --retry-delay 2 --connect-timeout 15 --max-time 120 \ + -o "$security_deb_dir/vim-common.deb" \ + "$snapshot_url/v/vim/vim-common_9.2.0782-1_all.deb" \ + && curl --proto '=https' --tlsv1.2 -fsSL \ + --retry 5 --retry-all-errors --retry-delay 2 --connect-timeout 15 --max-time 120 \ + -o "$security_deb_dir/vim-tiny.deb" \ + "$snapshot_url/v/vim/vim-tiny_9.2.0782-1_${arch}.deb" \ && printf '%s %s\n' \ "$libexpat_sha256" "$security_deb_dir/libexpat1.deb" \ + "$libonig_sha256" "$security_deb_dir/libonig5.deb" \ "$libjq_sha256" "$security_deb_dir/libjq1.deb" \ "$jq_sha256" "$security_deb_dir/jq.deb" \ + "$vim_common_sha256" "$security_deb_dir/vim-common.deb" \ + "$vim_tiny_sha256" "$security_deb_dir/vim-tiny.deb" \ | sha256sum -c - \ && dpkg -i \ "$security_deb_dir/libexpat1.deb" \ + "$security_deb_dir/libonig5.deb" \ "$security_deb_dir/libjq1.deb" \ "$security_deb_dir/jq.deb" \ + "$security_deb_dir/vim-common.deb" \ + "$security_deb_dir/vim-tiny.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" \ && test "$(dpkg-query -W -f='${Version}' jq)" = "1.8.2-1" \ + && test "$(dpkg-query -W -f='${Version}' vim-common)" = "2:9.2.0782-1" \ + && test "$(dpkg-query -W -f='${Version}' vim-tiny)" = "2:9.2.0782-1" \ && test "$(dpkg-query -W -f='${Version}' perl)" = "$perl_version_before" \ && test "$(jq --version)" = "jq-1.8.2" \ + && ldd /usr/bin/jq | grep -Eq 'libonig[.]so[.]5' \ && printf '%s\n' '{"sandbox":"healthy"}' | jq -e '.sandbox == "healthy"' >/dev/null \ && python3 -c "import pyexpat; assert pyexpat.EXPAT_VERSION == 'expat_2.8.2', pyexpat.EXPAT_VERSION" \ + && vim.tiny --version | head -n 1 | grep -Eq '^VIM - Vi IMproved 9[.]2 ' \ + && install -d -o root -g root -m 0755 /usr/local/share/nemoclaw \ + && printf '%s\n' \ + "architecture=$arch" \ + "libexpat1=2.8.2-1" \ + "libonig5=6.9.9-1+b1" \ + "libjq1=1.8.2-1" \ + "jq=1.8.2-1" \ + "vim-common=2:9.2.0782-1" \ + "vim-tiny=2:9.2.0782-1" \ + > /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 "$security_deb_dir" \ && rm -rf /var/lib/apt/lists/* \ && ln -s /usr/bin/python3 /usr/local/bin/python @@ -251,6 +291,10 @@ RUN apt-get update \ && 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 \ @@ -411,6 +455,7 @@ COPY ci/npm-audit-exceptions.json /scripts/npm-audit-exceptions.json COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts COPY scripts/lib/reviewed-npm-audit.mts /scripts/lib/reviewed-npm-audit.mts COPY scripts/lib/openclaw-npm-remediation.mts /scripts/lib/openclaw-npm-remediation.mts +COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts COPY scripts/upgrade-bundled-npm.mts /scripts/upgrade-bundled-npm.mts @@ -426,6 +471,12 @@ RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ RUN node --experimental-strip-types /scripts/upgrade-bundled-npm.mts \ --npm-root /usr/local/lib/node_modules/npm +# npm 11.18.0 contains brace-expansion 5.0.7. Replace only that private +# package from the reviewed 5.0.8 archive after the complete npm upgrade. +# hadolint ignore=DL3059 +RUN node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts \ + --npm-root /usr/local/lib/node_modules/npm + # Keep OpenClaw's jiti-generated source cache out of /tmp so provider marker # names do not persist in runtime snapshots or leak-scan inputs. ENV JITI_FS_CACHE=false diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 674b957f69..a07734cf40 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -13,6 +13,7 @@ ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@sha256:fa05221f5c7bca FROM scratch AS hermes-npm-patch-payload 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 COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts FROM scratch AS hermes-agent-payload @@ -72,6 +73,11 @@ RUN --mount=type=bind,from=hermes-npm-patch-payload,source=/,target=/run/nemocla RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ --npm-root /usr/local/lib/node_modules/npm +# Reassert the npm-private brace-expansion fix for the exact final filesystem. +# hadolint ignore=DL3059 +RUN node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts \ + --npm-root /usr/local/lib/node_modules/npm + # Keep the final image contract explicit even when the published base image # changes independently of this Dockerfile. RUN set -eu; \ @@ -182,7 +188,7 @@ RUN find /opt/nemoclaw-hermes-config -type d -exec chmod 755 {} + \ && find /opt/nemoclaw-hermes-config -type f -exec chmod 444 {} + \ && chmod 444 /src/lib/tool-disclosure.ts \ && chmod 444 /scripts/lib/reviewed-npm-archive.mts /scripts/lib/openclaw-npm-remediation.mts \ - /scripts/patch-bundled-npm-tar.mts \ + /scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-tar.mts \ && chmod -R a+rX /src/lib/messaging RUN --mount=type=bind,from=hermes-runtime-payload,source=/,target=/run/nemoclaw-payload \ @@ -753,6 +759,7 @@ RUN check_metadata() { \ && check_absent /root/.cache/electron \ && check_absent /root/.cache/node-gyp \ && check_absent /sandbox/.cache \ + && check_metadata /scripts/patch-bundled-npm-brace-expansion.mts 'root:root 444' \ && check_metadata /scripts/patch-bundled-npm-tar.mts 'root:root 444' \ && check_metadata /opt/nemoclaw-hermes-config/generate-config.ts 'root:root 444' \ && check_metadata /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py 'root:root 755' \ @@ -766,6 +773,37 @@ RUN check_metadata() { \ > /usr/local/share/nemoclaw/node-tar-inventory.json \ && chmod 0444 /usr/local/share/nemoclaw/node-tar-inventory.json +# Verify the immutable security package inventory in the completed image. +# hadolint ignore=DL4006 +RUN set -eu; \ + security_inventory=/usr/local/share/nemoclaw/security-packages.txt; \ + arch="$(dpkg --print-architecture)"; \ + test -f "$security_inventory"; \ + test ! -L "$security_inventory"; \ + test "$(stat -c '%u:%g:%a' "$security_inventory")" = "0:0:444"; \ + printf '%s\n' \ + "architecture=$arch" \ + "libexpat1=2.8.2-1" \ + "libonig5=6.9.9-1+b1" \ + "libjq1=1.8.2-1" \ + "jq=1.8.2-1" \ + "vim-common=2:9.2.0782-1" \ + "vim-tiny=2:9.2.0782-1" \ + | 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"; \ + test "$(dpkg-query -W -f='${Version}' libjq1)" = "1.8.2-1"; \ + test "$(dpkg-query -W -f='${Version}' jq)" = "1.8.2-1"; \ + test "$(dpkg-query -W -f='${Version}' vim-common)" = "2:9.2.0782-1"; \ + test "$(dpkg-query -W -f='${Version}' vim-tiny)" = "2:9.2.0782-1"; \ + 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; \ + python3 -c "import pyexpat; assert pyexpat.EXPAT_VERSION == 'expat_2.8.2', pyexpat.EXPAT_VERSION"; \ + vim.tiny --version | head -n 1 | grep -Eq '^VIM - Vi IMproved 9[.]2 '; \ + test -z "$(dpkg --audit)" +# End completed-image security package verification. + # start.sh handles privilege separation: runs as root initially, then drops # to 'gateway' user via gosu for the agent process. See start.sh. ENTRYPOINT ["/usr/local/bin/nemoclaw-start"] diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index 971ffbbc4b..40d5a08311 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -40,6 +40,9 @@ ARG UV_VERSION=0.11.8 # build-essential: hermes-agent >= 0.16.0 ships npm dependencies that need a # node-gyp native build during `npm ci`; the runtime Dockerfile purges build # tools again after install. +# Install the reviewed libexpat, jq, and Vim packages from the same immutable, +# checksum-bound Debian snapshot used by the OpenClaw base image. +# hadolint ignore=DL3001,DL4006 RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential=12.12 \ python3=3.13.5-1 \ @@ -58,11 +61,95 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ openssh-sftp-server=1:10.0p1-7+deb13u4 \ socat=1.8.0.3-1 \ "dos2unix=7.5.2-1*" \ - jq=1.7.1-6+deb13u2 \ - vim-tiny=2:9.1.1230-2 \ + && arch="$(dpkg --print-architecture)" \ + && case "$arch" in \ + amd64) \ + libexpat_sha256="37d24b40a745107941f823d1f22c38f197f01981f7f0783777fe0026af016463"; \ + libonig_sha256="3abee130696244050500bcc7870e3b4cb82ddd87149ece3fd55010c3d4e1d18c"; \ + libjq_sha256="9a5bf964cef39ed8f0f162e20d856e31961d28a57772b5313989b42a8be7e941"; \ + jq_sha256="b973a5d304f666845e8ccefab492e3850d4bc2e7aa2a1e7450862095125f2cc0"; \ + vim_tiny_sha256="0e6e231d6d2430a92cf76f8a78506090418fa37758c33b31ed50dfbfc76e22ed" \ + ;; \ + arm64) \ + libexpat_sha256="df928e3a8e4da79408d4b18e8cd80a03dffa90130d0698e50041aab5e14f9397"; \ + libonig_sha256="137e708575c0622d347815d19cb471a107546b16e9602805ee27afad7bba107f"; \ + libjq_sha256="eae4a828df2eb53d728f88109d9f9549e0983a90b573cf0c7fa1e4bbc7533a7e"; \ + jq_sha256="c25086443abd04d1457cbb322a0837f9ba986f82b28f44670467c8dc9be1f696"; \ + vim_tiny_sha256="be30f7e9de0b872bec0128ccd890452c0e0e29d99017d16c0f3aa74164f6700d" \ + ;; \ + *) echo "Unsupported architecture for Debian security packages: $arch" >&2; exit 1 ;; \ + esac \ + && security_deb_dir="/tmp/nemoclaw-debian-security" \ + && snapshot_url="https://snapshot.debian.org/archive/debian/20260724T000000Z/pool/main" \ + && vim_common_sha256="6b063038246492c4a20e0a212c896dde4d5aa9f59d6fb43ff33d10080bc53a39" \ + && mkdir -p "$security_deb_dir" \ + && curl --proto '=https' --tlsv1.2 -fsSL \ + --retry 5 --retry-all-errors --retry-delay 2 --connect-timeout 15 --max-time 120 \ + -o "$security_deb_dir/libexpat1.deb" \ + "$snapshot_url/e/expat/libexpat1_2.8.2-1_${arch}.deb" \ + && curl --proto '=https' --tlsv1.2 -fsSL \ + --retry 5 --retry-all-errors --retry-delay 2 --connect-timeout 15 --max-time 120 \ + -o "$security_deb_dir/libonig5.deb" \ + "$snapshot_url/libo/libonig/libonig5_6.9.9-1+b1_${arch}.deb" \ + && curl --proto '=https' --tlsv1.2 -fsSL \ + --retry 5 --retry-all-errors --retry-delay 2 --connect-timeout 15 --max-time 120 \ + -o "$security_deb_dir/libjq1.deb" \ + "$snapshot_url/j/jq/libjq1_1.8.2-1_${arch}.deb" \ + && curl --proto '=https' --tlsv1.2 -fsSL \ + --retry 5 --retry-all-errors --retry-delay 2 --connect-timeout 15 --max-time 120 \ + -o "$security_deb_dir/jq.deb" \ + "$snapshot_url/j/jq/jq_1.8.2-1_${arch}.deb" \ + && curl --proto '=https' --tlsv1.2 -fsSL \ + --retry 5 --retry-all-errors --retry-delay 2 --connect-timeout 15 --max-time 120 \ + -o "$security_deb_dir/vim-common.deb" \ + "$snapshot_url/v/vim/vim-common_9.2.0782-1_all.deb" \ + && curl --proto '=https' --tlsv1.2 -fsSL \ + --retry 5 --retry-all-errors --retry-delay 2 --connect-timeout 15 --max-time 120 \ + -o "$security_deb_dir/vim-tiny.deb" \ + "$snapshot_url/v/vim/vim-tiny_9.2.0782-1_${arch}.deb" \ + && printf '%s %s\n' \ + "$libexpat_sha256" "$security_deb_dir/libexpat1.deb" \ + "$libonig_sha256" "$security_deb_dir/libonig5.deb" \ + "$libjq_sha256" "$security_deb_dir/libjq1.deb" \ + "$jq_sha256" "$security_deb_dir/jq.deb" \ + "$vim_common_sha256" "$security_deb_dir/vim-common.deb" \ + "$vim_tiny_sha256" "$security_deb_dir/vim-tiny.deb" \ + | sha256sum -c - \ + && dpkg -i \ + "$security_deb_dir/libexpat1.deb" \ + "$security_deb_dir/libonig5.deb" \ + "$security_deb_dir/libjq1.deb" \ + "$security_deb_dir/jq.deb" \ + "$security_deb_dir/vim-common.deb" \ + "$security_deb_dir/vim-tiny.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" \ + && test "$(dpkg-query -W -f='${Version}' jq)" = "1.8.2-1" \ + && test "$(dpkg-query -W -f='${Version}' vim-common)" = "2:9.2.0782-1" \ + && test "$(dpkg-query -W -f='${Version}' vim-tiny)" = "2:9.2.0782-1" \ + && test "$(jq --version)" = "jq-1.8.2" \ + && ldd /usr/bin/jq | grep -Eq 'libonig[.]so[.]5' \ + && printf '%s\n' '{"sandbox":"healthy"}' | jq -e '.sandbox == "healthy"' >/dev/null \ + && python3 -c "import pyexpat; assert pyexpat.EXPAT_VERSION == 'expat_2.8.2', pyexpat.EXPAT_VERSION" \ + && vim.tiny --version | head -n 1 | grep -Eq '^VIM - Vi IMproved 9[.]2 ' \ + && install -d -o root -g root -m 0755 /usr/local/share/nemoclaw \ + && printf '%s\n' \ + "architecture=$arch" \ + "libexpat1=2.8.2-1" \ + "libonig5=6.9.9-1+b1" \ + "libjq1=1.8.2-1" \ + "jq=1.8.2-1" \ + "vim-common=2:9.2.0782-1" \ + "vim-tiny=2:9.2.0782-1" \ + > /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 "$security_deb_dir" \ && rm -rf /var/lib/apt/lists/* 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 COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts COPY scripts/upgrade-bundled-npm.mts /scripts/upgrade-bundled-npm.mts @@ -78,6 +165,12 @@ RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ RUN node --experimental-strip-types /scripts/upgrade-bundled-npm.mts \ --npm-root /usr/local/lib/node_modules/npm +# Replace npm 11.18.0's private brace-expansion 5.0.7 package with the +# registry- and SRI-pinned 5.0.8 release. +# hadolint ignore=DL3059 +RUN node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts \ + --npm-root /usr/local/lib/node_modules/npm + # gosu for privilege separation (gateway vs sandbox user). # Identical to OpenClaw base — pinned to 1.19 with checksum. # hadolint ignore=DL4006 diff --git a/agents/langchain-deepagents-code/Dockerfile b/agents/langchain-deepagents-code/Dockerfile index 5f9ca9a974..f7c7c109e4 100644 --- a/agents/langchain-deepagents-code/Dockerfile +++ b/agents/langchain-deepagents-code/Dockerfile @@ -13,6 +13,7 @@ FROM ${BASE_IMAGE} USER root 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 COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts # The final managed image owns the shipped dependency boundary independently @@ -20,6 +21,11 @@ COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ --npm-root /usr/local/lib/node_modules/npm +# Reassert the npm-private brace-expansion fix for the exact final filesystem. +# hadolint ignore=DL3059 +RUN node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts \ + --npm-root /usr/local/lib/node_modules/npm + RUN set -eu; \ dcode_path="$(command -v dcode 2>/dev/null || true)"; \ if [ "$dcode_path" != "/usr/local/bin/dcode" ]; then \ @@ -196,6 +202,37 @@ RUN install -d -m 0755 /usr/local/share/nemoclaw \ > /usr/local/share/nemoclaw/node-tar-inventory.json \ && chmod 0444 /usr/local/share/nemoclaw/node-tar-inventory.json +# Verify the immutable security package inventory in the completed image. +# hadolint ignore=DL4006 +RUN set -eu; \ + security_inventory=/usr/local/share/nemoclaw/security-packages.txt; \ + arch="$(dpkg --print-architecture)"; \ + test -f "$security_inventory"; \ + test ! -L "$security_inventory"; \ + test "$(stat -c '%u:%g:%a' "$security_inventory")" = "0:0:444"; \ + printf '%s\n' \ + "architecture=$arch" \ + "libexpat1=2.8.2-1" \ + "libonig5=6.9.9-1+b1" \ + "libjq1=1.8.2-1" \ + "jq=1.8.2-1" \ + "vim-common=2:9.2.0782-1" \ + "vim-tiny=2:9.2.0782-1" \ + | 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"; \ + test "$(dpkg-query -W -f='${Version}' libjq1)" = "1.8.2-1"; \ + test "$(dpkg-query -W -f='${Version}' jq)" = "1.8.2-1"; \ + test "$(dpkg-query -W -f='${Version}' vim-common)" = "2:9.2.0782-1"; \ + test "$(dpkg-query -W -f='${Version}' vim-tiny)" = "2:9.2.0782-1"; \ + 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; \ + python3 -c "import pyexpat; assert pyexpat.EXPAT_VERSION == 'expat_2.8.2', pyexpat.EXPAT_VERSION"; \ + vim.tiny --version | head -n 1 | grep -Eq '^VIM - Vi IMproved 9[.]2 '; \ + test -z "$(dpkg --audit)" +# End completed-image security package verification. + USER sandbox ENTRYPOINT ["/usr/local/bin/nemoclaw-start"] CMD ["/bin/bash"] diff --git a/agents/langchain-deepagents-code/Dockerfile.base b/agents/langchain-deepagents-code/Dockerfile.base index d2a914e6e4..63e5f9449c 100644 --- a/agents/langchain-deepagents-code/Dockerfile.base +++ b/agents/langchain-deepagents-code/Dockerfile.base @@ -10,6 +10,7 @@ FROM node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba 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 COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts COPY scripts/upgrade-bundled-npm.mts /scripts/upgrade-bundled-npm.mts @@ -17,6 +18,9 @@ ENV DEBIAN_FRONTEND=noninteractive \ VIRTUAL_ENV=/opt/venv \ PATH="/usr/local/bin:/opt/venv/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin" +# Install the reviewed libexpat, jq, and Vim packages from the same immutable, +# checksum-bound Debian snapshot used by the OpenClaw base image. +# hadolint ignore=DL3001,DL4006 RUN apt-get update && apt-get install -y --no-install-recommends \ python3=3.13.5-1 \ python3-pip=25.1.1+dfsg-1 \ @@ -31,9 +35,92 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ procps=2:4.0.4-9 \ e2fsprogs=1.47.2-3+b11 \ openssh-sftp-server=1:10.0p1-7+deb13u4 \ - jq=1.7.1-6+deb13u2 \ ripgrep=14.1.1-1+b4 \ - vim-tiny=2:9.1.1230-2 \ + && arch="$(dpkg --print-architecture)" \ + && case "$arch" in \ + amd64) \ + libexpat_sha256="37d24b40a745107941f823d1f22c38f197f01981f7f0783777fe0026af016463"; \ + libonig_sha256="3abee130696244050500bcc7870e3b4cb82ddd87149ece3fd55010c3d4e1d18c"; \ + libjq_sha256="9a5bf964cef39ed8f0f162e20d856e31961d28a57772b5313989b42a8be7e941"; \ + jq_sha256="b973a5d304f666845e8ccefab492e3850d4bc2e7aa2a1e7450862095125f2cc0"; \ + vim_tiny_sha256="0e6e231d6d2430a92cf76f8a78506090418fa37758c33b31ed50dfbfc76e22ed" \ + ;; \ + arm64) \ + libexpat_sha256="df928e3a8e4da79408d4b18e8cd80a03dffa90130d0698e50041aab5e14f9397"; \ + libonig_sha256="137e708575c0622d347815d19cb471a107546b16e9602805ee27afad7bba107f"; \ + libjq_sha256="eae4a828df2eb53d728f88109d9f9549e0983a90b573cf0c7fa1e4bbc7533a7e"; \ + jq_sha256="c25086443abd04d1457cbb322a0837f9ba986f82b28f44670467c8dc9be1f696"; \ + vim_tiny_sha256="be30f7e9de0b872bec0128ccd890452c0e0e29d99017d16c0f3aa74164f6700d" \ + ;; \ + *) echo "Unsupported architecture for Debian security packages: $arch" >&2; exit 1 ;; \ + esac \ + && security_deb_dir="/tmp/nemoclaw-debian-security" \ + && snapshot_url="https://snapshot.debian.org/archive/debian/20260724T000000Z/pool/main" \ + && vim_common_sha256="6b063038246492c4a20e0a212c896dde4d5aa9f59d6fb43ff33d10080bc53a39" \ + && mkdir -p "$security_deb_dir" \ + && curl --proto '=https' --tlsv1.2 -fsSL \ + --retry 5 --retry-all-errors --retry-delay 2 --connect-timeout 15 --max-time 120 \ + -o "$security_deb_dir/libexpat1.deb" \ + "$snapshot_url/e/expat/libexpat1_2.8.2-1_${arch}.deb" \ + && curl --proto '=https' --tlsv1.2 -fsSL \ + --retry 5 --retry-all-errors --retry-delay 2 --connect-timeout 15 --max-time 120 \ + -o "$security_deb_dir/libonig5.deb" \ + "$snapshot_url/libo/libonig/libonig5_6.9.9-1+b1_${arch}.deb" \ + && curl --proto '=https' --tlsv1.2 -fsSL \ + --retry 5 --retry-all-errors --retry-delay 2 --connect-timeout 15 --max-time 120 \ + -o "$security_deb_dir/libjq1.deb" \ + "$snapshot_url/j/jq/libjq1_1.8.2-1_${arch}.deb" \ + && curl --proto '=https' --tlsv1.2 -fsSL \ + --retry 5 --retry-all-errors --retry-delay 2 --connect-timeout 15 --max-time 120 \ + -o "$security_deb_dir/jq.deb" \ + "$snapshot_url/j/jq/jq_1.8.2-1_${arch}.deb" \ + && curl --proto '=https' --tlsv1.2 -fsSL \ + --retry 5 --retry-all-errors --retry-delay 2 --connect-timeout 15 --max-time 120 \ + -o "$security_deb_dir/vim-common.deb" \ + "$snapshot_url/v/vim/vim-common_9.2.0782-1_all.deb" \ + && curl --proto '=https' --tlsv1.2 -fsSL \ + --retry 5 --retry-all-errors --retry-delay 2 --connect-timeout 15 --max-time 120 \ + -o "$security_deb_dir/vim-tiny.deb" \ + "$snapshot_url/v/vim/vim-tiny_9.2.0782-1_${arch}.deb" \ + && printf '%s %s\n' \ + "$libexpat_sha256" "$security_deb_dir/libexpat1.deb" \ + "$libonig_sha256" "$security_deb_dir/libonig5.deb" \ + "$libjq_sha256" "$security_deb_dir/libjq1.deb" \ + "$jq_sha256" "$security_deb_dir/jq.deb" \ + "$vim_common_sha256" "$security_deb_dir/vim-common.deb" \ + "$vim_tiny_sha256" "$security_deb_dir/vim-tiny.deb" \ + | sha256sum -c - \ + && dpkg -i \ + "$security_deb_dir/libexpat1.deb" \ + "$security_deb_dir/libonig5.deb" \ + "$security_deb_dir/libjq1.deb" \ + "$security_deb_dir/jq.deb" \ + "$security_deb_dir/vim-common.deb" \ + "$security_deb_dir/vim-tiny.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" \ + && test "$(dpkg-query -W -f='${Version}' jq)" = "1.8.2-1" \ + && test "$(dpkg-query -W -f='${Version}' vim-common)" = "2:9.2.0782-1" \ + && test "$(dpkg-query -W -f='${Version}' vim-tiny)" = "2:9.2.0782-1" \ + && test "$(jq --version)" = "jq-1.8.2" \ + && ldd /usr/bin/jq | grep -Eq 'libonig[.]so[.]5' \ + && printf '%s\n' '{"sandbox":"healthy"}' | jq -e '.sandbox == "healthy"' >/dev/null \ + && python3 -c "import pyexpat; assert pyexpat.EXPAT_VERSION == 'expat_2.8.2', pyexpat.EXPAT_VERSION" \ + && vim.tiny --version | head -n 1 | grep -Eq '^VIM - Vi IMproved 9[.]2 ' \ + && install -d -o root -g root -m 0755 /usr/local/share/nemoclaw \ + && printf '%s\n' \ + "architecture=$arch" \ + "libexpat1=2.8.2-1" \ + "libonig5=6.9.9-1+b1" \ + "libjq1=1.8.2-1" \ + "jq=1.8.2-1" \ + "vim-common=2:9.2.0782-1" \ + "vim-tiny=2:9.2.0782-1" \ + > /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 "$security_deb_dir" \ && rm -rf /var/lib/apt/lists/* # Node remains available to the managed terminal at runtime, so remediate @@ -48,6 +135,12 @@ RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ RUN node --experimental-strip-types /scripts/upgrade-bundled-npm.mts \ --npm-root /usr/local/lib/node_modules/npm +# Replace npm 11.18.0's private brace-expansion 5.0.7 package with the +# registry- and SRI-pinned 5.0.8 release. +# hadolint ignore=DL3059 +RUN node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts \ + --npm-root /usr/local/lib/node_modules/npm + RUN groupadd -r sandbox \ && useradd -r -g sandbox -d /sandbox -s /bin/bash sandbox \ && usermod -a -G sandbox root \ diff --git a/docs/security/sandbox-base-2026-07-25-dependency-review.md b/docs/security/sandbox-base-2026-07-25-dependency-review.md new file mode 100644 index 0000000000..fd6b560342 --- /dev/null +++ b/docs/security/sandbox-base-2026-07-25-dependency-review.md @@ -0,0 +1,191 @@ + + + +# Sandbox base dependency review: Vim, jq, Expat, Perl modules, and bundled npm + +Date: 2026-07-25 + +## Scope + +This review covers the sandbox dependency changes that: + +- standardize the OpenClaw, Hermes, and Deep Agents Code base images on fixed Debian `libexpat1=2.8.2-1`, `libjq1=1.8.2-1`, `jq=1.8.2-1`, `vim-common=2:9.2.0782-1`, and `vim-tiny=2:9.2.0782-1` packages, with the reviewed `libonig5=6.9.9-1+b1` jq runtime dependency; +- replace the `brace-expansion@5.0.7` copy inside the reviewed `npm@11.18.0` package with `brace-expansion@5.0.8`; and +- verify the security-relevant dual-life module versions shipped by the checksum-pinned Perl 5.44.0 build. + +The OpenClaw base image retains its existing reviewed jq and Expat identities, while the Hermes and Deep Agents Code base images move to the same package boundary. +These changes preserve the existing supported image behavior and do not create a new integration or product surface. + +## Release and artifact identities + +| Dependency | Previous identity | Reviewed identity | Artifact binding | +| --- | --- | --- | --- | +| Vim | All managed images: Debian trixie `2:9.1.1230-2` | Debian sid `2:9.2.0782-1` | Debian Snapshot `20260724T000000Z` and package SHA-256 values below | +| jq | OpenClaw: `1.8.2-1`; Hermes and Deep Agents Code: `1.7.1-6+deb13u2` | All managed images: `libjq1=1.8.2-1` and `jq=1.8.2-1` | Debian Snapshot `20260724T000000Z` and architecture-specific SHA-256 values below | +| Oniguruma | Distro-selected jq runtime dependency | All managed images: `libonig5=6.9.9-1+b1` | Debian Snapshot `20260724T000000Z` and architecture-specific SHA-256 values below | +| Expat | OpenClaw: `2.8.2-1`; Hermes and Deep Agents Code: distro-selected package | All managed images: `libexpat1=2.8.2-1` | Debian Snapshot `20260724T000000Z` and architecture-specific SHA-256 values below | +| npm | `npm@11.18.0` | unchanged | Existing reviewed npm archive and integrity | +| npm private `brace-expansion` | `5.0.7` | `5.0.8` | Registry tarball and SHA-512 integrity below | +| Perl | `5.44.0-1nemoclaw1` | unchanged | Existing CPAN archive SHA-256 and complete upstream test suite | + +The fixed Vim source package is later than every reviewed 9.2 patch boundary. +The Debian security tracker records `2:9.2.0782-1` as fixed for the affected Vim issues while trixie's `2:9.1.1230-2` remains affected. + +The immutable Debian package SHA-256 values are: + +| Package | amd64 | arm64 | +| --- | --- | --- | +| `libexpat1_2.8.2-1` | `37d24b40a745107941f823d1f22c38f197f01981f7f0783777fe0026af016463` | `df928e3a8e4da79408d4b18e8cd80a03dffa90130d0698e50041aab5e14f9397` | +| `libonig5_6.9.9-1+b1` | `3abee130696244050500bcc7870e3b4cb82ddd87149ece3fd55010c3d4e1d18c` | `137e708575c0622d347815d19cb471a107546b16e9602805ee27afad7bba107f` | +| `libjq1_1.8.2-1` | `9a5bf964cef39ed8f0f162e20d856e31961d28a57772b5313989b42a8be7e941` | `eae4a828df2eb53d728f88109d9f9549e0983a90b573cf0c7fa1e4bbc7533a7e` | +| `jq_1.8.2-1` | `b973a5d304f666845e8ccefab492e3850d4bc2e7aa2a1e7450862095125f2cc0` | `c25086443abd04d1457cbb322a0837f9ba986f82b28f44670467c8dc9be1f696` | +| `vim-common_9.2.0782-1_all` | `6b063038246492c4a20e0a212c896dde4d5aa9f59d6fb43ff33d10080bc53a39` | same architecture-independent package | +| `vim-tiny_9.2.0782-1` | `0e6e231d6d2430a92cf76f8a78506090418fa37758c33b31ed50dfbfc76e22ed` | `be30f7e9de0b872bec0128ccd890452c0e0e29d99017d16c0f3aa74164f6700d` | + +The reviewed npm replacement is: + +- version: `brace-expansion@5.0.8`; +- integrity: `sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==`; and +- tarball: `https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz`. + +## Contract audit + +### Managed-image Debian package compatibility + +Each managed base image downloads the six exact Debian packages from the same immutable snapshot and verifies every package checksum before installation. +Each image installs the complete jq runtime closure and matching Vim package pair together, verifies every dpkg identity, confirms that jq links to `libonig.so.5`, exercises jq and Python Expat, and verifies that the Vim runtime reports version 9.2. +The package architecture is selected from `dpkg --print-architecture`, and any architecture other than amd64 or arm64 fails closed. +The base writes that architecture and the six exact package identities to a root-owned, read-only inventory. +Each completed OpenClaw, Hermes, and Deep Agents Code image reasserts the inventory metadata and exact content, every installed dpkg identity, jq-to-Oniguruma linkage, jq, Expat, and Vim runtime probes, and an empty `dpkg --audit` result. + +`vim-tiny=2:9.2.0782-1` depends on: + +- `vim-common=2:9.2.0782-1`; +- `libacl1 >= 2.2.23`; +- `libc6 >= 2.38`; +- `libselinux1 >= 3.1~`; and +- `libtinfo6 >= 6`. + +The pinned trixie bases satisfy those library floors. +`libjq1=1.8.2-1` requires `libonig5 >= 6.9.7.1`; the exact `libonig5=6.9.9-1+b1` artifact closes that dependency on both architectures. +The packages remain visible to dpkg and the generated software inventory, and no manual file overlay is used. + +The final reviewed dependency set and range evidence is: + +| Package or component | Previous or affected boundary | Final reviewed boundary | Runtime and inventory proof | +| --- | --- | --- | --- | +| `libexpat1` | distro-selected package or `2.8.2-1` | `2.8.2-1` | exact dpkg identity and Python `pyexpat` reports Expat 2.8.2 | +| `libonig5` | jq dependency floor `>= 6.9.7.1` | `6.9.9-1+b1` | exact dpkg identity and `/usr/bin/jq` links to `libonig.so.5` | +| `libjq1` | `1.7.1-6+deb13u2..1.8.2-1` | `1.8.2-1` | exact dpkg identity and matching `jq` runtime | +| `jq` | `1.7.1-6+deb13u2..1.8.2-1` | `1.8.2-1` | exact dpkg identity, `jq-1.8.2`, and a JSON expression probe | +| `vim-common` and `vim-tiny` | `2:9.1.1230-2..2:9.2.0782-1` | `2:9.2.0782-1` | exact dpkg identities and Vim 9.2 runtime probe | +| npm private `brace-expansion` | `5.0.7..5.0.8` | `5.0.8` | complete-tree identity, dependency-shape, SRI, rollback, and npm/npx ordering guards | +| Perl and reviewed dual-life modules | Perl `5.43.10..5.44.0`; `HTTP::Tiny < 0.095`; `IO::Compress < 2.223` | Perl `5.44.0`; `HTTP::Tiny 0.096`; `IO::Compress 2.223`; component identities listed below | native package identity and direct interpreter/module version probes | + +### Bundled npm package compatibility + +The npm release remains `11.18.0`. +Its private dependency tree contains one top-level `brace-expansion@5.0.7` package with the existing `balanced-match@^4.0.2` contract. +The replacement `5.0.8` package preserves that dependency contract, and its Node engine floor is compatible with the Node 22 and Node 24 base images. + +The replacement helper: + +1. rejects npm identities other than the reviewed `11.18.0`; +2. rejects every symlink except a `node_modules/.bin` link that resolves to a regular file inside the reviewed `node_modules` root; +3. downloads the exact registry tarball without invoking npm; +4. verifies the packed bytes against the reviewed SHA-512 integrity; +5. extracts without restoring archive owners or modes; +6. rejects unsafe extracted members; +7. replaces the complete private package directory transactionally; +8. restores the original directory if verification fails; +9. disarms rollback immediately after the verified replacement becomes authoritative, then retries backup cleanup without risking the live tree; and +10. invokes npm and npx only after the fixed package is active. + +All managed base images apply the helper after the complete npm upgrade. +Their final images reassert the same idempotent contract so the scanned filesystem, not an intermediate stage, owns the dependency boundary. + +### Perl component versions + +Perl remains the checksum-pinned 5.44.0 source release. +The existing build runs the complete upstream test selection on native amd64 and arm64 runners before building native packages. + +Perl 5.44.0 includes these reviewed component versions: + +- `Socket 2.041`; +- `Storable 3.41`; +- `HTTP::Tiny 0.096`; +- `IO::Compress 2.223`; +- `IO::Uncompress::Unzip 2.223`; and +- `File::GlobMapper 1.001`. + +The image build checks the IO::Compress distribution version through `IO::Compress::Base` and checks each affected module directly. +The HTTP::Tiny floor is `0.095`, and the reviewed IO::Compress fixes are in `2.223`. +The core interpreter version check also remains the binding for core-language fixes included after Perl 5.43.10. + +## Concern ledger + +### DEP-1: affected trixie Vim package + +- Range: `2:9.1.1230-2..2:9.2.0782-1` +- Surface: native package and runtime editor +- Severity: high +- Confidence: high +- Failure mode: attacker-controlled editor inputs can reach defects fixed across the reviewed Vim 9.2 patch range. +- Disposition: migrate, pin, test +- Implementation: install the matching immutable Debian Snapshot packages for amd64 and arm64 after SHA-256 verification in every managed base image. +- Verification: exact `RUN`-chain execution, checksum-rejection tests, dpkg identity checks, Vim runtime checks, and native image builds. +- Remaining gate: multi-image, multi-architecture CI. + +### DEP-2: affected package inside npm's private tree + +- Range: `brace-expansion 5.0.7..5.0.8` +- Surface: transitive bundled npm dependency +- Severity: high +- Confidence: high +- Failure mode: changing NemoClaw lockfiles does not replace npm's private package copy. +- Disposition: migrate, pin, guard, test +- Implementation: transactional, SRI-pinned complete-directory replacement after the reviewed npm archive is installed. +- Verification: pre-swap and post-swap rollback, idempotence, unsafe-tree, layout-drift, command-order, Dockerfile-order, and real-registry tests. +- Remaining gate: multi-image CI. + +### DEP-3: Perl package identity does not expose dual-life module versions + +- Range: Perl `5.44.0` with bundled component versions +- Surface: native package inventory and runtime modules +- Severity: high +- Confidence: high +- Failure mode: a package-only inventory can miss that the fixed module versions are already present in the interpreter distribution. +- Disposition: runtime-proof, test, document +- Implementation: exact runtime version assertions for every reviewed module family in addition to the existing interpreter and regression checks. +- Verification: native amd64 and arm64 image builds. +- Remaining gate: multi-architecture base-image build. + +### DEP-4: managed jq, Oniguruma, and Expat identities differ + +- Range: `libexpat1` distro-selected or `2.8.2-1` to `2.8.2-1`; `libjq1` and `jq` `1.7.1-6+deb13u2..1.8.2-1`; `libonig5 >= 6.9.7.1` to exact `6.9.9-1+b1`. +- Surface: native packages and runtime libraries +- Severity: high +- Confidence: high +- Failure mode: a managed image can retain an older jq or Expat runtime, or fail to configure jq when its architecture-specific Oniguruma dependency is absent. +- Disposition: migrate, pin, test, runtime-proof +- Implementation: use the same snapshot, architecture-specific checksums, dpkg identities, and runtime guards in every managed base image. +- Verification: exact base-package and completed-image `RUN`-chain execution, immutable inventory content and metadata checks, checksum-rejection tests, installed dpkg identities, runtime probes, and empty dpkg audit for all three images on amd64 and arm64. +- Remaining gate: multi-image, multi-architecture CI. + +## Removal conditions + +Remove the Debian snapshot override only when the supported Debian suite publishes packages at or beyond every reviewed fix boundary and the replacements pass the same amd64 and arm64 package and runtime checks for all managed base images. + +Remove the private brace-expansion helper only when every pinned Node base installs a reviewed npm release whose complete private tree contains no brace-expansion version below 5.0.8. +Updating the npm archive without revisiting this helper must fail the image contract. + +## Verification + +Required evidence for the final pull-request head: + +- focused helper and exact security-package `RUN`-chain tests for every managed base image and architecture; +- source-identity and optimized build-context tests; +- real reviewed npm archive replacement using the registry artifact; +- repository formatting and type checks; +- amd64 and arm64 builds for the OpenClaw, Hermes, and Deep Agents Code base images; and +- completed-image dependency inventory. diff --git a/scripts/patch-bundled-npm-brace-expansion.mts b/scripts/patch-bundled-npm-brace-expansion.mts new file mode 100755 index 0000000000..28d281d04b --- /dev/null +++ b/scripts/patch-bundled-npm-brace-expansion.mts @@ -0,0 +1,389 @@ +#!/usr/bin/env -S node --experimental-strip-types + +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { + closeSync, + constants, + cpSync, + fstatSync, + lstatSync, + mkdirSync, + mkdtempSync, + openSync, + readdirSync, + readFileSync, + realpathSync, + renameSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const AFFECTED_BRACE_EXPANSION_VERSION = "5.0.7"; +export const FIXED_BRACE_EXPANSION_VERSION = "5.0.8"; +export const FIXED_BRACE_EXPANSION_INTEGRITY = + "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="; +export const FIXED_BRACE_EXPANSION_TARBALL = + "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz"; +export const REVIEWED_NPM_VERSION = "11.18.0"; + +type JsonRecord = Record; + +function record(value: unknown, label: string): JsonRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${label} must be a JSON object`); + } + return value as JsonRecord; +} + +function readJson(file: string, label: string): JsonRecord { + const descriptor = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + if (!fstatSync(descriptor).isFile()) throw new Error(`${label} must be a real file: ${file}`); + return record(JSON.parse(readFileSync(descriptor, "utf8")), label); + } catch (error) { + throw new Error(`${label} is invalid: ${String(error)}`); + } finally { + closeSync(descriptor); + } +} + +function realDirectory(directory: string, label: string): string { + const resolved = resolve(directory); + const metadata = lstatSync(resolved); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error(`${label} must be a real directory: ${resolved}`); + } + return realpathSync(resolved); +} + +function rejectUnsafeTree(root: string): void { + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) { + throw new Error( + `replacement brace-expansion package contains an unsafe member: ${entry.name}`, + ); + } + if (entry.isDirectory()) rejectUnsafeTree(join(root, entry.name)); + } +} + +function isContainedBinSymlink( + nodeModulesRoot: string, + directory: string, + entryName: string, +): boolean { + if (basename(directory) !== ".bin") return false; + try { + const target = realpathSync(join(directory, entryName)); + const targetRelative = relative(nodeModulesRoot, target); + return ( + targetRelative !== "" && + targetRelative !== ".." && + !targetRelative.startsWith(`..${sep}`) && + !isAbsolute(targetRelative) && + lstatSync(target).isFile() + ); + } catch { + return false; + } +} + +function collectBraceExpansionVersions( + directory: string, + nodeModulesRoot: string, + versions: string[], +): void { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.isSymbolicLink()) { + if (!isContainedBinSymlink(nodeModulesRoot, directory, entry.name)) { + throw new Error(`npm package contains an unsafe symlink: ${join(directory, entry.name)}`); + } + continue; + } + if ( + entry.isDirectory() && + (entry.name.startsWith(".brace-expansion.nemoclaw-stage-") || + entry.name.startsWith("brace-expansion.nemoclaw-backup-")) + ) { + continue; + } + if (!entry.isDirectory() && !entry.isFile()) { + throw new Error(`npm package contains an unsafe member: ${join(directory, entry.name)}`); + } + const child = join(directory, entry.name); + if (entry.isDirectory()) { + collectBraceExpansionVersions(child, nodeModulesRoot, versions); + continue; + } + if (entry.name !== "package.json") continue; + const manifest = readJson(child, "npm bundled package manifest"); + if (manifest.name === "brace-expansion") { + if (typeof manifest.version !== "string") { + throw new Error("npm bundled brace-expansion version is invalid"); + } + versions.push(manifest.version); + } + } +} + +export type BundledNpmBraceExpansionState = Readonly<{ + braceExpansionVersion: string; + npmVersion: string; + state: "affected" | "fixed"; +}>; + +export function inspectBundledNpmBraceExpansion(npmRoot: string): BundledNpmBraceExpansionState { + const root = realDirectory(npmRoot, "npm package root"); + const npmManifest = readJson(join(root, "package.json"), "npm package manifest"); + if (npmManifest.name !== "npm" || npmManifest.version !== REVIEWED_NPM_VERSION) { + throw new Error(`npm package identity has drifted; expected npm@${REVIEWED_NPM_VERSION}`); + } + + const braceManifest = readJson( + join(root, "node_modules", "brace-expansion", "package.json"), + "npm bundled brace-expansion manifest", + ); + const dependencies = record( + braceManifest.dependencies, + "npm bundled brace-expansion dependencies", + ); + const version = braceManifest.version; + if ( + braceManifest.name !== "brace-expansion" || + (version !== AFFECTED_BRACE_EXPANSION_VERSION && version !== FIXED_BRACE_EXPANSION_VERSION) || + dependencies["balanced-match"] !== "^4.0.2" + ) { + throw new Error( + `npm bundled brace-expansion identity or dependency layout has drifted: ${JSON.stringify({ + dependencies, + version, + })}`, + ); + } + + const versions: string[] = []; + const nodeModulesRoot = realDirectory(join(root, "node_modules"), "npm node_modules root"); + collectBraceExpansionVersions(nodeModulesRoot, nodeModulesRoot, versions); + if (versions.length !== 1 || versions[0] !== version) { + throw new Error(`npm bundled brace-expansion layout has drifted: ${JSON.stringify(versions)}`); + } + + return { + braceExpansionVersion: version, + npmVersion: REVIEWED_NPM_VERSION, + state: version === FIXED_BRACE_EXPANSION_VERSION ? "fixed" : "affected", + }; +} + +export function verifyBundledNpmBraceExpansion(npmRoot: string): BundledNpmBraceExpansionState { + const inspected = inspectBundledNpmBraceExpansion(npmRoot); + if (inspected.state !== "fixed") { + throw new Error( + `npm@${inspected.npmVersion} bundles affected brace-expansion@${inspected.braceExpansionVersion}; expected ${FIXED_BRACE_EXPANSION_VERSION}`, + ); + } + return inspected; +} + +export function patchBundledNpmBraceExpansion(options: { + npmRoot: string; + replacementRoot: string; +}): BundledNpmBraceExpansionState { + const npmRoot = realDirectory(options.npmRoot, "npm package root"); + const replacementRoot = realDirectory( + options.replacementRoot, + "replacement brace-expansion root", + ); + rejectUnsafeTree(replacementRoot); + const replacement = readJson( + join(replacementRoot, "package.json"), + "replacement brace-expansion manifest", + ); + if ( + replacement.name !== "brace-expansion" || + replacement.version !== FIXED_BRACE_EXPANSION_VERSION + ) { + throw new Error(`replacement package must be brace-expansion@${FIXED_BRACE_EXPANSION_VERSION}`); + } + + const current = inspectBundledNpmBraceExpansion(npmRoot); + if (current.state === "fixed") return current; + + const livePath = join(npmRoot, "node_modules", "brace-expansion"); + const transactionId = `${process.pid}-${randomBytes(8).toString("hex")}`; + const stagingRoot = mkdtempSync(join(dirname(livePath), ".brace-expansion.nemoclaw-stage-")); + const stagedPath = join(stagingRoot, "replacement"); + const backupPath = `${livePath}.nemoclaw-backup-${transactionId}`; + let rollbackRequired = false; + try { + cpSync(replacementRoot, stagedPath, { dereference: false, recursive: true }); + cpSync(livePath, backupPath, { + dereference: false, + errorOnExist: true, + force: false, + preserveTimestamps: true, + recursive: true, + }); + rollbackRequired = true; + rmSync(livePath, { recursive: true }); + renameSync(stagedPath, livePath); + const fixed = verifyBundledNpmBraceExpansion(npmRoot); + rollbackRequired = false; + try { + rmSync(backupPath, { force: true, recursive: true }); + } catch { + rmSync(backupPath, { force: true, recursive: true }); + } + return fixed; + } catch (error) { + if (rollbackRequired) { + rmSync(livePath, { force: true, recursive: true }); + renameSync(backupPath, livePath); + } + throw error; + } finally { + rmSync(stagingRoot, { force: true, recursive: true }); + } +} + +export type BundledNpmBraceExpansionCommandRunner = ( + command: string, + args: readonly string[], +) => void; + +function run(command: string, args: readonly string[]): void { + const result = spawnSync(command, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 60_000, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${command} failed: ${`${result.stdout ?? ""}${result.stderr ?? ""}`.trim()}`); + } +} + +type PreparedReplacement = Readonly<{ + cleanup: () => void; + replacementRoot: string; +}>; + +function prepareFixedBraceExpansionReplacement( + commandRunner: BundledNpmBraceExpansionCommandRunner, +): PreparedReplacement { + const rootDirectory = mkdtempSync(join(tmpdir(), "nemoclaw-npm-brace-expansion-")); + const archivePath = join(rootDirectory, `brace-expansion-${FIXED_BRACE_EXPANSION_VERSION}.tgz`); + const replacementRoot = join(rootDirectory, "replacement"); + try { + commandRunner("curl", [ + "--proto", + "=https", + "--tlsv1.2", + "--fail", + "--silent", + "--show-error", + "--output", + archivePath, + FIXED_BRACE_EXPANSION_TARBALL, + ]); + const descriptor = openSync(archivePath, constants.O_RDONLY | constants.O_NOFOLLOW); + let archiveBytes: Buffer; + try { + if (!fstatSync(descriptor).isFile()) { + throw new Error("brace-expansion replacement download must be a real file"); + } + archiveBytes = readFileSync(descriptor); + } finally { + closeSync(descriptor); + } + const actualIntegrity = `sha512-${createHash("sha512").update(archiveBytes).digest("base64")}`; + if (actualIntegrity !== FIXED_BRACE_EXPANSION_INTEGRITY) { + throw new Error( + `brace-expansion replacement integrity mismatch\nExpected: ${FIXED_BRACE_EXPANSION_INTEGRITY}\nActual: ${actualIntegrity}`, + ); + } + + mkdirSync(replacementRoot, { mode: 0o700 }); + commandRunner("tar", [ + "--extract", + "--gzip", + "--file", + archivePath, + "--directory", + replacementRoot, + "--strip-components=1", + "--no-same-owner", + "--no-same-permissions", + ]); + return { + cleanup: () => rmSync(rootDirectory, { force: true, recursive: true }), + replacementRoot, + }; + } catch (error) { + rmSync(rootDirectory, { force: true, recursive: true }); + throw error; + } +} + +export type BundledNpmBraceExpansionRegistryDependencies = Readonly<{ + commandRunner?: BundledNpmBraceExpansionCommandRunner; + prepareReplacement?: ( + commandRunner: BundledNpmBraceExpansionCommandRunner, + ) => PreparedReplacement; +}>; + +export function patchBundledNpmBraceExpansionFromRegistry( + npmRoot: string, + dependencies: BundledNpmBraceExpansionRegistryDependencies = {}, +): BundledNpmBraceExpansionState { + const commandRunner = dependencies.commandRunner ?? run; + const current = inspectBundledNpmBraceExpansion(npmRoot); + if (current.state === "fixed") { + commandRunner("npm", ["--version"]); + commandRunner("npx", ["--version"]); + return current; + } + const prepared = (dependencies.prepareReplacement ?? prepareFixedBraceExpansionReplacement)( + commandRunner, + ); + try { + const result = patchBundledNpmBraceExpansion({ + npmRoot, + replacementRoot: prepared.replacementRoot, + }); + commandRunner("npm", ["--version"]); + commandRunner("npx", ["--version"]); + return result; + } finally { + prepared.cleanup(); + } +} + +function argument(name: string): string { + const index = process.argv.indexOf(name); + const value = index >= 0 ? process.argv[index + 1] : undefined; + if (!value || value.startsWith("--")) throw new Error(`${name} is required`); + return value; +} + +function isMainModule(): boolean { + return process.argv[1] ? fileURLToPath(import.meta.url) === resolve(process.argv[1]) : false; +} + +if (isMainModule()) { + try { + const result = patchBundledNpmBraceExpansionFromRegistry(argument("--npm-root")); + process.stdout.write( + `Verified npm@${result.npmVersion} bundled brace-expansion@${result.braceExpansionVersion}\n`, + ); + } catch (error) { + console.error(`ERROR: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} diff --git a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts index a11f894f44..447e75e1c2 100644 --- a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts +++ b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts @@ -52,7 +52,8 @@ const CANONICAL_POST_GENERATOR_RUN_SHA256 = new Set([ "a0a554d474cb70087e50686d998915eae06201d6182a2410d3ccc4879e5058e6", "5af905889f94ffed2f6c371111d0589e38eed7b0de54ddb0dd68ad912a23149a", "1197b99bdb996b37a3e4e386a507dfabcdfb2c26a40b015d617f97208668187d", - "4ce82cdbc0d5168d8129888854c7e2af97d2faf94218677b4a1843ce6e19d757", + "5cc53ef9c588470f325c5df8189a2eb1525140d947332ae2c0a80fccb2f36ccb", + "83567d1fa0e73bef6a3333383c13ace05e26704964ae6a7a76ee24a2f2be3d7e", ]); function instructionSha256(text: string): string { diff --git a/src/lib/sandbox-base-image/source-identity.test.ts b/src/lib/sandbox-base-image/source-identity.test.ts index b72fe9e040..20ac03b6fa 100644 --- a/src/lib/sandbox-base-image/source-identity.test.ts +++ b/src/lib/sandbox-base-image/source-identity.test.ts @@ -179,6 +179,7 @@ describe("sandbox base-image source identity", () => { "scripts/lib/openclaw-npm-remediation.mts", "scripts/lib/reviewed-npm-archive.mts", "scripts/checks/node-tar-image-scan.mts", + "scripts/patch-bundled-npm-brace-expansion.mts", "scripts/patch-bundled-npm-tar.mts", "scripts/upgrade-bundled-npm.mts", agentDockerfile, diff --git a/src/lib/sandbox-base-image/source-identity.ts b/src/lib/sandbox-base-image/source-identity.ts index 7eab79964e..4336981ecc 100644 --- a/src/lib/sandbox-base-image/source-identity.ts +++ b/src/lib/sandbox-base-image/source-identity.ts @@ -16,6 +16,7 @@ export const BASE_IMAGE_INPUT_PATHS = [ "scripts/lib/openclaw-npm-remediation.mts", "scripts/lib/reviewed-npm-archive.mts", "scripts/checks/node-tar-image-scan.mts", + "scripts/patch-bundled-npm-brace-expansion.mts", "scripts/patch-bundled-npm-tar.mts", "scripts/upgrade-bundled-npm.mts", ]; diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index 7e131dfdc6..e71618cb13 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -272,6 +272,10 @@ function stageOptimizedSandboxBuildContext( path.join(rootDir, "scripts", "patch-openclaw-shared-state-permissions.mts"), path.join(stagedScriptsDir, "patch-openclaw-shared-state-permissions.mts"), ); + fs.copyFileSync( + path.join(rootDir, "scripts", "patch-bundled-npm-brace-expansion.mts"), + path.join(stagedScriptsDir, "patch-bundled-npm-brace-expansion.mts"), + ); fs.copyFileSync( path.join(rootDir, "scripts", "patch-bundled-npm-tar.mts"), path.join(stagedScriptsDir, "patch-bundled-npm-tar.mts"), diff --git a/test/bundled-npm-brace-expansion-dockerfile-contract.test.ts b/test/bundled-npm-brace-expansion-dockerfile-contract.test.ts new file mode 100644 index 0000000000..36ee1e03b8 --- /dev/null +++ b/test/bundled-npm-brace-expansion-dockerfile-contract.test.ts @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; +import { + FIXED_BRACE_EXPANSION_INTEGRITY, + FIXED_BRACE_EXPANSION_TARBALL, + FIXED_BRACE_EXPANSION_VERSION, + REVIEWED_NPM_VERSION, +} from "../scripts/patch-bundled-npm-brace-expansion.mts"; +import { REVIEWED_NPM_VERSION as UPGRADED_NPM_VERSION } from "../scripts/upgrade-bundled-npm.mts"; + +const repoRoot = path.resolve(import.meta.dirname, ".."); +const baseDockerfiles = [ + "Dockerfile.base", + "agents/hermes/Dockerfile.base", + "agents/langchain-deepagents-code/Dockerfile.base", +] as const; +const finalDockerfiles = [ + "Dockerfile", + "agents/hermes/Dockerfile", + "agents/langchain-deepagents-code/Dockerfile", +] as const; +const copyInstruction = + "COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts"; +const patchInstruction = + "RUN node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts"; + +describe("bundled npm brace-expansion image remediation contract", () => { + it("binds the replacement to the reviewed npm and registry artifact", () => { + expect(REVIEWED_NPM_VERSION).toBe(UPGRADED_NPM_VERSION); + expect(REVIEWED_NPM_VERSION).toBe("11.18.0"); + expect(FIXED_BRACE_EXPANSION_VERSION).toBe("5.0.8"); + expect(FIXED_BRACE_EXPANSION_INTEGRITY).toMatch(/^sha512-[A-Za-z0-9+/]+=*$/u); + expect(FIXED_BRACE_EXPANSION_TARBALL).toBe( + "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + ); + }); + + it.each(baseDockerfiles)("patches the reviewed npm tree after upgrading it in %s", (file) => { + const source = fs.readFileSync(path.join(repoRoot, file), "utf8"); + const copy = source.indexOf(copyInstruction); + const upgrade = source.indexOf( + "RUN node --experimental-strip-types /scripts/upgrade-bundled-npm.mts", + ); + const patch = source.indexOf(patchInstruction); + + expect(copy, file).toBeGreaterThanOrEqual(0); + expect(upgrade, file).toBeGreaterThan(copy); + expect(patch, file).toBeGreaterThan(upgrade); + expect(source.slice(patch)).toContain("--npm-root /usr/local/lib/node_modules/npm"); + }); + + it.each( + finalDockerfiles, + )("reasserts the private package fix in the completed %s filesystem", (file) => { + const source = fs.readFileSync(path.join(repoRoot, file), "utf8"); + const copy = source.indexOf(copyInstruction); + const tarPatch = source.indexOf( + "RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts", + ); + const bracePatch = source.indexOf(patchInstruction); + + expect(copy, file).toBeGreaterThanOrEqual(0); + expect(tarPatch, file).toBeGreaterThan(copy); + expect(bracePatch, file).toBeGreaterThan(tarPatch); + expect(source.slice(bracePatch)).toContain("--npm-root /usr/local/lib/node_modules/npm"); + }); +}); diff --git a/test/helpers/base-apt-security-functions.ts b/test/helpers/base-apt-security-functions.ts index d57928ac0e..11f8ea0fd8 100644 --- a/test/helpers/base-apt-security-functions.ts +++ b/test/helpers/base-apt-security-functions.ts @@ -54,77 +54,128 @@ export function runLoggedDockerShell(command: string, tmp: string, functionDefs: }); } -export const BASE_APT_SECURITY_FUNCTIONS = [ - [ - "dpkg() {", - ' if [[ "$#" -eq 1 && "$1" == "--print-architecture" ]]; then', - ' printf "arm64\\n"', - ' elif [[ "$#" -eq 4 && "$1" == "-i" && "${2##*/}" == "libexpat1.deb" && "${3##*/}" == "libjq1.deb" && "${4##*/}" == "jq.deb" ]]; then', - ' [[ -f "$2" && -f "$3" && -f "$4" ]]', - " else", - " return 64", - " fi", - "}", - ].join("\n"), - [ - "dpkg-query() {", - ' [[ "$#" -eq 3 && "$1" == "-W" && "$2" == \'-f=${Version}\' ]] || return 64', - ' case "$3" in', - ' libexpat1) printf "2.8.2-1" ;;', - ' libjq1|jq) printf "1.8.2-1" ;;', - ' perl) printf "5.40.1-6" ;;', - " *) return 64 ;;", - " esac", - "}", - ].join("\n"), - [ - "curl() {", - ' [[ "$#" -eq 16 && "$1" == "--proto" && "$2" == "=https" && "$3" == "--tlsv1.2" && "$4" == "-fsSL" ]] || return 64', - ' [[ "$5" == "--retry" && "$6" == "5" && "$7" == "--retry-all-errors" && "$8" == "--retry-delay" && "$9" == "2" ]] || return 64', - ' [[ "${10}" == "--connect-timeout" && "${11}" == "15" && "${12}" == "--max-time" && "${13}" == "120" && "${14}" == "-o" ]] || return 64', - ' case "${16}" in', - ' */e/expat/libexpat1_2.8.2-1_arm64.deb) [[ "${15##*/}" == "libexpat1.deb" ]] ;;', - ' */j/jq/libjq1_1.8.2-1_arm64.deb) [[ "${15##*/}" == "libjq1.deb" ]] ;;', - ' */j/jq/jq_1.8.2-1_arm64.deb) [[ "${15##*/}" == "jq.deb" ]] ;;', - " *) return 64 ;;", - " esac", - ' printf "%s\\n" "${16}" > "${15}"', - "}", - ].join("\n"), - [ - "sha256sum() {", - ' [[ "$#" -eq 2 && "$1" == "-c" && "$2" == "-" ]] || return 64', - " local line path count=0", - " while IFS= read -r line; do", - ' path="${line#* }"', - ' [[ -f "$path" ]] || return 1', - ' case "$line" in', - ' "df928e3a8e4da79408d4b18e8cd80a03dffa90130d0698e50041aab5e14f9397 "*/libexpat1.deb) ;;', - ' "eae4a828df2eb53d728f88109d9f9549e0983a90b573cf0c7fa1e4bbc7533a7e "*/libjq1.deb) ;;', - ' "c25086443abd04d1457cbb322a0837f9ba986f82b28f44670467c8dc9be1f696 "*/jq.deb) ;;', - " *) return 1 ;;", - " esac", - " (( count += 1 ))", - " done", - ' [[ "$count" -eq 3 ]]', - "}", - ].join("\n"), - [ - "jq() {", - ' if [[ "$#" -eq 1 && "$1" == "--version" ]]; then', - ' printf "jq-1.8.2\\n"', - ' elif [[ "$#" -eq 2 && "$1" == "-e" && "$2" == \'.sandbox == "healthy"\' ]]; then', - " local input", - " IFS= read -r input", - ' [[ "$input" == \'{"sandbox":"healthy"}\' ]]', - " else", - " return 64", - " fi", - "}", - ].join("\n"), - [ - "python3() {", - ' [[ "$#" -eq 2 && "$1" == "-c" && "$2" == "import pyexpat; assert pyexpat.EXPAT_VERSION == \'expat_2.8.2\', pyexpat.EXPAT_VERSION" ]]', - "}", - ].join("\n"), -]; +type DebianArchitecture = "amd64" | "arm64"; + +export const BASE_APT_SECURITY_HASHES: Record< + DebianArchitecture, + { libexpat: string; libonig: string; libjq: string; jq: string; vimTiny: string } +> = { + amd64: { + libexpat: "37d24b40a745107941f823d1f22c38f197f01981f7f0783777fe0026af016463", + libonig: "3abee130696244050500bcc7870e3b4cb82ddd87149ece3fd55010c3d4e1d18c", + libjq: "9a5bf964cef39ed8f0f162e20d856e31961d28a57772b5313989b42a8be7e941", + jq: "b973a5d304f666845e8ccefab492e3850d4bc2e7aa2a1e7450862095125f2cc0", + vimTiny: "0e6e231d6d2430a92cf76f8a78506090418fa37758c33b31ed50dfbfc76e22ed", + }, + arm64: { + libexpat: "df928e3a8e4da79408d4b18e8cd80a03dffa90130d0698e50041aab5e14f9397", + libonig: "137e708575c0622d347815d19cb471a107546b16e9602805ee27afad7bba107f", + libjq: "eae4a828df2eb53d728f88109d9f9549e0983a90b573cf0c7fa1e4bbc7533a7e", + jq: "c25086443abd04d1457cbb322a0837f9ba986f82b28f44670467c8dc9be1f696", + vimTiny: "be30f7e9de0b872bec0128ccd890452c0e0e29d99017d16c0f3aa74164f6700d", + }, +}; + +export function baseAptSecurityFunctions(architecture: DebianArchitecture): string[] { + const hashes = BASE_APT_SECURITY_HASHES[architecture]; + return [ + [ + "dpkg() {", + ' if [[ "$#" -eq 1 && "$1" == "--print-architecture" ]]; then', + ` printf "${architecture}\\n"`, + ' elif [[ "$#" -eq 1 && "$1" == "--audit" ]]; then', + " return 0", + ' elif [[ "$#" -eq 7 && "$1" == "-i" && "${2##*/}" == "libexpat1.deb" && "${3##*/}" == "libonig5.deb" && "${4##*/}" == "libjq1.deb" && "${5##*/}" == "jq.deb" && "${6##*/}" == "vim-common.deb" && "${7##*/}" == "vim-tiny.deb" ]]; then', + ' printf "dpkg-install\\n" >> "$call_log"', + ' [[ -f "$2" && -f "$3" && -f "$4" && -f "$5" && -f "$6" && -f "$7" ]]', + " else", + " return 64", + " fi", + "}", + ].join("\n"), + [ + "dpkg-query() {", + ' [[ "$#" -eq 3 && "$1" == "-W" && "$2" == \'-f=${Version}\' ]] || return 64', + ' case "$3" in', + ' 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" ;;', + ' vim-common|vim-tiny) printf "2:9.2.0782-1" ;;', + " *) return 64 ;;", + " esac", + "}", + ].join("\n"), + [ + "curl() {", + ' [[ "$#" -eq 16 && "$1" == "--proto" && "$2" == "=https" && "$3" == "--tlsv1.2" && "$4" == "-fsSL" ]] || return 64', + ' [[ "$5" == "--retry" && "$6" == "5" && "$7" == "--retry-all-errors" && "$8" == "--retry-delay" && "$9" == "2" ]] || return 64', + ' [[ "${10}" == "--connect-timeout" && "${11}" == "15" && "${12}" == "--max-time" && "${13}" == "120" && "${14}" == "-o" ]] || return 64', + ' case "${16}" in', + ` */e/expat/libexpat1_2.8.2-1_${architecture}.deb) [[ "\${15##*/}" == "libexpat1.deb" ]] ;;`, + ` */libo/libonig/libonig5_6.9.9-1+b1_${architecture}.deb) [[ "\${15##*/}" == "libonig5.deb" ]] ;;`, + ` */j/jq/libjq1_1.8.2-1_${architecture}.deb) [[ "\${15##*/}" == "libjq1.deb" ]] ;;`, + ` */j/jq/jq_1.8.2-1_${architecture}.deb) [[ "\${15##*/}" == "jq.deb" ]] ;;`, + ' */v/vim/vim-common_9.2.0782-1_all.deb) [[ "${15##*/}" == "vim-common.deb" ]] ;;', + ` */v/vim/vim-tiny_9.2.0782-1_${architecture}.deb) [[ "\${15##*/}" == "vim-tiny.deb" ]] ;;`, + " *) return 64 ;;", + " esac", + ' printf "download %s\\n" "${16}" >> "$call_log"', + ' printf "%s\\n" "${16}" > "${15}"', + "}", + ].join("\n"), + [ + "sha256sum() {", + ' [[ "$#" -eq 2 && "$1" == "-c" && "$2" == "-" ]] || return 64', + " local line path count=0", + " while IFS= read -r line; do", + ' path="${line#* }"', + ' [[ -f "$path" ]] || return 1', + ' case "$line" in', + ` "${hashes.libexpat} "*/libexpat1.deb) ;;`, + ` "${hashes.libonig} "*/libonig5.deb) ;;`, + ` "${hashes.libjq} "*/libjq1.deb) ;;`, + ` "${hashes.jq} "*/jq.deb) ;;`, + ' "6b063038246492c4a20e0a212c896dde4d5aa9f59d6fb43ff33d10080bc53a39 "*/vim-common.deb) ;;', + ` "${hashes.vimTiny} "*/vim-tiny.deb) ;;`, + " *) return 1 ;;", + " esac", + " (( count += 1 ))", + " done", + ' [[ "$count" -eq 6 ]]', + "}", + ].join("\n"), + [ + "jq() {", + ' if [[ "$#" -eq 1 && "$1" == "--version" ]]; then', + ' printf "jq-1.8.2\\n"', + ' elif [[ "$#" -eq 2 && "$1" == "-e" && "$2" == \'.sandbox == "healthy"\' ]]; then', + " local input", + " IFS= read -r input", + ' [[ "$input" == \'{"sandbox":"healthy"}\' ]]', + " else", + " return 64", + " fi", + "}", + ].join("\n"), + [ + "ldd() {", + ' [[ "$#" -eq 1 && "$1" == "/usr/bin/jq" ]] || return 64', + ' printf "libonig.so.5 => /lib/libonig.so.5\\n"', + "}", + ].join("\n"), + [ + "python3() {", + ' [[ "$#" -eq 2 && "$1" == "-c" && "$2" == "import pyexpat; assert pyexpat.EXPAT_VERSION == \'expat_2.8.2\', pyexpat.EXPAT_VERSION" ]]', + "}", + ].join("\n"), + [ + "vim.tiny() {", + ' [[ "$#" -eq 1 && "$1" == "--version" ]] || return 64', + ' printf "VIM - Vi IMproved 9.2 (2024 Jan 2)\\n"', + "}", + ].join("\n"), + ]; +} + +export const BASE_APT_SECURITY_FUNCTIONS = baseAptSecurityFunctions("arm64"); diff --git a/test/hermes-final-image-layout.test.ts b/test/hermes-final-image-layout.test.ts index a40e8e8425..73c4ee989e 100644 --- a/test/hermes-final-image-layout.test.ts +++ b/test/hermes-final-image-layout.test.ts @@ -199,6 +199,7 @@ describe("Hermes final image layout", () => { finalStage.indexOf("RUN check_metadata()"), ); for (const metadataContract of [ + "/scripts/patch-bundled-npm-brace-expansion.mts 'root:root 444'", "/scripts/patch-bundled-npm-tar.mts 'root:root 444'", "/opt/nemoclaw-hermes-config/generate-config.ts 'root:root 444'", "/usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py 'root:root 755'", diff --git a/test/hermes-share-mount-deps.test.ts b/test/hermes-share-mount-deps.test.ts index f10e1426ad..ec96be1af0 100644 --- a/test/hermes-share-mount-deps.test.ts +++ b/test/hermes-share-mount-deps.test.ts @@ -6,6 +6,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { BASE_APT_SECURITY_FUNCTIONS } from "./helpers/base-apt-security-functions"; const ROOT = path.resolve(import.meta.dirname, ".."); const HERMES_DOCKERFILE_BASE = path.join(ROOT, "agents", "hermes", "Dockerfile.base"); @@ -51,7 +52,7 @@ function extractHermesRuntimeGuard(dockerfile: string): string { .replace(/\\\n/g, " "); } -function runLoggedShell(command: string, tmp: string) { +function runLoggedShell(command: string, tmp: string, functionDefs: string[] = []) { const logPath = path.join(tmp, "calls.log"); const scriptPath = path.join(tmp, "run-hermes-apt-layer.sh"); const script = [ @@ -59,6 +60,7 @@ function runLoggedShell(command: string, tmp: string) { "set -euo pipefail", `call_log=${JSON.stringify(logPath)}`, 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; }', + ...functionDefs, command, ].join("\n"); fs.writeFileSync(scriptPath, script, { mode: 0o700 }); @@ -189,11 +191,22 @@ describe("Hermes share mount package parity (#2947)", () => { const dockerfile = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8"); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-share-apt-")); const lists = path.join(tmp, "apt-lists"); + const securityDebs = path.join(tmp, "security-debs"); + const inventoryDirectory = path.join(tmp, "security-inventory"); + const inventory = path.join(inventoryDirectory, "security-packages.txt"); fs.mkdirSync(lists); try { - const command = extractAptInstallCommand(dockerfile).replaceAll("/var/lib/apt/lists", lists); - const { result, calls } = runLoggedShell(command, tmp); + const command = extractAptInstallCommand(dockerfile) + .replaceAll("/var/lib/apt/lists", lists) + .replaceAll("/tmp/nemoclaw-debian-security", securityDebs) + .replaceAll("/usr/local/share/nemoclaw/security-packages.txt", inventory) + .replaceAll("/usr/local/share/nemoclaw", inventoryDirectory); + const { result, calls } = runLoggedShell(command, tmp, [ + '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; }', + ...BASE_APT_SECURITY_FUNCTIONS, + ]); expect(result.status).toBe(0); expect(calls).toContain("apt-get update"); diff --git a/test/openclaw-final-image-layout.test.ts b/test/openclaw-final-image-layout.test.ts index 7fe8ec8ef5..3f30c8db58 100644 --- a/test/openclaw-final-image-layout.test.ts +++ b/test/openclaw-final-image-layout.test.ts @@ -18,7 +18,7 @@ describe("OpenClaw final image layout", () => { const payloads = [ { stage: "openclaw-dependency-payload", - copies: 11, + copies: 12, metadata: "/ /usr /usr/local /usr/local/lib", }, { @@ -70,6 +70,7 @@ describe("OpenClaw final image layout", () => { "COPY scripts/checks/node-tar-image-scan.mts /scripts/checks/node-tar-image-scan.mts", ]); for (const metadataContract of [ + "/scripts/patch-bundled-npm-brace-expansion.mts 'root:root:755'", "/scripts/patch-bundled-npm-tar.mts 'root:root:755'", "/opt/nemoclaw/openclaw.plugin.json 'root:root:644'", "/usr/local/lib/nemoclaw/patch-openclaw-tool-catalog.mts 'root:root:755'", @@ -87,6 +88,13 @@ describe("OpenClaw final image layout", () => { expect(dependency).toBeLessThan( finalStage.indexOf("RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts"), ); + expect( + finalStage.indexOf("RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts"), + ).toBeLessThan( + finalStage.indexOf( + "RUN node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts", + ), + ); expect(plugin).toBeGreaterThan(finalStage.indexOf("RUN npm ci --omit=dev")); expect(plugin).toBeLessThan( finalStage.indexOf("RUN chmod -R a+rX /opt/nemoclaw /opt/nemoclaw-blueprint/"), diff --git a/test/patch-bundled-npm-brace-expansion.test.ts b/test/patch-bundled-npm-brace-expansion.test.ts new file mode 100644 index 0000000000..3ba2b02d04 --- /dev/null +++ b/test/patch-bundled-npm-brace-expansion.test.ts @@ -0,0 +1,260 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + AFFECTED_BRACE_EXPANSION_VERSION, + FIXED_BRACE_EXPANSION_VERSION, + patchBundledNpmBraceExpansion, + patchBundledNpmBraceExpansionFromRegistry, + verifyBundledNpmBraceExpansion, +} from "../scripts/patch-bundled-npm-brace-expansion.mts"; + +const temporaryDirectories: string[] = []; + +function temporaryDirectory(): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-npm-brace-")); + temporaryDirectories.push(directory); + return directory; +} + +function writeJson(file: string, value: object): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function fixture(braceExpansionVersion: string) { + const root = temporaryDirectory(); + const npmRoot = path.join(root, "npm"); + const replacementRoot = path.join(root, "replacement"); + writeJson(path.join(npmRoot, "package.json"), { + name: "npm", + version: "11.18.0", + }); + writeJson(path.join(npmRoot, "node_modules", "brace-expansion", "package.json"), { + dependencies: { "balanced-match": "^4.0.2" }, + name: "brace-expansion", + version: braceExpansionVersion, + }); + fs.writeFileSync(path.join(npmRoot, "node_modules", "brace-expansion", "old.js"), "old\n"); + const arboristBin = path.join(npmRoot, "node_modules", "@npmcli", "arborist", "bin", "index.js"); + fs.mkdirSync(path.dirname(arboristBin), { recursive: true }); + fs.writeFileSync(arboristBin, "#!/usr/bin/env node\n"); + const npmBin = path.join(npmRoot, "node_modules", ".bin"); + fs.mkdirSync(npmBin); + fs.symlinkSync("../@npmcli/arborist/bin/index.js", path.join(npmBin, "arborist")); + writeJson(path.join(replacementRoot, "package.json"), { + dependencies: { "balanced-match": "^4.0.2" }, + name: "brace-expansion", + version: FIXED_BRACE_EXPANSION_VERSION, + }); + fs.mkdirSync(path.join(replacementRoot, "dist")); + fs.writeFileSync(path.join(replacementRoot, "dist", "fixed.js"), "fixed\n"); + return { npmRoot, replacementRoot }; +} + +function expectAffectedTreeUnchanged(target: ReturnType): void { + expect( + fs.existsSync(path.join(target.npmRoot, "node_modules", "brace-expansion", "old.js")), + ).toBe(true); +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { force: true, recursive: true }); + } +}); + +describe("npm bundled brace-expansion remediation", () => { + it("replaces the complete affected private package tree", () => { + const target = fixture(AFFECTED_BRACE_EXPANSION_VERSION); + + expect(patchBundledNpmBraceExpansion(target)).toMatchObject({ + braceExpansionVersion: FIXED_BRACE_EXPANSION_VERSION, + npmVersion: "11.18.0", + state: "fixed", + }); + expect( + fs.existsSync(path.join(target.npmRoot, "node_modules", "brace-expansion", "old.js")), + ).toBe(false); + expect( + fs.readFileSync( + path.join(target.npmRoot, "node_modules", "brace-expansion", "dist", "fixed.js"), + "utf8", + ), + ).toBe("fixed\n"); + }); + + it("does not invoke npm or npx until the private package is replaced and verified", () => { + const target = fixture(AFFECTED_BRACE_EXPANSION_VERSION); + const commands: string[] = []; + const postPatchAssertions: Record void> = { + npm: () => expect(verifyBundledNpmBraceExpansion(target.npmRoot).state).toBe("fixed"), + npx: () => expect(verifyBundledNpmBraceExpansion(target.npmRoot).state).toBe("fixed"), + }; + + const result = patchBundledNpmBraceExpansionFromRegistry(target.npmRoot, { + commandRunner(command) { + commands.push(command); + postPatchAssertions[command]?.(); + }, + prepareReplacement(commandRunner) { + commandRunner("curl", []); + commandRunner("tar", []); + return { + cleanup: () => commands.push("cleanup"), + replacementRoot: target.replacementRoot, + }; + }, + }); + + expect(result.state).toBe("fixed"); + expect(commands).toEqual(["curl", "tar", "npm", "npx", "cleanup"]); + }); + + it("is idempotent when npm already bundles the fixed release", () => { + const target = fixture(FIXED_BRACE_EXPANSION_VERSION); + expect(patchBundledNpmBraceExpansion(target)).toMatchObject({ state: "fixed" }); + expect( + fs.existsSync(path.join(target.npmRoot, "node_modules", "brace-expansion", "old.js")), + ).toBe(true); + }); + + it("restores the original package when the replacement rename fails", () => { + const target = fixture(AFFECTED_BRACE_EXPANSION_VERSION); + const originalRenameSync = fs.renameSync.bind(fs); + const renameSpy = vi + .spyOn(fs, "renameSync") + .mockImplementationOnce(() => { + throw new Error("injected replacement rename failure"); + }) + .mockImplementation(originalRenameSync); + syncBuiltinESMExports(); + + try { + expect(() => patchBundledNpmBraceExpansion(target)).toThrow( + "injected replacement rename failure", + ); + } finally { + renameSpy.mockRestore(); + syncBuiltinESMExports(); + } + + expect( + fs.existsSync(path.join(target.npmRoot, "node_modules", "brace-expansion", "old.js")), + ).toBe(true); + expect(() => verifyBundledNpmBraceExpansion(target.npmRoot)).toThrow( + `affected brace-expansion@${AFFECTED_BRACE_EXPANSION_VERSION}`, + ); + }); + + it("restores the original package when post-swap verification fails", () => { + const target = fixture(AFFECTED_BRACE_EXPANSION_VERSION); + const livePath = path.join(target.npmRoot, "node_modules", "brace-expansion"); + const originalRenameSync = fs.renameSync.bind(fs); + const renameSpy = vi + .spyOn(fs, "renameSync") + .mockImplementationOnce((oldPath, newPath) => { + originalRenameSync(oldPath, newPath); + writeJson(path.join(livePath, "package.json"), { + dependencies: { "balanced-match": "^0.0.0" }, + name: "brace-expansion", + version: FIXED_BRACE_EXPANSION_VERSION, + }); + }) + .mockImplementation(originalRenameSync); + syncBuiltinESMExports(); + + try { + expect(() => patchBundledNpmBraceExpansion(target)).toThrow( + "npm bundled brace-expansion identity or dependency layout has drifted", + ); + } finally { + renameSpy.mockRestore(); + syncBuiltinESMExports(); + } + + expectAffectedTreeUnchanged(target); + expect(() => verifyBundledNpmBraceExpansion(target.npmRoot)).toThrow( + `affected brace-expansion@${AFFECTED_BRACE_EXPANSION_VERSION}`, + ); + }); + + it("keeps the verified replacement authoritative when backup cleanup is partial", () => { + const target = fixture(AFFECTED_BRACE_EXPANSION_VERSION); + const originalRmSync = fs.rmSync.bind(fs); + let injectedCleanupFailure = false; + const failPartialBackupCleanup = (targetPath: fs.PathLike): never => { + injectedCleanupFailure = true; + originalRmSync(path.join(String(targetPath), "old.js"), { force: true }); + throw new Error("injected partial backup cleanup failure"); + }; + const rmSpy = vi.spyOn(fs, "rmSync").mockImplementation((targetPath, options) => { + return !injectedCleanupFailure && + String(targetPath).includes("brace-expansion.nemoclaw-backup-") + ? failPartialBackupCleanup(targetPath) + : originalRmSync(targetPath, options); + }); + syncBuiltinESMExports(); + + try { + expect(patchBundledNpmBraceExpansion(target)).toMatchObject({ state: "fixed" }); + } finally { + rmSpy.mockRestore(); + syncBuiltinESMExports(); + } + + expect(injectedCleanupFailure).toBe(true); + expect(verifyBundledNpmBraceExpansion(target.npmRoot)).toMatchObject({ state: "fixed" }); + expect( + fs + .readdirSync(path.join(target.npmRoot, "node_modules")) + .some((entry) => entry.startsWith("brace-expansion.nemoclaw-backup-")), + ).toBe(false); + }); + + it("fails closed on npm layout drift and unsafe replacement members", () => { + const drifted = fixture(AFFECTED_BRACE_EXPANSION_VERSION); + writeJson(path.join(drifted.npmRoot, "package.json"), { + name: "npm", + version: "12.0.0", + }); + expect(() => patchBundledNpmBraceExpansion(drifted)).toThrow( + "npm package identity has drifted", + ); + expectAffectedTreeUnchanged(drifted); + + const unsafe = fixture(AFFECTED_BRACE_EXPANSION_VERSION); + fs.symlinkSync("package.json", path.join(unsafe.replacementRoot, "unsafe-link")); + expect(() => patchBundledNpmBraceExpansion(unsafe)).toThrow("unsafe member"); + expectAffectedTreeUnchanged(unsafe); + + const symlinkedNpmTree = fixture(AFFECTED_BRACE_EXPANSION_VERSION); + fs.symlinkSync( + "brace-expansion", + path.join(symlinkedNpmTree.npmRoot, "node_modules", "brace-expansion-alias"), + ); + expect(() => patchBundledNpmBraceExpansion(symlinkedNpmTree)).toThrow( + "npm package contains an unsafe symlink", + ); + expectAffectedTreeUnchanged(symlinkedNpmTree); + + const escapingBinLink = fixture(AFFECTED_BRACE_EXPANSION_VERSION); + const outsideNodeModules = path.join(path.dirname(escapingBinLink.npmRoot), "outside.js"); + fs.writeFileSync(outsideNodeModules, "outside\n"); + fs.symlinkSync( + "../../../outside.js", + path.join(escapingBinLink.npmRoot, "node_modules", ".bin", "escape"), + ); + expect(() => patchBundledNpmBraceExpansion(escapingBinLink)).toThrow( + "npm package contains an unsafe symlink", + ); + expectAffectedTreeUnchanged(escapingBinLink); + }); +}); diff --git a/test/perl-critical-cve-remediation.test.ts b/test/perl-critical-cve-remediation.test.ts index 3e5d00e4ab..98f6eade21 100644 --- a/test/perl-critical-cve-remediation.test.ts +++ b/test/perl-critical-cve-remediation.test.ts @@ -137,12 +137,24 @@ describe("sandbox base critical Perl CVE remediation", () => { expect(runtime).toContain('test -z "$(dpkg --audit)"'); }); - it("fails the image build unless all three critical fixes are active (#7338)", () => { + it("fails the image build unless the reviewed critical and high fixes are active (#7338)", () => { const runtime = completedStage(); expect(runtime).toContain(`test "$(perl -e 'print $^V')" = "v${fixedPerlVersion}"`); expect(runtime).toContain(`test "$(perl -MSocket -e 'print Socket->VERSION')" = "2.041"`); expect(runtime).toContain(`test "$(perl -MStorable -e 'print Storable->VERSION')" = "3.41"`); + expect(runtime).toContain( + `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"'); diff --git a/test/sandbox-base-security-packages.test.ts b/test/sandbox-base-security-packages.test.ts index d479b64147..e4ac64d899 100644 --- a/test/sandbox-base-security-packages.test.ts +++ b/test/sandbox-base-security-packages.test.ts @@ -6,37 +6,174 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { - BASE_APT_SECURITY_FUNCTIONS, + BASE_APT_SECURITY_HASHES, + baseAptSecurityFunctions, dockerRunCommandBetween, runLoggedDockerShell, } from "./helpers/base-apt-security-functions"; const ROOT = path.resolve(import.meta.dirname, ".."); -const DOCKERFILE_BASE = path.join(ROOT, "Dockerfile.base"); +const SECURITY_IMAGES = [ + { + name: "OpenClaw", + dockerfile: path.join(ROOT, "Dockerfile.base"), + finalDockerfile: path.join(ROOT, "Dockerfile"), + startMarker: "# Trixie has not published fixes", + endMarker: "# gosu for privilege separation", + }, + { + name: "Hermes", + dockerfile: path.join(ROOT, "agents", "hermes", "Dockerfile.base"), + finalDockerfile: path.join(ROOT, "agents", "hermes", "Dockerfile"), + startMarker: "# Install the reviewed libexpat, jq, and Vim packages", + endMarker: "COPY scripts/lib/reviewed-npm-archive.mts", + }, + { + name: "Deep Agents Code", + 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", + endMarker: "# Node remains available", + }, +] as const; +const ARCHITECTURES = ["amd64", "arm64"] as const; +const SECURITY_CASES = SECURITY_IMAGES.flatMap((image) => + ARCHITECTURES.map((architecture) => [image.name, architecture, image] as const), +); + +function sandboxSecurityCommand( + image: (typeof SECURITY_IMAGES)[number], + tmp: string, +): { command: string; inventory: string; securityDebs: string } { + const lists = path.join(tmp, "apt-lists"); + const securityDebs = path.join(tmp, "security-debs"); + const inventoryDirectory = path.join(tmp, "security-inventory"); + const inventory = path.join(inventoryDirectory, "security-packages.txt"); + const fakePython3 = path.join(tmp, "usr-bin", "python3"); + const fakePythonLink = path.join(tmp, "usr-local-bin", "python"); + fs.mkdirSync(lists); + fs.mkdirSync(path.dirname(fakePython3), { recursive: true }); + fs.mkdirSync(path.dirname(fakePythonLink), { recursive: true }); + fs.writeFileSync(fakePython3, "#!/bin/sh\n", { mode: 0o755 }); + + const dockerfile = fs.readFileSync(image.dockerfile, "utf-8"); + const command = dockerRunCommandBetween(dockerfile, image.startMarker, image.endMarker) + .replaceAll("/var/lib/apt/lists", lists) + .replaceAll("/tmp/nemoclaw-debian-security", securityDebs) + .replaceAll("/usr/local/share/nemoclaw/security-packages.txt", inventory) + .replaceAll("/usr/local/share/nemoclaw", inventoryDirectory) + .replaceAll("/usr/local/bin/python", fakePythonLink) + .replaceAll("/usr/bin/python3", fakePython3); + return { command, inventory, securityDebs }; +} + +function securityInventory(architecture: (typeof ARCHITECTURES)[number]): string { + return [ + `architecture=${architecture}`, + "libexpat1=2.8.2-1", + "libonig5=6.9.9-1+b1", + "libjq1=1.8.2-1", + "jq=1.8.2-1", + "vim-common=2:9.2.0782-1", + "vim-tiny=2:9.2.0782-1", + "", + ].join("\n"); +} + +function completedImageSecurityCommand( + image: (typeof SECURITY_IMAGES)[number], + tmp: string, + architecture: (typeof ARCHITECTURES)[number], +): { command: string; inventory: string } { + const inventory = path.join(tmp, "security-packages.txt"); + fs.writeFileSync(inventory, securityInventory(architecture), { mode: 0o444 }); + const dockerfile = fs.readFileSync(image.finalDockerfile, "utf-8"); + const command = dockerRunCommandBetween( + dockerfile, + "# Verify the immutable security package inventory in the completed image.", + "# End completed-image security package verification.", + ).replaceAll("/usr/local/share/nemoclaw/security-packages.txt", inventory); + return { command, inventory }; +} describe("sandbox base security packages", () => { - it("rejects a sandbox security package when its expected checksum changes", () => { - const dockerfile = fs.readFileSync(DOCKERFILE_BASE, "utf-8"); + it.each( + SECURITY_CASES, + )("executes the exact security package contract for %s on %s", (_name, architecture, image) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-base-security-")); + const { command, inventory, securityDebs } = sandboxSecurityCommand(image, tmp); + + try { + const result = runLoggedDockerShell(command, tmp, [ + 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; }', + '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; }', + ...baseAptSecurityFunctions(architecture), + ]); + 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(inventory, "utf-8")).toBe(securityInventory(architecture)); + expect(fs.statSync(inventory).mode & 0o777).toBe(0o444); + expect( + calls + .split("\n") + .filter((line) => line.startsWith("download ")) + .map((line) => line.slice(line.lastIndexOf("/") + 1)), + ).toEqual([ + `libexpat1_2.8.2-1_${architecture}.deb`, + `libonig5_6.9.9-1+b1_${architecture}.deb`, + `libjq1_1.8.2-1_${architecture}.deb`, + `jq_1.8.2-1_${architecture}.deb`, + "vim-common_9.2.0782-1_all.deb", + `vim-tiny_9.2.0782-1_${architecture}.deb`, + ]); + expect(fs.existsSync(securityDebs)).toBe(false); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it.each( + SECURITY_CASES, + )("executes the completed-image package contract for %s on %s", (_name, architecture, image) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-final-security-")); + const prepared = completedImageSecurityCommand(image, tmp, architecture); + + try { + const result = runLoggedDockerShell(prepared.command, tmp, [ + [ + "stat() {", + ` [[ "$#" -eq 3 && "$1" == "-c" && "$2" == "%u:%g:%a" && "$3" == ${JSON.stringify(prepared.inventory)} ]] || return 64`, + ' printf "0:0:444\\n"', + "}", + ].join("\n"), + ...baseAptSecurityFunctions(architecture), + ]); + expect({ status: result.status, stderr: result.stderr }).toEqual({ status: 0, stderr: "" }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it.each( + 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 untouchedTail = path.join(tmp, "untouched-python-link"); - const command = dockerRunCommandBetween( - dockerfile, - "ENV DEBIAN_FRONTEND=noninteractive", - "# gosu for privilege separation", - ) - .replace("df928e3a8e4da79408d4b18e8cd80a03dffa90130d0698e50041aab5e14f9397", "0".repeat(64)) - .replaceAll("/var/lib/apt/lists", tmp) - .replaceAll("/tmp/nemoclaw-debian-security", path.join(tmp, "security-debs")) - .replaceAll("/usr/local/bin/python", untouchedTail) - .replaceAll("/usr/bin/python3", path.join(tmp, "python3")); + const prepared = sandboxSecurityCommand(image, tmp); + const command = prepared.command.replace( + BASE_APT_SECURITY_HASHES[architecture].libexpat, + "0".repeat(64), + ); try { const result = runLoggedDockerShell(command, tmp, [ 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; }', - ...BASE_APT_SECURITY_FUNCTIONS, + ...baseAptSecurityFunctions(architecture), ]); expect(result.status).not.toBe(0); - expect(fs.existsSync(untouchedTail)).toBe(false); + expect(fs.readFileSync(path.join(tmp, "calls.log"), "utf-8")).not.toContain("dpkg-install"); + expect(fs.existsSync(prepared.securityDebs)).toBe(true); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } diff --git a/test/sandbox-build-context.test.ts b/test/sandbox-build-context.test.ts index f98d95bfb1..80759c13cb 100644 --- a/test/sandbox-build-context.test.ts +++ b/test/sandbox-build-context.test.ts @@ -119,6 +119,7 @@ describe("sandbox build context staging", () => { writeFixture(path.join("scripts", "patch-openclaw-device-self-approval.mts")); writeFixture(path.join("scripts", "extract-semver.sh")); writeFixture(path.join("scripts", "patch-openclaw-shared-state-permissions.mts")); + writeFixture(path.join("scripts", "patch-bundled-npm-brace-expansion.mts")); writeFixture(path.join("scripts", "patch-bundled-npm-tar.mts")); writeFixture(path.join("scripts", "upgrade-bundled-npm.mts")); writeFixture(path.join("scripts", "verify-wechat-runtime-lock.mts")); @@ -524,6 +525,9 @@ describe("sandbox build context staging", () => { ), ).toBe(true); expect(fs.existsSync(path.join(buildCtx, "scripts", "patch-bundled-npm-tar.mts"))).toBe(true); + expect( + fs.existsSync(path.join(buildCtx, "scripts", "patch-bundled-npm-brace-expansion.mts")), + ).toBe(true); expect(fs.existsSync(path.join(buildCtx, "scripts", "upgrade-bundled-npm.mts"))).toBe(true); expect( fs.existsSync(path.join(buildCtx, "scripts", "checks", "node-tar-image-scan.mts")), diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index 973f2e77c1..a6d4b5e0c9 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -1069,11 +1069,14 @@ describe("sandbox provisioning: base runtime tools", () => { ) .replaceAll("/var/lib/apt/lists", lists) .replaceAll("/tmp/nemoclaw-debian-security", securityDebs) + .replaceAll("/usr/local/share/nemoclaw", path.join(tmp, "security-inventory")) .replaceAll("/usr/local/bin/python", fakePyLink) .replaceAll("/usr/bin/python3", fakePy3); try { const { result, calls } = runLoggedDockerShell(command, tmp, [ 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; }', + '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; }', ...BASE_APT_SECURITY_FUNCTIONS, ]); expect(result.status).toBe(0); @@ -1104,11 +1107,14 @@ describe("sandbox provisioning: base runtime tools", () => { ) .replaceAll("/var/lib/apt/lists", lists) .replaceAll("/tmp/nemoclaw-debian-security", securityDebs) + .replaceAll("/usr/local/share/nemoclaw", path.join(tmp, "security-inventory")) .replaceAll("/usr/local/bin/python", fakePyLink) .replaceAll("/usr/bin/python3", fakePy3); try { const { result } = runLoggedDockerShell(command, tmp, [ 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; }', + '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; }', ...BASE_APT_SECURITY_FUNCTIONS, ]); expect(result.status).toBe(0);