diff --git a/.github/actions/build-base-image-platform/action.yaml b/.github/actions/build-base-image-platform/action.yaml index 51050771ef6..6abf11a4eb7 100644 --- a/.github/actions/build-base-image-platform/action.yaml +++ b/.github/actions/build-base-image-platform/action.yaml @@ -52,6 +52,10 @@ inputs: description: Optional same-run reviewed mcporter raw audit report path. required: false default: "" + mcporter-audit-policy-result: + description: Optional same-run reviewed mcporter audit policy result path. + required: false + default: "" runs: using: composite @@ -74,6 +78,7 @@ runs: OPENCLAW_VERSION_INPUT: ${{ inputs.openclaw-version }} MCPORTER_AUDIT_RECEIPT: ${{ inputs.mcporter-audit-receipt }} MCPORTER_AUDIT_RAW_REPORT: ${{ inputs.mcporter-audit-raw-report }} + MCPORTER_AUDIT_POLICY_RESULT: ${{ inputs.mcporter-audit-policy-result }} run: | set -euo pipefail build_args=() @@ -101,7 +106,9 @@ runs: if [ "$AGENT" = "openclaw" ] && [ -n "${MCPORTER_AUDIT_RECEIPT:-}" ]; then test -f "$MCPORTER_AUDIT_RECEIPT" test -f "${MCPORTER_AUDIT_RAW_REPORT:-}" - audit_build_args="NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256=$(sha256sum "$MCPORTER_AUDIT_RECEIPT" | cut -d' ' -f1)" + test -f "${MCPORTER_AUDIT_POLICY_RESULT:-}" + audit_build_args="NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256=$(sha256sum "$MCPORTER_AUDIT_RECEIPT" | cut -d' ' -f1) + NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256=$(sha256sum "$MCPORTER_AUDIT_POLICY_RESULT" | cut -d' ' -f1)" fi printf 'openclaw_build_arg=%s\n' "$openclaw_build_arg" >> "$GITHUB_OUTPUT" if [ -n "$audit_build_args" ]; then @@ -127,6 +134,7 @@ runs: secret-files: | ${{ inputs.agent == 'openclaw' && format('nemoclaw-mcporter-audit-receipt={0}', inputs.mcporter-audit-receipt) || '' }} ${{ inputs.agent == 'openclaw' && format('nemoclaw-mcporter-audit-raw-report={0}', inputs.mcporter-audit-raw-report) || '' }} + ${{ inputs.agent == 'openclaw' && format('nemoclaw-mcporter-audit-policy-result={0}', inputs.mcporter-audit-policy-result) || '' }} - name: Validate Deep Agents Code dos2unix executable if: ${{ inputs.agent == 'langchain-deepagents-code' }} diff --git a/.github/workflows/base-image-platform.yaml b/.github/workflows/base-image-platform.yaml index 2685e94613d..5db5a28c891 100644 --- a/.github/workflows/base-image-platform.yaml +++ b/.github/workflows/base-image-platform.yaml @@ -86,3 +86,4 @@ jobs: openclaw-version: ${{ inputs.openclaw-version }} mcporter-audit-receipt: ${{ inputs.agent == 'openclaw' && format('{0}/reviewed-npm-audit/mcporter-runtime.receipt.json', runner.temp) || '' }} mcporter-audit-raw-report: ${{ inputs.agent == 'openclaw' && format('{0}/reviewed-npm-audit/mcporter-runtime.raw.json', runner.temp) || '' }} + mcporter-audit-policy-result: ${{ inputs.agent == 'openclaw' && format('{0}/reviewed-npm-audit/mcporter-runtime.policy.json', runner.temp) || '' }} diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index 3eb055e3670..248784fcdb7 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -482,6 +482,25 @@ jobs: name: reviewed-npm-audit path: ${{ runner.temp }}/reviewed-npm-audit + - name: Checkout trusted mcporter audit verifier + if: matrix.agent == 'openclaw' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: .trusted-mcporter-audit + persist-credentials: false + sparse-checkout: | + ci/npm-audit-exceptions.json + ci/reviewed-npm-audit.json + scripts/lib/npm-audit-receipt.mts + scripts/lib/reviewed-npm-audit.mts + sparse-checkout-cone-mode: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.19.0 + - name: Prepare same-run mcporter audit evidence if: matrix.agent == 'openclaw' id: mcporter-audit @@ -490,18 +509,29 @@ jobs: set -euo pipefail receipt="$RUNNER_TEMP/reviewed-npm-audit/mcporter-runtime.receipt.json" raw="$RUNNER_TEMP/reviewed-npm-audit/mcporter-runtime.raw.json" - test -f "$receipt"; test -f "$raw" - printf 'receipt=%s\nraw=%s\nreceipt_sha256=%s\n' "$receipt" "$raw" "$(sha256sum "$receipt" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + policy="$RUNNER_TEMP/reviewed-npm-audit/mcporter-runtime.policy.json" + trusted_root="$GITHUB_WORKSPACE/.trusted-mcporter-audit" + test "$(git -C "$trusted_root" rev-parse --verify HEAD)" = '${{ github.event.pull_request.base.sha }}' + node --experimental-strip-types --no-warnings \ + "$trusted_root/scripts/lib/npm-audit-receipt.mts" \ + --receipt "$receipt" \ + --package-json "$GITHUB_WORKSPACE/agents/openclaw/mcporter-runtime/package.json" \ + --package-lock "$GITHUB_WORKSPACE/agents/openclaw/mcporter-runtime/package-lock.json" \ + --raw-report "$raw" \ + --exceptions "$trusted_root/ci/npm-audit-exceptions.json" \ + --graph mcporter-runtime \ + --audit-config "$trusted_root/ci/reviewed-npm-audit.json" \ + --registry https://registry.yarnpkg.com \ + --threshold high \ + --legacy-audit true \ + --result "$policy" + test -f "$receipt"; test -f "$raw"; test -s "$policy"; test ! -L "$policy" + printf 'receipt=%s\nraw=%s\npolicy=%s\nreceipt_sha256=%s\npolicy_sha256=%s\n' "$receipt" "$raw" "$policy" "$(sha256sum "$receipt" | cut -d' ' -f1)" "$(sha256sum "$policy" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx id: buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 22.19.0 - - name: Validate Deep Agents PR base build arguments if: matrix.agent == 'langchain-deepagents-code' shell: bash @@ -550,7 +580,9 @@ jobs: RESOLUTION_LABEL: ${{ steps.base.outputs.resolution_label }} MCPORTER_AUDIT_RECEIPT: ${{ steps.mcporter-audit.outputs.receipt }} MCPORTER_AUDIT_RAW_REPORT: ${{ steps.mcporter-audit.outputs.raw }} + MCPORTER_AUDIT_POLICY_RESULT: ${{ steps.mcporter-audit.outputs.policy }} MCPORTER_AUDIT_RECEIPT_SHA256: ${{ steps.mcporter-audit.outputs.receipt_sha256 }} + MCPORTER_AUDIT_POLICY_RESULT_SHA256: ${{ steps.mcporter-audit.outputs.policy_sha256 }} run: | set -euo pipefail # The base resolver loads a changed base into Docker's local image @@ -561,8 +593,10 @@ jobs: if [ "$AGENT" = "openclaw" ]; then audit_options+=( --build-arg "NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256=${MCPORTER_AUDIT_RECEIPT_SHA256}" + --build-arg "NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256=${MCPORTER_AUDIT_POLICY_RESULT_SHA256}" --secret "id=nemoclaw-mcporter-audit-receipt,src=${MCPORTER_AUDIT_RECEIPT}" --secret "id=nemoclaw-mcporter-audit-raw-report,src=${MCPORTER_AUDIT_RAW_REPORT}" + --secret "id=nemoclaw-mcporter-audit-policy-result,src=${MCPORTER_AUDIT_POLICY_RESULT}" ) fi if [ "$AGENT" = "langchain-deepagents-code" ]; then @@ -618,9 +652,11 @@ jobs: NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=1 NEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root ${{ matrix.agent == 'openclaw' && format('NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256={0}', steps.mcporter-audit.outputs.receipt_sha256) || '' }} + ${{ matrix.agent == 'openclaw' && format('NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256={0}', steps.mcporter-audit.outputs.policy_sha256) || '' }} secret-files: | ${{ matrix.agent == 'openclaw' && format('nemoclaw-mcporter-audit-receipt={0}', steps.mcporter-audit.outputs.receipt) || '' }} ${{ matrix.agent == 'openclaw' && format('nemoclaw-mcporter-audit-raw-report={0}', steps.mcporter-audit.outputs.raw) || '' }} + ${{ matrix.agent == 'openclaw' && format('nemoclaw-mcporter-audit-policy-result={0}', steps.mcporter-audit.outputs.policy) || '' }} cache-from: type=registry,ref=ghcr.io/nvidia/nemoclaw/${{ matrix.agent }}-sandbox:buildcache-linux-amd64 provenance: false sbom: false @@ -853,9 +889,11 @@ jobs: NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=1 NEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root ${{ matrix.agent == 'openclaw' && format('NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256={0}', steps.mcporter-audit.outputs.receipt_sha256) || '' }} + ${{ matrix.agent == 'openclaw' && format('NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256={0}', steps.mcporter-audit.outputs.policy_sha256) || '' }} secret-files: | ${{ matrix.agent == 'openclaw' && format('nemoclaw-mcporter-audit-receipt={0}', steps.mcporter-audit.outputs.receipt) || '' }} ${{ matrix.agent == 'openclaw' && format('nemoclaw-mcporter-audit-raw-report={0}', steps.mcporter-audit.outputs.raw) || '' }} + ${{ matrix.agent == 'openclaw' && format('nemoclaw-mcporter-audit-policy-result={0}', steps.mcporter-audit.outputs.policy) || '' }} cache-from: type=registry,ref=ghcr.io/nvidia/nemoclaw/${{ matrix.agent }}-sandbox:buildcache-linux-amd64 provenance: false sbom: false @@ -1736,8 +1774,9 @@ jobs: set -euo pipefail receipt="$RUNNER_TEMP/reviewed-npm-audit/mcporter-runtime.receipt.json" raw="$RUNNER_TEMP/reviewed-npm-audit/mcporter-runtime.raw.json" - test -f "$receipt"; test -f "$raw" - printf 'receipt=%s\nraw=%s\nreceipt_sha256=%s\n' "$receipt" "$raw" "$(sha256sum "$receipt" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + policy="$RUNNER_TEMP/reviewed-npm-audit/mcporter-runtime.policy.json" + test -f "$receipt"; test -f "$raw"; test -s "$policy"; test ! -L "$policy" + printf 'receipt=%s\nraw=%s\npolicy=%s\nreceipt_sha256=%s\npolicy_sha256=%s\n' "$receipt" "$raw" "$policy" "$(sha256sum "$receipt" | cut -d' ' -f1)" "$(sha256sum "$policy" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" - name: Restore exact base image contract shell: bash @@ -1920,9 +1959,11 @@ jobs: NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=1 NEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root ${{ matrix.agent == 'openclaw' && format('NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256={0}', steps.mcporter-audit.outputs.receipt_sha256) || '' }} + ${{ matrix.agent == 'openclaw' && format('NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256={0}', steps.mcporter-audit.outputs.policy_sha256) || '' }} secret-files: | ${{ matrix.agent == 'openclaw' && format('nemoclaw-mcporter-audit-receipt={0}', steps.mcporter-audit.outputs.receipt) || '' }} ${{ matrix.agent == 'openclaw' && format('nemoclaw-mcporter-audit-raw-report={0}', steps.mcporter-audit.outputs.raw) || '' }} + ${{ matrix.agent == 'openclaw' && format('nemoclaw-mcporter-audit-policy-result={0}', steps.mcporter-audit.outputs.policy) || '' }} cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.artifact_platform }} cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.artifact_platform }},mode=max provenance: mode=max diff --git a/Dockerfile b/Dockerfile index 6d512afd7d7..590092fcbad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -544,8 +544,8 @@ COPY ci/npm-audit-exceptions.json ci/reviewed-npm-audit.json /scripts/ COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts COPY scripts/lib/reviewed-npm-audit.mts /scripts/lib/reviewed-npm-audit.mts -COPY scripts/lib/npm-audit-receipt.mts /scripts/lib/npm-audit-receipt.mts COPY scripts/lib/openclaw-npm-remediation.mts /scripts/lib/openclaw-npm-remediation.mts +COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts @@ -634,6 +634,7 @@ ARG MCPORTER_VERSION=0.7.3 ARG MCPORTER_0_7_3_INTEGRITY=sha512-egoPVYqTnWb3NjRIxo+xc8OrAI0dlPrJm9pAiZx0pImuNIV5rKhGtTnIfH/Y1ldGPVu74ibj3KR5c9U/QSdQFA== ARG MCPORTER_0_7_3_TARBALL=https://registry.npmjs.org/mcporter/-/mcporter-0.7.3.tgz ARG NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256= +ARG NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256= # A cross-stage root copy is accepted by Docker's legacy builder and creates one # final-image layer while preserving metadata on existing parent directories. @@ -817,9 +818,9 @@ RUN command -v codex-acp >/dev/null # OPENCLAW_VERSION is the NemoClaw runtime build target and must meet the blueprint minimum. # Reviewed archives retain registry and packed-byte SRI, basename, local-only install, and cleanup gates. # hadolint ignore=DL3059,DL4006,DL3016,SC2015 -RUN --network=default \ - --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ +RUN --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ --mount=type=secret,id=nemoclaw-mcporter-audit-raw-report,required=false \ + --mount=type=secret,id=nemoclaw-mcporter-audit-policy-result,required=false \ set -eu; \ if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ @@ -865,11 +866,22 @@ RUN --network=default \ MCPORTER_LOCK_SHA256="$(sha256sum /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json | awk '{print $1}')"; \ [ -n "$MCPORTER_LOCK_SHA256" ] \ || { echo "ERROR: Could not hash the committed mcporter lockfile" >&2; exit 1; }; \ - MCPORTER_AUDIT_POLICY_SHA256="$(sha256sum /scripts/npm-audit-exceptions.json | awk '{print $1}')"; \ - MCPORTER_EXPECTED_AUDIT_EXCEPTIONS="$(node --input-type=module -e \ - 'import fs from "node:fs"; import { parseAuditExceptionRegistry } from "/scripts/lib/reviewed-npm-audit.mts"; const policy=parseAuditExceptionRegistry(fs.readFileSync("/scripts/npm-audit-exceptions.json", "utf-8")); const ids=policy.exceptions.filter((entry)=>entry.graph==="mcporter-runtime").map((entry)=>entry.advisory).sort(); process.stdout.write(ids.join(",") || "none");')"; \ - MCPORTER_EXPECTED_AUDIT_STATUS=clean; \ - if [ "$MCPORTER_EXPECTED_AUDIT_EXCEPTIONS" != "none" ]; then MCPORTER_EXPECTED_AUDIT_STATUS=accepted-exceptions; fi; \ + MCPORTER_AUDIT_EVIDENCE=0; \ + if [ -n "${NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256:-}${NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256:-}" ]; then \ + NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH=/tmp/mcporter-npm-audit.json \ + NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH=/tmp/mcporter-npm-audit-policy.json \ + bash /scripts/lib/verify-mcporter-audit.sh; \ + MCPORTER_AUDIT_EVIDENCE=1; \ + MCPORTER_AUDIT_POLICY_SHA256="$(node -p "require('/tmp/mcporter-npm-audit-policy.json').exceptionPolicySha256")"; \ + MCPORTER_EXPECTED_AUDIT_EXCEPTIONS="$(node -p "require('/tmp/mcporter-npm-audit-policy.json').acceptedAdvisories.join(',') || 'none'")"; \ + MCPORTER_EXPECTED_AUDIT_STATUS="$(node -p "require('/tmp/mcporter-npm-audit-policy.json').status")"; \ + else \ + MCPORTER_AUDIT_POLICY_SHA256="$(sha256sum /scripts/npm-audit-exceptions.json | awk '{print $1}')"; \ + MCPORTER_EXPECTED_AUDIT_EXCEPTIONS="$(node --input-type=module -e \ + 'import fs from "node:fs"; import { parseAuditExceptionRegistry } from "/scripts/lib/reviewed-npm-audit.mts"; const policy=parseAuditExceptionRegistry(fs.readFileSync("/scripts/npm-audit-exceptions.json", "utf-8")); const ids=policy.exceptions.filter((entry)=>entry.graph==="mcporter-runtime").map((entry)=>entry.advisory).sort(); process.stdout.write(ids.join(",") || "none");')"; \ + MCPORTER_EXPECTED_AUDIT_STATUS=clean; \ + if [ "$MCPORTER_EXPECTED_AUDIT_EXCEPTIONS" != "none" ]; then MCPORTER_EXPECTED_AUDIT_STATUS=accepted-exceptions; fi; \ + fi; \ CUR_VER_OUTPUT="$(openclaw --version 2>/dev/null)" \ || { echo "ERROR: Could not execute openclaw --version" >&2; exit 1; }; \ CUR_VER="$(printf '%s\n' "$CUR_VER_OUTPUT" | /usr/local/lib/nemoclaw/extract-semver openclaw)" \ @@ -983,24 +995,10 @@ RUN --network=default \ ln -s /usr/local/lib/nemoclaw/mcporter-runtime/node_modules/.bin/mcporter /usr/local/bin/mcporter; \ test "$(mcporter --version)" = "$MCPORTER_VERSION"; \ fi; \ - MCPORTER_RECEIPT=/run/secrets/nemoclaw-mcporter-audit-receipt; \ - MCPORTER_RAW_REPORT=/run/secrets/nemoclaw-mcporter-audit-raw-report; \ - if [ -f "$MCPORTER_RECEIPT" ] || [ -f "$MCPORTER_RAW_REPORT" ] || [ -n "${NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256:-}" ]; then \ - [ -f "$MCPORTER_RECEIPT" ] && [ -f "$MCPORTER_RAW_REPORT" ] && printf %s "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" | grep -qxE '[0-9a-f]{64}' \ - || { echo "ERROR: cached mcporter audit requires paired receipt, raw report, and receipt SHA-256" >&2; exit 1; }; \ - printf '%s %s\n' "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" "$MCPORTER_RECEIPT" | sha256sum -c -; \ -node /scripts/lib/npm-audit-receipt.mts \ ---receipt "$MCPORTER_RECEIPT" \ ---package-json /usr/local/lib/nemoclaw/mcporter-runtime/package.json \ ---package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json \ ---raw-report "$MCPORTER_RAW_REPORT" --exceptions /scripts/npm-audit-exceptions.json \ ---graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json \ ---registry https://registry.yarnpkg.com --threshold high --legacy-audit true; \ - else \ - node /scripts/lib/reviewed-npm-audit.mts \ - --directory /usr/local/lib/nemoclaw/mcporter-runtime \ - --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high; \ - fi + if [ "$MCPORTER_AUDIT_EVIDENCE" = 0 ]; then \ + bash /scripts/lib/verify-mcporter-audit.sh; \ + fi; \ + rm -f /tmp/mcporter-npm-audit.json /tmp/mcporter-npm-audit-policy.json # Patch OpenClaw media fetch for proxy-only sandbox (NVIDIA/NemoClaw#1755). # diff --git a/Dockerfile.base b/Dockerfile.base index 6132673ead1..8f17d53e4d0 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -413,6 +413,7 @@ ARG MCPORTER_VERSION=0.7.3 ARG MCPORTER_0_7_3_INTEGRITY=sha512-egoPVYqTnWb3NjRIxo+xc8OrAI0dlPrJm9pAiZx0pImuNIV5rKhGtTnIfH/Y1ldGPVu74ibj3KR5c9U/QSdQFA== ARG MCPORTER_0_7_3_TARBALL=https://registry.npmjs.org/mcporter/-/mcporter-0.7.3.tgz ARG NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256= +ARG NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256= # Keep paired runtime manifests and remediation helpers in grouped layers so # the published base retains its established image layout. COPY agents/openclaw/openclaw-runtime/package.json \ @@ -427,7 +428,7 @@ COPY scripts/lib/reviewed-npm-archive.mts \ scripts/lib/reviewed-npm-audit.mts \ scripts/lib/openclaw-npm-remediation.mts \ /scripts/lib/ -COPY scripts/lib/npm-audit-receipt.mts /scripts/lib/npm-audit-receipt.mts +COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts @@ -477,6 +478,7 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/blueprint.yaml \ --mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false \ --mount=type=secret,id=nemoclaw-mcporter-audit-raw-report,required=false \ + --mount=type=secret,id=nemoclaw-mcporter-audit-policy-result,required=false \ echo "$OPENCLAW_VERSION" | grep -qxE '[0-9]+(\.[0-9]+)*' \ || { echo "Error: OPENCLAW_VERSION='$OPENCLAW_VERSION' is invalid (expected e.g. 2026.3.11)."; exit 1; }; \ OPENCLAW_MIN_VERSION=$(grep -m 1 'min_openclaw_version' /tmp/blueprint.yaml | awk '{print $2}' | tr -d '"'); \ @@ -579,28 +581,9 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep 'const { StreamableHTTPServerTransport } = await import("file:///usr/local/lib/nemoclaw/mcporter-runtime/node_modules/@modelcontextprotocol/sdk/dist/esm/server/streamableHttp.js"); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); await transport.close();' \ && ln -s /usr/local/lib/nemoclaw/mcporter-runtime/node_modules/.bin/mcporter /usr/local/bin/mcporter \ && test "$(mcporter --version)" = "$MCPORTER_VERSION" \ - && MCPORTER_RECEIPT=/run/secrets/nemoclaw-mcporter-audit-receipt \ - && MCPORTER_RAW_REPORT=/run/secrets/nemoclaw-mcporter-audit-raw-report \ - && if [ -f "$MCPORTER_RECEIPT" ] || [ -f "$MCPORTER_RAW_REPORT" ] || [ -n "${NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256:-}" ]; then \ - [ -f "$MCPORTER_RECEIPT" ] && [ -f "$MCPORTER_RAW_REPORT" ] && printf %s "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" | grep -qxE '[0-9a-f]{64}' \ - || { echo "ERROR: cached mcporter audit requires paired receipt, raw report, and receipt SHA-256" >&2; exit 1; }; \ - printf '%s %s\n' "$NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256" "$MCPORTER_RECEIPT" | sha256sum -c -; \ - node /scripts/lib/npm-audit-receipt.mts \ - --receipt "$MCPORTER_RECEIPT" \ - --package-json /usr/local/lib/nemoclaw/mcporter-runtime/package.json \ - --package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json \ - --raw-report "$MCPORTER_RAW_REPORT" --exceptions /scripts/npm-audit-exceptions.json \ - --graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json \ - --registry https://registry.yarnpkg.com --threshold high \ - --legacy-audit true \ - --result /tmp/mcporter-npm-audit-policy.json \ - && cp "$MCPORTER_RAW_REPORT" /tmp/mcporter-npm-audit.json; \ - else \ - node /scripts/lib/reviewed-npm-audit.mts \ - --directory /usr/local/lib/nemoclaw/mcporter-runtime \ - --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high \ - --report /tmp/mcporter-npm-audit.json --result /tmp/mcporter-npm-audit-policy.json; \ - fi \ + && NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH=/tmp/mcporter-npm-audit.json \ + NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH=/tmp/mcporter-npm-audit-policy.json \ + bash /scripts/lib/verify-mcporter-audit.sh \ && MCPORTER_AUDIT_STATUS="$(node -p "require('/tmp/mcporter-npm-audit-policy.json').status")" \ && MCPORTER_AUDIT_EXCEPTIONS="$(node -p "require('/tmp/mcporter-npm-audit-policy.json').acceptedAdvisories.join(',') || 'none'")" \ && MCPORTER_AUDIT_POLICY_SHA256="$(node -p "require('/tmp/mcporter-npm-audit-policy.json').exceptionPolicySha256")" \ diff --git a/agents/openclaw/dependency-review.md b/agents/openclaw/dependency-review.md index 2477d64aecb..118298109e6 100644 --- a/agents/openclaw/dependency-review.md +++ b/agents/openclaw/dependency-review.md @@ -42,7 +42,7 @@ The reviewed audit wrapper reports lower-severity production findings and blocks It also exercises the reviewed archive through a copied writable cache while the trusted source remains read-only. Signature verification makes at most three attempts and retries only `npm error Failed to download`; all other failures stop immediately. The shared report artifact stores the audit policy, signature-attempt evidence, and whether each response came from a matching cache entry or a live registry request. - Its mcporter receipt and raw response cross into the image build; the other graph receipts remain CI evidence. + Its mcporter receipt, raw report, and trusted policy result cross into image builds; the other graph receipts remain CI evidence. The archive graph also retains the generated manifest and lock bytes authenticated by its receipt. - Advisory command: `npm ci --ignore-scripts --omit=dev --legacy-peer-deps --prefix agents/openclaw/wechat-runtime && npm audit --registry=https://registry.yarnpkg.com --omit=dev --audit-level=low --json --prefix agents/openclaw/wechat-runtime && npm audit signatures --registry=https://registry.yarnpkg.com --omit=dev --prefix agents/openclaw/wechat-runtime`. - Advisory review: `2026-07-12`; result: `0` known vulnerabilities across the resolved production graph. @@ -57,7 +57,10 @@ The lock records the exact version, registry URL, and integrity for every transi - `invalidState`: the image installs a package graph, tarball, license, or advisory state that differs from the independently queried npm registry records for `mcporter@0.7.3`, resolves `@hono/node-server` to any version other than exact `2.0.11`, resolves `fast-uri` to any version other than exact `3.1.6`, resolves `hono` to any version other than exact `4.12.34`, or resolves `ip-address` to any version other than exact `10.3.1`. - `sourceBoundary`: npm owns registry metadata, tarball integrity, provenance signatures, and advisory responses; NemoClaw owns the exact lock, script-disabled install, Docker integrity assertion, empty-by-default audit exception registry, and review record. - `whyNotSourceFix`: a repository note cannot make external registry state trustworthy, so the required `reviewed-npm-audit` CI check materializes the exact locked production graph and verifies its registry signatures. -- `imageBuildBoundary`: image builds verify the committed lock, registry origin, tarball integrity, installed graph, lifecycle suppression, and reviewed advisory policy without connecting to Sigstore. +- `imageBuildBoundary`: image builds verify the committed lock, registry origin, tarball integrity, installed graph, and lifecycle suppression. + Builds without supplied audit evidence evaluate the reviewed advisory policy directly. + Evidence-backed builds instead verify the receipt and policy-result transport hashes after trusted workflow code validates the candidate graph and policy. + Neither path connects to Sigstore. The `schema=4` and `mcporter-recipe=locked-ci+reviewed-audit-v3` provenance values record this boundary. They do not attest that trusted CI verified registry signatures. - `enforcementBoundary`: any nonzero `npm audit signatures` status fails the required CI check. diff --git a/scripts/audit-reviewed-npm-graph.mts b/scripts/audit-reviewed-npm-graph.mts index 367915c9e64..ba5c076d95b 100755 --- a/scripts/audit-reviewed-npm-graph.mts +++ b/scripts/audit-reviewed-npm-graph.mts @@ -729,7 +729,7 @@ function auditLockedGraph( }, reviewedNpmIdentity: config, reportFile: path.join(artifactDirectory, `locked-graph-${index + 1}.json`), - resultFile: path.join(artifactDirectory, `locked-graph-${index + 1}-policy.json`), + resultFile: path.join(artifactDirectory, `${graph.id}.policy.json`), threshold: graph.severityThreshold ?? config.severityThreshold, throwOnBlock: false, }); diff --git a/scripts/lib/reviewed-npm-audit.mts b/scripts/lib/reviewed-npm-audit.mts index 7b8c18293b6..7c218421cef 100755 --- a/scripts/lib/reviewed-npm-audit.mts +++ b/scripts/lib/reviewed-npm-audit.mts @@ -177,7 +177,27 @@ type NpmAuditCommandResult = Readonly<{ stdout: string; }>; -type NpmAuditRetryReason = "empty-output" | "incomplete-report" | "invalid-json" | "timeout"; +export type NpmAuditFailureReason = + | "empty-output" + | "incomplete-report" + | "invalid-exit-status" + | "invalid-json" + | "npm-error-document" + | "registry-network-error" + | "timeout"; + +export type NpmAuditFailureClassification = Readonly<{ + diagnostic: string; + reason: NpmAuditFailureReason; + retryable: boolean; +}>; + +export type NpmAuditResponseClassification = + | Readonly<{ failure: NpmAuditFailureClassification }> + | Readonly<{ report: Record }>; + +const RETRYABLE_TRANSPORT_CODES = ["EAI_AGAIN", "ECONNRESET", "ECONNREFUSED"] as const; +type RetryableTransportCode = (typeof RETRYABLE_TRANSPORT_CODES)[number]; function asRecord(value: unknown, label: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { @@ -320,57 +340,152 @@ export function assertExceptionGraphs( throw new Error(`npm audit exceptions use unknown graphs: ${unknown.join(", ")}`); } -export function parseAuditReport(result: { +function valueShape(value: unknown): string { + if (value === undefined) return "missing"; + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + return typeof value; +} + +function firstInvalidAuditField(value: unknown): string | undefined { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return `report:${valueShape(value)}`; + } + const report = value as Record; + if ( + report.metadata === null || + typeof report.metadata !== "object" || + Array.isArray(report.metadata) + ) { + return `metadata:${valueShape(report.metadata)}`; + } + const metadata = report.metadata as Record; + if ( + metadata.vulnerabilities === null || + typeof metadata.vulnerabilities !== "object" || + Array.isArray(metadata.vulnerabilities) + ) { + return `metadata.vulnerabilities:${valueShape(metadata.vulnerabilities)}`; + } + const vulnerabilities = metadata.vulnerabilities as Record; + for (const severity of SEVERITIES) { + const count = vulnerabilities[severity]; + if (typeof count !== "number" || !Number.isSafeInteger(count) || count < 0) { + return `metadata.vulnerabilities.${severity}:${typeof count === "number" ? "invalid-number" : valueShape(count)}`; + } + } + return undefined; +} + +function retryableTransportCode( + report: Record, + stderr: string, +): RetryableTransportCode | undefined { + const error = + typeof report.error === "object" && report.error !== null && !Array.isArray(report.error) + ? (report.error as Record) + : {}; + const values = [report.message, error.code, error.summary, error.detail, stderr].filter( + (value): value is string => typeof value === "string", + ); + return RETRYABLE_TRANSPORT_CODES.find((code) => + values.some((value) => new RegExp(`(?:^|[^A-Z0-9_])${code}(?:$|[^A-Z0-9_])`, "u").test(value)), + ); +} + +function rejectedAuditResponse( + result: Readonly<{ status: number | null; stdout: string }>, + reason: NpmAuditFailureReason, + retryable: boolean, + fields: readonly string[] = [], +): NpmAuditResponseClassification { + const status = result.status === null ? "null" : String(result.status); + return { + failure: { + diagnostic: [ + `exit=${status}`, + `stdout-bytes=${Buffer.byteLength(result.stdout)}`, + `stdout-sha256=${sha256(result.stdout)}`, + `condition=${reason}`, + ...fields, + ].join(" "), + reason, + retryable, + }, + }; +} + +/** Classify one npm response without retaining payload text or unbounded field names. */ +export function classifyNpmAuditResponse(result: { status: number | null; stderr: string; stdout: string; -}): Record { - if (!result.stdout.trim()) throw new Error(`npm audit did not produce JSON: ${result.stderr}`); - let report: Record; - try { - report = JSON.parse(result.stdout) as Record; - } catch (error) { - throw new Error(`npm audit returned invalid JSON: ${String(error)}`); +}): NpmAuditResponseClassification { + if (!result.stdout.trim()) { + const transport = retryableTransportCode({}, result.stderr); + return rejectedAuditResponse( + result, + transport ? "registry-network-error" : "empty-output", + transport !== undefined, + transport ? [`transport=${transport}`] : [], + ); } - let counts: Record; + + let value: unknown; try { - counts = vulnerabilityCounts(report); - } catch (error) { - const detail = report.error === undefined ? result.stderr : JSON.stringify(report.error); - throw new Error( - `npm audit failed without a complete vulnerability report: ${error instanceof Error ? error.message : String(error)}${detail ? `; ${detail}` : ""}`, - ); + value = JSON.parse(result.stdout); + } catch { + return rejectedAuditResponse(result, "invalid-json", false); + } + + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return rejectedAuditResponse(result, "incomplete-report", false, [ + `required-field=report:${valueShape(value)}`, + ]); } + const report = value as Record; + const invalidField = firstInvalidAuditField(report); + if (report.error !== undefined) { + const transport = retryableTransportCode(report, result.stderr); + const retryable = transport !== undefined; + const reason = retryable ? "registry-network-error" : "npm-error-document"; + return rejectedAuditResponse(result, reason, retryable, [ + ...(transport ? [`transport=${transport}`] : []), + ...(invalidField ? [`required-field=${invalidField}`] : []), + ]); + } + if (invalidField) { + return rejectedAuditResponse(result, "incomplete-report", false, [ + `required-field=${invalidField}`, + ]); + } + + const counts = vulnerabilityCounts(report); const findingCount = SEVERITIES.reduce((total, severity) => total + counts[severity], 0); - if ( - report.error !== undefined || - result.status === null || - result.status > 1 || - (result.status !== 0 && findingCount === 0) - ) { - const detail = report.error === undefined ? result.stderr : JSON.stringify(report.error); + if (result.status === null || result.status > 1 || (result.status !== 0 && findingCount === 0)) { + return rejectedAuditResponse(result, "invalid-exit-status", false); + } + return { report }; +} + +export function parseAuditReport(result: { + status: number | null; + stderr: string; + stdout: string; +}): Record { + const classified = classifyNpmAuditResponse(result); + if ("failure" in classified) { throw new Error( - `npm audit failed without vulnerability findings${detail ? `: ${detail}` : ""}`, + `npm audit response rejected (reason=${classified.failure.reason}; ${classified.failure.diagnostic})`, ); } - return report; + return classified.report; } function waitSynchronously(delayMs: number): void { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMs); } -function npmAuditRetryReason(result: NpmAuditCommandResult): NpmAuditRetryReason { - if (result.error) return "timeout"; - if (!result.stdout.trim()) return "empty-output"; - try { - JSON.parse(result.stdout); - } catch { - return "invalid-json"; - } - return "incomplete-report"; -} - export function runNpmAuditWithRetry( input: Readonly<{ run: () => NpmAuditCommandResult; @@ -378,6 +493,7 @@ export function runNpmAuditWithRetry( warn?: (message: string) => void; }>, ): Readonly<{ + classification?: NpmAuditFailureClassification; failure?: Error; report?: Record; result: NpmAuditCommandResult; @@ -386,6 +502,7 @@ export function runNpmAuditWithRetry( const warn = input.warn ?? console.warn; const attemptCount = NPM_AUDIT_RETRY_DELAYS_MS.length + 1; let lastResult: NpmAuditCommandResult | undefined; + let lastFailure: NpmAuditFailureClassification | undefined; for (let attempt = 1; attempt <= attemptCount; attempt += 1) { const result = input.run(); @@ -393,25 +510,36 @@ export function runNpmAuditWithRetry( throw result.error; } lastResult = result; - try { - if (result.error) { - throw new Error(`npm audit exceeded its ${NPM_AUDIT_ATTEMPT_TIMEOUT_MS} ms timeout`); - } - return { report: parseAuditReport(result), result }; - } catch { - const delayMs = NPM_AUDIT_RETRY_DELAYS_MS[attempt - 1]; - if (delayMs === undefined) break; - warn( - `npm audit scan incomplete on attempt ${attempt}/${attemptCount}; retrying in ${delayMs} ms (reason=${npmAuditRetryReason(result)})`, - ); - wait(delayMs); + const classified: NpmAuditResponseClassification = result.error + ? rejectedAuditResponse(result, "timeout", true, [ + `timeout-ms=${NPM_AUDIT_ATTEMPT_TIMEOUT_MS}`, + ]) + : classifyNpmAuditResponse(result); + if ("report" in classified) return { report: classified.report, result }; + lastFailure = classified.failure; + if (!lastFailure.retryable) { + return { + classification: lastFailure, + failure: new Error( + `npm audit scan failed closed on attempt ${attempt}/${attemptCount} without retry (reason=${lastFailure.reason}; ${lastFailure.diagnostic})`, + ), + result, + }; } + const delayMs = NPM_AUDIT_RETRY_DELAYS_MS[attempt - 1]; + if (delayMs === undefined) break; + warn( + `npm audit scan failed on attempt ${attempt}/${attemptCount}; retrying in ${delayMs} ms (reason=${lastFailure.reason}; ${lastFailure.diagnostic})`, + ); + wait(delayMs); } - if (!lastResult) throw new Error("npm audit retry loop completed without running the scanner"); + if (!lastResult || !lastFailure) + throw new Error("npm audit retry loop completed without running the scanner"); return { + classification: lastFailure, failure: new Error( - `npm audit scan remained incomplete after ${attemptCount} attempts (reason=${npmAuditRetryReason(lastResult)})`, + `npm audit scan failed after ${attemptCount} attempts (reason=${lastFailure.reason}; ${lastFailure.diagnostic})`, ), result: lastResult, }; @@ -945,7 +1073,20 @@ export function runReviewedNpmAudit( : undefined); const auditFailure = audit.failure; const report = audit.report ?? {}; - if (options.reportFile) fs.writeFileSync(options.reportFile, audit.result.stdout); + if (options.reportFile) { + const retainedReport = audit.classification + ? `${JSON.stringify( + { + schemaVersion: 1, + status: "failed", + failure: audit.classification, + }, + null, + 2, + )}\n` + : audit.result.stdout; + fs.writeFileSync(options.reportFile, retainedReport); + } if (options.provenance && options.reportFile) { const provenance = buildAuditProvenance({ cache: cacheEvidence, diff --git a/scripts/lib/verify-mcporter-audit.sh b/scripts/lib/verify-mcporter-audit.sh new file mode 100755 index 00000000000..11248a1b059 --- /dev/null +++ b/scripts/lib/verify-mcporter-audit.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +receipt=/run/secrets/nemoclaw-mcporter-audit-receipt +raw_report=/run/secrets/nemoclaw-mcporter-audit-raw-report +policy_result=/run/secrets/nemoclaw-mcporter-audit-policy-result +receipt_sha256="${NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256:-}" +policy_result_sha256="${NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256:-}" +seed=/run/nemoclaw-mcporter-audit-cache/reviewed-npm-audit +report_path="${NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH:-}" +result_path="${NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH:-}" +audit_output_args=() +[[ -z "$report_path" ]] || audit_output_args+=(--report "$report_path") +[[ -z "$result_path" ]] || audit_output_args+=(--result "$result_path") + +if [[ -e "$receipt" || -L "$receipt" || -e "$raw_report" || -L "$raw_report" || -e "$policy_result" || -L "$policy_result" || -n "$receipt_sha256" || -n "$policy_result_sha256" ]]; then + [[ -f "$receipt" && ! -L "$receipt" && -f "$raw_report" && ! -L "$raw_report" && -f "$policy_result" && ! -L "$policy_result" && -n "$receipt_sha256" && -n "$policy_result_sha256" ]] || { + echo "ERROR: cached mcporter audit requires paired receipt, raw report, trusted policy result, and transport SHA-256 values" >&2 + exit 1 + } +elif [[ -e "$seed" || -L "$seed" ]]; then + echo "ERROR: build-context mcporter audit evidence is not trusted" >&2 + exit 1 +else + node /scripts/lib/reviewed-npm-audit.mts \ + --directory /usr/local/lib/nemoclaw/mcporter-runtime \ + --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high \ + "${audit_output_args[@]}" + exit +fi + +printf '%s' "$receipt_sha256" | grep -qxE '[0-9a-f]{64}' || { + echo "ERROR: cached mcporter audit receipt SHA-256 is invalid" >&2 + exit 1 +} +printf '%s %s\n' "$receipt_sha256" "$receipt" | sha256sum --check --status - || { + echo "ERROR: cached mcporter audit receipt hash does not match" >&2 + exit 1 +} +printf '%s' "$policy_result_sha256" | grep -qxE '[0-9a-f]{64}' || { + echo "ERROR: cached mcporter audit policy result SHA-256 is invalid" >&2 + exit 1 +} +printf '%s %s\n' "$policy_result_sha256" "$policy_result" | sha256sum --check --status - || { + echo "ERROR: cached mcporter audit policy result hash does not match" >&2 + exit 1 +} +raw_report_sha256="$(jq -er ' + .rawResponseSha256 | select(type == "string" and test("^[0-9a-f]{64}$")) +' "$receipt")" || { + echo "ERROR: verified mcporter audit receipt does not declare a raw response SHA-256" >&2 + exit 1 +} +printf '%s %s\n' "$raw_report_sha256" "$raw_report" | sha256sum --check --status - || { + echo "ERROR: cached mcporter audit raw report does not match the verified receipt" >&2 + exit 1 +} +[[ -z "$report_path" ]] || cp -- "$raw_report" "$report_path" +[[ -z "$result_path" ]] || cp -- "$policy_result" "$result_path" diff --git a/src/lib/onboard/setup-nim-flow-serving-profile.test.ts b/src/lib/onboard/setup-nim-flow-serving-profile.test.ts index 9b473d00405..995a676d518 100644 --- a/src/lib/onboard/setup-nim-flow-serving-profile.test.ts +++ b/src/lib/onboard/setup-nim-flow-serving-profile.test.ts @@ -51,6 +51,13 @@ async function selectAgainstRunningVllm( isNonInteractive: () => true, getNonInteractiveProvider: () => "install-vllm", detectInferenceProviderHostState: () => runningVllmHostState(), + discoverManagedLlamaCppSelections: () => ({ + choices: [], + resolution: { + kind: "rejected", + reason: "the vLLM profile test does not select llama.cpp", + }, + }), handleVllmSelection, resolveRequestedServingProfileModel, selectVllmModelFromEnv, diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index 5630f7e2246..14dca2e7387 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -450,14 +450,14 @@ function stageOptimizedSandboxBuildContext( path.join(rootDir, "scripts", "lib", "reviewed-npm-audit.mts"), path.join(stagedScriptsDir, "lib", "reviewed-npm-audit.mts"), ); - fs.copyFileSync( - path.join(rootDir, "scripts", "lib", "npm-audit-receipt.mts"), - path.join(stagedScriptsDir, "lib", "npm-audit-receipt.mts"), - ); fs.copyFileSync( path.join(rootDir, "scripts", "lib", "openclaw-npm-remediation.mts"), path.join(stagedScriptsDir, "lib", "openclaw-npm-remediation.mts"), ); + fs.copyFileSync( + path.join(rootDir, "scripts", "lib", "verify-mcporter-audit.sh"), + path.join(stagedScriptsDir, "lib", "verify-mcporter-audit.sh"), + ); normalizeReadModesForDockerCopy(stagedScriptsDir); return { buildCtx, stagedDockerfile }; diff --git a/test/agents/openclaw/openclaw-integrity-pin-suite.ts b/test/agents/openclaw/openclaw-integrity-pin-suite.ts index f95c3259361..317942305ae 100644 --- a/test/agents/openclaw/openclaw-integrity-pin-suite.ts +++ b/test/agents/openclaw/openclaw-integrity-pin-suite.ts @@ -459,6 +459,10 @@ function runInstallBlock( .replaceAll("/usr/local/bin", path.join(tmp, "usr-local-bin")) .replaceAll("/scripts/lib/reviewed-npm-archive.mts", REVIEWED_NPM_ARCHIVE_HELPER) .replaceAll("/scripts/lib/openclaw-npm-remediation.mts", remediationHelper) + .replaceAll( + "bash /scripts/lib/verify-mcporter-audit.sh", + `node --experimental-strip-types ${auditHelper} --directory ${mcporterRuntime} --exceptions ${auditExceptionFile} --graph mcporter-runtime --threshold high --report /tmp/mcporter-npm-audit.json --result /tmp/mcporter-npm-audit-policy.json`, + ) .replaceAll("/scripts/lib/reviewed-npm-audit.mts", auditHelper) .replaceAll("/scripts/npm-audit-exceptions.json", auditExceptionFile), ].join("\n"); diff --git a/test/automation/releases/npm-audit-receipt.test.ts b/test/automation/releases/npm-audit-receipt.test.ts index 9ca80bc75d7..f169aee6bc6 100644 --- a/test/automation/releases/npm-audit-receipt.test.ts +++ b/test/automation/releases/npm-audit-receipt.test.ts @@ -203,6 +203,12 @@ describe("npm audit receipt", () => { packageLock: "changed", }), ).toThrow(/packageLockSha256/); + expect(() => + parseAndVerifyAuditReceipt(canonicalAuditReceipt(receipt()), { + ...inputs, + rawResponse: `${inputs.rawResponse}\n`, + }), + ).toThrow(/rawResponseSha256/); }); it.each([ diff --git a/test/automation/releases/reviewed-npm-audit-handoff.test.ts b/test/automation/releases/reviewed-npm-audit-handoff.test.ts index ef1b40ce871..5b19553f1f6 100644 --- a/test/automation/releases/reviewed-npm-audit-handoff.test.ts +++ b/test/automation/releases/reviewed-npm-audit-handoff.test.ts @@ -27,6 +27,9 @@ type Workflow = { string, { readonly steps?: readonly { + readonly name?: string; + readonly run?: string; + readonly uses?: string; readonly with?: Readonly>; }[]; } @@ -247,4 +250,372 @@ describe("npm audit handoff", () => { fs.rmSync(root, { recursive: true, force: true }); } }); + + it("keeps protected audit acceptance under trusted policy and rejects forged transport", () => { + const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), "reviewed-audit-receipt-handoff-")), + ); + const trustedRoot = path.join(root, "trusted"); + const targetRoot = path.join(root, "target"); + const runtime = path.join(targetRoot, "agents/openclaw/mcporter-runtime"); + const artifactDirectory = path.join(targetRoot, "artifacts/reviewed-npm-audit"); + const exceptionFile = path.join(trustedRoot, "ci/npm-audit-exceptions.json"); + const auditConfigFile = path.join(trustedRoot, "ci/reviewed-npm-audit.json"); + const auditConfig = JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, "ci/reviewed-npm-audit.json"), "utf8"), + ); + const reviewedNpmIdentity = { + npmArchiveSha256: auditConfig.npmArchiveSha256 as string, + npmIntegrity: auditConfig.npmIntegrity as string, + npmVersion: auditConfig.npmVersion as string, + }; + const acceptedAdvisory = "GHSA-aaaa-bbbb-cccc"; + const rawReport = `${JSON.stringify({ + auditReportVersion: 2, + vulnerabilities: { + "vulnerable-package": { + effects: [], + isDirect: true, + name: "vulnerable-package", + nodes: ["node_modules/vulnerable-package"], + severity: "high", + via: [ + { + dependency: "vulnerable-package", + name: "vulnerable-package", + range: "<=1.0.0", + severity: "high", + source: 123456, + title: "test advisory", + url: `https://github.com/advisories/${acceptedAdvisory}`, + }, + ], + }, + }, + metadata: { + vulnerabilities: { info: 0, low: 0, moderate: 0, high: 1, critical: 0 }, + }, + })}\n`; + try { + fs.mkdirSync(runtime, { recursive: true }); + fs.mkdirSync(path.join(trustedRoot, "ci"), { recursive: true }); + fs.cpSync(path.join(REPO_ROOT, "scripts"), path.join(trustedRoot, "scripts"), { + recursive: true, + }); + fs.cpSync(path.join(REPO_ROOT, "agents/openclaw/mcporter-runtime"), runtime, { + recursive: true, + }); + fs.mkdirSync(path.join(runtime, "node_modules", "vulnerable-package"), { + recursive: true, + }); + fs.writeFileSync( + path.join(runtime, "node_modules", "vulnerable-package", "package.json"), + '{"name":"vulnerable-package","version":"1.0.0"}\n', + ); + fs.mkdirSync(path.join(targetRoot, "ci"), { recursive: true }); + fs.mkdirSync(path.join(targetRoot, "scripts", "lib"), { recursive: true }); + fs.writeFileSync(path.join(targetRoot, "ci", "reviewed-npm-audit.json"), "{}\n"); + fs.writeFileSync( + path.join(targetRoot, "ci", "npm-audit-exceptions.json"), + '{"schemaVersion":1,"exceptions":[]}\n', + ); + fs.writeFileSync( + path.join(targetRoot, "scripts", "audit-reviewed-npm-graph.mts"), + "throw new Error('candidate producer executed');\n", + ); + fs.writeFileSync( + path.join(targetRoot, "scripts", "lib", "npm-audit-receipt.mts"), + "throw new Error('candidate verifier executed');\n", + ); + fs.writeFileSync( + exceptionFile, + `${JSON.stringify({ + schemaVersion: 1, + exceptions: [ + { + advisory: acceptedAdvisory, + compensatingControls: ["The vulnerable input is rejected before use."], + decision: "temporary-risk-acceptance", + expires: "2026-09-16", + graph: "mcporter-runtime", + installedVersion: "1.0.0", + owner: "security-maintainers", + package: "vulnerable-package", + rationale: "The fix is in validation.", + severity: "high", + trackingIssue: "https://github.com/NVIDIA/NemoClaw/issues/11088", + }, + ], + })}\n`, + ); + fs.copyFileSync(path.join(REPO_ROOT, "ci/reviewed-npm-audit.json"), auditConfigFile); + fs.mkdirSync(artifactDirectory, { recursive: true }); + const rawReportFile = path.join(artifactDirectory, "audit.json"); + fs.writeFileSync(rawReportFile, rawReport); + fs.writeFileSync( + path.join(artifactDirectory, "audit.provenance.json"), + JSON.stringify({ run: { startedAt: new Date().toISOString() } }), + ); + emitAuditReceipt({ + artifactDirectory, + graphId: "mcporter-runtime", + packageJsonFile: path.join(runtime, "package.json"), + packageLockFile: path.join(runtime, "package-lock.json"), + rawReportFile, + registryOrigin: "https://registry.yarnpkg.com", + reviewedNpmIdentity, + result: { + acceptedAdvisories: [acceptedAdvisory], + blockingThreshold: "high", + exceptionPolicySha256: createHash("sha256") + .update(fs.readFileSync(exceptionFile)) + .digest("hex"), + graph: "mcporter-runtime", + reported: { info: 0, low: 0, moderate: 0, high: 1, critical: 0 }, + schemaVersion: 1, + status: "accepted-exceptions", + unacceptedBlockingAdvisories: [], + }, + threshold: "high", + }); + + const receiptFile = path.join(artifactDirectory, "mcporter-runtime.receipt.json"); + const retainedPackageJson = path.join(runtime, "package.json"); + const retainedPackageLock = path.join(runtime, "package-lock.json"); + const transportRawReport = path.join(artifactDirectory, "mcporter-runtime.raw.json"); + const trustedPolicyResult = path.join(root, "trusted-policy-result.json"); + const receiptVerifier = path.join(trustedRoot, "scripts", "lib", "npm-audit-receipt.mts"); + const retainedReport = path.join(root, "retained-report.json"); + const retainedResult = path.join(root, "retained-result.json"); + const verifierArgs = [ + receiptVerifier, + "--receipt", + receiptFile, + "--package-json", + retainedPackageJson, + "--package-lock", + retainedPackageLock, + "--raw-report", + transportRawReport, + "--exceptions", + exceptionFile, + "--graph", + "mcporter-runtime", + "--audit-config", + auditConfigFile, + "--registry", + "https://registry.yarnpkg.com", + "--threshold", + "high", + "--legacy-audit", + "true", + "--result", + trustedPolicyResult, + ]; + const nodeLog = path.join(root, "node.log"); + const stubBin = path.join(root, "bin"); + const helper = path.join(root, "verify-mcporter-audit.sh"); + fs.mkdirSync(stubBin); + fs.writeFileSync( + path.join(stubBin, "node"), + '#!/usr/bin/env bash\nset -euo pipefail\nprintf \'%s\\n\' "$*" >>"$NEMOCLAW_TEST_NODE_LOG"\n[[ "$*" != *"/scripts/lib/reviewed-npm-audit.mts"* ]] || exit 0\nexec "$NEMOCLAW_TEST_REAL_NODE" "$@"\n', + { mode: 0o755 }, + ); + let helperSource = fs.readFileSync( + path.join(REPO_ROOT, "scripts/lib/verify-mcporter-audit.sh"), + "utf8", + ); + helperSource = helperSource + .replaceAll("/run/secrets/nemoclaw-mcporter-audit-receipt", receiptFile) + .replaceAll("/run/secrets/nemoclaw-mcporter-audit-raw-report", transportRawReport) + .replaceAll("/run/secrets/nemoclaw-mcporter-audit-policy-result", trustedPolicyResult) + .replaceAll( + "/run/nemoclaw-mcporter-audit-cache/reviewed-npm-audit", + path.join(root, "no-seed"), + ); + fs.writeFileSync(helper, helperSource, { mode: 0o755 }); + const correctReceiptSha256 = createHash("sha256") + .update(fs.readFileSync(receiptFile)) + .digest("hex"); + const policyResultSha256 = () => + createHash("sha256").update(fs.readFileSync(trustedPolicyResult)).digest("hex"); + const runHelper = ( + receiptSha256 = correctReceiptSha256, + trustedPolicyResultSha256 = policyResultSha256(), + helperFile = helper, + ) => + spawnSync("bash", [helperFile], { + encoding: "utf8", + env: { + ...process.env, + NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: receiptSha256, + NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256: trustedPolicyResultSha256, + NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH: retainedReport, + NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH: retainedResult, + NEMOCLAW_TEST_NODE_LOG: nodeLog, + NEMOCLAW_TEST_REAL_NODE: process.execPath, + PATH: `${stubBin}:${process.env.PATH ?? ""}`, + }, + }); + + fs.writeFileSync(transportRawReport, "{}\n"); + const rejectedByTrustedPolicy = spawnSync(process.execPath, verifierArgs, { + encoding: "utf8", + }); + expect(rejectedByTrustedPolicy.status).not.toBe(0); + expect(rejectedByTrustedPolicy.stderr).toContain("receipt rawResponseSha256 does not match"); + expect(fs.existsSync(trustedPolicyResult)).toBe(false); + + fs.writeFileSync(transportRawReport, rawReport); + const acceptedByTrustedPolicy = spawnSync(process.execPath, verifierArgs, { + encoding: "utf8", + }); + expect(acceptedByTrustedPolicy.status, acceptedByTrustedPolicy.stderr).toBe(0); + expect(JSON.parse(fs.readFileSync(trustedPolicyResult, "utf8"))).toMatchObject({ + acceptedAdvisories: [acceptedAdvisory], + graph: "mcporter-runtime", + status: "accepted-exceptions", + }); + + const wrongHash = "0".repeat(64); + expect(wrongHash).not.toBe(correctReceiptSha256); + const rejectedTransport = runHelper(wrongHash); + expect(rejectedTransport.status).not.toBe(0); + expect(rejectedTransport.stderr).toContain("receipt hash does not match"); + expect(fs.existsSync(retainedReport)).toBe(false); + expect(fs.existsSync(retainedResult)).toBe(false); + expect(fs.existsSync(nodeLog)).toBe(false); + + const verifiedPolicyResultSha256 = policyResultSha256(); + const forgedPolicyResult = path.join(root, "forged-policy-result.json"); + const forgedPolicyHelper = path.join(root, "verify-forged-mcporter-audit.sh"); + fs.writeFileSync(forgedPolicyResult, '{"graph":"mcporter-runtime","status":"failed"}\n'); + fs.writeFileSync( + forgedPolicyHelper, + helperSource.replaceAll(trustedPolicyResult, forgedPolicyResult), + { mode: 0o755 }, + ); + const rejectedPolicyResult = runHelper( + correctReceiptSha256, + verifiedPolicyResultSha256, + forgedPolicyHelper, + ); + expect(rejectedPolicyResult.status).not.toBe(0); + expect(rejectedPolicyResult.stderr).toContain("policy result hash does not match"); + expect(fs.existsSync(retainedReport)).toBe(false); + expect(fs.existsSync(retainedResult)).toBe(false); + expect(fs.existsSync(nodeLog)).toBe(false); + + const forgedRawReport = path.join(root, "forged-raw-report.json"); + const forgedRawHelper = path.join(root, "verify-forged-mcporter-raw-report.sh"); + fs.writeFileSync(forgedRawReport, "{}\n"); + fs.writeFileSync( + forgedRawHelper, + helperSource.replaceAll(transportRawReport, forgedRawReport), + { mode: 0o755 }, + ); + const rejectedRawReport = runHelper( + correctReceiptSha256, + verifiedPolicyResultSha256, + forgedRawHelper, + ); + expect(rejectedRawReport.status).not.toBe(0); + expect(rejectedRawReport.stderr).toContain("raw report does not match the verified receipt"); + expect(fs.existsSync(retainedReport)).toBe(false); + expect(fs.existsSync(retainedResult)).toBe(false); + expect(fs.existsSync(nodeLog)).toBe(false); + + const malformedReceipt = path.join(root, "malformed-receipt.json"); + const malformedReceiptHelper = path.join(root, "verify-malformed-mcporter-receipt.sh"); + fs.writeFileSync( + malformedReceipt, + `not-json "rawResponseSha256":"${createHash("sha256").update(rawReport).digest("hex")}"\n`, + ); + fs.writeFileSync( + malformedReceiptHelper, + helperSource.replaceAll(receiptFile, malformedReceipt), + { mode: 0o755 }, + ); + const malformedReceiptSha256 = createHash("sha256") + .update(fs.readFileSync(malformedReceipt)) + .digest("hex"); + const rejectedMalformedReceipt = runHelper( + malformedReceiptSha256, + verifiedPolicyResultSha256, + malformedReceiptHelper, + ); + expect(rejectedMalformedReceipt.status).not.toBe(0); + expect(rejectedMalformedReceipt.stderr).toContain( + "receipt does not declare a raw response SHA-256", + ); + expect(fs.existsSync(retainedReport)).toBe(false); + expect(fs.existsSync(retainedResult)).toBe(false); + expect(fs.existsSync(nodeLog)).toBe(false); + + const accepted = runHelper(); + expect(accepted.status, accepted.stderr).toBe(0); + expect(fs.readFileSync(transportRawReport, "utf8")).toBe(rawReport); + expect(fs.readFileSync(retainedReport, "utf8")).toBe(rawReport); + expect(fs.readFileSync(retainedResult, "utf8")).toBe( + fs.readFileSync(trustedPolicyResult, "utf8"), + ); + expect(fs.existsSync(nodeLog)).toBe(false); + const directHelper = path.join(root, "verify-mcporter-direct-audit.sh"); + fs.writeFileSync( + directHelper, + helperSource + .replaceAll(receiptFile, path.join(root, "missing-direct-receipt")) + .replaceAll(transportRawReport, path.join(root, "missing-direct-report")) + .replaceAll(trustedPolicyResult, path.join(root, "missing-direct-policy-result")), + { mode: 0o755 }, + ); + const direct = spawnSync("bash", [directHelper], { + encoding: "utf8", + env: { + ...process.env, + NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: "", + NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256: "", + NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH: retainedReport, + NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH: retainedResult, + NEMOCLAW_TEST_NODE_LOG: nodeLog, + PATH: `${stubBin}:${process.env.PATH ?? ""}`, + }, + }); + expect(direct.status, direct.stderr).toBe(0); + expect(fs.readFileSync(nodeLog, "utf8").trim().split("\n").at(-1)).toBe( + `/scripts/lib/reviewed-npm-audit.mts --directory /usr/local/lib/nemoclaw/mcporter-runtime --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high --report ${retainedReport} --result ${retainedResult}`, + ); + + const seedEvidence = path.join(root, "seed", "reviewed-npm-audit"); + const seedHelper = path.join(root, "verify-mcporter-seed-audit.sh"); + fs.mkdirSync(seedEvidence, { recursive: true }); + fs.copyFileSync(receiptFile, path.join(seedEvidence, "mcporter-runtime.receipt.json")); + fs.writeFileSync(path.join(seedEvidence, "mcporter-runtime.raw.json"), rawReport); + fs.writeFileSync( + path.join(seedEvidence, "mcporter-runtime.receipt.sha256"), + `${createHash("sha256").update(fs.readFileSync(receiptFile)).digest("hex")}\n`, + ); + fs.writeFileSync( + seedHelper, + helperSource + .replaceAll(receiptFile, path.join(root, "missing-secret-receipt")) + .replaceAll(transportRawReport, path.join(root, "missing-secret-report")) + .replaceAll(trustedPolicyResult, path.join(root, "missing-secret-policy-result")) + .replaceAll(path.join(root, "no-seed"), seedEvidence), + { mode: 0o755 }, + ); + const rejectedSeed = spawnSync("bash", [seedHelper], { + encoding: "utf8", + env: { + ...process.env, + NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256: "", + NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256: "", + }, + }); + expect(rejectedSeed.status).not.toBe(0); + expect(rejectedSeed.stderr).toContain("build-context mcporter audit evidence is not trusted"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/test/automation/releases/reviewed-npm-audit-workflow.test.ts b/test/automation/releases/reviewed-npm-audit-workflow.test.ts index e13a3353521..ac82a439523 100644 --- a/test/automation/releases/reviewed-npm-audit-workflow.test.ts +++ b/test/automation/releases/reviewed-npm-audit-workflow.test.ts @@ -409,9 +409,9 @@ describe("trusted npm audit workflow (#5896)", () => { ["malformed npm output", "{not-json", 1, /invalid-json/], [ "parseable npm error JSON", - JSON.stringify({ error: { code: "ECONNREFUSED", summary: "registry unreachable" } }), + JSON.stringify({ error: { summary: "registry request failed: ECONNRESET" } }), 1, - /incomplete-report/, + /registry-network-error/, ], ["missing vulnerability metadata", JSON.stringify({}), 0, /incomplete-report/], [ diff --git a/test/automation/releases/reviewed-npm-audit.test.ts b/test/automation/releases/reviewed-npm-audit.test.ts index 68e8793028d..0cdf620bb80 100644 --- a/test/automation/releases/reviewed-npm-audit.test.ts +++ b/test/automation/releases/reviewed-npm-audit.test.ts @@ -15,6 +15,7 @@ import { assertExceptionGraphs, buildAuditCacheInput, buildAuditProvenance, + classifyNpmAuditResponse, deriveAuditEndpoints, evaluateAuditPolicy, exceedsAuditThreshold, @@ -156,17 +157,49 @@ describe("npm audit gate", () => { ); }); - it("rejects a parseable npm transport failure instead of treating it as clean", () => { - expect(() => - parseAuditReport({ + it("accepts a complete clean npm audit report", () => { + const report = { + vulnerabilities: {}, + metadata: { + vulnerabilities: { info: 0, low: 0, moderate: 0, high: 0, critical: 0 }, + }, + }; + + expect( + classifyNpmAuditResponse({ status: 0, stderr: "", stdout: JSON.stringify(report) }), + ).toEqual({ report }); + }); + + it.each(["EAI_AGAIN", "ECONNRESET", "ECONNREFUSED"])( + "classifies the %s registry error without exposing its message (#11088)", + (transport) => { + const secret = "https://audit-user:registry-secret@registry.example/private"; + const classified = classifyNpmAuditResponse({ status: 1, - stderr: "npm registry unavailable", + stderr: `authorization: Bearer stderr-secret for ${secret}`, stdout: JSON.stringify({ - error: { code: "ECONNREFUSED", summary: "request to registry failed" }, + message: `request to ${secret} failed, reason: ${transport}`, + error: { summary: "", detail: "" }, }), - }), - ).toThrow(/ECONNREFUSED/); - }); + }); + + expect(classified).toEqual({ + failure: { + diagnostic: expect.stringMatching( + new RegExp( + `^exit=1 stdout-bytes=\\d+ stdout-sha256=[a-f0-9]{64} condition=registry-network-error transport=${transport} required-field=metadata:missing$`, + ), + ), + reason: "registry-network-error", + retryable: true, + }, + }); + expect(JSON.stringify(classified)).not.toContain("audit-user"); + expect(JSON.stringify(classified)).not.toContain("registry-secret"); + expect(JSON.stringify(classified)).not.toContain("stderr-secret"); + expect(JSON.stringify(classified)).not.toContain("registry.example"); + }, + ); it.each([ ["missing metadata", {}], @@ -177,19 +210,36 @@ describe("npm audit gate", () => { ])("rejects %s", (_label, report) => { expect(() => parseAuditReport({ status: 0, stderr: "", stdout: JSON.stringify(report) }), - ).toThrow(/vulnerability report|vulnerability count/); + ).toThrow(/incomplete-report.*required-field=metadata/); }); - it("retries scan-incomplete npm responses with bounded backoff", () => { + it.each([ + ["empty output", "", "empty-output"], + ["truncated JSON", '{"metadata":{"vulnerabilities":', "invalid-json"], + ])("classifies %s for the bounded retry policy", (_label, stdout, reason) => { + expect(classifyNpmAuditResponse({ status: 1, stderr: "", stdout })).toEqual({ + failure: { + diagnostic: expect.stringContaining(`condition=${reason}`), + reason, + retryable: false, + }, + }); + }); + + it("retries an empty registry lookup response with bounded backoff", () => { const completeReport = { metadata: { vulnerabilities: { info: 0, low: 0, moderate: 0, high: 0, critical: 0 }, }, }; const sensitiveStderr = - "request failed for https://audit-user:secret-token@registry.example/\n\u001b[31mstderr detail"; + "request failed with EAI_AGAIN for https://audit-user:secret-token@registry.example/\n\u001b[31mstderr detail"; const responses = [ - { status: 1, stderr: sensitiveStderr, stdout: "" }, + { + status: 1, + stderr: sensitiveStderr, + stdout: "", + }, { status: 0, stderr: "", stdout: JSON.stringify(completeReport) }, ]; const delays: number[] = []; @@ -205,7 +255,9 @@ describe("npm audit gate", () => { expect(attempt).toBe(2); expect(delays).toEqual([1_000]); expect(warnings).toEqual([ - "npm audit scan incomplete on attempt 1/2; retrying in 1000 ms (reason=empty-output)", + expect.stringMatching( + /^npm audit scan failed on attempt 1\/2; retrying in 1000 ms \(reason=registry-network-error; exit=1 stdout-bytes=0 stdout-sha256=[a-f0-9]{64} condition=registry-network-error transport=EAI_AGAIN\)$/, + ), ]); const warningOutput = warnings.join("\n"); expect(warningOutput).not.toContain("audit-user"); @@ -330,7 +382,7 @@ describe("npm audit gate", () => { expect(warnings).toEqual([]); }); - it("fails closed after the scan-incomplete retry budget is exhausted", () => { + it("fails closed after the bounded registry-network retry budget is exhausted (#11088)", () => { const delays: number[] = []; let attempts = 0; @@ -341,6 +393,7 @@ describe("npm audit gate", () => { status: 1, stderr: "registry-token=terminal-stderr-secret", stdout: JSON.stringify({ + message: "request failed with ECONNRESET and terminal-message-secret", error: { summary: "registry-token=terminal-summary-secret", detail: "authorization: bearer terminal-detail-secret", @@ -355,12 +408,41 @@ describe("npm audit gate", () => { expect(attempts).toBe(2); expect(delays).toEqual([1_000]); expect(audit.report).toBeUndefined(); - expect(audit.failure?.message).toBe( - "npm audit scan remained incomplete after 2 attempts (reason=incomplete-report)", + expect(audit.failure?.message).toMatch( + /^npm audit scan failed after 2 attempts \(reason=registry-network-error; exit=1 stdout-bytes=\d+ stdout-sha256=[a-f0-9]{64} condition=registry-network-error transport=ECONNRESET required-field=metadata:missing\)$/, ); expect(audit.failure?.message).not.toContain("terminal-stderr-secret"); expect(audit.failure?.message).not.toContain("terminal-summary-secret"); expect(audit.failure?.message).not.toContain("terminal-detail-secret"); + expect(audit.failure?.message).not.toContain("terminal-message-secret"); + }); + + it.each([ + ["missing metadata", {}], + [ + "malformed severity count", + { metadata: { vulnerabilities: { info: 0, low: 0, moderate: 0, high: [], critical: 0 } } }, + ], + ["unknown npm error document", { error: { summary: "unsupported response" } }], + ])("does not retry deterministic %s responses (#11088)", (_label, report) => { + const delays: number[] = []; + const warnings: string[] = []; + let attempts = 0; + + const audit = runNpmAuditWithRetry({ + run: () => { + attempts += 1; + return { status: 1, stderr: "", stdout: JSON.stringify(report) }; + }, + wait: (delayMs) => delays.push(delayMs), + warn: (message) => warnings.push(message), + }); + + expect(attempts).toBe(1); + expect(delays).toEqual([]); + expect(warnings).toEqual([]); + expect(audit.report).toBeUndefined(); + expect(audit.failure?.message).toMatch(/failed closed on attempt 1\/2 without retry/); }); it("accepts one exact blocking advisory and its propagated meta-vulnerability", () => { @@ -795,7 +877,7 @@ describe("npm audit provenance", () => { [ "#!/bin/sh", 'test "$1" = "audit" && {', - ' echo \'{"error":{"code":"ECONNREFUSED","summary":"registry unreachable"}}\'', + ' echo \'{"message":"request to https://audit-user:secret-token@registry.example failed: ECONNRESET","error":{"summary":"registry unreachable"}}\'', " exit 1", "}", "exit 7", @@ -819,18 +901,25 @@ describe("npm audit provenance", () => { reportFile: reportPath, threshold: "high", }), - ).toThrow("npm audit scan remained incomplete after 2 attempts (reason=incomplete-report)"); + ).toThrow(/failed after 2 attempts.*registry-network-error.*transport=ECONNRESET/); const sidecar = JSON.parse( fs.readFileSync(path.join(tempRoot, "graph.provenance.json"), "utf-8"), ) as Record; - expect(sidecar.failure).toBe( - "npm audit scan remained incomplete after 2 attempts (reason=incomplete-report)", + expect(sidecar.failure).toMatch( + /^npm audit scan failed after 2 attempts \(reason=registry-network-error; exit=1 stdout-bytes=\d+ stdout-sha256=[a-f0-9]{64} condition=registry-network-error transport=ECONNRESET required-field=metadata:missing\)$/, ); - expect(sidecar.failure).not.toContain("ECONNREFUSED"); + expect(sidecar.failure).toContain("ECONNRESET"); expect(sidecar.failure).not.toContain("registry unreachable"); expect(sidecar.advisoryIds).toEqual([]); expect(sidecar.rawReportPath).toBe("graph.json"); expect(sidecar.registry).toEqual(deriveAuditEndpoints("https://registry.yarnpkg.com")); + const retainedFailure = fs.readFileSync(reportPath, "utf8"); + expect(retainedFailure).toMatch(/"reason": "registry-network-error"/); + expect(retainedFailure).toContain("transport=ECONNRESET"); + expect(retainedFailure).not.toContain("audit-user"); + expect(retainedFailure).not.toContain("secret-token"); + expect(retainedFailure).not.toContain("registry.example"); + expect(retainedFailure).not.toContain("registry unreachable"); } finally { process.env.PATH = originalPath; fs.rmSync(tempRoot, { recursive: true, force: true }); diff --git a/test/e2e/live/managed-image-activation-e2e-helpers.ts b/test/e2e/live/managed-image-activation-e2e-helpers.ts index e7b777daa21..d4c3421615d 100644 --- a/test/e2e/live/managed-image-activation-e2e-helpers.ts +++ b/test/e2e/live/managed-image-activation-e2e-helpers.ts @@ -5,6 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { shellQuote } from "../../../src/lib/core/shell-quote.ts"; +import { resolveGatewayLogPathForPort } from "../../../src/lib/onboard/gateway/state-dir.ts"; import { type ManagedImageContractCatalog, type ManagedImageContractV1, @@ -343,6 +344,25 @@ async function collectOnboardFailureDockerDiagnostics( env: NodeJS.ProcessEnv, ): Promise { try { + await host.command( + "tail", + [ + "-c", + "65536", + resolveGatewayLogPathForPort({ + configured: env.NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR, + home: os.homedir(), + port: 8080, + }), + ], + { + artifactName: `managed-activation-onboard-failure-${agent}-gateway-log`, + captureLimitBytes: 65536, + env, + redactionValues: [API_KEY], + timeoutMs: 5_000, + }, + ); const inventory = await host.command( "docker", [ diff --git a/test/inference/managed/managed-image-publication-workflow.test.ts b/test/inference/managed/managed-image-publication-workflow.test.ts index 9f39bfc00d7..f384a1c8ead 100644 --- a/test/inference/managed/managed-image-publication-workflow.test.ts +++ b/test/inference/managed/managed-image-publication-workflow.test.ts @@ -1149,6 +1149,7 @@ fi publisher = managedPublisher(workflow), action = readAction("publish-managed-image-digest"), source = JSON.stringify(workflow); + const auditEvidence = step(publisher, "Prepare same-run mcporter audit evidence"); expect(workflow.jobs?.["reviewed-npm-audit"]?.if).toBe("github.event_name != 'pull_request'"); expect(publisher.needs).toEqual(["publication-identity", "reviewed-npm-audit"]); expect( @@ -1157,12 +1158,17 @@ fi "Prepare same-run mcporter audit evidence", "mcporter-runtime.receipt.json", "mcporter-runtime.raw.json", + "mcporter-runtime.policy.json", "nemoclaw-mcporter-audit-receipt", "nemoclaw-mcporter-audit-raw-report", + "nemoclaw-mcporter-audit-policy-result", "NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256", + "NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256", ].filter((marker) => !source.includes(marker)), ).toEqual([]); expect(source).not.toContain("NEMOCLAW_MCPORTER_AUDIT_RAW_REPORT_SHA256"); + expect(auditEvidence.run).toContain('test -s "$policy"'); + expect(auditEvidence.run).toContain('test ! -L "$policy"'); const actionSource = JSON.stringify(action); expect([ actionSource.includes('"secret-files":{"description"'), diff --git a/test/runtime/sandbox/sandbox-build-context.test.ts b/test/runtime/sandbox/sandbox-build-context.test.ts index c2958a7a9f7..ba2cc831fdf 100644 --- a/test/runtime/sandbox/sandbox-build-context.test.ts +++ b/test/runtime/sandbox/sandbox-build-context.test.ts @@ -289,8 +289,8 @@ describe("sandbox build context staging", () => { writeFixture(path.join("scripts", "lib", "bundled-npm-package.mts"), "fixture\n", 0o700); writeFixture(path.join("scripts", "lib", "seed-reviewed-npm-cache.mts"), "fixture\n", 0o700); writeFixture(path.join("scripts", "lib", "reviewed-npm-audit.mts"), "fixture\n", 0o700); - writeFixture(path.join("scripts", "lib", "npm-audit-receipt.mts"), "fixture\n", 0o700); writeFixture(path.join("scripts", "lib", "openclaw-npm-remediation.mts"), "fixture\n", 0o700); + writeFixture(path.join("scripts", "lib", "verify-mcporter-audit.sh"), "fixture\n", 0o700); fs.chmodSync(path.join(sourceRoot, "scripts"), 0o700); fs.chmodSync(path.join(sourceRoot, "scripts", "lib"), 0o700); } @@ -550,6 +550,9 @@ describe("sandbox build context staging", () => { ); expect((fs.statSync(stagedFile).mode & 0o777).toString(8)).toBe("644"); } + expect( + fs.readFileSync(path.join(buildCtx, "scripts/lib/verify-mcporter-audit.sh"), "utf8"), + ).toBe(fs.readFileSync(path.join(sourceRoot, "scripts/lib/verify-mcporter-audit.sh"), "utf8")); } it("normalizes restrictive and group-writable modes for Docker COPY", () => { diff --git a/test/security/fetch-guard-patch-regression.test.ts b/test/security/fetch-guard-patch-regression.test.ts index 5658323a94d..8cc51f427a9 100644 --- a/test/security/fetch-guard-patch-regression.test.ts +++ b/test/security/fetch-guard-patch-regression.test.ts @@ -192,6 +192,10 @@ function runOpenClawUpgradeBlock(currentVersion: string) { "# OPENCLAW_VERSION is the NemoClaw runtime build target", "# Patch OpenClaw media fetch", ) + .replaceAll( + "bash /scripts/lib/verify-mcporter-audit.sh", + "node /scripts/lib/reviewed-npm-audit.mts --directory /usr/local/lib/nemoclaw/mcporter-runtime --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high", + ) .replaceAll("/opt/nemoclaw-blueprint/blueprint.yaml", blueprint) .replaceAll("/usr/local/lib/node_modules/openclaw", openclawInstall) .replaceAll( diff --git a/test/security/mcporter-supply-chain.test.ts b/test/security/mcporter-supply-chain.test.ts index 9318ada4e00..be30b8b2cf4 100644 --- a/test/security/mcporter-supply-chain.test.ts +++ b/test/security/mcporter-supply-chain.test.ts @@ -47,7 +47,10 @@ const reviewedAuditDriver = fs.readFileSync( path.join(repoRoot, "scripts", "audit-reviewed-npm-graph.mts"), "utf8", ); - +const mcporterAuditHelper = fs.readFileSync( + path.join(repoRoot, "scripts", "lib", "verify-mcporter-audit.sh"), + "utf8", +); function extractIntegrityGate(contents: string): string { const startMarker = 'MCPORTER_EXPECTED_INTEGRITY=""'; const start = contents.indexOf(startMarker); @@ -67,19 +70,6 @@ function extractIntegrityGate(contents: string): string { .trim(); } -function extractAuditReceiptInvocation(contents: string): string { - const startMarker = "node /scripts/lib/npm-audit-receipt.mts"; - const endMarker = "--legacy-audit true"; - const start = contents.indexOf(startMarker); - const end = contents.indexOf(endMarker, start); - expect(start).toBeGreaterThanOrEqual(0); - expect(end).toBeGreaterThan(start); - return contents - .slice(start, end + endMarker.length) - .replace(/\\\s*\n/g, " ") - .replace(/\s+/g, " "); -} - function runIntegrityGate(contents: string, version: string) { const script = [ "set -euo pipefail", @@ -189,8 +179,8 @@ describe("mcporter image supply-chain controls", () => { }); it.each(dockerfiles)("audits the committed dependency graph in $name", ({ contents }) => { - const flattenedContents = contents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); - const auditReceiptInvocation = extractAuditReceiptInvocation(contents); + const auditContents = `${contents}\n${mcporterAuditHelper}`; + const flattenedContents = auditContents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); expect(contents).toContain( "COPY ci/npm-audit-exceptions.json ci/reviewed-npm-audit.json /scripts/", ); @@ -206,24 +196,21 @@ describe("mcporter image supply-chain controls", () => { "node /scripts/lib/reviewed-npm-audit.mts --directory /usr/local/lib/nemoclaw/mcporter-runtime --exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --threshold high", ); expect(contents).toContain("ARG NEMOCLAW_MCPORTER_AUDIT_RECEIPT_SHA256="); + expect(contents).toContain("ARG NEMOCLAW_MCPORTER_AUDIT_POLICY_RESULT_SHA256="); expect(contents).toContain( "--mount=type=secret,id=nemoclaw-mcporter-audit-receipt,required=false", ); expect(contents).toContain( "--mount=type=secret,id=nemoclaw-mcporter-audit-raw-report,required=false", ); - expect(flattenedContents).toContain("node /scripts/lib/npm-audit-receipt.mts --receipt"); - expect(flattenedContents).toContain( - "--package-json /usr/local/lib/nemoclaw/mcporter-runtime/package.json --package-lock /usr/local/lib/nemoclaw/mcporter-runtime/package-lock.json --raw-report", - ); - expect(auditReceiptInvocation).toContain( - "--exceptions /scripts/npm-audit-exceptions.json --graph mcporter-runtime --audit-config /scripts/reviewed-npm-audit.json --registry https://registry.yarnpkg.com --threshold high --legacy-audit true", + expect(contents).toContain( + "--mount=type=secret,id=nemoclaw-mcporter-audit-policy-result,required=false", ); expect(expectedReviewedNpmVersion).toMatch(/^[0-9]+\.[0-9]+\.[0-9]+$/); - expect(auditReceiptInvocation).not.toContain("--npm-version"); - expect(contents).not.toContain("--raw-copy"); - expect(auditReceiptInvocation).not.toMatch(/\bnpm\s+--version\b/); - expect(auditReceiptInvocation).not.toMatch(/\$\(|`/); + expect(auditContents).not.toContain("/scripts/lib/npm-audit-receipt.mts"); + expect(auditContents).toContain("sha256sum --check --status"); + expect(auditContents).toContain("policy_result_sha256"); + expect(auditContents).not.toContain("--raw-copy"); expect(contents).not.toContain(`${runtimePrefix} audit --omit=dev --audit-level=low`); expect(contents).not.toContain(`${runtimePrefix} audit signatures`); expect(flattenedContents).toContain( @@ -236,9 +223,24 @@ describe("mcporter image supply-chain controls", () => { const contents = fs.readFileSync(path.join(repoRoot, "Dockerfile.base"), "utf8"); const flattenedContents = contents.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); + expect(contents).toContain( + "COPY scripts/lib/verify-mcporter-audit.sh /scripts/lib/verify-mcporter-audit.sh", + ); expect(flattenedContents).toContain( - '--result /tmp/mcporter-npm-audit-policy.json && cp "$MCPORTER_RAW_REPORT" /tmp/mcporter-npm-audit.json;', + "NEMOCLAW_MCPORTER_AUDIT_REPORT_PATH=/tmp/mcporter-npm-audit.json NEMOCLAW_MCPORTER_AUDIT_RESULT_PATH=/tmp/mcporter-npm-audit-policy.json bash /scripts/lib/verify-mcporter-audit.sh", + ); + const receiptVerification = mcporterAuditHelper.indexOf( + '"$receipt_sha256" "$receipt" | sha256sum --check --status', + ); + const rawBinding = mcporterAuditHelper.indexOf(".rawResponseSha256"); + const rawVerification = mcporterAuditHelper.indexOf( + '"$raw_report_sha256" "$raw_report" | sha256sum --check --status', ); + const reportCopy = mcporterAuditHelper.indexOf('cp -- "$raw_report" "$report_path"'); + expect(receiptVerification).toBeGreaterThan(-1); + expect(rawBinding).toBeGreaterThan(receiptVerification); + expect(rawVerification).toBeGreaterThan(rawBinding); + expect(reportCopy).toBeGreaterThan(rawVerification); }); it("verifies the exact committed dependency graph signatures in trusted CI (#8925)", () => {