diff --git a/.github/workflows/sandbox-images-and-e2e.yaml b/.github/workflows/sandbox-images-and-e2e.yaml index 0a5fba35049..478ae1b8007 100644 --- a/.github/workflows/sandbox-images-and-e2e.yaml +++ b/.github/workflows/sandbox-images-and-e2e.yaml @@ -218,6 +218,30 @@ jobs: cache-from: type=gha,scope=hermes-production-${{ runner.os }}-${{ runner.arch }} cache-to: type=gha,mode=max,scope=hermes-production-${{ runner.os }}-${{ runner.arch }} + # The production build intentionally omits NEMOCLAW_CORPORATE_CA_B64. A + # successful final stage therefore proves its registry remediations and + # Hermes agent-install phase complete with the base image's default trust. + - name: Verify Hermes default-trust final image + shell: bash + run: | + set -euo pipefail + docker run --rm \ + --network none \ + --read-only \ + --cap-drop ALL \ + --security-opt no-new-privileges \ + --pids-limit 64 \ + --memory 256m \ + --entrypoint /bin/sh \ + nemoclaw-hermes-production -eu -c ' + test "$NODE_EXTRA_CA_CERTS" = /usr/local/share/nemoclaw/corporate-ca.pem + test ! -e /usr/local/share/nemoclaw/corporate-ca.pem + test ! -L /usr/local/share/nemoclaw/corporate-ca.pem + test -x /usr/local/bin/hermes + node -e "const tls = require(\"node:tls\"); if (tls.rootCertificates.length === 0) process.exit(1); tls.createSecureContext()" + /opt/hermes/.venv/bin/python -I -c "import ssl; assert ssl.create_default_context().get_ca_certs()" + ' + - name: Scan completed Hermes image for node-tar id: node-tar-scan shell: bash @@ -416,14 +440,32 @@ jobs: shell: bash run: | set -euo pipefail + # curl and Python replace their default roots with this build argument. + compact_ca_bundle="$(mktemp)" + trap 'rm -f "$compact_ca_bundle"' EXIT + node --experimental-strip-types scripts/checks/select-ci-endpoint-ca-roots.mts \ + --output "$compact_ca_bundle" + corporate_ca_b64="$(base64 -w 0 "$compact_ca_bundle")" + corporate_ca_sha256="$(sha256sum "$compact_ca_bundle" | cut -d ' ' -f 1)" messaging_plan_b64="$(node --experimental-strip-types scripts/check-messaging-plan-image-boundary.mts plan hermes)" build_args=( -f agents/hermes/Dockerfile --build-arg "BASE_IMAGE=${HERMES_BASE_IMAGE}" + --build-arg "NEMOCLAW_CORPORATE_CA_B64=${corporate_ca_b64}" --build-arg "NEMOCLAW_MESSAGING_PLAN_B64=${messaging_plan_b64}" ) scripts/check-production-build-args.sh "${build_args[@]}" docker build "${build_args[@]}" -t nemoclaw-hermes-plan-boundary . + installed_ca_sha256="$( + docker run --rm --network none --entrypoint sha256sum \ + nemoclaw-hermes-plan-boundary \ + /usr/local/share/nemoclaw/corporate-ca.pem | + cut -d ' ' -f 1 + )" + test "$installed_ca_sha256" = "$corporate_ca_sha256" + docker run --rm --network none --entrypoint openssl \ + nemoclaw-hermes-plan-boundary crl2pkcs7 -nocrl \ + -certfile /usr/local/share/nemoclaw/corporate-ca.pem -out /dev/null node --experimental-strip-types scripts/check-messaging-plan-image-boundary.mts verify \ nemoclaw-hermes-plan-boundary hermes diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 977ee162692..635b8dc159e 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -150,24 +150,70 @@ COPY scripts/checks/node-tar-image-scan.mts /scripts/checks/node-tar-image-scan. # hadolint ignore=DL3006 FROM ${BASE_IMAGE} +# Base64-encoded host corporate-proxy CA bundle (#6210). Empty by default. When +# onboard detects an operator-supplied corporate CA on the host it bakes it +# here; the RUN below decodes it to a root-owned file that the entrypoint +# appends to the OpenShell trust bundle at runtime. The CA is a public +# certificate, not a secret, so baking it into an image layer is acceptable. +ARG NEMOCLAW_CORPORATE_CA_B64 + +# Decode the host corporate-proxy CA (#6210) to a root-owned, read-only file +# when onboard baked one in. No-op when NEMOCLAW_CORPORATE_CA_B64 is empty. The +# ARG is expanded by the shell (not interpolated into source), and its value is +# base64 sanitized host-side, so this is not an injection vector. Must run as +# root, before the USER sandbox drop below. +# hadolint ignore=DL3059,DL4006 +RUN if [ -n "${NEMOCLAW_CORPORATE_CA_B64}" ]; then \ + command -v base64 >/dev/null 2>&1 || { echo "[nemoclaw] base64 is required to decode NEMOCLAW_CORPORATE_CA_B64 but is not installed in the build image" >&2; exit 1; }; \ + command -v openssl >/dev/null 2>&1 || { echo "[nemoclaw] openssl is required to validate NEMOCLAW_CORPORATE_CA_B64 but is not installed in the build image (#6210)" >&2; exit 1; }; \ + mkdir -p /usr/local/share/nemoclaw \ + && { printf '%s' "${NEMOCLAW_CORPORATE_CA_B64}" | base64 --decode > /tmp/nemoclaw-corporate-ca.decoded 2>/dev/null \ + || { echo "[nemoclaw] NEMOCLAW_CORPORATE_CA_B64 is not valid base64; expected a single-line base64-encoded PEM (#6210)" >&2; exit 1; }; } \ + && awk '/-----BEGIN CERTIFICATE-----/{f=1} f{print} /-----END CERTIFICATE-----/{f=0}' /tmp/nemoclaw-corporate-ca.decoded > /usr/local/share/nemoclaw/corporate-ca.pem \ + && rm -f /tmp/nemoclaw-corporate-ca.decoded \ + && { grep -qF -- "-----BEGIN CERTIFICATE-----" /usr/local/share/nemoclaw/corporate-ca.pem || { echo "[nemoclaw] NEMOCLAW_CORPORATE_CA_B64 did not decode to a bundle of valid X.509 certificates (#6210)" >&2; exit 1; }; } \ + && { openssl crl2pkcs7 -nocrl -certfile /usr/local/share/nemoclaw/corporate-ca.pem >/dev/null 2>&1 || { echo "[nemoclaw] NEMOCLAW_CORPORATE_CA_B64 did not decode to a bundle of valid X.509 certificates (#6210)" >&2; exit 1; }; } \ + && chown root:root /usr/local/share/nemoclaw/corporate-ca.pem \ + && chmod 0444 /usr/local/share/nemoclaw/corporate-ca.pem \ + && echo "[nemoclaw] baked host corporate-proxy CA into image trust (#6210)"; \ + fi + +# Use the decoded CA for Node.js package operations in this final stage. Node.js +# ignores the path when no CA was baked. At runtime, nemoclaw-start replaces it +# with the merged OpenShell and corporate bundle. +ENV NODE_EXTRA_CA_CERTS=/usr/local/share/nemoclaw/corporate-ca.pem + # Cross-stage root copies are accepted by Docker's legacy builder and create # one final-image layer while preserving metadata on existing parent paths. COPY --from=hermes-npm-patch-payload / / # The final Hermes image owns the shipped dependency boundary independently of -# base freshness. Reassert the idempotent npm-private node-tar fix here. -RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ - --npm-root /usr/local/lib/node_modules/npm +# base freshness. Reassert the idempotent npm-private node-tar fix here. When +# onboarding supplied a corporate CA, use it for the registry-backed download. +RUN if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ + export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ + fi; \ + 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. +# When onboarding supplied a corporate CA, use it for the registry-backed +# download. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts \ - --npm-root /usr/local/lib/node_modules/npm +RUN if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ + export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ + fi; \ + node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts \ + --npm-root /usr/local/lib/node_modules/npm -# Reassert the npm-private ip-address fix for the exact final filesystem. +# Reassert the npm-private ip-address fix for the exact final filesystem. When +# onboarding supplied a corporate CA, use it for the registry-backed download. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /scripts/lib/patch-bundled-npm-ip-address.mts \ - --npm-root /usr/local/lib/node_modules/npm +RUN if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ + export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ + fi; \ + node --experimental-strip-types /scripts/lib/patch-bundled-npm-ip-address.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. @@ -605,12 +651,6 @@ ARG NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=0 ARG NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64=W10= ARG NEMOCLAW_BUILD_ID=default ARG NEMOCLAW_DARWIN_VM_COMPAT=0 -# Base64-encoded host corporate-proxy CA bundle (#6210). Empty by default. When -# onboard detects an operator-supplied corporate CA on the host it bakes it -# here; the RUN below decodes it to a root-owned file that the entrypoint -# appends to the OpenShell trust bundle at runtime. The CA is a public -# certificate, not a secret, so baking it into an image layer is acceptable. -ARG NEMOCLAW_CORPORATE_CA_B64 # Total model context window (input + output tokens). Empty by default so # Hermes auto-detects from the endpoint's /v1/models max_model_len; onboard # rewrites this ARG (via dockerfile-patch) when it probes a runtime value or @@ -660,34 +700,20 @@ RUN node --experimental-strip-types /src/lib/messaging/applier/build/messaging-b # Apply messaging agent-install hooks as root so Hermes Python packages can update # /opt/hermes/.venv before the runtime drops to the sandbox user. WORKDIR /opt/hermes +# Clear inherited Python and uv trust overrides before package installation. +# When the decoded corporate CA exists, use it only for this RUN instruction. # hadolint ignore=DL3059 -RUN node --experimental-strip-types /src/lib/messaging/applier/build/messaging-build-applier.mts --agent hermes --phase agent-install \ +RUN unset SSL_CERT_FILE REQUESTS_CA_BUNDLE; \ + if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ + export SSL_CERT_FILE=/usr/local/share/nemoclaw/corporate-ca.pem; \ + export REQUESTS_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ + fi; \ + node --experimental-strip-types /src/lib/messaging/applier/build/messaging-build-applier.mts --agent hermes --phase agent-install \ && if [ "$NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION" = "1" ]; then \ node --experimental-strip-types /src/lib/messaging/applier/build/messaging-build-applier.mts \ --agent hermes --phase managed-image-capability-union; \ fi -# Decode the host corporate-proxy CA (#6210) to a root-owned, read-only file -# when onboard baked one in. No-op when NEMOCLAW_CORPORATE_CA_B64 is empty. The -# ARG is expanded by the shell (not interpolated into source), and its value is -# base64 sanitized host-side, so this is not an injection vector. Must run as -# root, before the USER sandbox drop below. -# hadolint ignore=DL3059,DL4006 -RUN if [ -n "${NEMOCLAW_CORPORATE_CA_B64}" ]; then \ - command -v base64 >/dev/null 2>&1 || { echo "[nemoclaw] base64 is required to decode NEMOCLAW_CORPORATE_CA_B64 but is not installed in the build image" >&2; exit 1; }; \ - command -v openssl >/dev/null 2>&1 || { echo "[nemoclaw] openssl is required to validate NEMOCLAW_CORPORATE_CA_B64 but is not installed in the build image (#6210)" >&2; exit 1; }; \ - mkdir -p /usr/local/share/nemoclaw \ - && { printf '%s' "${NEMOCLAW_CORPORATE_CA_B64}" | base64 --decode > /tmp/nemoclaw-corporate-ca.decoded 2>/dev/null \ - || { echo "[nemoclaw] NEMOCLAW_CORPORATE_CA_B64 is not valid base64; expected a single-line base64-encoded PEM (#6210)" >&2; exit 1; }; } \ - && awk '/-----BEGIN CERTIFICATE-----/{f=1} f{print} /-----END CERTIFICATE-----/{f=0}' /tmp/nemoclaw-corporate-ca.decoded > /usr/local/share/nemoclaw/corporate-ca.pem \ - && rm -f /tmp/nemoclaw-corporate-ca.decoded \ - && { grep -qF -- "-----BEGIN CERTIFICATE-----" /usr/local/share/nemoclaw/corporate-ca.pem || { echo "[nemoclaw] NEMOCLAW_CORPORATE_CA_B64 did not decode to a bundle of valid X.509 certificates (#6210)" >&2; exit 1; }; } \ - && { openssl crl2pkcs7 -nocrl -certfile /usr/local/share/nemoclaw/corporate-ca.pem >/dev/null 2>&1 || { echo "[nemoclaw] NEMOCLAW_CORPORATE_CA_B64 did not decode to a bundle of valid X.509 certificates (#6210)" >&2; exit 1; }; } \ - && chown root:root /usr/local/share/nemoclaw/corporate-ca.pem \ - && chmod 0444 /usr/local/share/nemoclaw/corporate-ca.pem \ - && echo "[nemoclaw] baked host corporate-proxy CA into image trust (#6210)"; \ - fi - WORKDIR /sandbox USER sandbox diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index 0a8883a340c..25da412c881 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -91,6 +91,11 @@ "test": "trusts the corporate CA before the DCode discovery runtime npm install", "category": "security" }, + { + "file": "test/corporate-ca-build-tls-anchor.test.ts", + "test": "uses the corporate CA conditionally for all Hermes registry remediations", + "category": "security" + }, { "file": "test/dcode-base-image-workflow.test.ts", "test": "accepts every discovered publisher and rejects supply-chain mutations", diff --git a/docs/security/configure-corporate-ca-trust.mdx b/docs/security/configure-corporate-ca-trust.mdx index 201d9808465..7b85bd677fe 100644 --- a/docs/security/configure-corporate-ca-trust.mdx +++ b/docs/security/configure-corporate-ca-trust.mdx @@ -39,7 +39,12 @@ It sets `NODE_EXTRA_CA_CERTS` before build-time Node.js dependency verification, -The Hermes Dockerfile decodes the bundle after its managed build-time dependency steps, so the corporate CA does not apply to those earlier operations. +The Hermes discovery-runtime installer applies the corporate CA before its npm operations. +The final Hermes image stage decodes the CA immediately after `FROM ${BASE_IMAGE}` and sets `NODE_EXTRA_CA_CERTS` before later npm operations. +The registry-backed npm remediations set `CURL_CA_BUNDLE` before each download only when the decoded certificate file exists. +The Hermes package installer clears inherited `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` values before its build-time `uv pip install` commands. +When the decoded CA exists, it sets both variables to that file for those commands. +If the file does not exist, uv and Python use their default trust configuration. diff --git a/scripts/checks/select-ci-endpoint-ca-roots.mts b/scripts/checks/select-ci-endpoint-ca-roots.mts new file mode 100644 index 00000000000..cf503d47ddb --- /dev/null +++ b/scripts/checks/select-ci-endpoint-ca-roots.mts @@ -0,0 +1,322 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { X509Certificate } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +export const CI_CA_SYSTEM_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"; +export const CI_CA_ENDPOINTS = Object.freeze([ + "registry.npmjs.org", + "pypi.org", + "files.pythonhosted.org", +] as const); +export const MAX_CI_CA_CERTIFICATES = 24; +export const MAX_CI_CA_ENCODED_BYTES = 65_536; + +const PEM_RE = /-----BEGIN CERTIFICATE-----\r?\n[A-Za-z0-9+/=\r\n]+?-----END CERTIFICATE-----/gu; +const OPENSSL_TIMEOUT_MS = 30_000; + +type CertificateRecord = { readonly cert: X509Certificate; readonly pem: string }; +type OpenSslResult = { + readonly error?: Error; + readonly status: number | null; + readonly stderr: string; + readonly stdout: string; +}; +export type OpenSslRunner = (args: readonly string[]) => OpenSslResult; + +function runOpenSsl(args: readonly string[]): OpenSslResult { + const result = spawnSync("openssl", [...args], { + encoding: "utf8", + input: "", + killSignal: "SIGKILL", + maxBuffer: 4 * 1024 * 1024, + timeout: OPENSSL_TIMEOUT_MS, + }); + return { + error: result.error, + status: result.status, + stderr: result.stderr ?? "", + stdout: result.stdout ?? "", + }; +} + +function parseCertificates(bundle: string, label: string): CertificateRecord[] { + const blocks = bundle.match(PEM_RE); + if (!blocks?.length) throw new Error(`${label} contains no PEM certificate`); + return blocks.map((pem, index) => { + try { + return { cert: new X509Certificate(pem), pem: pem.trim() }; + } catch { + throw new Error(`${label} certificate ${index + 1} is not valid X.509`); + } + }); +} + +function isSignedBy(cert: X509Certificate, issuer: X509Certificate): boolean { + try { + return cert.verify(issuer.publicKey); + } catch { + return false; + } +} + +function isSelfSigned(cert: X509Certificate): boolean { + return cert.subject === cert.issuer && isSignedBy(cert, cert); +} + +function isCurrentSelfSignedRoot(cert: X509Certificate, nowMs = Date.now()): boolean { + const validFromMs = Date.parse(cert.validFrom); + const validToMs = Date.parse(cert.validTo); + if ( + !cert.ca || + !isSelfSigned(cert) || + Number.isNaN(validFromMs) || + Number.isNaN(validToMs) || + nowMs < validFromMs || + nowMs > validToMs + ) { + return false; + } + return true; +} + +function fingerprint(cert: X509Certificate): string { + return cert.fingerprint256.replaceAll(":", "").toLowerCase(); +} + +export function normalizeCompactRootBundle( + roots: readonly string[], + limits: { readonly certificates: number; readonly encodedBytes: number } = { + certificates: MAX_CI_CA_CERTIFICATES, + encodedBytes: MAX_CI_CA_ENCODED_BYTES, + }, +): string { + const unique = new Map(); + for (const [index, pem] of roots.entries()) { + const records = parseCertificates(pem, `selected root ${index + 1}`); + if (records.length !== 1 || !isCurrentSelfSignedRoot(records[0].cert)) { + throw new Error(`selected root ${index + 1} must be a current self-signed CA:TRUE root`); + } + unique.set(fingerprint(records[0].cert), records[0]); + } + if (unique.size === 0) throw new Error("selected root bundle is empty"); + if (unique.size > limits.certificates) { + throw new Error(`selected root bundle exceeds ${limits.certificates} certificates`); + } + const bundle = `${[...unique.values()].map(({ pem }) => pem).join("\n")}\n`; + if (Buffer.from(bundle).toString("base64").length > limits.encodedBytes) { + throw new Error(`selected root bundle exceeds ${limits.encodedBytes} encoded bytes`); + } + return bundle; +} + +function opensslOutput( + runner: OpenSslRunner, + args: readonly string[], + label: string, + requireVerifyOk = false, +): string { + const result = runner(args); + if (result.error || result.status !== 0) { + throw new Error(`${label} failed without emitting certificate data`); + } + const output = `${result.stdout}\n${result.stderr}`; + if (requireVerifyOk && !/Verify return code:\s*0\s*\(ok\)/iu.test(output)) { + throw new Error(`${label} did not report successful certificate verification`); + } + return output; +} + +function connectionArgs(endpoint: string, caFile: string, showCerts: boolean): string[] { + return [ + "s_client", + "-connect", + `${endpoint}:443`, + "-servername", + endpoint, + "-verify_hostname", + endpoint, + "-verify_return_error", + "-CAfile", + caFile, + "-no-CApath", + "-no-CAstore", + ...(showCerts ? ["-showcerts"] : []), + ]; +} + +function systemRoots(systemBundle: string): CertificateRecord[] { + const roots = new Map(); + for (const record of parseCertificates(systemBundle, "system CA bundle")) { + if (isCurrentSelfSignedRoot(record.cert)) roots.set(fingerprint(record.cert), record); + } + if (roots.size === 0) throw new Error("system CA bundle contains no current CA:TRUE root"); + return [...roots.values()]; +} + +function verifiesOffline( + runner: OpenSslRunner, + endpoint: string, + chain: readonly CertificateRecord[], + root: CertificateRecord, + tempDir: string, +): boolean { + const stem = path.join(tempDir, endpoint); + const leaf = `${stem}-leaf.pem`; + const intermediates = `${stem}-intermediates.pem`; + const rootFile = `${stem}-root.pem`; + fs.writeFileSync(leaf, `${chain[0].pem}\n`, { mode: 0o600 }); + fs.writeFileSync(rootFile, `${root.pem}\n`, { mode: 0o600 }); + const untrusted = chain.slice(1).filter(({ cert }) => !isSelfSigned(cert)); + if (untrusted.length) { + fs.writeFileSync(intermediates, `${untrusted.map(({ pem }) => pem).join("\n")}\n`, { + mode: 0o600, + }); + } + const result = runner([ + "verify", + "-purpose", + "sslserver", + "-verify_hostname", + endpoint, + "-CAfile", + rootFile, + "-no-CApath", + "-no-CAstore", + ...(untrusted.length ? ["-untrusted", intermediates] : []), + leaf, + ]); + return !result.error && result.status === 0; +} + +function selectRoot( + runner: OpenSslRunner, + endpoint: string, + chain: readonly CertificateRecord[], + roots: readonly CertificateRecord[], + tempDir: string, +): CertificateRecord { + const untrusted = chain.filter(({ cert }) => !isSelfSigned(cert)); + const candidates = roots + .filter(({ cert: root }) => + untrusted.some(({ cert }) => cert.issuer === root.subject && isSignedBy(cert, root)), + ) + .sort((left, right) => fingerprint(left.cert).localeCompare(fingerprint(right.cert))); + const selected = candidates.find((root) => + verifiesOffline(runner, endpoint, chain, root, tempDir), + ); + if (!selected) throw new Error(`no system CA root verifies the chain for ${endpoint}`); + return selected; +} + +export function writeCiEndpointCaRootsOutput(outputPath: string, bundle: string): void { + const noFollow = fs.constants.O_NOFOLLOW; + if (typeof noFollow !== "number") { + throw new Error("output requires O_NOFOLLOW support"); + } + + let fd: number; + try { + // Open without following symlinks or blocking on special files, then validate before writing. + fd = fs.openSync( + outputPath, + fs.constants.O_WRONLY | noFollow | (fs.constants.O_NONBLOCK ?? 0), + ); + } catch (error) { + throw new Error("output must be an existing regular file that is not a symlink", { + cause: error, + }); + } + try { + const opened = fs.fstatSync(fd); + const afterOpen = fs.lstatSync(outputPath); + if ( + !opened.isFile() || + opened.nlink !== 1 || + !afterOpen.isFile() || + afterOpen.isSymbolicLink() || + afterOpen.nlink !== 1 || + opened.dev !== afterOpen.dev || + opened.ino !== afterOpen.ino + ) { + throw new Error("output must remain the same regular file with exactly one link"); + } + fs.ftruncateSync(fd, 0); + fs.writeFileSync(fd, bundle); + fs.fchmodSync(fd, 0o600); + } finally { + fs.closeSync(fd); + } +} + +export function selectCiEndpointCaRoots( + outputPath: string, + runner: OpenSslRunner = runOpenSsl, +): { readonly certificates: number; readonly encodedBytes: number } { + if (path.resolve(outputPath) === path.resolve(CI_CA_SYSTEM_BUNDLE)) { + throw new Error("output must not replace the system CA bundle"); + } + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-ci-ca-roots-")); + try { + opensslOutput(runner, ["version"], "OpenSSL availability check"); + const roots = systemRoots(fs.readFileSync(CI_CA_SYSTEM_BUNDLE, "utf8")); + const selected = CI_CA_ENDPOINTS.map((endpoint) => { + const chainOutput = opensslOutput( + runner, + connectionArgs(endpoint, CI_CA_SYSTEM_BUNDLE, true), + `system CA verification for ${endpoint}`, + true, + ); + return selectRoot( + runner, + endpoint, + parseCertificates(chainOutput, `server chain for ${endpoint}`), + roots, + tempDir, + ); + }); + const bundle = normalizeCompactRootBundle(selected.map(({ pem }) => pem)); + const compactPath = path.join(tempDir, "compact.pem"); + fs.writeFileSync(compactPath, bundle, { mode: 0o600 }); + for (const endpoint of CI_CA_ENDPOINTS) { + opensslOutput( + runner, + connectionArgs(endpoint, compactPath, false), + `compact CA verification for ${endpoint}`, + true, + ); + } + writeCiEndpointCaRootsOutput(outputPath, bundle); + return { + certificates: parseCertificates(bundle, "compact CA bundle").length, + encodedBytes: Buffer.from(bundle).toString("base64").length, + }; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function main(argv: readonly string[]): void { + if (argv.length !== 2 || argv[0] !== "--output" || !argv[1]) { + throw new Error("usage: select-ci-endpoint-ca-roots.mts --output "); + } + const result = selectCiEndpointCaRoots(argv[1]); + process.stdout.write( + `Selected CA roots: ${result.certificates} (${result.encodedBytes} encoded bytes).\n`, + ); +} + +const invokedPath = process.argv[1] ? pathToFileURL(process.argv[1]).href : ""; +if (invokedPath === import.meta.url) { + try { + main(process.argv.slice(2)); + } catch (error) { + process.stderr.write(`ERROR: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/test/bundled-npm-brace-expansion-dockerfile-contract.test.ts b/test/bundled-npm-brace-expansion-dockerfile-contract.test.ts index 5cb814cc124..d8124e411d7 100644 --- a/test/bundled-npm-brace-expansion-dockerfile-contract.test.ts +++ b/test/bundled-npm-brace-expansion-dockerfile-contract.test.ts @@ -12,6 +12,7 @@ import { 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"; +import { requireSingleReviewedDockerfileRunCommand } from "./helpers/dockerfile-run-commands"; const repoRoot = path.resolve(import.meta.dirname, ".."); const baseDockerfiles = [ @@ -27,7 +28,8 @@ const finalDockerfiles = [ 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"; + "node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts"; +const npmRootArguments = ["--npm-root", "/usr/local/lib/node_modules/npm"] as const; describe("bundled npm brace-expansion image remediation contract", () => { it("binds the replacement to the reviewed npm and registry artifact", () => { @@ -43,15 +45,20 @@ describe("bundled npm brace-expansion image remediation contract", () => { 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 upgrade = requireSingleReviewedDockerfileRunCommand( + source, + "node --experimental-strip-types /scripts/upgrade-bundled-npm.mts", + npmRootArguments, + ).commandStart; + const patch = requireSingleReviewedDockerfileRunCommand( + source, + patchInstruction, + npmRootArguments, ); - 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"); + expect(patch.commandStart, file).toBeGreaterThan(upgrade); }); it.each( @@ -59,14 +66,19 @@ describe("bundled npm brace-expansion image remediation contract", () => { )("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 tarPatch = requireSingleReviewedDockerfileRunCommand( + source, + "node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts", + npmRootArguments, + ).commandStart; + const bracePatch = requireSingleReviewedDockerfileRunCommand( + source, + patchInstruction, + npmRootArguments, ); - 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"); + expect(bracePatch.commandStart, file).toBeGreaterThan(tarPatch); }); }); diff --git a/test/bundled-npm-ip-address-dockerfile-contract.test.ts b/test/bundled-npm-ip-address-dockerfile-contract.test.ts index 67309121327..01d17988562 100644 --- a/test/bundled-npm-ip-address-dockerfile-contract.test.ts +++ b/test/bundled-npm-ip-address-dockerfile-contract.test.ts @@ -12,6 +12,7 @@ import { REVIEWED_NPM_VERSION, } from "../scripts/lib/patch-bundled-npm-ip-address.mts"; import { REVIEWED_NPM_VERSION as UPGRADED_NPM_VERSION } from "../scripts/upgrade-bundled-npm.mts"; +import { requireSingleReviewedDockerfileRunCommand } from "./helpers/dockerfile-run-commands"; const repoRoot = path.resolve(import.meta.dirname, ".."); const baseDockerfiles = [ @@ -28,10 +29,7 @@ const copyInstruction = "COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts"; const patchCommand = "node --experimental-strip-types /scripts/lib/patch-bundled-npm-ip-address.mts"; - -function instructionBody(source: string, start: number): string { - return source.slice(start).split(/\n(?=\S)/u, 1)[0] ?? ""; -} +const npmRootArguments = ["--npm-root", "/usr/local/lib/node_modules/npm"] as const; describe("bundled npm ip-address image remediation contract", () => { it("binds the replacement to the reviewed npm and registry artifact", () => { @@ -49,36 +47,40 @@ describe("bundled npm ip-address image remediation contract", () => { 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(patchCommand); + const upgrade = requireSingleReviewedDockerfileRunCommand( + source, + "node --experimental-strip-types /scripts/upgrade-bundled-npm.mts", + npmRootArguments, + ).commandStart; + const patch = requireSingleReviewedDockerfileRunCommand(source, patchCommand, npmRootArguments); expect(copy, file).toBeGreaterThanOrEqual(0); expect(upgrade, file).toBeGreaterThan(copy); - expect(patch, file).toBeGreaterThan(upgrade); - expect(instructionBody(source, patch), file).toContain( - "--npm-root /usr/local/lib/node_modules/npm", - ); + expect(patch.commandStart, file).toBeGreaterThan(upgrade); }); it.each(finalDockerfiles)("reasserts the private package fix in the completed %s", (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 tarPatch = requireSingleReviewedDockerfileRunCommand( + source, + "node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts", + npmRootArguments, + ).commandStart; + const bracePatch = requireSingleReviewedDockerfileRunCommand( + source, + "node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts", + npmRootArguments, + ).commandStart; + const ipAddressPatch = requireSingleReviewedDockerfileRunCommand( + source, + patchCommand, + npmRootArguments, ); - const bracePatch = source.indexOf( - "RUN node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts", - ); - const ipAddressPatch = source.indexOf(patchCommand); expect(copy, file).toBeGreaterThanOrEqual(0); expect(tarPatch, file).toBeGreaterThan(copy); expect(bracePatch, file).toBeGreaterThan(tarPatch); - expect(ipAddressPatch, file).toBeGreaterThan(bracePatch); - expect(instructionBody(source, ipAddressPatch), file).toContain( - "--npm-root /usr/local/lib/node_modules/npm", - ); + expect(ipAddressPatch.commandStart, file).toBeGreaterThan(bracePatch); }); }); diff --git a/test/corporate-ca-build-tls-anchor.test.ts b/test/corporate-ca-build-tls-anchor.test.ts index 1a4ca5174b6..cb168608505 100644 --- a/test/corporate-ca-build-tls-anchor.test.ts +++ b/test/corporate-ca-build-tls-anchor.test.ts @@ -4,6 +4,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { dockerfileInstructions } from "./helpers/dockerfile-run-commands"; const DOCKERFILE = join(import.meta.dirname, "../Dockerfile"); @@ -173,3 +174,88 @@ describe("DCode corporate proxy CA cold-build trust (#8119)", () => { expect(curlAnchorIndex).toBeLessThan(ipAddressPatchIndex); }); }); + +describe("Hermes corporate proxy CA final-stage trust", () => { + const dockerfile = readFileSync( + join(import.meta.dirname, "../agents/hermes/Dockerfile"), + "utf-8", + ); + + // source-shape-contract: security -- Hermes final-stage registry clients must trust the decoded corporate CA before making HTTPS requests + it("uses the corporate CA conditionally for all Hermes registry remediations", () => { + const finalFromIndex = dockerfile.indexOf("FROM ${BASE_IMAGE}"); + const finalStage = dockerfile.slice(finalFromIndex); + const argIndex = finalStage.indexOf("ARG NEMOCLAW_CORPORATE_CA_B64"); + const decodeIndex = finalStage.indexOf( + 'RUN if [ -n "${NEMOCLAW_CORPORATE_CA_B64}" ]; then', + argIndex, + ); + const nodeAnchorIndex = finalStage.indexOf( + "ENV NODE_EXTRA_CA_CERTS=/usr/local/share/nemoclaw/corporate-ca.pem", + decodeIndex, + ); + const payloadCopyIndex = finalStage.indexOf( + "COPY --from=hermes-npm-patch-payload / /", + nodeAnchorIndex, + ); + const conditionalCurlTrust = `RUN if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \\ + export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \\ + fi; \\`; + const remediationCommands = [ + "node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts", + "node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts", + "node --experimental-strip-types /scripts/lib/patch-bundled-npm-ip-address.mts", + ]; + const agentInstallCommand = + "node --experimental-strip-types /src/lib/messaging/applier/build/messaging-build-applier.mts --agent hermes --phase agent-install"; + const packageInstallRun = dockerfileInstructions(finalStage).find( + (instruction) => + instruction.keyword === "RUN" && instruction.body.includes(agentInstallCommand), + ); + const expectedPackageInstallRun = [ + "RUN unset SSL_CERT_FILE REQUESTS_CA_BUNDLE; \\", + " if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \\", + " export SSL_CERT_FILE=/usr/local/share/nemoclaw/corporate-ca.pem; \\", + " export REQUESTS_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \\", + " fi; \\", + ` ${agentInstallCommand} \\`, + ' && if [ "$NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION" = "1" ]; then \\', + " node --experimental-strip-types /src/lib/messaging/applier/build/messaging-build-applier.mts \\", + " --agent hermes --phase managed-image-capability-union; \\", + " fi", + "", + ].join("\n"); + const npmCommandIndexes = [...finalStage.matchAll(/^\s*npm\s+(?:ci|run)\b/gmu)].map( + (match) => match.index, + ); + + for (const [name, index] of Object.entries({ + finalFromIndex, + argIndex, + decodeIndex, + nodeAnchorIndex, + payloadCopyIndex, + })) { + expect(index, name).toBeGreaterThan(-1); + } + expect(argIndex).toBeLessThan(decodeIndex); + expect(decodeIndex).toBeLessThan(nodeAnchorIndex); + expect(nodeAnchorIndex).toBeLessThan(payloadCopyIndex); + for (const remediationCommand of remediationCommands) { + const remediationIndex = finalStage.indexOf(remediationCommand, payloadCopyIndex); + expect(remediationIndex, remediationCommand).toBeGreaterThan(payloadCopyIndex); + const runIndex = finalStage.lastIndexOf("\nRUN ", remediationIndex) + 1; + expect(runIndex, remediationCommand).toBeGreaterThan(payloadCopyIndex); + expect(finalStage.slice(runIndex, remediationIndex).trim(), remediationCommand).toBe( + conditionalCurlTrust, + ); + } + expect(npmCommandIndexes.length).toBeGreaterThan(0); + for (const npmCommandIndex of npmCommandIndexes) { + expect(nodeAnchorIndex).toBeLessThan(npmCommandIndex); + } + expect(packageInstallRun?.text).toBe(expectedPackageInstallRun); + expect(packageInstallRun?.text).not.toContain("else"); + expect(finalStage.match(/^ENV (?:SSL_CERT_FILE|REQUESTS_CA_BUNDLE)=/gmu) ?? []).toEqual([]); + }); +}); diff --git a/test/dockerfile-run-commands.test.ts b/test/dockerfile-run-commands.test.ts new file mode 100644 index 00000000000..1aa4f3eb85b --- /dev/null +++ b/test/dockerfile-run-commands.test.ts @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { requireSingleReviewedDockerfileRunCommand } from "./helpers/dockerfile-run-commands"; + +const command = "node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts"; +const corporateCaPath = "/usr/local/share/nemoclaw/corporate-ca.pem"; +const requiredArguments = ["--npm-root", "/usr/local/lib/node_modules/npm"] as const; +const invocation = [command, ...requiredArguments].join(" "); +const splicedCommand = command.replace("strip-types", "strip-\\\ntypes"); + +describe("Dockerfile RUN command discovery", () => { + it("ignores command text in comments, strings, and non-RUN instructions", () => { + const source = [ + `# ${command}`, + `LABEL remediation=\"${command}\"`, + `RUN printf '%s\\n' '${command}'`, + `RUN printf '%s\\n' complete # ${command}`, + "", + ].join("\n"); + + expect(() => + requireSingleReviewedDockerfileRunCommand(source, command, requiredArguments), + ).toThrow("Expected one reviewed RUN command"); + }); + + it("accepts the reviewed command and arguments as a direct RUN instruction", () => { + const source = `RUN ${invocation}\nENV NEXT=instruction\n`; + + const match = requireSingleReviewedDockerfileRunCommand(source, command, requiredArguments); + + expect(match.commandStart).toBe(source.indexOf(command)); + expect(match.instruction.text).toBe(`RUN ${invocation}\n`); + }); + + it("finds a command after a guard in one complete multiline RUN instruction", () => { + const continuation = "\\"; + const source = [ + `RUN if [ -f ${corporateCaPath} ]; then ${continuation}`, + ` export CURL_CA_BUNDLE=${corporateCaPath}; ${continuation}`, + ` fi; ${continuation}`, + ` ${command} ${continuation}`, + ` ${requiredArguments.join(" ")}`, + "ENV NEXT=instruction", + "", + ].join("\n"); + + const match = requireSingleReviewedDockerfileRunCommand(source, command, requiredArguments); + + expect(match.commandStart).toBe(source.indexOf(command)); + expect(match.instruction.text).toContain(`export CURL_CA_BUNDLE=${corporateCaPath}`); + expect(match.instruction.text).toContain("--npm-root /usr/local/lib/node_modules/npm"); + expect(match.instruction.text).not.toContain("ENV NEXT=instruction"); + }); + + it("reports an extra unguarded command instead of selecting one occurrence", () => { + const source = [`RUN ${invocation}`, `RUN ${invocation}`, ""].join("\n"); + + expect(() => + requireSingleReviewedDockerfileRunCommand(source, command, requiredArguments), + ).toThrow("found 2"); + }); + + it.each([ + ["inside a command substitution", `RUN printf '%s\\n' "$(${invocation})"\n`], + ["inside backticks", `RUN printf '%s\\n' \`${invocation}\`\n`], + ["after a parameter-length expansion", `RUN : \${#PATH}; ${invocation}\n`], + ["inside a split command substitution", `RUN printf '%s\\n' "$\\\n(${invocation})"\n`], + ["after a split parameter-length expansion", `RUN : $\\\r\n{#PATH}; ${invocation}\n`], + ["with a spliced command token", `RUN ${splicedCommand} ${requiredArguments.join(" ")}\n`], + ])("rejects an extra invocation %s", (_label, hiddenInvocation) => { + const source = [`RUN ${invocation}`, hiddenInvocation].join("\n"); + + expect(() => + requireSingleReviewedDockerfileRunCommand(source, command, requiredArguments), + ).toThrow("unreviewed RUN instruction"); + }); + + it.each([ + ["a short-circuit branch", `RUN false && ${invocation}\n`], + ["an uncalled function", `RUN patch() { ${invocation}; }; true\n`], + ["an unreachable conditional branch", `RUN if false; then ${invocation}; fi\n`], + ])("rejects the reviewed command inside %s", (_label, source) => { + expect(() => + requireSingleReviewedDockerfileRunCommand(source, command, requiredArguments), + ).toThrow("unreviewed RUN instruction"); + }); + + it.each([ + ["before the command", `RUN printf '%s' '${requiredArguments.join(" ")}'; ${command}\n`], + ["in one quoted value", `RUN ${command} '${requiredArguments.join(" ")}'\n`], + ["in a comment", `RUN ${command} # ${requiredArguments.join(" ")}\n`], + ])("rejects required arguments that occur %s", (_label, source) => { + expect(() => + requireSingleReviewedDockerfileRunCommand(source, command, requiredArguments), + ).toThrow("unreviewed RUN instruction"); + }); + + it.each([ + ["between the command and arguments", `RUN ${command}\u00a0${requiredArguments.join(" ")}\n`], + ["after the arguments", `RUN ${invocation}\u00a0\n`], + ])("rejects non-shell whitespace %s", (_label, source) => { + expect(() => + requireSingleReviewedDockerfileRunCommand(source, command, requiredArguments), + ).toThrow("unreviewed RUN instruction"); + }); +}); diff --git a/test/e2e/support/sandbox-images-workflow-boundary.test.ts b/test/e2e/support/sandbox-images-workflow-boundary.test.ts index c7c2c9712e3..b76ac91d0f6 100644 --- a/test/e2e/support/sandbox-images-workflow-boundary.test.ts +++ b/test/e2e/support/sandbox-images-workflow-boundary.test.ts @@ -153,6 +153,27 @@ describe("sandbox image workflow boundary", () => { ); }); + it("requires the canonical no-CA Hermes build and its default-trust image proof", () => { + const { imageWorkflow, mainWorkflow } = readWorkflows(); + const producer = imageWorkflow.jobs["build-hermes-sandbox-image"]; + const build = producer.steps!.find((step) => step.name === "Build Hermes production image")!; + build.with!["build-args"] = `${build.with!["build-args"]}\nNEMOCLAW_CORPORATE_CA_B64=test`; + const proof = producer.steps!.find( + (step) => step.name === "Verify Hermes default-trust final image", + )!; + proof.run = proof.run!.replace( + "test ! -e /usr/local/share/nemoclaw/corporate-ca.pem", + "test -e /usr/local/share/nemoclaw/corporate-ca.pem", + ); + + expect(validateSandboxImagesWorkflow(imageWorkflow, mainWorkflow)).toEqual( + expect.arrayContaining([ + "Hermes producer must build the production image exactly once with the canonical local-load Buildx action and OS/architecture-scoped GHA cache", + "Hermes producer must prove the no-CA final image uses default trust before completed-image scans", + ]), + ); + }); + it("rejects non-canonical Hermes Buildx action pins", () => { for (const stepName of ["Set up Docker Buildx", "Build Hermes production image"]) { const { imageWorkflow, mainWorkflow } = readWorkflows(); @@ -537,10 +558,20 @@ describe("sandbox image workflow boundary", () => { const hermes = probe.steps!.find( (step) => step.name === "Build and verify Hermes messaging plan boundary", )!; - hermes.run = hermes.run!.replace( - "check-messaging-plan-image-boundary.mts verify", - "check-messaging-plan-image-boundary.mts bypass", - ); + hermes.run = hermes + .run!.replace( + 'scripts/check-production-build-args.sh "${build_args[@]}"', + 'echo "guard bypassed"', + ) + .replace( + '--build-arg "NEMOCLAW_CORPORATE_CA_B64=${corporate_ca_b64}"', + '--build-arg "NEMOCLAW_CORPORATE_CA_B64="', + ) + .replace("crl2pkcs7 -nocrl", "version") + .replace( + "check-messaging-plan-image-boundary.mts verify", + "check-messaging-plan-image-boundary.mts bypass", + ); expect(validateSandboxImagesWorkflow(imageWorkflow, mainWorkflow)).toEqual( expect.arrayContaining([ @@ -549,12 +580,103 @@ describe("sandbox image workflow boundary", () => { "messaging plan image boundary must set up Node exactly once", "messaging plan image boundary must use Node 22.19.0", 'openclaw messaging plan image boundary must include scripts/check-production-build-args.sh "${build_args[@]}"', + 'hermes messaging plan image boundary must include scripts/check-production-build-args.sh "${build_args[@]}"', + 'hermes messaging plan image boundary must include --build-arg "NEMOCLAW_CORPORATE_CA_B64=${corporate_ca_b64}"', + "hermes messaging plan image boundary must include docker run --rm --network none --entrypoint openssl nemoclaw-hermes-plan-boundary crl2pkcs7 -nocrl -certfile /usr/local/share/nemoclaw/corporate-ca.pem -out /dev/null", "hermes messaging plan image boundary must include node --experimental-strip-types scripts/check-messaging-plan-image-boundary.mts verify nemoclaw-hermes-plan-boundary hermes", "messaging plan image boundary must not publish probe image artifacts", ]), ); }); + it("requires the exact compact CA root helper invocation", () => { + const { imageWorkflow, mainWorkflow } = readWorkflows(); + const hermes = imageWorkflow.jobs["messaging-plan-image-boundary"].steps!.find( + (step) => step.name === "Build and verify Hermes messaging plan boundary", + )!; + hermes.run = hermes.run!.replace( + "select-ci-endpoint-ca-roots.mts", + "select-ci-endpoint-ca-roots.mts --endpoint registry.example.invalid", + ); + + expect(validateSandboxImagesWorkflow(imageWorkflow, mainWorkflow)).toContain( + 'hermes messaging plan image boundary must include exactly node --experimental-strip-types scripts/checks/select-ci-endpoint-ca-roots.mts --output "$compact_ca_bundle"', + ); + }); + + it("rejects direct base64 encoding of the broad system CA bundle", () => { + for (const forbidden of [ + 'forbidden_ca_b64="$(base64 -w 0 "$system_ca_bundle")"', + [ + "corporate_ca_bundle=/etc/ssl/certs/ca-certificates.crt", + 'forbidden_ca_b64="$(base64 -w 0 "$corporate_ca_bundle")"', + ].join("\n"), + ]) { + const { imageWorkflow, mainWorkflow } = readWorkflows(); + const hermes = imageWorkflow.jobs["messaging-plan-image-boundary"].steps!.find( + (step) => step.name === "Build and verify Hermes messaging plan boundary", + )!; + hermes.run = `${hermes.run}\n${forbidden}`; + + expect(validateSandboxImagesWorkflow(imageWorkflow, mainWorkflow)).toContain( + "hermes messaging plan image boundary must not encode the system CA bundle directly", + ); + } + }); + + it("requires offline equality and parse proofs for the installed Hermes CA bundle", () => { + const { imageWorkflow, mainWorkflow } = readWorkflows(); + const hermes = imageWorkflow.jobs["messaging-plan-image-boundary"].steps!.find( + (step) => step.name === "Build and verify Hermes messaging plan boundary", + )!; + hermes.run = hermes + .run!.replace( + 'test "$installed_ca_sha256" = "$corporate_ca_sha256"', + 'test -n "$installed_ca_sha256"', + ) + .replace("crl2pkcs7 -nocrl", "version"); + + expect(validateSandboxImagesWorkflow(imageWorkflow, mainWorkflow)).toEqual( + expect.arrayContaining([ + 'hermes messaging plan image boundary must include test "$installed_ca_sha256" = "$corporate_ca_sha256"', + "hermes messaging plan image boundary must include docker run --rm --network none --entrypoint openssl nemoclaw-hermes-plan-boundary crl2pkcs7 -nocrl -certfile /usr/local/share/nemoclaw/corporate-ca.pem -out /dev/null", + ]), + ); + }); + + it("requires the Hermes build guard before the build and offline CA proofs", () => { + const { imageWorkflow, mainWorkflow } = readWorkflows(); + const hermes = imageWorkflow.jobs["messaging-plan-image-boundary"].steps!.find( + (step) => step.name === "Build and verify Hermes messaging plan boundary", + )!; + const guard = 'scripts/check-production-build-args.sh "${build_args[@]}"'; + hermes.run = `${hermes.run!.replace(guard, "")}\n${guard}`; + + expect(validateSandboxImagesWorkflow(imageWorkflow, mainWorkflow)).toEqual( + expect.arrayContaining([ + "hermes messaging plan image boundary steps are out of order", + "hermes messaging plan image boundary CA fixture steps are out of order", + ]), + ); + }); + + it("rejects the Hermes CA build argument after the image build", () => { + const { imageWorkflow, mainWorkflow } = readWorkflows(); + const hermes = imageWorkflow.jobs["messaging-plan-image-boundary"].steps!.find( + (step) => step.name === "Build and verify Hermes messaging plan boundary", + )!; + const buildArg = '--build-arg "NEMOCLAW_CORPORATE_CA_B64=${corporate_ca_b64}"'; + const buildCommand = 'docker build "${build_args[@]}" -t nemoclaw-hermes-plan-boundary .'; + expect(hermes.run).toContain(buildArg); + hermes.run = hermes + .run!.replace(buildArg, "") + .replace(buildCommand, `${buildCommand}\n${buildArg}`); + + expect(validateSandboxImagesWorkflow(imageWorkflow, mainWorkflow)).toContain( + "hermes messaging plan image boundary CA fixture steps are out of order", + ); + }); + it("requires bounded swap before every hosted Hermes image export", () => { const { imageWorkflow, mainWorkflow } = readWorkflows(); for (const jobName of ["build-hermes-sandbox-image", "messaging-plan-image-boundary"]) { diff --git a/test/helpers/dockerfile-run-commands.ts b/test/helpers/dockerfile-run-commands.ts new file mode 100644 index 00000000000..723d505a633 --- /dev/null +++ b/test/helpers/dockerfile-run-commands.ts @@ -0,0 +1,188 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export interface DockerfileInstruction { + readonly body: string; + readonly bodyStart: number; + readonly end: number; + readonly keyword: string; + readonly start: number; + readonly text: string; +} + +export interface ReviewedDockerfileRunCommand { + readonly commandStart: number; + readonly instruction: DockerfileInstruction; +} + +const CORPORATE_CA_PATH = "/usr/local/share/nemoclaw/corporate-ca.pem"; +const CORPORATE_CA_GUARD = `if [ -f ${CORPORATE_CA_PATH} ]; then export CURL_CA_BUNDLE=${CORPORATE_CA_PATH}; fi;`; + +function lineEnd(source: string, start: number): number { + const newline = source.indexOf("\n", start); + return newline === -1 ? source.length : newline + 1; +} + +function continuesInstruction(line: string): boolean { + const content = line.replace(/\r?\n$/u, "").trimEnd(); + let escapeCount = 0; + for (let index = content.length - 1; index >= 0 && content[index] === "\\"; index -= 1) { + escapeCount += 1; + } + return escapeCount % 2 === 1; +} + +export function dockerfileInstructions(source: string): DockerfileInstruction[] { + const instructions: DockerfileInstruction[] = []; + let offset = 0; + + while (offset < source.length) { + const endOfFirstLine = lineEnd(source, offset); + const firstLine = source.slice(offset, endOfFirstLine); + const instructionMatch = firstLine.match(/^[ \t]*([A-Za-z]+)(?:[ \t]+|(?=\r?$))/u); + if (instructionMatch === null) { + offset = endOfFirstLine; + continue; + } + + let end = endOfFirstLine; + let currentLine = firstLine; + while (continuesInstruction(currentLine)) { + if (end >= source.length) { + throw new Error(`Dockerfile ends inside the ${instructionMatch[1]} instruction`); + } + const nextEnd = lineEnd(source, end); + currentLine = source.slice(end, nextEnd); + end = nextEnd; + } + + const bodyStart = offset + instructionMatch[0].length; + instructions.push({ + body: source.slice(bodyStart, end), + bodyStart, + end, + keyword: instructionMatch[1].toUpperCase(), + start: offset, + text: source.slice(offset, end), + }); + offset = end; + } + + return instructions; +} + +function collapseDockerfileContinuations(source: string): { + readonly originalIndexes: readonly number[]; + readonly text: string; +} { + const characters: string[] = []; + const originalIndexes: number[] = []; + + for (let index = 0; index < source.length; index += 1) { + if (source[index] === "\\" && source[index + 1] === "\n") { + index += 1; + continue; + } + if (source[index] === "\\" && source[index + 1] === "\r" && source[index + 2] === "\n") { + index += 2; + continue; + } + characters.push(source[index]); + originalIndexes.push(index); + } + + return { originalIndexes, text: characters.join("") }; +} + +function unquotedTextIndexes(source: string, text: string): number[] { + const indexes: number[] = []; + let quote: "'" | '"' | "`" | null = null; + let comment = false; + + for (let index = 0; index < source.length; index += 1) { + const character = source[index]; + if (comment) { + if (character === "\n") comment = false; + continue; + } + if (quote !== null) { + if (character === "\\" && quote !== "'") { + index += 1; + } else if (character === quote) { + quote = null; + } + continue; + } + if (character === "'" || character === '"' || character === "`") { + quote = character; + continue; + } + if (character === "\\") { + index += 1; + continue; + } + if (character === "#" && (index === 0 || /[\s;&|(){}]/u.test(source[index - 1]))) { + comment = true; + continue; + } + if (!source.startsWith(text, index)) continue; + indexes.push(index); + index += text.length - 1; + } + + return indexes; +} + +function normalizedInstructionBody(source: string): string { + return source + .replace(/\\\r?\n/gu, " ") + .replace(/[ \t\r\n]+/gu, " ") + .replace(/^[ \t\r\n]+|[ \t\r\n]+$/gu, ""); +} + +export function requireSingleReviewedDockerfileRunCommand( + source: string, + command: string, + requiredArguments: readonly string[], +): ReviewedDockerfileRunCommand { + const invocation = [command, ...requiredArguments].join(" "); + const reviewedBodies = new Set([invocation, `${CORPORATE_CA_GUARD} ${invocation}`]); + const matches: ReviewedDockerfileRunCommand[] = []; + let unreviewedInstructions = 0; + + for (const instruction of dockerfileInstructions(source)) { + if (instruction.keyword !== "RUN") continue; + const collapsed = collapseDockerfileContinuations(instruction.body); + const containsCommand = collapsed.text.includes(command); + const hasUnsupportedShellConstruct = ["$(", "${", "`"].some((token) => + collapsed.text.includes(token), + ); + if (containsCommand && hasUnsupportedShellConstruct) { + unreviewedInstructions += 1; + continue; + } + const commandIndexes = unquotedTextIndexes(collapsed.text, command); + if (commandIndexes.length === 0) continue; + if ( + commandIndexes.length !== 1 || + !reviewedBodies.has(normalizedInstructionBody(instruction.body)) + ) { + unreviewedInstructions += 1; + continue; + } + matches.push({ + commandStart: instruction.bodyStart + collapsed.originalIndexes[commandIndexes[0]], + instruction, + }); + } + + if (unreviewedInstructions > 0) { + throw new Error( + `Expected '${invocation}' only as a direct RUN or the reviewed corporate CA guarded RUN; found ${unreviewedInstructions} unreviewed RUN instruction(s)`, + ); + } + if (matches.length !== 1) { + throw new Error(`Expected one reviewed RUN command '${invocation}', found ${matches.length}`); + } + return matches[0]; +} diff --git a/test/hermes-final-image-layout.test.ts b/test/hermes-final-image-layout.test.ts index 93867396d2b..0ed2c5aa3bf 100644 --- a/test/hermes-final-image-layout.test.ts +++ b/test/hermes-final-image-layout.test.ts @@ -6,11 +6,13 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { requireSingleReviewedDockerfileRunCommand } from "./helpers/dockerfile-run-commands"; import { dockerRunCommandBetween, runDockerShell } from "./helpers/hermes-dockerfile-run"; import { expectManagedBootstrapNativeImageContract } from "./support/managed-bootstrap-image-contract"; const ROOT = path.resolve(import.meta.dirname, ".."); const HERMES_DOCKERFILE = path.join(ROOT, "agents", "hermes", "Dockerfile"); +const NPM_ROOT_ARGUMENTS = ["--npm-root", "/usr/local/lib/node_modules/npm"] as const; const HERMES_INTEGRITY_FILES = [ { arg: "NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256", @@ -317,10 +319,11 @@ describe("Hermes final image layout", () => { const runtime = indexOfRequired(finalStage, runtimeCopy); const wrapper = indexOfRequired(finalStage, wrapperCopy); const scan = indexOfRequired(finalStage, scanCopy); - const tarPatch = indexOfRequired( + const tarPatch = requireSingleReviewedDockerfileRunCommand( finalStage, - "RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts", - ); + "node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts", + NPM_ROOT_ARGUMENTS, + ).commandStart; const certifiInstall = indexOfRequired(finalStage, "RUN _hermes_certifi="); const agentChmod = indexOfRequired( finalStage, diff --git a/test/node-tar-dockerfile-contract.test.ts b/test/node-tar-dockerfile-contract.test.ts index 80ee6da1289..0e4eca19c38 100644 --- a/test/node-tar-dockerfile-contract.test.ts +++ b/test/node-tar-dockerfile-contract.test.ts @@ -7,6 +7,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { NODE_BASES_REQUIRING_BUNDLED_NPM_TAR_PATCH } from "../scripts/patch-bundled-npm-tar.mts"; +import { requireSingleReviewedDockerfileRunCommand } from "./helpers/dockerfile-run-commands"; const repoRoot = path.resolve(import.meta.dirname, ".."); const dockerfiles = [ @@ -29,6 +30,8 @@ const dockerfiles = [ installsWithNpm: false, }, ] as const; +const patchCommand = "node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts"; +const npmRootArguments = ["--npm-root", "/usr/local/lib/node_modules/npm"] as const; function completedStage(source: string): string { const finalStageStart = [...source.matchAll(/^FROM\b/gmu)].at(-1)?.index; @@ -61,9 +64,11 @@ describe("node-tar image remediation contract", () => { ])("installs curl before patching the bundled npm tar in $file", (file) => { const source = completedStage(fs.readFileSync(path.join(repoRoot, file), "utf8")); const curlInstall = source.indexOf("curl="); - const patchRun = source.indexOf( - "RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts", - ); + const patchRun = requireSingleReviewedDockerfileRunCommand( + source, + patchCommand, + npmRootArguments, + ).commandStart; expect(curlInstall, file).toBeGreaterThanOrEqual(0); expect(patchRun, file).toBeGreaterThan(curlInstall); @@ -90,9 +95,11 @@ describe("node-tar image remediation contract", () => { const patchCopy = patchInputStage.indexOf( "COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts", ); - const patchRun = source.indexOf( - "RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts", - ); + const patchRun = requireSingleReviewedDockerfileRunCommand( + source, + patchCommand, + npmRootArguments, + ).commandStart; const scanCopy = scanInputStage.indexOf( "COPY scripts/checks/node-tar-image-scan.mts /scripts/checks/node-tar-image-scan.mts", ); @@ -151,15 +158,19 @@ describe("reviewed npm image remediation contract", () => { { file: "agents/langchain-deepagents-code/Dockerfile.base", installsWithNpm: false }, ])("upgrades npm before use in $file", ({ file, installsWithNpm }) => { const source = completedStage(fs.readFileSync(path.join(repoRoot, file), "utf8")); - const patchRun = source.indexOf( - "RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts", - ); + const patchRun = requireSingleReviewedDockerfileRunCommand( + source, + patchCommand, + npmRootArguments, + ).commandStart; const upgradeCopy = source.indexOf( "COPY scripts/upgrade-bundled-npm.mts /scripts/upgrade-bundled-npm.mts", ); - const upgradeRun = source.indexOf( - "RUN node --experimental-strip-types /scripts/upgrade-bundled-npm.mts", - ); + const upgradeRun = requireSingleReviewedDockerfileRunCommand( + source, + "node --experimental-strip-types /scripts/upgrade-bundled-npm.mts", + npmRootArguments, + ).commandStart; expect(upgradeCopy, file).toBeGreaterThanOrEqual(0); expect(patchRun, file).toBeGreaterThan(upgradeCopy); diff --git a/test/select-ci-endpoint-ca-roots.test.ts b/test/select-ci-endpoint-ca-roots.test.ts new file mode 100644 index 00000000000..e30a207094e --- /dev/null +++ b/test/select-ci-endpoint-ca-roots.test.ts @@ -0,0 +1,432 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { X509Certificate } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import { + CI_CA_ENDPOINTS, + CI_CA_SYSTEM_BUNDLE, + MAX_CI_CA_CERTIFICATES, + MAX_CI_CA_ENCODED_BYTES, + normalizeCompactRootBundle, + type OpenSslRunner, + selectCiEndpointCaRoots, + writeCiEndpointCaRootsOutput, +} from "../scripts/checks/select-ci-endpoint-ca-roots.mts"; +import { LEAF_PEM, PEM, tmpDir } from "../src/lib/onboard/__test-helpers__/corporate-ca-fixtures"; + +const hasOpenSsl = spawnSync("openssl", ["version"], { encoding: "utf8" }).status === 0; + +function openssl(args: readonly string[], cwd: string): void { + const result = spawnSync("openssl", [...args], { + cwd, + encoding: "utf8", + killSignal: "SIGKILL", + timeout: 10_000, + }); + expect(result.status, `OpenSSL fixture command failed: ${args[0]}`).toBe(0); +} + +function createEndpointCertificate(directory: string): { + chain: string; + crossSignedRoot: string; + root: string; +} { + fs.writeFileSync( + path.join(directory, "root.ext"), + [ + "basicConstraints=critical,CA:TRUE", + "keyUsage=critical,keyCertSign,cRLSign", + "subjectKeyIdentifier=hash", + "authorityKeyIdentifier=keyid,issuer", + "", + ].join("\n"), + ); + fs.writeFileSync( + path.join(directory, "leaf.ext"), + [ + "basicConstraints=critical,CA:FALSE", + "keyUsage=critical,digitalSignature,keyEncipherment", + "extendedKeyUsage=serverAuth", + `subjectAltName=${CI_CA_ENDPOINTS.map((endpoint) => `DNS:${endpoint}`).join(",")}`, + "", + ].join("\n"), + ); + openssl( + [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-subj", + "/CN=NemoClaw CI Root", + "-keyout", + "root.key", + "-out", + "root.pem", + "-days", + "2", + "-addext", + "basicConstraints=critical,CA:TRUE", + "-addext", + "keyUsage=critical,keyCertSign,cRLSign", + ], + directory, + ); + openssl( + [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-subj", + "/CN=NemoClaw Alternate Root", + "-keyout", + "alternate-root.key", + "-out", + "alternate-root.pem", + "-days", + "2", + "-addext", + "basicConstraints=critical,CA:TRUE", + "-addext", + "keyUsage=critical,keyCertSign,cRLSign", + ], + directory, + ); + openssl( + ["req", "-new", "-key", "root.key", "-subj", "/CN=NemoClaw CI Root", "-out", "root.csr"], + directory, + ); + openssl( + [ + "x509", + "-req", + "-in", + "root.csr", + "-CA", + "alternate-root.pem", + "-CAkey", + "alternate-root.key", + "-CAcreateserial", + "-out", + "root-cross-signed.pem", + "-days", + "2", + "-extfile", + "root.ext", + ], + directory, + ); + openssl( + [ + "req", + "-newkey", + "rsa:2048", + "-nodes", + "-subj", + `/CN=${CI_CA_ENDPOINTS[0]}`, + "-keyout", + "leaf.key", + "-out", + "leaf.csr", + ], + directory, + ); + openssl( + [ + "x509", + "-req", + "-in", + "leaf.csr", + "-CA", + "root.pem", + "-CAkey", + "root.key", + "-CAcreateserial", + "-out", + "leaf.pem", + "-days", + "2", + "-extfile", + "leaf.ext", + ], + directory, + ); + const leaf = fs.readFileSync(path.join(directory, "leaf.pem"), "utf8").trim(); + const crossSignedRoot = fs + .readFileSync(path.join(directory, "root-cross-signed.pem"), "utf8") + .trim(); + return { + chain: `${leaf}\n${crossSignedRoot}\n`, + crossSignedRoot, + root: fs.readFileSync(path.join(directory, "root.pem"), "utf8"), + }; +} + +describe("CI endpoint CA root selection", () => { + it("keeps the endpoint set and build-argument limits fixed", () => { + expect(CI_CA_SYSTEM_BUNDLE).toBe("/etc/ssl/certs/ca-certificates.crt"); + expect(CI_CA_ENDPOINTS).toEqual(["registry.npmjs.org", "pypi.org", "files.pythonhosted.org"]); + expect(MAX_CI_CA_CERTIFICATES).toBe(24); + expect(MAX_CI_CA_ENCODED_BYTES).toBe(65_536); + }); + + it("deduplicates CA roots and rejects leaf certificates or oversized output", () => { + expect(normalizeCompactRootBundle([PEM, PEM])).toBe(PEM); + expect(() => normalizeCompactRootBundle([LEAF_PEM])).toThrow(/CA:TRUE root/u); + expect(() => + normalizeCompactRootBundle([PEM], { certificates: 0, encodedBytes: 65_536 }), + ).toThrow(/exceeds 0 certificates/u); + expect(() => normalizeCompactRootBundle([PEM], { certificates: 24, encodedBytes: 1 })).toThrow( + /exceeds 1 encoded bytes/u, + ); + }); + + it("validates each CA output identity before truncating it", () => { + const output = path.join(tmpDir(), "compact.pem"); + fs.writeFileSync(output, "unchanged", { mode: 0o644 }); + const calls: string[] = []; + const realLstatSync = fs.lstatSync.bind(fs); + const realOpenSync = fs.openSync.bind(fs); + const realFstatSync = fs.fstatSync.bind(fs); + const realFtruncateSync = fs.ftruncateSync.bind(fs); + vi.spyOn(fs, "lstatSync").mockImplementation((file) => { + calls.push("lstat"); + return realLstatSync(file); + }); + vi.spyOn(fs, "openSync").mockImplementation((file, flags, mode) => { + calls.push("open"); + return realOpenSync(file, flags, mode); + }); + vi.spyOn(fs, "fstatSync").mockImplementation((descriptor) => { + calls.push("fstat"); + expect(fs.readFileSync(output, "utf8")).toBe("unchanged"); + return realFstatSync(descriptor); + }); + vi.spyOn(fs, "ftruncateSync").mockImplementation((descriptor, length) => { + calls.push("truncate"); + expect(fs.readFileSync(output, "utf8")).toBe("unchanged"); + return realFtruncateSync(descriptor, length); + }); + + writeCiEndpointCaRootsOutput(output, "replacement"); + + expect(calls).toEqual(["open", "fstat", "lstat", "truncate"]); + expect(fs.readFileSync(output, "utf8")).toBe("replacement"); + expect(fs.statSync(output).mode & 0o777).toBe(0o600); + }); + + it.skipIf(process.platform === "win32")("rejects a FIFO CA output without truncating it", () => { + const output = path.join(tmpDir(), "compact.pem"); + const created = spawnSync("mkfifo", [output], { encoding: "utf8", timeout: 5_000 }); + expect(created.status, created.stderr).toBe(0); + const openSync = vi.spyOn(fs, "openSync"); + const ftruncateSync = vi.spyOn(fs, "ftruncateSync"); + + expect(() => writeCiEndpointCaRootsOutput(output, "replacement")).toThrow( + "output must be an existing regular file that is not a symlink", + ); + + expect(openSync).toHaveBeenCalledOnce(); + expect(ftruncateSync).not.toHaveBeenCalled(); + expect(fs.lstatSync(output).isFIFO()).toBe(true); + }); + + it.skipIf(process.platform === "win32")( + "rejects a symlinked CA output without opening its target", + () => { + const directory = tmpDir(); + const target = path.join(directory, "target.pem"); + const output = path.join(directory, "compact.pem"); + fs.writeFileSync(target, "target", { mode: 0o640 }); + fs.symlinkSync(target, output); + const openSync = vi.spyOn(fs, "openSync"); + const ftruncateSync = vi.spyOn(fs, "ftruncateSync"); + + expect(() => writeCiEndpointCaRootsOutput(output, "replacement")).toThrow( + "output must be an existing regular file that is not a symlink", + ); + + expect(openSync).toHaveBeenCalledOnce(); + expect(ftruncateSync).not.toHaveBeenCalled(); + expect(fs.lstatSync(output).isSymbolicLink()).toBe(true); + expect(fs.readFileSync(target, "utf8")).toBe("target"); + expect(fs.statSync(target).mode & 0o777).toBe(0o640); + }, + ); + + it.skipIf(process.platform === "win32")( + "rejects a device CA output without truncating it", + () => { + const ftruncateSync = vi.spyOn(fs, "ftruncateSync"); + const writeFileSync = vi.spyOn(fs, "writeFileSync"); + const fchmodSync = vi.spyOn(fs, "fchmodSync"); + + expect(() => writeCiEndpointCaRootsOutput("/dev/null", "replacement")).toThrow( + "output must remain the same regular file with exactly one link", + ); + + expect(ftruncateSync).not.toHaveBeenCalled(); + expect(writeFileSync).not.toHaveBeenCalled(); + expect(fchmodSync).not.toHaveBeenCalled(); + }, + ); + + it.skipIf(process.platform === "win32")( + "rejects a hard-linked CA output without changing either path", + () => { + const directory = tmpDir(); + const output = path.join(directory, "compact.pem"); + const linked = path.join(directory, "linked.pem"); + fs.writeFileSync(output, "unchanged", { mode: 0o640 }); + fs.linkSync(output, linked); + const openSync = vi.spyOn(fs, "openSync"); + const ftruncateSync = vi.spyOn(fs, "ftruncateSync"); + + expect(() => writeCiEndpointCaRootsOutput(output, "replacement")).toThrow( + "output must remain the same regular file with exactly one link", + ); + + expect(openSync).toHaveBeenCalledOnce(); + expect(ftruncateSync).not.toHaveBeenCalled(); + for (const file of [output, linked]) { + expect(fs.readFileSync(file, "utf8")).toBe("unchanged"); + expect(fs.statSync(file).mode & 0o777).toBe(0o640); + } + }, + ); + + it("rejects a substituted CA output without changing either file", () => { + const directory = tmpDir(); + const output = path.join(directory, "compact.pem"); + const original = path.join(directory, "original.pem"); + const replacement = path.join(directory, "replacement.pem"); + fs.writeFileSync(output, "original", { mode: 0o640 }); + fs.writeFileSync(replacement, "replacement", { mode: 0o604 }); + const realFstatSync = fs.fstatSync.bind(fs); + vi.spyOn(fs, "fstatSync").mockImplementation((descriptor) => { + const stat = realFstatSync(descriptor); + fs.renameSync(output, original); + fs.renameSync(replacement, output); + return stat; + }); + const ftruncateSync = vi.spyOn(fs, "ftruncateSync"); + + expect(() => writeCiEndpointCaRootsOutput(output, "written")).toThrow( + "output must remain the same regular file with exactly one link", + ); + + expect(ftruncateSync).not.toHaveBeenCalled(); + expect(fs.readFileSync(original, "utf8")).toBe("original"); + expect(fs.statSync(original).mode & 0o777).toBe(0o640); + expect(fs.readFileSync(output, "utf8")).toBe("replacement"); + expect(fs.statSync(output).mode & 0o777).toBe(0o604); + }); + + it.skipIf(!hasOpenSsl)( + "selects a self-signed system root when the server sends its cross-signed form", + () => { + const directory = tmpDir(); + const output = path.join(directory, "compact.pem"); + fs.writeFileSync(output, "", { mode: 0o600 }); + const fixture = createEndpointCertificate(directory); + const systemRoot = new X509Certificate(fixture.root); + const crossSignedRoot = new X509Certificate(fixture.crossSignedRoot); + expect(crossSignedRoot.subject).toBe(systemRoot.subject); + expect(crossSignedRoot.issuer).not.toBe(crossSignedRoot.subject); + expect(crossSignedRoot.publicKey.export({ format: "der", type: "spki" })).toEqual( + systemRoot.publicKey.export({ format: "der", type: "spki" }), + ); + expect(crossSignedRoot.verify(crossSignedRoot.publicKey)).toBe(false); + const realReadFile = fs.readFileSync.bind(fs); + vi.spyOn(fs, "readFileSync").mockImplementation(((file, ...args) => + file === CI_CA_SYSTEM_BUNDLE + ? fixture.root + : realReadFile(file, ...args)) as typeof fs.readFileSync); + + const connections: string[][] = []; + const runConnection: OpenSslRunner = (args) => { + connections.push([...args]); + return { + status: 0, + stderr: "", + stdout: `${fixture.chain}Verify return code: 0 (ok)\n`, + }; + }; + const runActualOpenSsl: OpenSslRunner = (args) => { + const result = spawnSync("openssl", [...args], { + encoding: "utf8", + killSignal: "SIGKILL", + timeout: 10_000, + }); + return { + error: result.error, + status: result.status, + stderr: result.stderr ?? "", + stdout: result.stdout ?? "", + }; + }; + const runner: OpenSslRunner = (args) => + args[0] === "s_client" ? runConnection(args) : runActualOpenSsl(args); + + expect(selectCiEndpointCaRoots(output, runner)).toEqual({ + certificates: 1, + encodedBytes: Buffer.from(fixture.root).toString("base64").length, + }); + expect(fs.readFileSync(output, "utf8")).toBe(fixture.root); + expect(connections).toHaveLength(CI_CA_ENDPOINTS.length * 2); + for (const endpoint of CI_CA_ENDPOINTS) { + const endpointConnections = connections.filter((args) => args.includes(`${endpoint}:443`)); + expect(endpointConnections).toEqual([ + [ + "s_client", + "-connect", + `${endpoint}:443`, + "-servername", + endpoint, + "-verify_hostname", + endpoint, + "-verify_return_error", + "-CAfile", + CI_CA_SYSTEM_BUNDLE, + "-no-CApath", + "-no-CAstore", + "-showcerts", + ], + [ + "s_client", + "-connect", + `${endpoint}:443`, + "-servername", + endpoint, + "-verify_hostname", + endpoint, + "-verify_return_error", + "-CAfile", + expect.stringMatching(/\/compact\.pem$/u), + "-no-CApath", + "-no-CAstore", + ], + ]); + } + + fs.writeFileSync(output, "unchanged", { mode: 0o600 }); + const rejectCompactVerification: OpenSslRunner = (args) => + args[0] === "s_client" && !args.includes("-showcerts") + ? { status: 1, stderr: "verification failed", stdout: "" } + : runner(args); + expect(() => selectCiEndpointCaRoots(output, rejectCompactVerification)).toThrow( + /compact CA verification for registry\.npmjs\.org failed/u, + ); + expect(fs.readFileSync(output, "utf8")).toBe("unchanged"); + }, + ); +}); diff --git a/tools/e2e/sandbox-images-workflow-boundary.mts b/tools/e2e/sandbox-images-workflow-boundary.mts index 85c7a6a7b74..b1058190bc4 100644 --- a/tools/e2e/sandbox-images-workflow-boundary.mts +++ b/tools/e2e/sandbox-images-workflow-boundary.mts @@ -27,6 +27,7 @@ const HERMES_SETUP_BUILDX_ACTION = "docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c"; const HERMES_BUILD_PUSH_ACTION = "docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a"; +const HERMES_DEFAULT_TRUST_STEP_NAME = "Verify Hermes default-trust final image"; const HERMES_DOWNLOAD_ARTIFACT_ACTION = "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c"; const HERMES_UPLOAD_ARTIFACT_ACTION = @@ -478,6 +479,30 @@ function validateGuardedProductionBuild( "Hermes producer must build the production image exactly once with the canonical local-load Buildx action and OS/architecture-scoped GHA cache", ); } + const defaultTrust = requireStep(errors, contract.jobName, job, HERMES_DEFAULT_TRUST_STEP_NAME); + const defaultTrustRun = normalizedShell(defaultTrust.run); + const requiredDefaultTrustFragments = [ + "set -euo pipefail", + "docker run --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges --pids-limit 64 --memory 256m --entrypoint /bin/sh nemoclaw-hermes-production -eu -c", + 'test "$NODE_EXTRA_CA_CERTS" = /usr/local/share/nemoclaw/corporate-ca.pem', + "test ! -e /usr/local/share/nemoclaw/corporate-ca.pem", + "test ! -L /usr/local/share/nemoclaw/corporate-ca.pem", + "test -x /usr/local/bin/hermes", + 'node -e "const tls = require(\\"node:tls\\"); if (tls.rootCertificates.length === 0) process.exit(1); tls.createSecureContext()"', + '/opt/hermes/.venv/bin/python -I -c "import ssl; assert ssl.create_default_context().get_ca_certs()"', + ]; + if ( + steps(job).filter((step) => step.name === HERMES_DEFAULT_TRUST_STEP_NAME).length !== 1 || + defaultTrust.shell !== "bash" || + requiredDefaultTrustFragments.some((fragment) => !defaultTrustRun.includes(fragment)) || + stepIndex(job, action.name ?? "") >= stepIndex(job, HERMES_DEFAULT_TRUST_STEP_NAME) || + stepIndex(job, HERMES_DEFAULT_TRUST_STEP_NAME) >= + stepIndex(job, "Scan completed Hermes image for node-tar") + ) { + errors.push( + "Hermes producer must prove the no-CA final image uses default trust before completed-image scans", + ); + } return; } @@ -605,6 +630,7 @@ function validateMessagingPlanBoundaryBuild( readonly agent: "hermes" | "openclaw"; readonly baseArgName: "BASE_IMAGE"; readonly baseEnvName: "BASE_IMAGE" | "HERMES_BASE_IMAGE"; + readonly extraRequiredFragments?: readonly string[]; readonly stepName: string; readonly target: string; }, @@ -622,6 +648,7 @@ function validateMessagingPlanBoundaryBuild( 'scripts/check-production-build-args.sh "${build_args[@]}"', `docker build \"\${build_args[@]}\" -t ${options.target} .`, `node --experimental-strip-types scripts/check-messaging-plan-image-boundary.mts verify ${options.target} ${options.agent}`, + ...(options.extraRequiredFragments ?? []), ]; if (step.shell !== "bash" || !isDeepStrictEqual(record(step.env), expectedEnv)) { @@ -647,6 +674,64 @@ function validateMessagingPlanBoundaryBuild( } } +function validateHermesMessagingPlanCaFixture( + errors: string[], + job: SandboxImagesWorkflowJob, +): void { + const step = findStep(job, "Build and verify Hermes messaging plan boundary"); + const run = normalizedShell(step?.run); + const helperInvocation = + 'node --experimental-strip-types scripts/checks/select-ci-endpoint-ca-roots.mts --output "$compact_ca_bundle"'; + const compactEncoding = 'corporate_ca_b64="$(base64 -w 0 "$compact_ca_bundle")"'; + const sourceHash = 'corporate_ca_sha256="$(sha256sum "$compact_ca_bundle" | cut -d \' \' -f 1)"'; + const corporateCaBuildArg = '--build-arg "NEMOCLAW_CORPORATE_CA_B64=${corporate_ca_b64}"'; + const installedHash = + "installed_ca_sha256=\"$( docker run --rm --network none --entrypoint sha256sum nemoclaw-hermes-plan-boundary /usr/local/share/nemoclaw/corporate-ca.pem | cut -d ' ' -f 1 )\""; + const matchingHash = 'test "$installed_ca_sha256" = "$corporate_ca_sha256"'; + const parseProof = + "docker run --rm --network none --entrypoint openssl nemoclaw-hermes-plan-boundary crl2pkcs7 -nocrl -certfile /usr/local/share/nemoclaw/corporate-ca.pem -out /dev/null"; + const orderedFragments = [ + 'compact_ca_bundle="$(mktemp)"', + "trap 'rm -f \"$compact_ca_bundle\"' EXIT", + helperInvocation, + compactEncoding, + sourceHash, + "check-messaging-plan-image-boundary.mts plan", + corporateCaBuildArg, + "check-production-build-args.sh", + 'docker build "${build_args[@]}" -t nemoclaw-hermes-plan-boundary', + installedHash, + matchingHash, + parseProof, + "check-messaging-plan-image-boundary.mts verify", + ]; + for (const fragment of orderedFragments) { + if (!run.includes(fragment)) { + errors.push(`hermes messaging plan image boundary must include ${fragment}`); + } + } + if ( + run.split("select-ci-endpoint-ca-roots.mts").length - 1 !== 1 || + !run.includes(`${helperInvocation} ${compactEncoding}`) + ) { + errors.push(`hermes messaging plan image boundary must include exactly ${helperInvocation}`); + } + if ( + run.includes("/etc/ssl/certs/ca-certificates.crt") || + /base64 -w 0 "?\$\{?system_ca_bundle\}?"?/u.test(run) + ) { + errors.push( + "hermes messaging plan image boundary must not encode the system CA bundle directly", + ); + } + const positions = orderedFragments.map((fragment) => run.indexOf(fragment)); + if ( + positions.some((position, index) => position < 0 || position <= (positions[index - 1] ?? -1)) + ) { + errors.push("hermes messaging plan image boundary CA fixture steps are out of order"); + } +} + function validateMessagingPlanImageBoundary( errors: string[], workflow: SandboxImagesWorkflow, @@ -685,9 +770,11 @@ function validateMessagingPlanImageBoundary( agent: "hermes", baseArgName: "BASE_IMAGE", baseEnvName: "HERMES_BASE_IMAGE", + extraRequiredFragments: ['--build-arg "NEMOCLAW_CORPORATE_CA_B64=${corporate_ca_b64}"'], stepName: "Build and verify Hermes messaging plan boundary", target: "nemoclaw-hermes-plan-boundary", }); + validateHermesMessagingPlanCaFixture(errors, job); const builds = dockerBuildLines(job); if (