diff --git a/.github/workflows/patch-validator-image.yml b/.github/workflows/patch-validator-image.yml new file mode 100644 index 000000000..1f96ad44d --- /dev/null +++ b/.github/workflows/patch-validator-image.yml @@ -0,0 +1,603 @@ +name: patch-validator-image + +on: + pull_request: + workflow_dispatch: + +concurrency: + group: noema-patch-validator-image-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + PR_NUMBER: ${{ github.event.pull_request.number || '' }} + IMAGE_TAG: noema-patch-validator:${{ github.event.pull_request.head.sha || github.sha }} + SYFT_VERSION: 1.50.0 + SYFT_CHECKSUMS_SHA256: bb8824a06c27c625fc103db5d7e9d7131ba2cc6e7c7a79318ee71686ede3c3f0 + GRYPE_VERSION: 0.116.1 + GRYPE_CHECKSUMS_SHA256: 38ffeb0fbdf1955e46ebfb3cb7369b78888168954a77df02985c0c06505f85e9 + +jobs: + verify_image: + name: verify-patch-validator-image + runs-on: ubuntu-latest + timeout-minutes: 90 + permissions: + contents: read + steps: + - name: Check out exact pull-request head without persisted credentials + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ env.SOURCE_SHA }} + persist-credentials: false + + - name: Refuse stale pull-request head before verification + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + checked_out="$(git rev-parse HEAD)" + test "$checked_out" = "$SOURCE_SHA" + test -z "$(git status --porcelain=v2 --untracked-files=all --ignored=matching)" + if [ -n "$PR_NUMBER" ]; then + live_head="$(gh api --method GET "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq ".head.sha")" + test "$live_head" = "$SOURCE_SHA" + fi + + - name: Install Trivy + uses: aquasecurity/setup-trivy@81e514348e19b6112ce2a7e3ecbafe19c1e1f567 # v0.3.1 + with: + version: v0.73.0 + cache: true + + - name: Install checksum-pinned Syft and Grype + shell: bash + run: | + set -euo pipefail + scanner_dir="$RUNNER_TEMP/noema-binary-scanners" + mkdir -p "$scanner_dir" + + install_scanner() { + scanner="$1" + version="$2" + checksums_sha256="$3" + archive="${scanner}_${version}_linux_amd64.tar.gz" + checksums="${scanner}_${version}_checksums.txt" + release_base="https://github.com/anchore/${scanner}/releases/download/v${version}" + + curl --proto '=https' --tlsv1.2 --location --fail --silent --show-error \ + --output "$scanner_dir/$checksums" "$release_base/$checksums" + printf '%s %s\n' "$checksums_sha256" "$scanner_dir/$checksums" | sha256sum --check --strict + + curl --proto '=https' --tlsv1.2 --location --fail --silent --show-error \ + --output "$scanner_dir/$archive" "$release_base/$archive" + ( + cd "$scanner_dir" + grep -E "^[0-9a-f]{64} ${archive}$" "$checksums" | sha256sum --check --strict + ) + tar -xzf "$scanner_dir/$archive" -C "$scanner_dir" "$scanner" + test -x "$scanner_dir/$scanner" + } + + install_scanner syft "$SYFT_VERSION" "$SYFT_CHECKSUMS_SHA256" + install_scanner grype "$GRYPE_VERSION" "$GRYPE_CHECKSUMS_SHA256" + "$scanner_dir/syft" version | grep -Fq "$SYFT_VERSION" + "$scanner_dir/grype" version | grep -Fq "$GRYPE_VERSION" + printf 'SCANNER_BIN_DIR=%s\n' "$scanner_dir" >>"$GITHUB_ENV" + + - name: Build exact-head patch-validator image + shell: bash + run: | + set -euo pipefail + docker build \ + --platform=linux/amd64 \ + --file=Dockerfile.patch-validator \ + --build-arg=SOURCE_REVISION=${SOURCE_SHA} \ + --tag="$IMAGE_TAG" \ + . + image_digest="$(docker image inspect "$IMAGE_TAG" --format '{{.Id}}')" + case "$image_digest" in + sha256:????????????????????????????????????????????????????????????????) ;; + *) printf '::error::Unexpected local image identity: %s\n' "$image_digest"; exit 1 ;; + esac + printf 'VALIDATOR_IMAGE_DIGEST=%s\n' "$image_digest" >>"$GITHUB_ENV" + + - name: Verify static Node runtime identity + shell: bash + run: | + set -euo pipefail + test "$(docker run --rm --pull=never --entrypoint=/nodejs/bin/node "$IMAGE_TAG" --version)" = "v24.19.0" + + - name: Inspect exact image metadata and exclude dynamic runtime payloads + shell: bash + run: | + set -euo pipefail + evidence_dir="$RUNNER_TEMP/patch-validator-evidence" + image_inspect="$evidence_dir/image-inspect.json" + image_archive="$RUNNER_TEMP/patch-validator-image.tar" + node_binary="$RUNNER_TEMP/patch-validator-node" + container_name="noema-image-inspect-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + cleanup() { + rm -f "$node_binary" + docker rm -f "$container_name" >/dev/null 2>&1 || true + } + trap cleanup EXIT + mkdir -p "$evidence_dir" + + docker image inspect "$IMAGE_TAG" >"$image_inspect" + docker create --name "$container_name" "$IMAGE_TAG" >/dev/null + docker export "$container_name" --output "$image_archive" + if tar -tf "$image_archive" | sed 's#^\./##' | grep -Eq \ + '^(bin/(sh|bash)|usr/bin/(apt|apt-get|apk|dpkg|npm|npx)|usr/local/bin/(npm|npx))$'; then + echo "::error::The final patch-validator image contains a shell or package manager." + exit 1 + fi + if tar -tf "$image_archive" | sed 's#^\./##' | grep -Eq '\.(node|so)(\.|$)'; then + echo "::error::The final patch-validator image contains a native addon or shared library." + exit 1 + fi + tar -xOf "$image_archive" nodejs/bin/node >"$node_binary" + test -s "$node_binary" + if readelf -l "$node_binary" | grep -q 'Requesting program interpreter'; then + echo "::error::The final Node runtime requests a dynamic program interpreter." + exit 1 + fi + if readelf -d "$node_binary" | grep -q '(NEEDED)'; then + echo "::error::The final Node runtime declares a dynamic shared-library dependency." + exit 1 + fi + + SOURCE_SHA="$SOURCE_SHA" \ + VALIDATOR_IMAGE_DIGEST="$VALIDATOR_IMAGE_DIGEST" \ + IMAGE_INSPECT_PATH="$image_inspect" \ + IMAGE_METADATA_PATH="$evidence_dir/image-metadata.json" \ + node --input-type=module <<'NODE' + import { readFileSync, writeFileSync } from "node:fs"; + + const [image] = JSON.parse(readFileSync(process.env.IMAGE_INSPECT_PATH, "utf8")); + const metadata = { + schema_version: "noema.patch-validator-image-metadata.v1", + source_revision: process.env.SOURCE_SHA, + validator_image_digest: process.env.VALIDATOR_IMAGE_DIGEST, + os: image.Os, + architecture: image.Architecture, + user: image.Config.User, + entrypoint: image.Config.Entrypoint, + labels: image.Config.Labels ?? {}, + }; + writeFileSync( + process.env.IMAGE_METADATA_PATH, + `${JSON.stringify(metadata, null, 2)}\n`, + { mode: 0o600 }, + ); + NODE + + - name: Prepare realistic exact-head smoke fixture + shell: bash + run: | + set -euo pipefail + source_dir="$RUNNER_TEMP/patch-validator-source" + patch_dir="$RUNNER_TEMP/patch-validator-patch" + evidence_dir="$RUNNER_TEMP/patch-validator-evidence" + mkdir -p "$source_dir/src" "$source_dir/test" "$patch_dir" "$evidence_dir" + + cat >"$source_dir/package.json" <<'JSON' + { + "name": "noema-patch-validator-smoke", + "private": true, + "type": "module" + } + JSON + printf '{}\n' >"$source_dir/package-lock.json" + printf 'SOURCE_TSCONFIG_MUST_NOT_BE_PARSED\n' >"$source_dir/tsconfig.json" + cat >"$source_dir/vitest.config.ts" <<'TS' + throw new Error("source Vitest config must not execute"); + TS + printf 'export const validatedValue = "old";\n' >"$source_dir/src/value.ts" + cat >"$source_dir/test/value.test.ts" <<'TS' + import { describe, expect, it } from "vitest"; + import { validatedValue } from "../src/value.js"; + + describe("patch-validator image", () => { + it("executes the exact patched source", () => { + expect(validatedValue).toBe("new"); + }); + }); + TS + cat >"$patch_dir/input.patch" <<'PATCH' + diff --git a/src/value.ts b/src/value.ts + index 1111111..2222222 100644 + --- a/src/value.ts + +++ b/src/value.ts + @@ -1 +1 @@ + -export const validatedValue = "old"; + +export const validatedValue = "new"; + PATCH + patch_sha256="$(sha256sum "$patch_dir/input.patch" | cut -d' ' -f1)" + printf 'SMOKE_SOURCE_DIR=%s\n' "$source_dir" >>"$GITHUB_ENV" + printf 'SMOKE_PATCH_PATH=%s\n' "$patch_dir/input.patch" >>"$GITHUB_ENV" + printf 'SMOKE_RESULT_PATH=%s\n' "$evidence_dir/smoke-result.json" >>"$GITHUB_ENV" + printf 'SMOKE_PATCH_SHA256=%s\n' "$patch_sha256" >>"$GITHUB_ENV" + + - name: Run real no-network, read-only, non-root smoke validation + shell: bash + run: | + set -euo pipefail + uid="$(id -u)" + gid="$(id -g)" + test "$uid" -gt 0 + test "$gid" -gt 0 + container_name="noema-patch-smoke-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + diagnostic_path="$RUNNER_TEMP/patch-validator-untrusted-diagnostic.json" + cleanup() { + rm -f "$diagnostic_path" + docker rm -f "$container_name" >/dev/null 2>&1 || true + } + trap cleanup EXIT + + set +e + docker run \ + --name="$container_name" \ + --pull=never \ + --network=none \ + --read-only \ + --cap-drop=ALL \ + --security-opt=no-new-privileges=true \ + --security-opt=seccomp=builtin \ + --pids-limit=256 \ + --memory=2g \ + --memory-swap=2g \ + --cpus=2 \ + --ipc=none \ + --ulimit=nofile=1024:1024 \ + --ulimit=nproc=256:256 \ + --ulimit=core=0:0 \ + --ulimit=fsize=67108864:67108864 \ + --user="${uid}:${gid}" \ + --tmpfs=/workspace:rw,nosuid,nodev,size=1073741824,mode=0700,uid=${uid},gid=${gid} \ + --tmpfs=/tmp:rw,noexec,nosuid,nodev,size=67108864,mode=1777 \ + --mount=type=bind,src="${SMOKE_SOURCE_DIR}",dst=/input,readonly \ + --mount=type=bind,src="${SMOKE_PATCH_PATH}",dst=/patch/input.patch,readonly \ + --workdir=/workspace \ + --env=HOME=/workspace/home \ + --env=XDG_CACHE_HOME=/workspace/cache \ + --env=NOEMA_RESULT_PATH=/workspace/result.json \ + --env=NOEMA_REPOSITORY=ContextualWisdomLab/noema \ + --env=NOEMA_BASE_SHA=0000000000000000000000000000000000000000 \ + --env=NOEMA_HEAD_SHA="${SOURCE_SHA}" \ + --env=NOEMA_PATCH_SHA256="${SMOKE_PATCH_SHA256}" \ + --env=NOEMA_PATCH_PROFILE=node_patch_verify \ + --env=NOEMA_COMMAND_PROFILE=node_patch_verify_v1 \ + --env=NOEMA_VALIDATOR_IMAGE_DIGEST="${VALIDATOR_IMAGE_DIGEST}" \ + "$IMAGE_TAG" >/dev/null 2>"$diagnostic_path" + container_exit_code=$? + set -e + + if [ "$container_exit_code" -ne 0 ]; then + if ! DIAGNOSTIC_PATH="$diagnostic_path" node --input-type=module <<'NODE' + import { readPatchValidatorDiagnostic } from "./scripts/lib/patch-validator-smoke-diagnostic.mjs"; + + const diagnostic = readPatchValidatorDiagnostic(process.env.DIAGNOSTIC_PATH); + process.stderr.write( + `Untrusted patch-validator diagnostic: ${JSON.stringify(diagnostic)}\n`, + ); + NODE + then + echo "::warning::Patch-validator image failed without a readable bounded diagnostic." + fi + exit "$container_exit_code" + fi + + SOURCE_SHA="$SOURCE_SHA" \ + VALIDATOR_IMAGE_DIGEST="$VALIDATOR_IMAGE_DIGEST" \ + SMOKE_PATCH_SHA256="$SMOKE_PATCH_SHA256" \ + SMOKE_RESULT_PATH="$SMOKE_RESULT_PATH" \ + node --input-type=module <<'NODE' + import { writeFileSync } from "node:fs"; + + const smokeResult = { + status: "passed", + repository_full_name: "ContextualWisdomLab/noema", + base_sha: "0".repeat(40), + head_sha: process.env.SOURCE_SHA, + patch_sha256: process.env.SMOKE_PATCH_SHA256, + profile: "node_patch_verify", + command_profile: "node_patch_verify_v1", + validator_image_digest: process.env.VALIDATOR_IMAGE_DIGEST, + exit_code: 0, + duration_ms: 0, + stdout_excerpt: "", + stderr_excerpt: "", + reason_codes: [], + }; + writeFileSync( + process.env.SMOKE_RESULT_PATH, + `${JSON.stringify(smokeResult, null, 2)}\n`, + { mode: 0o600, flag: "wx" }, + ); + NODE + + - name: Generate CycloneDX SBOM and vulnerability receipt + shell: bash + run: | + set -euo pipefail + evidence_dir="$RUNNER_TEMP/patch-validator-evidence" + trivy image \ + --format cyclonedx \ + --output "$evidence_dir/image-sbom.cdx.json" \ + --no-progress \ + "$IMAGE_TAG" + trivy image \ + --format json \ + --output "$evidence_dir/image-vulnerability-scan.json" \ + --exit-code 1 \ + --severity MEDIUM,HIGH,CRITICAL \ + --scanners vuln \ + --no-progress \ + "$IMAGE_TAG" + + - name: Generate static-runtime binary inventory and vulnerability receipt + shell: bash + run: | + set -euo pipefail + evidence_dir="$RUNNER_TEMP/patch-validator-evidence" + "$SCANNER_BIN_DIR/syft" scan "docker:$IMAGE_TAG" \ + --output "syft-json=$evidence_dir/image-binary-sbom.syft.json" + "$SCANNER_BIN_DIR/grype" --config /dev/null "docker:$IMAGE_TAG" \ + --fail-on medium \ + --output json \ + >"$evidence_dir/image-binary-vulnerability-scan.json" + + - name: Generate embedded static-runtime dependency inventory and vulnerability receipt + shell: bash + run: | + set -euo pipefail + evidence_dir="$RUNNER_TEMP/patch-validator-evidence" + versions_path="$evidence_dir/embedded-runtime-process-versions.json" + inventory_path="$evidence_dir/embedded-runtime-inventory.json" + scan_plan_path="$evidence_dir/embedded-runtime-scan-plan.json" + scan_dir="$evidence_dir/embedded-runtime-component-scans" + scan_path="$evidence_dir/embedded-runtime-vulnerability-scan.json" + mkdir -p "$scan_dir" + + docker run --rm --pull=never --entrypoint=/nodejs/bin/node "$IMAGE_TAG" \ + --input-type=module \ + --eval='process.stdout.write(JSON.stringify(process.versions))' \ + >"$versions_path" + versions_bytes="$(wc -c <"$versions_path")" + test "$versions_bytes" -gt 0 + test "$versions_bytes" -le 32768 + + PROCESS_VERSIONS_PATH="$versions_path" \ + INVENTORY_PATH="$inventory_path" \ + SCAN_PLAN_PATH="$scan_plan_path" \ + VALIDATOR_IMAGE_DIGEST="$VALIDATOR_IMAGE_DIGEST" \ + node --input-type=module <<'NODE' + import { readFileSync, writeFileSync } from "node:fs"; + + const versions = JSON.parse(readFileSync(process.env.PROCESS_VERSIONS_PATH, "utf8")); + if (Object.prototype.toString.call(versions) !== "[object Object]") { + throw new Error("process.versions evidence must be a JSON record"); + } + if (versions.node !== "24.19.0") { + throw new Error("process.versions Node version does not match the reviewed runtime"); + } + + const metadata = { + modules: ["node_modules_abi", "Node.js native module ABI version"], + napi: ["node_api_level", "Node-API compatibility level"], + }; + const reviewedIdentity = { + acorn: { + name: "acorn", + identityFor: (version) => ({ purl: `pkg:npm/acorn@${version}` }), + }, + amaro: { + name: "amaro", + identityFor: (version) => ({ purl: `pkg:npm/amaro@${version}` }), + }, + undici: { + name: "undici", + identityFor: (version) => ({ purl: `pkg:npm/undici@${version}` }), + }, + openssl: { + name: "openssl", + identityFor: (version) => ({ + cpe: `cpe:2.3:a:openssl:openssl:${version}:*:*:*:*:*:*:*`, + }), + }, + ngtcp2: { + name: "ngtcp2", + identityFor: (version) => ({ + cpe: `cpe:2.3:a:nghttp2:ngtcp2:${version}:*:*:*:*:*:*:*`, + }), + }, + }; + const components = []; + const scanPlan = []; + for (const key of Object.keys(versions).filter((key) => key !== "node").sort()) { + if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(key)) { + throw new Error(`process.versions dependency ${key} has an invalid key`); + } + const version = versions[key]; + if ( + typeof version !== "string" || + version.length === 0 || + version.length > 128 || + /[\u0000-\u0020\u007f]/.test(version) + ) { + throw new Error(`process.versions ${key} has an invalid version`); + } + if (Object.hasOwn(metadata, key)) { + const [name, reason] = metadata[key]; + components.push({ + key, + name, + version, + classification: "runtime_metadata", + reason, + }); + continue; + } + + const definition = reviewedIdentity[key]; + if (definition === undefined) { + throw new Error( + `process.versions dependency ${key} has no reviewed vulnerability identity`, + ); + } + const identityFields = definition.identityFor(version); + const identity = identityFields.purl ?? identityFields.cpe; + components.push({ + key, + name: definition.name, + version, + classification: "bundled_dependency", + ...identityFields, + }); + scanPlan.push({ key, identity }); + } + + if (scanPlan.length === 0) { + throw new Error("embedded runtime has no reviewed bundled dependency to scan"); + } + const inventory = { + schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", + validator_image_digest: process.env.VALIDATOR_IMAGE_DIGEST, + node_version: versions.node, + process_versions: versions, + components, + }; + writeFileSync(process.env.INVENTORY_PATH, `${JSON.stringify(inventory, null, 2)}\n`, { + mode: 0o600, + flag: "wx", + }); + writeFileSync(process.env.SCAN_PLAN_PATH, `${JSON.stringify(scanPlan, null, 2)}\n`, { + mode: 0o600, + flag: "wx", + }); + NODE + + "$SCANNER_BIN_DIR/grype" db update + export GRYPE_DB_AUTO_UPDATE=false + + mapfile -t scan_rows < <( + SCAN_PLAN_PATH="$scan_plan_path" node --input-type=module <<'NODE' + import { readFileSync } from "node:fs"; + + const scanPlan = JSON.parse(readFileSync(process.env.SCAN_PLAN_PATH, "utf8")); + if (!Array.isArray(scanPlan) || scanPlan.length === 0 || scanPlan.length > 128) { + throw new Error("embedded runtime scan plan must be a bounded non-empty array"); + } + for (const entry of scanPlan) { + if ( + Object.prototype.toString.call(entry) !== "[object Object]" || + typeof entry.key !== "string" || + typeof entry.identity !== "string" || + entry.identity.length === 0 || + entry.identity.length > 512 || + /[\t\r\n]/.test(entry.identity) + ) { + throw new Error("embedded runtime scan plan entry is invalid"); + } + process.stdout.write(`${entry.key}\t${entry.identity}\n`); + } + NODE + ) + test "${#scan_rows[@]}" -gt 0 + test "${#scan_rows[@]}" -le 128 + + for scan_row in "${scan_rows[@]}"; do + IFS=$'\t' read -r key identity <<<"$scan_row" + test -n "$key" + test -n "$identity" + raw_path="$scan_dir/${key}.json" + "$SCANNER_BIN_DIR/grype" --config /dev/null "$identity" \ + --output json \ + >"$raw_path" + raw_scan_bytes="$(wc -c <"$raw_path")" + test "$raw_scan_bytes" -gt 0 + test "$raw_scan_bytes" -le 8388608 + done + + INVENTORY_PATH="$inventory_path" \ + SCAN_DIR="$scan_dir" \ + EMBEDDED_SCAN_PATH="$scan_path" \ + VALIDATOR_IMAGE_DIGEST="$VALIDATOR_IMAGE_DIGEST" \ + node --input-type=module <<'NODE' + import { readFileSync, writeFileSync } from "node:fs"; + import { join } from "node:path"; + + const inventory = JSON.parse(readFileSync(process.env.INVENTORY_PATH, "utf8")); + const bundled = inventory.components.filter( + (component) => component.classification === "bundled_dependency", + ); + const components = bundled.map((component) => { + const identity = component.purl ?? component.cpe; + const rawPath = join(process.env.SCAN_DIR, `${component.key}.json`); + const raw = JSON.parse(readFileSync(rawPath, "utf8")); + if (Object.prototype.toString.call(raw) !== "[object Object]") { + throw new Error(`Grype embedded-runtime result ${component.key} must be a JSON record`); + } + return { + key: component.key, + identity, + scanner_output: raw, + }; + }); + const receipt = { + schema_version: "noema.patch-validator-embedded-runtime-vulnerability-scan.v1", + validator_image_digest: process.env.VALIDATOR_IMAGE_DIGEST, + scanner: "grype@0.116.1", + components, + ignoredMatches: [], + }; + writeFileSync(process.env.EMBEDDED_SCAN_PATH, `${JSON.stringify(receipt, null, 2)}\n`, { + mode: 0o600, + flag: "wx", + }); + NODE + + - name: Verify exact-source, exact-image, smoke, SBOM, and vulnerability receipts + shell: bash + run: | + set -euo pipefail + evidence_dir="$RUNNER_TEMP/patch-validator-evidence" + node scripts/verify-patch-validator-image.mjs \ + --metadata "$evidence_dir/image-metadata.json" \ + --smoke "$evidence_dir/smoke-result.json" \ + --sbom "$evidence_dir/image-sbom.cdx.json" \ + --vulnerability-scan "$evidence_dir/image-vulnerability-scan.json" \ + --binary-sbom "$evidence_dir/image-binary-sbom.syft.json" \ + --binary-vulnerability-scan "$evidence_dir/image-binary-vulnerability-scan.json" \ + --embedded-runtime-inventory "$evidence_dir/embedded-runtime-inventory.json" \ + --embedded-vulnerability-scan "$evidence_dir/embedded-runtime-vulnerability-scan.json" \ + --expected-image-digest "$VALIDATOR_IMAGE_DIGEST" \ + --expected-source-revision "$SOURCE_SHA" \ + >"$evidence_dir/image-verification.json" + + - name: Refuse stale pull-request head after verification + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$SOURCE_SHA" + test -z "$(git status --porcelain=v2 --untracked-files=all --ignored=matching)" + if [ -n "$PR_NUMBER" ]; then + live_head="$(gh api --method GET "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq ".head.sha")" + test "$live_head" = "$SOURCE_SHA" + fi + + - name: Upload bounded verification evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: patch-validator-image-verification-${{ env.SOURCE_SHA }} + path: ${{ runner.temp }}/patch-validator-evidence + if-no-files-found: error + retention-days: 90 diff --git a/CHANGELOG.md b/CHANGELOG.md index a11c8223b..119f791e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,18 @@ # Changelog ## Unreleased +- embedded-runtime 취약점 증빙을 raw per-component Grype evidence로 정합화했다. exact-image `process.versions`의 reviewed component catalog가 각 dependency를 supported npm PURL 또는 reviewed application CPE에 결합하고, checksum-pinned Grype 0.116.1의 원본 per-component JSON에서 scanner/version, exact source target, raw match artifact identity, canonical vulnerability-database/provider snapshot을 검증한다. 모든 component scan은 동일한 DB snapshot을 사용해야 하며 `pkg:generic`, synthetic `status=completed` assessment, unsupported·partial·wildcard·substituted identity, ignored match/VEX, MEDIUM/HIGH/CRITICAL/UNKNOWN finding은 실패-폐쇄한다. scanner-negative evidence가 불충분한 reviewed component에는 explicit fixed-version floor를 추가하고, unsupported/unmapped component는 release blocker로 남긴다. public operations 문서와 APA 7th doctoring을 현재 구현에 맞췄다. +- repository-owned patch-validator image 검증 경계를 현재 구현과 정합화했다. immutable Dockerfile frontend와 digest-pinned builder에서 SHA-256-authenticated Node.js 24.19.0 source를 fully static으로 빌드해 `scratch` final image에 반입하고, shell/package-manager/native addon/shared library/dynamic interpreter/`NEEDED` dependency 부재, no-network·read-only·non-root·capability-dropped·seccomp/resource-bounded 실제 smoke, Trivy CycloneDX, Syft/Grype static-binary evidence를 exact local image ID에 결합한다. 추가로 exact-image `process.versions`를 bounded inventory source로 사용해 `modules`와 `napi`만 reviewed ABI/runtime metadata로 분리하고 나머지는 bundled dependency로 모델링하며, 별도 CycloneDX/Grype lane에서 one-result-per-component와 MEDIUM/HIGH/CRITICAL/UNKNOWN fail-closed 정책을 강제한다. missing·duplicate·unknown·ambiguous component, ignored match, unsupported identity는 clean evidence로 취급하지 않는다. workflow는 전후 live-head equality를 확인하고 container output을 identity evidence로 신뢰하지 않으며, public operations 문서와 Node.js/Grype/OCI/NIST SSDF/CycloneDX/Sigstore/SLSA 근거의 APA 7th doctoring을 갱신했다. 이 slice는 GHCR publication, repository-owned image signing, SLSA provenance, digest activation, merge, release 또는 deployment authority를 주장하지 않는다. - quarantined patch validation의 authenticated source contract를 문서화했다. source object directory에 `objects/info/alternates` 또는 `objects/info/http-alternates`가 있으면 실패-폐쇄하고, bounded exact-tree inventory에 canonical path·Git mode·object ID·size를 보존한다. archive regular-file set은 해당 inventory와 path·executable-derived `100644`/`100755` mode·size가 정확히 같아야 하며, extraction 후 각 파일을 no-follow·inode-stable descriptor로 읽어 Git `blob \0` SHA-1/SHA-256 object identity가 inventory와 일치해야 Docker를 시작한다. public documentation과 APA 7th doctoring을 구현 계약에 맞췄다. - `git ls-tree -r -l -z --full-tree` exact-tree metadata 검증을 Git의 실제 long-output grammar에 맞춰 강화. mode·object type·object identity의 구분자는 literal ASCII space로 고정하고, blob size는 ASCII decimal만 허용한다. Git이 `%(objectsize:padded)`를 최소 폭 7로 right-justify한다는 공식 계약에 따라 정확한 선행 ASCII padding과 deterministic fixture용 unpadded form만 통과시키며, 임의 under/over-padding·nonbreaking space·Arabic-Indic digit·trailing text는 실패-폐쇄한다. 모든 repeated ASCII space가 악성이라는 초기 가정은 실제 `-l` 출력과 충돌하므로 부분적으로 잘못된 피드백으로 분류하고, valid Unicode-normalization finding만 보존해 RED 회귀 테스트를 GREEN으로 전환했다. public/doctoring 문서와 APA 7th Git 근거를 함께 갱신했다. - patch file-mode metadata 검증을 suffix 검사에서 full-line grammar로 강화. `old mode`·`new mode`·`new file mode`·`deleted file mode`는 정확히 하나의 6자리 mode token만 허용하고, `100644`·`100755`만 통과시킨다. exact `120000`·`160000`은 symlink/gitlink로 계속 명시적으로 차단하며, `new file mode 120000 100644`처럼 허용 suffix 뒤에 악성 mode를 숨긴 trailing-token 입력은 `malformed mode metadata`로 실패-폐쇄한다. 기존 RED 회귀 테스트를 GREEN으로 전환하고 public/doctoring 문서에 진단 분리와 보안 결정을 기록했다. - untrusted patch를 exact repository/base/head/patch SHA-256와 allowlisted validation profile에 결합해 credential-free, no-network, read-only, non-root Docker sandbox에서 검증하는 reviewer 경계를 추가. text-only preflight가 malformed UTF-8·binary payload·symlink/gitlink mode(새/삭제/변경 mode뿐 아니라 기존 entry의 `index … 120000|160000`)·traversal·absolute/control-character/raw-backslash path·중복/과다 변경 파일·GitHub governance 경로를 Docker 실행 전에 실패-폐쇄한다. unified hunk header와 old/new line count를 정확히 소진해 다중 hunk·context·zero-count·`No newline` marker를 지원하면서 truncated/overlong hunk와 hunk 뒤 전통 diff section을 거부하고, `---`·`+++`·rename/copy source/target은 counted primary `diff --git` path identity와 일치해야 한다. Git source는 caller `.git`의 config/index/hooks/attributes를 직접 신뢰하지 않고 descriptor-safe gitfile·commondir·object-store resolution과 private bare control metadata를 사용하며, highest-precedence `* -export-ignore -export-subst`로 committed/local archive transforms를 제거해 failing test 누락과 blob substitution을 방지한다. exact `read-tree`·isolated status·bounded raw-tree archive·member allowlist·post-extraction manifest equality를 강제하고, Docker에는 writable host directory 대신 pre-created `/output/result.json` 한 파일만 전달한다. process-wide `RLIMIT_FSIZE`는 현실적인 검증 artifact를 허용하는 64 MiB로 제한하고 host result parser는 evidence를 독립적으로 16 KiB에 제한한다. immutable digest-pinned image·capability drop·seccomp·resource quotas·bounded timeout cleanup·exact structured result 재검증을 유지하고, beginner-readable 운영 문서와 Git 2.54/2.55·NIST SP 800-190·NIST SP 800-218·OCI Runtime Specification 1.3.0·SLSA 1.2 근거를 APA 7th doctoring에 기록했다. reviewer production statement/branch/docstring 100% gate와 committed/local attributes·linked worktree·descriptor race·archive/extraction·hunk/path/mode·single-result-file 악성 회귀 테스트를 유지한다. - `hourly-product-development`가 `NVIDIA_NIM_API_KEY`뿐 아니라 `NOEMA_MAINTAINER_APP_CLIENT_ID`와 `NOEMA_MAINTAINER_APP_PRIVATE_KEY` 존재를 checkout·OpenCode 설치·NVIDIA 호출 전에 검증한다. 게시 경로가 준비되지 않았으면 `maintainer_app_unavailable`로 실패 폐쇄하여 알려진 실패에 추론 비용을 쓰지 않으며, `dry_run`은 credential 없이 queue와 task contract를 검토하는 경로로 유지한다. 기존 reviewer App 및 `NOEMA_LLM_API_KEY`·`contextual-orchestrator` reviewer credential 경계는 변경하지 않는다. -- zero open pull requests일 때만 `NVIDIA_NIM_API_KEY` 전용 OpenCode 1.17.13 세션을 실행하는 proposal-only `hourly-product-development` 루프를 추가. minute-47 schedule·non-cancelling single flight·OpenCode binary SHA-256 pin·NVIDIA NIM model fallback·후보 실패 시 clean reset·GitHub/OIDC credential 제거·reviewer key 비참조·full release verification·40-file/500,000-byte proposal budget·trusted one-PR packaging을 강제한다. 각 후보 실행은 900초와 30초 kill grace로 제한하고, 실패 후 `npm ci --ignore-scripts` 재설치는 별도 60초와 10초 kill grace로 제한한다. 재설치가 실패하거나 시간 초과되면 불완전한 dependency tree로 다음 후보를 실행하지 않고 실패 폐쇄한다. 세 후보의 실행·종료 2,790초, 두 번의 후보 간 재설치 140초, 300초 setup/diagnostic reserve를 합친 3,230초가 55분(3,300초) job budget에 들어가며 70초 여유를 남긴다. 마지막 후보가 실패하면 불필요한 reset·clean·재설치를 생략하고 안정적인 전체 후보 실패 진단으로 곧바로 종료한다. 모델 실행, 제안 코드 검증, publication credential을 각각 별도의 GitHub-hosted runner로 분리하고, immutable artifact의 exact ID·workflow-run ID·archive digest와 patch SHA-256·base SHA·file/byte count를 교차 검증하며 symlink(`120000`)와 gitlink(`160000`)를 세 경계 모두에서 차단한다. 제안 코드를 실행한 runner에는 Maintainer App secret/token을 절대 제공하지 않고, 세 번째 non-executing publisher에서만 late-bound repository-scoped App token을 발급한다. merge/release/deploy authority는 기존 `hourly-commercial-readiness` exact-head governance에 유지하며, 운영 Runbook과 OpenCode/NVIDIA/GitHub Actions/NIST SP 800-218 근거를 APA 7th doctoring에 기록했다. package version은 release·deployment·production KPI evidence를 발행하지 않으므로 유지한다. +- zero open pull requests일 때만 `NVIDIA_NIM_API_KEY` 전용 OpenCode 1.17.13 세션을 실행하는 proposal-only `hourly-product-development` 루프를 추가. minute-47 schedule·non-cancelling single flight·OpenCode binary SHA-256 pin·NVIDIA NIM model fallback·후보 실패 시 clean reset·GitHub/OIDC credential 제거·reviewer key 비참조·full release verification·40-file/500,000-byte proposal budget·trusted one-PR packaging을 강제한다. 각 후보 실행은 900초와 30초 kill grace로 제한하고, 실패 후 `npm ci --ignore-scripts` 재설치는 별도 60초와 10초 kill grace로 제한한다. 재설치가 실패하거나 시간 초과되면 불완전한 dependency tree로 다음 후보를 실행하지 않고 실패 폐쇄한다. 세 후보의 실행·종료 2,790초, 두 번의 후보 간 재설치 140초, 300초 setup/diagnostic reserve를 합친 3,230초가 55분(3,300초) job budget에 들어가며 70초 여유를 남긴다. 마지막 후보가 실패하면 불필요한 reset·clean·재설치를 생략하고 안정적인 전체 후보 실패 진단으로 곧바로 종료한다. 모델 실행, 제안 코드 검증, publication credential을 각각 별도의 GitHub-hosted runner로 분리하고, immutable artifact의 exact ID·workflow-run ID·archive digest와 patch SHA-256·base SHA·file/byte count를 교차검증하며 symlink(`120000`)와 gitlink(`160000`)를 세 경계 모두에서 차단한다. 제안 코드를 실행한 runner에는 Maintainer App secret/token을 절대 제공하지 않고, 세 번째 non-executing publisher에서만 late-bound repository-scoped App token을 발급한다. merge/release/deploy authority는 기존 `hourly-commercial-readiness` exact-head governance에 유지하며, 운영 Runbook과 OpenCode/NVIDIA/GitHub Actions/NIST SP 800-218 근거를 APA 7th doctoring에 기록했다. package version은 release·deployment·production KPI evidence를 발행하지 않으므로 유지한다. - `/health` liveness와 분리된 unauthenticated `GET`/`HEAD /ready` runtime readiness endpoint를 추가. GitHub Actions OIDC issuer·audience·organization/workflow binding·exact workflow ref·GitHub Cloud API origin·GitHub App identifiers·PKCS#8 private key를 외부 호출 없이 검증하며, 불완전한 설정은 secret/config value를 반사하지 않는 deterministic failure codes와 `503 ERR_SERVICE_NOT_READY`, `Retry-After`, no-store/nosniff/trace/latency headers로 실패-폐쇄한다. exact workflow named ref는 Git `check-ref-format`의 모호성·유효성 경계(`..`, `//`, dot-leading/`.lock` component, revision-expression 문자, trailing dot/slash 등)를 만족해야 하므로 GitHub가 실제로 표현할 수 없는 ref에서 false-ready가 발생하지 않는다. 배포 smoke contract가 liveness·runtime readiness·unauthenticated exchange challenge를 모두 요구하도록 확장하고 Kubernetes probe separation, RFC 9110, NIST SSDF, Git ref-format 근거를 APA 7th doctoring에 기록했다. - 공개 readiness probe의 반복 WebCrypto 비용을 줄이되 binding freshness를 보존하도록 exact unchanged PKCS#8 PEM의 importability decision만 `WeakMap`으로 재사용한다. App id·audience·workflow ref·API boundary 등 비키 binding은 매 요청 재검증하고, Cloudflare가 binding-only 변경 후 isolate를 재사용해도 key rotation은 재import되므로 이전 `ready` 결과가 새 설정을 가리지 않는다. 동일 probe·비키 binding update·key rotation 현실 회귀 테스트와 Cloudflare Workers CPU/binding lifecycle 근거를 APA 7th doctoring에 기록했다. - 배포 smoke evidence를 exact canonical `/exchange` endpoint에 결합하고 userinfo·query·fragment·trailing/alternate path·noncanonical URL은 probe 전에 차단한다. production은 HTTPS만 허용하고 loopback test만 HTTP를 허용하며, 각 probe에 5초 connect·15초 total timeout과 1 MiB response ceiling을 적용한다. 14개 status/schema/header 판단은 `jq`의 structured JSON으로 직렬화하고 canonical endpoint와 timestamp에 결합해 owner-only mode로 보존하며 RFC 3986 근거와 현실 회귀 테스트를 doctoring에 기록했다. -- 전용 Maintainer GitHub App 활성화 전에 effective token을 기계적으로 감사하는 default-branch-only `maintainer-app-readiness` workflow와 `operations:preflight`를 추가. exact Maintainer/reviewer bot identity 분리, 단일 `ContextualWisdomLab/noema` repository scope, required Actions/checks/statuses/PR/contents read probes, live `main` governance PASS, no-write commercial-loop dry run을 실패-폐쇄 검증하고 bounded JSON evidence를 90일 보존한다. 별도의 Metadata-read Reviewer App token에서 인증된 `app-slug`·`installation-id`를 받아 `NOEMA_REVIEWER_LOGIN`이 실제 Reviewer App의 `[bot]` identity와 일치하는지 검증하며 Reviewer token 값은 script에 노출하지 않는다. 모든 retained source evidence는 checkout 밖 `${RUNNER_TEMP}`에 격리하고, dry-run evidence는 1 MiB·canonical UTC·exact repository·`apply=false` schema·decoded object-key uniqueness·최대 256단계 nesting으로 재검증한다. malformed/duplicate-key JSON, unknown fields, excessive nesting, symlink·descriptor swap·short read·예측 가능한 temporary-path 공격은 atomically replaced `dry_run_report_invalid` 증빙과 실패 gate로 처리한다. 이 증빙은 해당 run의 scoped token과 reviewer credential binding만 입증하며 complete App registration, key ownership, administrator bypass, break-glass ownership은 #29/#27의 독립 검토 대상으로 명시한다. +- 전용 Maintainer GitHub App 활성화 전에 effective token을 기계적으로 감사하는 default-branch-only `maintainer-app-readiness` workflow와 `operations:preflight`를 추가. exact Maintainer/reviewer bot identity 분리, 단일 `ContextualWisdomLab/noema` repository scope, required Actions/checks/statuses/PR/contents read probes, live `main` governance PASS, no-write commercial-loop dry run을 실패-폐쇄 검증하고 bounded JSON evidence를 90일 보존한다. 별도의 Metadata-read Reviewer App token에서 인증된 `app-slug`·installation-id를 받아 `NOEMA_REVIEWER_LOGIN`이 실제 Reviewer App의 `[bot]` identity와 일치하는지 검증하며 Reviewer token 값은 script에 노출하지 않는다. 모든 retained source evidence는 checkout 밖 `${RUNNER_TEMP}`에 격리하고, dry-run evidence는 1 MiB·canonical UTC·exact repository·`apply=false` schema·decoded object-key uniqueness·최대 256단계 nesting으로 재검증한다. malformed/duplicate-key JSON, unknown fields, excessive nesting, symlink·descriptor swap·short read·예측 가능한 temporary-path 공격은 atomically replaced `dry_run_report_invalid` 증빙과 실패 gate로 처리한다. 이 증빙은 해당 run의 scoped token과 reviewer credential binding만 입증하며 complete App registration, key ownership, administrator bypass, break-glass ownership은 #29/#27의 독립 검토 대상으로 명시한다. - credential-bearing GitHub API와 OIDC discovery/JWKS subrequest에 요청별 10초 deadline을 추가. `Request.signal`과 호출자 `RequestInit.signal`을 `AbortSignal.any()`로 보존하면서 독립 timeout signal을 결합하고, upstream stall은 bodyless `504 blocked-timeout` 정책 응답으로 실패-폐쇄하며 timer는 모든 종료 경로에서 정리한다. - `/exchange`의 `application/json` request body를 UTF-8 wire bytes 기준 8,192 bytes로 제한. 신뢰 가능한 `Content-Length` 초과는 body read 전에 413으로 차단하고, 길이 헤더가 없거나 잘못된 요청도 stream을 bounded-read하여 chunked 우회를 막는다. 검증된 작은 body만 재구성해 downstream parser로 전달하며 OIDC/JWKS 조회·GitHub App private-key 사용·GitHub API 호출 전에 실패-폐쇄하고 body 원문은 응답·로그에 남기지 않는다. - credential-bearing GitHub API와 OIDC subrequest에 `redirect: "manual"`을 강제하고, `3xx`/redirected response를 bodyless `502`로 치환하는 fail-closed egress wrapper를 추가. exact `api.github.com` origin과 pinned GitHub Actions discovery/JWKS endpoint 외 destination은 network call 전에 차단하며, wrapper 설치 실패나 runtime 교체 감지는 `/exchange` credential 처리 전에 `503 ERR_GITHUB_API`로 중단한다. @@ -24,7 +26,7 @@ - GitHub Actions OIDC `jti`를 SQLite-backed Durable Object에서 원자적으로 1회만 소비하도록 `/exchange`를 강화. 기존 RS256/JWKS/issuer/audience/repository/exact-workflow 검증과 GitHub installation-token 생성이 성공한 뒤에만 해시된 `jti`를 claim하고, 동일 bearer 재사용은 `401 ERR_AUTH_REPLAY`, binding·storage·결정 이상이나 필수 `jti`/`exp` 누락은 token 전달 없이 `503`으로 실패-폐쇄한다. claim은 OIDC 만료 직후 alarm으로 삭제하며 raw `jti`는 저장·로그하지 않는다. - OIDC 중앙 workflow trust를 접두사 비교에서 배포 entrypoint의 전체 ref exact-match 게이트로 강화. `main-attacker`처럼 허용 ref와 접두사만 공유하는 branch/tag는 JWKS·GitHub API 호출 전에 403으로 차단하고, wildcard·공백·쉼표·불완전한 workflow/ref 설정은 503으로 실패-폐쇄한다. 사전 점검은 deny-only이며 exact match 이후에도 기존 RS256/JWKS/issuer/audience/repository 검증을 모두 요구한다. - `/exchange`의 권한 발급 전 abuse-control을 Worker isolate별 메모리 맵에서 SQLite-backed Durable Object의 전역 고정-window 결정으로 강화. `CF-Connecting-IP`만 신뢰하고 SHA-256 bucket 이름으로 개인정보 노출을 줄이며, transactional storage·alarm cleanup·429/Retry-After·분산 limit headers를 제공하고 binding/응답 이상은 503으로 실패-폐쇄. 기존 isolate-local limiter는 defense in depth로 유지. -- production 배포 증빙을 acquisition final gate에 연결. 선택된 release tag·commit·production Worker·100% traffic·immutable release·strict KPI·smoke·independent environment reviewer·Sigstore signer/OIDC/runner policy·deployment receipt SHA-256을 교차 검증하는 `acquisition:deployment-evidence`를 추가하고, 배포 attestation 검증 성공 후 생성되는 verification receipt와 governance report를 buyer data room 필수 evidence로 색인. +- production 배포 증빙을 acquisition final gate에 연결. 선택된 release tag·commit·production Worker·100% traffic·immutable release·strict KPI·smoke·independent environment reviewer·Sigstore signer/OIDC/runner policy·deployment receipt SHA-256을 교차검증하는 `acquisition:deployment-evidence`를 추가하고, 배포 attestation 검증 성공 후 생성되는 verification receipt와 governance report를 buyer data room 필수 evidence로 색인. - GitHub `production` environment의 live protection rules를 배포 전에 감사하는 `production:governance` 게이트를 추가. 구체적인 User/Team required reviewer, self-review 금지, branch-policy rule, protected-branch-only 정책을 검증하고 `main`에서 dispatch되지 않았거나 환경 설정이 약화되면 Cloudflare credential 사용 전에 실패-폐쇄하며, bounded governance JSON을 배포 증빙과 함께 365일 보존. - production 배포를 임의 branch가 아닌 immutable SemVer GitHub Release에 결합하고, Wrangler structured output·Cloudflare pre/post deployment snapshot·strict 30일 KPI·post-deploy smoke를 검증해 active 100% opaque Worker version ID와 rollback identity를 기록하는 `deployment-evidence.json`을 추가. GitHub/Sigstore custom attestation을 자체 검증하고 release/KPI/smoke/rollback 증빙과 함께 365일 보존하며, tag·release manifest·Worker version·traffic·검증 상태가 불일치하면 실패-폐쇄. 실제 격리 환경이 없는 staging 선택지는 노출하지 않음. - exact-tag source archive·CycloneDX SBOM·checksum·Sigstore bundle을 immutable GitHub Release의 6개 고정 asset으로 게시하고 release/asset attestation을 모두 검증하는 격리 publication job 및 365일 acquisition receipt를 추가. immutable-release policy·tag/commit·asset digest/size가 불일치하거나 기존 release가 있으면 overwrite 없이 실패-폐쇄. diff --git a/Dockerfile.patch-validator b/Dockerfile.patch-validator new file mode 100644 index 000000000..d2e8ded50 --- /dev/null +++ b/Dockerfile.patch-validator @@ -0,0 +1,94 @@ +# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e + +FROM alpine:3.24.1@sha256:79ff19e9084a00eece421b2523fb93e22d730e2c0e525905de047e848e56d95f AS node_builder + +ARG NODE_VERSION=24.19.0 +ARG NODE_SOURCE_SHA256=f6d95e10a0431ee1067fc6aabe9f762908b4716dd35324e1ddb4b1466b76659f + +RUN apk add --no-cache \ + binutils-gold \ + g++ \ + gcc \ + libgcc \ + linux-headers \ + make \ + python3 \ + py3-setuptools \ + xz + +ADD --checksum=sha256:${NODE_SOURCE_SHA256} https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}.tar.xz /tmp/node.tar.xz + +RUN mkdir -p /usr/src/node \ + && tar -xJf /tmp/node.tar.xz --strip-components=1 -C /usr/src/node \ + && cd /usr/src/node \ + && ./configure \ + --prefix=/opt/node \ + --fully-static \ + --with-intl=small-icu \ + --without-npm \ + --without-corepack \ + --disable-single-executable-application \ + && make -j"$(getconf _NPROCESSORS_ONLN)" V= \ + && make install \ + && test "$(/opt/node/bin/node --version)" = "v${NODE_VERSION}" \ + && /opt/node/bin/node --input-type=module --eval="new RegExp('\\p{ID_Continue}', 'u')" \ + && ! readelf -l /opt/node/bin/node | grep -q 'Requesting program interpreter' \ + && ! readelf -d /opt/node/bin/node | grep -q '(NEEDED)' \ + && strip /opt/node/bin/node \ + && printf '%s\n' \ + '{"name":"node","version":"24.19.0","cpe":"cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*","license":"MIT","type":"binary","architecture":"x86_64","appCpe":"cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*","system":"nodejs","vendor":"nodejs","sourceRepo":"https://github.com/nodejs/node"}' \ + >/tmp/node-package-note.json \ + && objcopy \ + --add-section .note.package=/tmp/node-package-note.json \ + --set-section-flags .note.package=noload,readonly \ + /opt/node/bin/node \ + && readelf -p .note.package /opt/node/bin/node \ + | grep -Fq 'cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*' \ + && test "$(/opt/node/bin/node --version)" = "v${NODE_VERSION}" \ + && /opt/node/bin/node --input-type=module --eval="new RegExp('\\p{ID_Continue}', 'u')" \ + && ! readelf -l /opt/node/bin/node | grep -q 'Requesting program interpreter' \ + && ! readelf -d /opt/node/bin/node | grep -q '(NEEDED)' + +FROM node:24.18.0-alpine3.24@sha256:4ba75f835bb8802193e4c114572113d4b26f95f6f094f4b5229d2a77773e0afc AS dependencies + +WORKDIR /build + +COPY package.json package-lock.json ./ +RUN npm_config_os=wasip1-threads npm_config_cpu=wasm32 \ + npm ci --include=optional --ignore-scripts --no-audit --no-fund \ + && npm pkg delete devDependencies.@cloudflare/workers-types devDependencies.wrangler \ + && npm_config_os=wasip1-threads npm_config_cpu=wasm32 \ + npm prune --include=optional --ignore-scripts --no-audit --no-fund \ + && test -f node_modules/typescript/bin/tsc \ + && test -f node_modules/vitest/vitest.mjs \ + && test -f node_modules/@vitest/coverage-v8/package.json \ + && test -f node_modules/@rolldown/binding-wasm32-wasi/package.json \ + && test -z "$(find node_modules -type f -name '*.node' -print -quit)" \ + && test ! -e node_modules/@cloudflare/workers-types \ + && test ! -e node_modules/wrangler \ + && test ! -e node_modules/workerd \ + && test ! -e node_modules/miniflare + +FROM scratch AS runtime + +ARG SOURCE_REVISION +LABEL org.opencontainers.image.source="https://github.com/ContextualWisdomLab/noema" \ + org.opencontainers.image.revision="${SOURCE_REVISION}" \ + org.opencontainers.image.title="Noema Patch Validator" \ + org.opencontainers.image.description="Credential-free exact-head text patch validation runtime" \ + org.opencontainers.image.documentation="https://github.com/ContextualWisdomLab/noema/blob/main/docs/patch-validator-image.md" + +ENV NAPI_RS_FORCE_WASI=error + +USER 65532:65532 +WORKDIR /workspace + +COPY --from=node_builder --chown=65532:65532 /opt/node/bin/node /nodejs/bin/node +COPY --from=dependencies --chown=65532:65532 /build/node_modules /opt/noema/node_modules +COPY --chown=65532:65532 patch-validator/entrypoint.mjs /opt/noema/entrypoint.mjs +COPY --chown=65532:65532 patch-validator/validate-patch.mjs /opt/noema/validate-patch.mjs +COPY --chown=65532:65532 patch-validator/runtime.mjs /opt/noema/runtime.mjs +COPY --chown=65532:65532 patch-validator/validator-tsconfig.json /opt/noema/validator-tsconfig.json +COPY --chown=65532:65532 patch-validator/validator-vitest.config.mjs /opt/noema/validator-vitest.config.mjs + +ENTRYPOINT ["/nodejs/bin/node", "--input-type=module", "--eval", "import { runCli } from '/opt/noema/runtime.mjs'; import { runEntrypoint } from '/opt/noema/entrypoint.mjs'; process.exitCode = runEntrypoint({ runCliImpl: runCli, writeDiagnostic: (message) => process.stderr.write(message) });"] diff --git a/Dockerfile.patch-validator.dockerignore b/Dockerfile.patch-validator.dockerignore new file mode 100644 index 000000000..17958a9fd --- /dev/null +++ b/Dockerfile.patch-validator.dockerignore @@ -0,0 +1,10 @@ +* +!package.json +!package-lock.json +!patch-validator/ +patch-validator/* +!patch-validator/entrypoint.mjs +!patch-validator/validate-patch.mjs +!patch-validator/runtime.mjs +!patch-validator/validator-tsconfig.json +!patch-validator/validator-vitest.config.mjs diff --git a/docs/doctoring/patch-validator-embedded-scan-assessment.md b/docs/doctoring/patch-validator-embedded-scan-assessment.md new file mode 100644 index 000000000..685fefede --- /dev/null +++ b/docs/doctoring/patch-validator-embedded-scan-assessment.md @@ -0,0 +1,69 @@ +# Doctoring amendment: raw embedded-runtime scanner evidence + +## Status + +- **Decision date:** 2026-08-07 +- **Applies to:** PR #67 patch-validator static-runtime evidence +- **Release claim:** none +- **Production activation claim:** none +- **Workflow migration state:** direct raw per-component scanning is implemented for the reviewed identity catalog; integrated acceptance remains fail-closed until the exact runtime component set is fully reviewed and exact-head verification passes + +This amendment supersedes the earlier locally synthesized completion model. The exact-head regressions demonstrated that a zero-match component could look complete without proving that Grype evaluated the reviewed package identity, and that independently valid per-component results could still be substituted across artifacts or vulnerability-database snapshots. + +## Finding + +An empty vulnerability match list is negative finding evidence, not proof that a particular package identity was evaluated. The earlier workflow constructed a local completion object after an aggregate SBOM scan and used generic package identities for native dependencies. That was insufficient for a fully static Node runtime because an unsupported or weak identity could produce zero matches without distinguishing “evaluated and clean” from “no applicable matcher.” + +The workflow now builds its inventory only from a bounded reviewed identity catalog, updates the Grype vulnerability database once, disables per-component automatic database updates, scans each reviewed PURL or CPE directly, and retains each raw scanner result. Any `process.versions` dependency without a reviewed identity aborts evidence generation instead of being omitted, converted to a generic package, or marked locally complete. The verifier independently rejects unsupported identities, synthetic completion fields, mismatched match artifacts, blocking findings, and database drift. + +## Control decision + +The embedded-runtime evidence boundary uses a **reviewed identity catalog** keyed by the exact `process.versions` key. A bundled dependency is eligible for scanning only when the catalog binds that key to the expected inventory name and to exactly one scanner-supported identity form: + +1. an exact npm PURL whose package name and version match the catalog and `process.versions`; or +2. an exact CPE 2.3 application identity whose reviewed vendor, product, and version match the catalog and `process.versions`. + +Unknown keys, generic PURLs, wildcard or placeholder CPE vendors/products, arbitrary aliases, and identities inferred from receipt-controlled fields are explicit release blockers. Current catalog entries are deliberately bounded; adding a new native dependency requires evidence review rather than automatic identity fabrication. + +Every bundled dependency must carry **raw Grype** JSON for a direct scan of that exact reviewed identity. The verifier requires: + +- scanner descriptor `grype` at the pinned version; +- scanner source type consistent with PURL or CPE and a source target exactly equal to the reviewed identity; +- a valid vulnerability-database status record; +- bounded provider metadata with capture timestamps and input digests; +- no ignored matches; +- every reported match artifact bound back to the same reviewed PURL or CPE and exact component version; +- no MEDIUM, HIGH, CRITICAL, or UNKNOWN finding; and +- the **same vulnerability database** identity across every component scan. + +The canonical shared database identity is derived from the database schema/build metadata plus sorted provider capture/input metadata and is retained in verification output. This makes database drift visible rather than allowing two components in one acceptance decision to be evaluated against different snapshots. + +The workflow updates Grype's vulnerability database once, freezes per-component auto-update for the scan set, invokes each reviewed PURL or CPE directly, and retains the raw scanner result. A local “completed” flag, aggregate-only SBOM result, scanner process exit alone, or grouped synthetic result is not evidence of component evaluation. The workflow and verifier both fail closed when the reviewed catalog is incomplete; a red exact-head action caused by an unmapped runtime dependency is therefore expected evidence of a missing review, not a reason to weaken the gate. + +## Why this is stricter than zero findings + +Grype's *Supported scan targets* documentation treats individual PURL and CPE identities as explicit scan targets. Its vulnerability-database documentation also explains that the database is locally cached and can be updated explicitly. Noema therefore binds acceptance to the literal scanner target and one shared database snapshot rather than inferring assessment from an empty match array. + +The National Vulnerability Database maintains the Official Common Platform Enumeration (CPE) Dictionary. Noema treats an authoritative NVD CPE mapping as reviewed identity evidence where a CPE is used; a merely syntactically valid CPE is not sufficient. For example, NVD records OpenSSL under vendor/product `openssl:openssl`, and NVD's analysis of CVE-2026-40170 maps ngtcp2 through reviewed CPE configurations including `nghttp2:ngtcp2` while identifying 1.22.1 as the fixed floor. Those mappings are controls, not heuristics generated from a package name at runtime. + +Node.js documents `process.versions` as version information for Node.js and its dependencies. Noema uses it as the exact-runtime dependency declaration that the reviewed component set must match, not as a cryptographic proof of binary composition. `modules` and `napi` remain explicitly reviewed runtime metadata rather than fabricated vulnerable packages. + +## Residual risk + +Raw scanner evidence does not prove that vulnerability databases are complete or that every upstream project has an authoritative ecosystem identity. Catalog omissions therefore fail closed. The scanner binary, database acquisition path, hosted runner, workflow source, CPE/PURL review process, and upstream advisory coverage remain part of the trust chain. Future registry publication must separately bind signature, SBOM, vulnerability evidence, SLSA provenance, and the published digest before release acceptance. + +## APA 7th references + +Anchore. (2026). *Supported scan targets*. Anchore Open Source. https://oss.anchore.com/docs/guides/vulnerability/scan-targets/ + +Anchore. (2026). *Vulnerability database*. Anchore Open Source. https://oss.anchore.com/docs/guides/vulnerability/database/ + +National Institute of Standards and Technology. (2025). *Official Common Platform Enumeration (CPE) dictionary*. National Vulnerability Database. https://nvd.nist.gov/products/cpe + +National Institute of Standards and Technology. (2026). *CVE-2026-40170 detail*. National Vulnerability Database. https://nvd.nist.gov/vuln/detail/CVE-2026-40170 + +Node.js contributors. (2026). *Process: `process.versions`* (Node.js v24 documentation). OpenJS Foundation. https://nodejs.org/download/release/latest-v24.x/docs/api/process.html#processversions + +OWASP Foundation. (2025). *CycloneDX specification 1.7*. https://cyclonedx.org/specification/overview/ + +Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure Software Development Framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 diff --git a/docs/doctoring/patch-validator-image.md b/docs/doctoring/patch-validator-image.md new file mode 100644 index 000000000..9cc23b811 --- /dev/null +++ b/docs/doctoring/patch-validator-image.md @@ -0,0 +1,327 @@ +# Doctoring record: patch-validator image + +## Record status + +- **Original decision date:** 2026-08-06 +- **Security amendment:** 2026-08-07 +- **Scope:** pull-request build and verification of Noema's repository-owned patch-validator image +- **Related implementation:** `Dockerfile.patch-validator`, `.github/workflows/patch-validator-image.yml`, `patch-validator/`, `scripts/verify-patch-validator-image.mjs`, and `scripts/lib/patch-validator-static-runtime-evidence.mjs` +- **Related issues and PRs:** #9, #27, #29, #65, #66, #67 +- **Release claim:** none +- **Production activation claim:** none + +This record separates source-supported requirements, measured repository evidence, project decisions, assumptions, and residual risk. It is not evidence that a registry image has been published, signed, attested, activated, released, or deployed. + +## Problem statement + +PR #65 establishes a host-side boundary that authenticates an exact Git source, rejects unsafe patch syntax and objects, materializes a bounded committed snapshot, and keeps untrusted execution away from credentials. PR #67 adds an independently reviewable image implementation for one closed validation profile. + +The current goal is narrower than production publication: prove that one exact pull-request head can build and execute a credential-free validator image under a least-privileged, fail-closed verification process without conflating image execution with trusted evidence, independent approval, protected merge, provenance, release, or deployment authority. + +On 2026-08-07 the runtime design changed from a distribution runtime to a fully static Node.js 24.19.0 executable copied into a `scratch` final image. That change removed the previously observed Debian runtime CVEs and distribution attack surface, but it also removed package-manager metadata. Two related threats therefore had to be addressed: a package-oriented scanner could report a clean image while never classifying the self-compiled Node executable, and a Node-level classification could still hide bundled dependency versions inside the static binary. The current design uses independent binary presence evidence plus an exact-image `process.versions` inventory and per-component vulnerability evidence. + +## Evidence classification + +### Measured repository evidence + +The pull-request workflow and tests measure or enforce: + +- exact pull-request head checkout and live-head equality before and after verification; +- a clean worktree and immutable Dockerfile frontend; +- a digest-pinned Node builder; +- SHA-256 verification of the official Node.js 24.19.0 source archive; +- a fully static Node build copied into a `scratch` final image; +- rejection of dynamic interpreters, dynamic `NEEDED` dependencies, shared libraries, native addons, shells, and package managers in the final runtime; +- numeric non-root runtime identity and a fixed exec-form entrypoint; +- no-network, read-only, capability-dropped, seccomp-constrained smoke execution; +- bounded CPU, memory, swap, PID, descriptor, process, core, file-size, tmpfs, and wall-time resources; +- read-only source and patch mounts, no Docker socket, and no host-writable result mount; +- trusted-host synthesis of retained smoke evidence only after a zero container exit; +- exact source, patch, profile, command-profile, and local image-ID binding in trusted evidence; +- Trivy CycloneDX and vulnerability receipts for package and JavaScript dependency coverage; +- checksum-manifest-pinned Syft 1.50.0 inventory of the same exact local image; +- checksum-manifest-pinned Grype 0.116.1 vulnerability scanning of the same exact local image; +- fail-closed verification that Syft identifies exactly one Node 24.19.0 executable at `/nodejs/bin/node` with the expected Node.js CPE; +- exact-image `process.versions` capture with exact component-set equality against the reviewed inventory; +- `modules` and `napi` handled only as reviewed ABI/runtime metadata and never fabricated as vulnerability packages; +- every other `process.versions` key classified as a bundled dependency with a reviewed PURL or CPE; +- a separate CycloneDX/Grype embedded-runtime lane with one result per bundled dependency, no missing, duplicate, unknown, or ambiguous component result, and exact image identity binding; +- fail-closed rejection of ignored matches, unsupported severity vocabulary, and MEDIUM/HIGH/CRITICAL/UNKNOWN findings across the static-runtime and embedded-runtime lanes; and +- 100% production statement and branch coverage for included production modules plus public-docstring gates. + +A successful workflow run proves only the exact workflow run, exact source head, local image content identity, selected receipts, and tested runtime boundary. It does not prove registry publication, universal vulnerability absence, independent approval, protected merge, provenance, release acceptance, or deployment safety. + +### Source-supported requirements + +The external sources support these general requirements: + +- OCI image configuration and content-addressed image identity should follow the OCI Image Specification. +- Container execution should apply explicit isolation and least-privilege controls consistent with the OCI Runtime Specification and NIST container-security guidance. +- Secure development should protect build integrity, review changes, verify third-party components, respond to vulnerabilities, and retain evidence consistent with NIST SSDF. +- SBOM and vulnerability evidence must identify the artifact actually consumed; an empty finding set is not useful if the relevant component was never inventoried. +- Trivy's OS-package vulnerability scanner does not support third-party or self-compiled packages/binaries. Therefore Trivy alone cannot serve as positive evidence that the self-compiled Node runtime was assessed. +- Syft's binary classifier catalog includes Node executable classification and Node.js CPE evidence, which supports an explicit runtime-presence assertion. +- Node.js 24.19.0 documents `process.versions` as an object of version strings for Node.js and its dependencies. This supports using the exact built runtime as the source of reviewed embedded dependency-version declarations, while not claiming it is a complete binary-composition proof. +- Grype supports container/SBOM inputs and package identities including PURLs and CPEs; matcher selection depends on package type and can fall back to CPE/NVD matching for otherwise modeled packages. Its output remains vulnerability-database- and identity-quality-dependent. +- Build provenance, artifact signatures, independent approval, branch protection, release acceptance, and deployment authority are separate evidence/authority planes. + +## 2026-08-07 security finding: self-compiled binary blind spot + +### Finding + +The `scratch` runtime intentionally has no Debian/Alpine package database. Trivy documents that third-party and self-compiled packages/binaries are outside the support boundary of its OS-package vulnerability scanner. Consequently, a clean Trivy image scan could be a false assurance signal for the self-compiled Node executable. + +This was classified as a valid current-head security finding rather than a stale consequence of the previous Distroless runtime. A test-first RED regression required an independent static-binary inventory and vulnerability receipt before production implementation. + +### Control decision + +Noema keeps Trivy for its existing package/language coverage and adds an independent binary-aware lane: + +1. download Syft 1.50.0 and Grype 0.116.1 only from their versioned immutable GitHub releases; +2. pin the SHA-256 of each release checksum manifest in workflow source; +3. authenticate each Linux/amd64 archive through the already authenticated manifest before extraction; +4. run Syft against `docker:` and retain native Syft JSON; +5. require Syft's source image ID to equal the trusted Docker image ID; +6. require exactly one `node` package at version 24.19.0 located at `/nodejs/bin/node` and carrying a Node.js 24.19.0 CPE; +7. run Grype independently against the same exact local Docker image with `--config /dev/null`, preventing a repository-local `.grype.yaml` from silently introducing ignore policy; +8. require Grype's source image ID to equal the trusted Docker image ID; +9. reject any non-empty `ignoredMatches` collection; +10. reject MEDIUM, HIGH, CRITICAL, and UNKNOWN findings; and +11. merge those assertions into the final cross-receipt verification record only after all checks pass. + +The CLI additionally uses `--fail-on medium`, so a known blocking finding fails the workflow before a success receipt can be emitted. The trusted verifier separately blocks unknown severity so an unclassified finding cannot become positive evidence merely because it falls outside the CLI's ordered threshold. + +## 2026-08-07 security finding: bundled static dependency blind spot + +### Finding + +A clean Node CPE lane is not sufficient for a fully static executable. Node incorporates versioned libraries and language components, and a vulnerability can apply to one of those embedded dependencies even when the top-level Node package identity has no matching advisory. Treating the binary as one package would therefore preserve a false-negative path. + +Node.js exposes the runtime's own dependency version declarations through `process.versions`. The exact-image record is useful evidence because it comes from the executable under review, but it must not be treated as a statement that every compiled object or every future vulnerability database identity is complete. + +### Control decision + +The exact-image embedded-runtime lane now: + +1. executes the exact built `/nodejs/bin/node` and bounds the serialized `process.versions` record; +2. requires `process.versions.node` to equal the reviewed Node.js version `24.19.0`; +3. requires the reviewed inventory component keys to equal every non-`node` `process.versions` key exactly; +4. treats only `modules` and `napi` as runtime metadata, with exact reviewed meanings, and forbids package identities for those counters; +5. requires every other key to be a `bundled_dependency` with an explicit reviewed PURL or CPE; +6. serializes bundled dependencies into a separate CycloneDX inventory; +7. scans that inventory with checksum-pinned Grype 0.116.1 and requires one result per bundled dependency; +8. requires every match to bind to exactly one reviewed component identity and rejects duplicate, omitted, unknown, or ambiguous mappings; +9. forbids aggregate and per-component ignored matches; +10. rejects MEDIUM, HIGH, CRITICAL, and UNKNOWN findings; and +11. applies an explicit reviewed fail-closed security floor where a known component identity is not adequately represented by the scanner, instead of using ignore, VEX, severity downgrade, or fabricated clean evidence. + +The dedicated receipt is then cross-bound to the same exact local image ID as image metadata, smoke, Trivy, Syft, and the Node binary Grype lane. + +### Why the lanes remain separate + +This is defense in depth, not scanner voting. Trivy, Syft, Node's `process.versions`, and Grype answer different questions. Trivy remains useful for ordinary language/package evidence. Syft establishes that the intended self-compiled Node binary was actually classified. The Node-level Grype lane assesses that top-level runtime identity. `process.versions` enumerates reviewed dependency-version declarations from the exact runtime. The embedded-runtime Grype lane then evaluates those separately modeled dependencies. + +A disagreement fails closed when required evidence is missing, malformed, mismatched, ignored, unsupported, stale, ambiguous, or blocking. No VEX assertion, severity downgrade, ignore file, or repeat-until-green behavior is introduced to make the image pass. + +### Residual limits + +- Vulnerability scanners depend on classifier quality, ecosystem modeling, advisory feeds, and database freshness; no scanner can prove absence of unknown vulnerabilities. +- CPE and PURL matching can produce false positives or false negatives, so package-presence/version evidence is retained separately from vulnerability matching. +- `process.versions` is the runtime's dependency-version declaration, not a cryptographic inventory of every translation unit or vendored byte compiled into the executable. Source integrity, exact-runtime enumeration, Node-level scanning, and per-component scanning reduce this risk but do not establish universal component completeness. +- If a future Node release introduces a new `process.versions` key without a reviewed identity, exact component-set verification fails closed until that identity is reviewed. +- Hosted runners, Docker daemon, network retrieval, GitHub release hosting, scanner vulnerability databases, and upstream source distribution remain trusted dependencies. +- Linux/amd64 is the only platform verified by this slice; multi-architecture parity is not claimed. + +## Project decisions + +Noema adopts the following stricter decisions for this slice: + +1. **PR verification is read-only.** The workflow has `contents: read` and no package, OIDC, attestation, model, reviewer, release, or deployment credential. +2. **Mutable identity is not accepted as evidence.** Build inputs are version/digest/checksum pinned as applicable, and receipts bind to a local SHA-256 image ID. +3. **Live-head equality is checked twice.** Concurrency cancellation reduces wasted work but is not the stale-head security control. +4. **The final runtime is `scratch`, static, and numeric non-root.** A shell, package manager, Git client, network client, and distribution runtime are unnecessary for the fixed Node profile. +5. **Commands are image-owned.** Callers select an enum profile and cannot supply shell text. +6. **Control-plane files are outside the patch language.** Dependency, lockfile, configuration, reviewer, validator, Dockerfile, and GitHub workflow changes require ordinary review and image rebuild. +7. **Container output is untrusted.** Private container files, stdout, and stderr do not establish repository, source, profile, or image identity. +8. **The trusted host synthesizes retained smoke evidence.** Zero container exit is necessary but not sufficient for acceptance. +9. **Evidence planes remain separate.** Check runs, commit statuses, scanner evidence, validator evidence, model judgement, independent approval, provenance, release acceptance, and deployment evidence are not interchangeable. +10. **No publication claim is made.** Local image verification is intentionally separated from future main-only publication and digest-lock activation. + +## Standards and evidence mapping + +| Noema control | Source rationale | Current evidence | +|---|---|---| +| Digest/checksum-pinned build inputs | OCI identity; SSDF supply-chain integrity | Dockerfile source hash, pinned builder, pinned scanner manifests | +| Numeric non-root, read-only runtime | NIST container least privilege | Image metadata verifier and real Docker smoke | +| No network and no Docker socket | NIST isolation/credential separation | Docker flags and workflow contract tests | +| Capability drop, seccomp, no-new-privileges | Container runtime hardening | Real smoke command and static workflow tests | +| `scratch` + static-link verification | Attack-surface reduction project decision | Archive inspection and `readelf` checks | +| Trivy package/dependency lane | Component inventory/vulnerability response | CycloneDX + Trivy JSON receipts | +| Explicit self-compiled Node presence | Trivy documented limitation; Syft Node classifier | Syft native JSON + exact package/CPE verifier | +| Independent binary vulnerability lane | SSDF component-risk response | Grype JSON + exact-image/severity verifier | +| Exact embedded dependency declarations | Node.js `process.versions` contract | bounded exact-image `process.versions` + exact component-set verifier | +| Per-component embedded vulnerability evidence | Grype PURL/CPE package targeting and SSDF component-risk response | embedded CycloneDX + one Grype result per bundled dependency | +| Double live-head refusal | Noema fail-closed exact-head policy | GitHub API equality before and after verification | +| Future signature verification | Sigstore exact subject/identity model | Not implemented for repository-owned image in this slice | +| Future provenance | SLSA provenance model | Not implemented in this slice | + +## OCI image and runtime decisions + +The Dockerfile uses an immutable frontend and digest-pinned builder. The Node source archive is authenticated by an explicit SHA-256. The final `scratch` image records OCI source, revision, license, title, description, and documentation labels. + +The local Docker image ID used in PR verification is explicitly not described as a registry digest, signature, or provenance record. A future publication stage must bind registry subject, signature, SBOM, provenance, and verification receipts to one exact registry digest. + +The fixed profile does not require a shell, package manager, Git client, network, privileged capabilities, writable root filesystem, or host-writable result mount. Runtime controls include non-root identity, read-only root, no network, all capabilities dropped, no-new-privileges, built-in seccomp, isolated IPC, bounded resources, read-only inputs, private tmpfs, and no Docker socket. + +These controls reduce impact but do not prove complete kernel isolation. The Docker daemon, host kernel, runner image, and hosted-runner policy remain part of the trusted computing base. + +## NIST SSDF decision + +NIST SP 800-218 supports protecting software, producing well-secured software, responding to vulnerabilities, and maintaining traceable development practices. Noema maps that guidance to test-first security regressions, immutable external action references, verified external scanner release bytes, exact-head refusal, component inventory, multiple vulnerability evidence lanes, bounded evidence retention, independent approval, protected merge, and documented residual risk. + +The 100% production statement and branch coverage requirement is a project quality gate, not a claim that coverage proves correctness or security. + +## SBOM and vulnerability decisions + +CycloneDX remains the machine-readable interoperability format for Trivy inventory and the separately modeled embedded-runtime dependency inventory. Native Syft JSON is retained separately because binary classifier identity, package locations, CPEs, source image ID, and Syft descriptor are evidence the trusted verifier needs to authenticate the self-compiled runtime classification. + +The workflow rejects a successful vulnerability scan as sufficient by itself. Positive runtime evidence requires **presence** (Syft identified the intended Node binary), **top-level assessment** (Grype assessed the same exact image with no forbidden ignore or blocking severity evidence), **dependency declaration** (the exact built runtime emitted the reviewed `process.versions` set), and **per-component assessment** (one result per bundled dependency, with exact identity binding and no forbidden ignored/blocking evidence). This prevents both "zero findings because zero relevant component was cataloged" and "clean Node package while a bundled dependency is unassessed" from being accepted as clean evidence. + +## Signature, attestation, and provenance decisions + +This PR workflow does not sign the repository-owned local image and does not claim provenance. A future main-only publication stage must push an exact registry digest, sign that digest, verify exact subject/issuer/repository/workflow identity, attach SBOM and provenance attestations to the same digest, and retain bounded verification receipts. + +SLSA 1.2 is used as the provenance vocabulary for that future stage. Neither a CodeRabbit status, model comment, smoke result, scanner result, trusted validator receipt, signature, nor attestation can substitute for an eligible independent GitHub approval or enforceable branch rules. + +## Stale-head threat model + +### Threat + +A workflow begins on head A, another writer pushes head B, and the older workflow later reports success. + +### Controls + +- exact event head captured as `SOURCE_SHA`; +- checkout by exact SHA without persisted credentials; +- clean-worktree verification; +- live PR-head lookup before verification; +- live PR-head lookup after verification; +- equality required both times; and +- concurrency cancellation only as an operational optimization. + +API unavailability, malformed output, permission failure, or a mismatched head fails closed. Manual dispatch proves only the selected ref and must not be represented as live-PR-head evidence. + +## Credential boundary + +The untrusted image receives no repository write credential, GitHub App key/token, `GITHUB_TOKEN`, reviewer/model credential, `NVIDIA_NIM_API_KEY`, package credential, OIDC token, release credential, deployment credential, or Docker socket. The workflow's read-only token is used only by trusted host steps to compare the live PR head and is not passed into the container. + +The scanner installation path also requires no repository write authority. It downloads versioned release assets into runner temporary storage and authenticates them against workflow-pinned checksum-manifest hashes. + +## Interoperability and modularity + +The image contract is repository-independent at the structured request/evidence boundary while Noema currently restricts the trusted repository and first profile. It preserves standalone Noema operation and modular integration with `ContextualWisdomLab/.github`, `naruon`, contextual-orchestrator, and other CWL services through explicit immutable evidence rather than shared mutable state. + +No database object is introduced by this slice. + +## Alternatives considered + +### Rely on Trivy alone after moving to `scratch` + +Rejected. Trivy explicitly documents that its OS-package scanner does not support third-party/self-compiled packages/binaries. An empty result would not prove the static Node runtime was inventoried. + +### Treat the Node CPE as sufficient for all statically bundled dependencies + +Rejected. A top-level Node package result does not independently demonstrate advisory coverage for each versioned dependency compiled into the runtime. The exact-image `process.versions` lane and one result per bundled dependency close that evidence gap more defensibly. + +### Fabricate package identities for `modules` or `napi` + +Rejected. These are ABI/compatibility level values, not ordinary dependency packages. They remain explicit reviewed runtime metadata and must not produce synthetic vulnerability identities. + +### Add an ignore/VEX/severity exception for the runtime + +Rejected. That would weaken the gate rather than improve evidence and could conceal a real acquisition-risk finding. + +### Trust scanner exit status without exact-image receipt binding + +Rejected. A successful tool invocation is not evidence that the intended image was scanned. Syft, Grype, and embedded-runtime receipts must bind to the trusted local image ID. + +### Use mutable scanner installer scripts + +Rejected for this gate. Versioned release assets are authenticated with workflow-pinned checksum-manifest hashes so the PR evidence does not depend solely on a mutable remote installer script. + +### Run validation directly on a credential-bearing host + +Rejected. Repository, model, publication, or deployment credentials would share a process/filesystem boundary with untrusted patch execution. + +### Allow caller-provided shell commands + +Rejected. Command injection and unconstrained tools would make the validation contract non-reviewable. + +### Publish from the pull-request workflow + +Rejected. PR-selected code and workflow changes must not receive package or OIDC publication authority. + +## Residual risks and open gates + +- PR #65 must merge before this stacked slice can be retargeted and revalidated. +- Issue #27 must establish enforceable `main` rules and independently reviewed break-glass controls. +- Issue #29 must provision separate Reviewer and Maintainer App identities. +- Issue #66 remains the main-only publication, signature, SBOM/provenance attestation, digest-lock activation, and end-to-end reviewer integration boundary. +- Production KPI, revenue, transfer, release, and deployment evidence remain separate acquisition-readiness gates. +- Scanner databases, hosted runners, Docker, GitHub release hosting, and upstream source infrastructure remain external dependencies. +- Static linking reduces runtime files and makes ordinary filesystem package inventory less granular; the exact-image dependency declarations and per-component scans reduce that blind spot but remain bounded by what Node reports and what vulnerability identities/databases can represent. + +## Verification record requirements + +Before describing an exact head as verified, retain or link: + +- exact PR head and base; +- terminal successful `ci`, `reviewer-ci`, and `patch-validator-image` runs; +- exact immutable workflow source; +- local image metadata and content ID; +- Trivy CycloneDX and vulnerability receipts; +- Syft native binary inventory proving Node 24.19.0 presence; +- Grype exact-image vulnerability receipt with no ignored/blocking findings; +- bounded exact-image `process.versions` record; +- reviewed embedded-runtime inventory with exact component-set equality; +- embedded-runtime CycloneDX inventory and one result per bundled dependency in the Grype receipt; +- trusted-host exact-bound smoke receipt; +- merged cross-receipt verification result; +- zero unresolved current review threads; and +- explicit independent approval and protected-merge evidence outside the scanner/validator plane. + +Queued, pending, skipped, cancelled, neutral, rate-limited, status-only, stale-head, or partially completed signals are not success. + +## References + +Anchore, Inc. (2026a). *Grype v0.116.1* [Software release]. GitHub. https://github.com/anchore/grype/releases/tag/v0.116.1 + +Anchore, Inc. (2026b). *Syft v1.50.0* [Software release]. GitHub. https://github.com/anchore/syft/releases/tag/v1.50.0 + +Anchore, Inc. (2026c). *Supported package ecosystems*. Grype documentation. https://oss.anchore.com/docs/guides/vulnerability/scanning/supported-ecosystems/ + +Anchore, Inc. (2026d). *Scan targets*. Grype documentation. https://oss.anchore.com/docs/guides/vulnerability/scanning/scan-targets/ + +Anchore, Inc. (2026e). *Syft: CLI tool and library for generating a software bill of materials from container images and filesystems*. GitHub. https://github.com/anchore/syft + +Aqua Security. (2026a). *Container image scanning*. Trivy. https://trivy.dev/latest/docs/target/container_image/ + +Aqua Security. (2026b). *Vulnerability scanning*. Trivy. https://trivy.dev/latest/docs/scanner/vulnerability/ + +CycloneDX. (2026). *CycloneDX JSON reference: Version 1.7*. https://cyclonedx.org/docs/1.7/json/ + +GitHub, Inc. (2026). *Using artifact attestations to establish provenance for builds*. GitHub Docs. https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations/using-artifact-attestations-to-establish-provenance-for-builds + +National Institute of Standards and Technology. (2017). *Application container security guide* (NIST Special Publication 800-190). U.S. Department of Commerce. https://doi.org/10.6028/NIST.SP.800-190 + +National Institute of Standards and Technology. (2022). *Secure software development framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). U.S. Department of Commerce. https://doi.org/10.6028/NIST.SP.800-218 + +Node.js. (2026). *Process: `process.versions` (Node.js v24.19.0 documentation)*. https://nodejs.org/download/release/v24.19.0/docs/api/process.html#processversions + +Open Container Initiative. (2024). *OCI image format specification* (Version 1.1.1). https://specs.opencontainers.org/image-spec/?v=v1.1.1 + +Open Container Initiative. (2025). *OCI runtime specification* (Version 1.3.0). https://specs.opencontainers.org/runtime-spec/?v=v1.3.0 + +Sigstore. (2026). *Verifying signatures with Cosign*. https://docs.sigstore.dev/cosign/verifying/verify/ + +Supply-chain Levels for Software Artifacts. (2025). *SLSA specification* (Version 1.2). https://slsa.dev/spec/v1.2/ diff --git a/docs/patch-validator-image.md b/docs/patch-validator-image.md new file mode 100644 index 000000000..11d8f881c --- /dev/null +++ b/docs/patch-validator-image.md @@ -0,0 +1,167 @@ +# Patch-validator image + +Noema's patch-validator image executes one fixed, credential-free validation profile against a source snapshot and patch that have already crossed the authenticated host boundary described in [`quarantined-patch-validation.md`](quarantined-patch-validation.md). + +Image execution and trusted-host receipts are **validation evidence only**. They cannot approve a pull request, replace independent review, satisfy branch protection, authorize merge, publish a release, or authorize deployment. + +## Current delivery scope + +The current slice verifies a locally built Linux/amd64 image from the exact pull-request head. It: + +- checks and re-checks the live exact PR head with read-only GitHub authority; +- builds with an immutable Dockerfile frontend and digest-pinned builders; +- compiles Node.js 24.19.0 from the official source tarball after fixed SHA-256 authentication; +- links Node fully statically and copies it into a `scratch` final image; +- excludes a shell, package manager, native addon, shared library, dynamic interpreter, and dynamic `NEEDED` dependency from the final runtime; +- runs as numeric user/group `65532:65532` with a fixed exec-form entrypoint; +- executes a real no-network, read-only, capability-dropped, non-root smoke validation; +- keeps the image's private result inside container tmpfs and treats container output as untrusted; +- synthesizes the exact-bound smoke receipt on the trusted host only after a zero container exit; +- generates a Trivy CycloneDX image SBOM and image vulnerability receipt; +- inventories the self-compiled Node executable with checksum-pinned Syft 1.50.0; +- scans the same exact local image with checksum-pinned Grype 0.116.1; +- captures the exact image's Node `process.versions` record and requires an exact reviewed embedded-component set; +- treats only `modules` and `napi` as reviewed ABI/runtime metadata instead of fabricating package identities for those counters; +- requires every other reviewed component to have an exact supported npm PURL or reviewed application CPE; +- retains raw per-component Grype JSON for every reviewed embedded dependency rather than inventing a local completion object; +- requires one canonical vulnerability-database/provider snapshot across those per-component scans; +- binds every vulnerability match artifact back to the exact reviewed component identity; +- fails closed on unsupported, partial, wildcard, substituted, omitted, ambiguous, or unmapped component identities; +- rejects ignored findings and blocks MEDIUM, HIGH, CRITICAL, or UNKNOWN severity findings; +- applies an explicit reviewed fixed-version floor when scanner-negative evidence is known to be insufficient for a component; +- cross-binds image metadata, smoke, SBOM, Trivy, Syft, Grype, embedded-runtime inventory, and embedded-runtime scan evidence to the same local SHA-256 image identity; and +- refuses a stale pull-request head after verification as well as before it. + +The additional Syft/Grype evidence is required because a package-oriented scanner cannot by itself prove complete vulnerability assessment of a self-compiled, fully static Node executable. Likewise, a clean Node-level CPE result is not accepted as complete evidence for bundled native dependencies. If a reviewed `process.versions` entry cannot be represented or matched reliably, the verifier fails closed instead of omitting it or treating scanner silence as proof of absence. + +This slice does **not** publish the image to GHCR, sign a repository-owned registry digest, create SLSA provenance, attach registry attestations, activate a digest in the reviewer decision flow, or grant release or deployment authority. + +## Architecture and authority separation + +```mermaid +flowchart LR + A[Reviewed exact PR head] --> B[Read-only deterministic image build] + B --> C[Local immutable image ID] + C --> D[Structure and static-link verification] + C --> E[Trivy package and JS scan] + C --> F[Syft static-binary inventory] + C --> G[Grype static-binary vulnerability scan] + C --> H[Exact-image process.versions inventory] + H --> I[Raw per-component Grype evidence] + D --> J[No-network patch smoke] + E --> K[Trusted host receipt verifier] + F --> K + G --> K + H --> K + I --> K + J --> K + K --> L[Exact-image bounded evidence] + + L -. evidence only .-> M[Review evidence] + M -. cannot replace .-> N[Independent GitHub approval] + N --> O[Protected merge] + + C -. future separate gate .-> P[Main-only registry publication] + P --> Q[Signature, SBOM and provenance attestations] + Q --> R[Reviewed digest-lock activation] + R -. evidence only .-> M + + O -. separate authority .-> S[Release acceptance] + S -. separate authority .-> T[Protected deployment] +``` + +The following authorities remain separate: source authentication, image build, untrusted validation execution, trusted receipt synthesis, vulnerability evidence, model judgement, GitHub review publication, independent approval, protected merge, registry publication/provenance, release acceptance, and production deployment. Success in one plane is not evidence that another plane passed. + +## Image identity and runtime contents + +`Dockerfile.patch-validator` pins its Dockerfile frontend and non-final builders by SHA-256 digest. The Node builder downloads the official Node.js 24.19.0 source tarball with a fixed SHA-256 and compiles it with the fully-static configuration. The final stage is `scratch`; it does not inherit a distribution runtime or package database. + +The static Node package note carries the reviewed Node.js application CPE and metadata. It deliberately does not claim a `pkg:generic` identity. Generic package URLs are not accepted as evidence that the configured vulnerability matcher can identify a component. + +The PR workflow records Docker's local content identity as `sha256:<64 lowercase hexadecimal characters>`. That identity is adequate for exact-run PR verification, but it is **not** a published registry digest and is not a substitute for a signed registry subject or provenance attestation. + +The final runtime contains the fully static Node executable, lockfile-resolved image-owned TypeScript/Vitest/coverage modules, and validator runtime files under `/opt/noema`. Static and runtime checks reject dynamic libraries, native addons, shells, and package managers. + +The image carries OCI source, revision, title, description, and documentation labels. It intentionally emits **no `org.opencontainers.image.licenses` label while Noema has no approved outbound-rights declaration and `package.json` has no license field**. Repository visibility, `private: true`, or an invented `LicenseRef-*` value is not legal authority. An owner/legal licensing decision must be captured through the repository-wide licensing/IP evidence contract before an OCI license claim is added. The trusted verifier still requires the repository source and revision to match the reviewed exact head. + +## Fixed validation profile + +| Field | Value | +|---|---| +| profile | `node_patch_verify` | +| command profile | `node_patch_verify_v1` | +| arbitrary caller command | forbidden | + +The profile accepts ordinary UTF-8 text creation, modification, and deletion for regular `100644` and `100755` files. It rejects dependency and lockfile changes, Node/Vitest configuration, reviewer and validator controls, Dockerfiles, GitHub workflows, rename/copy operations, standalone mode changes, symlink and gitlink modes, binary payloads, noncanonical paths, malformed patch metadata, and request/image identity mismatches. + +The host implementation and image runtime exercise the same realistic create/delete and forbidden-mode corpus so an operation advertised by one boundary cannot silently become broader or unreachable in the other. + +## Container isolation + +The real smoke uses `--pull=never`, `--network=none`, `--read-only`, all capabilities dropped, `no-new-privileges`, Docker's built-in seccomp profile, isolated IPC, bounded PIDs/CPU/memory/swap/descriptors/processes/core/file size/tmpfs, numeric non-root execution, read-only source and patch mounts, private writable tmpfs, no host-writable result mount, and no Docker socket. + +The untrusted container receives no GitHub App token, `GITHUB_TOKEN`, reviewer/model credential, `NVIDIA_NIM_API_KEY`, Cloudflare credential, OIDC publication token, package credential, release credential, or deployment credential. + +Container stdout, stderr, and private result contents are not trusted as identity evidence. After a zero exit, trusted host code constructs the retained smoke receipt from exact workflow inputs and image identity. Any non-zero exit fails the smoke step. + +## Static-runtime vulnerability boundary + +Trivy covers the ordinary image/package and JavaScript dependency surface. Syft separately proves that the self-compiled executable is catalogued as Node 24.19.0 at `/nodejs/bin/node` with the reviewed Node.js CPE. Grype separately scans that exact local image. The trusted verifier binds scanner descriptors, source targets, image identity, match structure, ignored-match policy, and severity policy. + +A zero match count is not independently interpreted as proof that every static dependency was evaluated. The embedded-runtime boundary below exists specifically to avoid that inference. + +## Embedded-runtime component evidence + +The workflow runs the exact built Node executable and records `process.versions`. The trusted verifier requires `node` to equal `24.19.0`. It accepts `modules` and `napi` only as reviewed runtime metadata with explicit reasons; every other key must be represented by the reviewed component-identity catalog. + +For each reviewed embedded dependency, the catalog binds the exact `process.versions` key, inventory name, version, and either a supported npm PURL package identity or a reviewed application CPE vendor/product identity. Wildcards, placeholders, partial identities, arbitrary aliases, version substitution, package substitution, and unknown components fail closed. + +The workflow invokes Grype directly for each exact reviewed identity and retains the **raw per-component Grype** JSON. The verifier does not synthesize a local assessment object merely because Grype exited zero. For every component it verifies: + +- scanner name and exact version; +- exact scanner source type and target identity; +- canonical vulnerability-database status and provider metadata; +- one identical canonical database/provider snapshot across all component scans; +- raw match structure; +- exact reviewed PURL or CPE binding on each match artifact; +- reviewed name/version agreement where those fields are present; +- no ignored-match or VEX shortcut; and +- the blocking severity policy. + +When an exact supported scanner identity cannot be established for a component, that component remains an explicit release blocker. Scanner silence is not converted into a supported/clean claim. A reviewed component-specific fixed-version floor can add a stricter fail-closed condition; it cannot downgrade severity or ignore a scanner finding. + +## Exact-head refusal + +For pull-request events the workflow captures `github.event.pull_request.head.sha` and the PR number, checks out that exact SHA without persisted credentials, verifies a clean worktree, asks the GitHub API for the current live head, and requires equality. It repeats live-head, checkout, and worktree equality after verification. + +A concurrent push therefore invalidates the older run. Concurrency cancellation is only an optimization; explicit live-head equality is the security control. + +## Evidence files + +The workflow retains bounded evidence under `patch-validator-image-verification-` for 90 days. Expected files include: + +| Evidence | Meaning | +|---|---| +| `image-inspect.json` | raw local image inspection used to derive bounded metadata | +| `image-metadata.json` | selected exact source, local digest, platform, user, entrypoint, and OCI labels | +| `smoke-result.json` | trusted-host receipt synthesized after a zero container exit | +| `image-sbom.cdx.json` | Trivy CycloneDX image inventory | +| `image-vulnerability-scan.json` | Trivy image/package vulnerability receipt | +| `image-binary-sbom.syft.json` | Syft native inventory proving the self-compiled Node executable was classified | +| `image-binary-vulnerability-scan.json` | Grype vulnerability receipt for the exact local image | +| `embedded-runtime-process-versions.json` | bounded exact-image `process.versions` source record | +| `embedded-runtime-inventory.json` | reviewed exact component set derived from `process.versions` | +| `embedded-runtime-vulnerability-scan.json` | exact-image-bound retained raw per-component Grype evidence and trusted verification summary | +| `image-verification.json` | merged exact-image cross-receipt verification result | + +Artifact retention does not make evidence authoritative by itself. Consumers must bind the artifact to the repository, workflow run, exact source SHA, exact workflow source, and terminal successful check run. + +## Operations + +Expected successful checks for this stacked slice are root `ci`, `reviewer-ci`, and `patch-validator-image`. Queued, pending, skipped, cancelled, neutral, stale-head, or failed runs are not success. + +Failure handling is: identify the exact head and exact workflow run; separate build, static-link, smoke, Trivy, Syft, Grype, embedded-runtime inventory/scan, receipt, and stale-head failures; reproduce the smallest failing contract test; preserve a RED regression before production changes; change only the failing boundary; rerun all exact-head checks; and resolve review feedback only after its addressed exact head passes the relevant gates. + +## Scientific and standards rationale + +The implementation rationale and APA 7th references are maintained in the doctoring documents associated with this image and its embedded-runtime assessment. Public operations text intentionally describes only controls that current repository code and retained evidence can prove. Publication, signature, provenance, independent approval, protected merge, release acceptance, and deployment remain separate gates. \ No newline at end of file diff --git a/docs/superpowers/plans/2026-08-06-patch-validator-image-supply-chain.md b/docs/superpowers/plans/2026-08-06-patch-validator-image-supply-chain.md new file mode 100644 index 000000000..3dfeb80d8 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-patch-validator-image-supply-chain.md @@ -0,0 +1,143 @@ +# Patch-Validator Image Supply-Chain Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement and verify this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build, scan, sign, attest, smoke-test, and prepare activation of a repository-owned, credential-free patch-validator image whose exact digest is bound to validation evidence. + +**Architecture:** Extend the PR #65 host contract with a fixed `node_patch_verify` profile and image-digest result binding. A shell-free Node entrypoint performs a second strict unified-diff parse, copies the authenticated source into tmpfs, applies only ordinary text creation/modification/deletion, executes lock-pinned TypeScript and Vitest modules with bounded subprocesses, and writes one bounded JSON result. A digest-pinned multi-stage Docker build produces a non-root Distroless image. A read-only PR workflow builds, scans, and smoke-tests; a main-only workflow publishes, signs, and creates CycloneDX/SLSA attestations. Activation remains a separate reviewed digest-lock step. + +**Tech Stack:** Python 3.11, Pydantic 2, Node.js 24, ECMAScript modules, TypeScript, Vitest, Docker/BuildKit, Distroless Node 24, Trivy 0.73, Cosign 4.1.2, GitHub Actions artifact attestations, CycloneDX 1.7, SLSA 1.2. + +## Global constraints + +- Keep this branch stacked on PR #65 until the host patch-validation contract lands. +- Never add a pull-request workflow with package, OIDC, attestation, reviewer, model, publication, release, or deployment credentials. +- Never pass GitHub, NVIDIA NIM, Cloudflare, OIDC, package, publication, deployment, or Docker-socket credentials into untrusted execution. +- Pin every base image and GitHub Action by immutable digest or full commit SHA. +- Do not use `COPILOT_GITHUB_TOKEN`, temporary repair workflows, self-modifying Actions, branch patchers, skips, ignores, or test-name bypasses. +- Maintain 100% production statement and branch coverage and 100% public Python docstrings. +- Treat tags, predecessor digests, queued checks, commit statuses, and unsigned local images as non-evidence. +- Keep issue #27 governance, issue #29 App provisioning, and issue #40 production environment as explicit external gates. + +--- + +### Task 1: Exact profile and image-digest evidence contract + +**Files:** +- Modify: `reviewer/noema_reviewer/patch_validation.py` +- Modify: `reviewer/noema_reviewer/__init__.py` +- Modify/Create: focused reviewer tests + +- [ ] Add `PatchValidationProfile.NODE_PATCH_VERIFY`. +- [ ] Replace caller-visible shell text with a fixed command-profile identifier owned by the image. +- [ ] Add exact immutable `validator_image_digest` to `PatchValidationResult`. +- [ ] Bind the result digest to the exact `NOEMA_PATCH_SANDBOX_IMAGE` reference. +- [ ] Add profile-specific forbidden control paths: package/lock/config, reviewer, patch-validator, workflow/action, and governance files. +- [ ] Reject unsupported rename/copy/mode-only patch metadata for the first image profile. +- [ ] Add RED tests first for wrong/missing image digest, control-path mutation, unsupported metadata, and ordinary source/test changes. +- [ ] Restore all-pass and 100% reviewer statement/branch/docstring coverage. + +### Task 2: Shell-free validator runtime + +**Files:** +- Create: `patch-validator/validate-patch.mjs` +- Create: `test/patch-validator-runtime.test.ts` +- Modify: `vitest.config.ts` + +- [ ] Export pure helpers for environment parsing, path validation, patch parsing, source copying, hunk application, subprocess execution, bounded capture, result creation, and atomic result writes. +- [ ] Revalidate UTF-8, canonical paths, complete primary headers, exact hunk counts, context equality, `/dev/null`, final-newline markers, changed-file count, and byte ceilings. +- [ ] Support ordinary canonical text file creation, modification, and deletion only. +- [ ] Reject binary, rename, copy, mode, symlink, gitlink, dependency, validator, config, and governance mutations. +- [ ] Copy only regular non-symlink source files into private workspace with member, per-file, and aggregate quotas. +- [ ] Remove the empty `.git` placeholder from the copied workspace before tool execution. +- [ ] Run TypeScript and Vitest through `process.execPath`, fixed absolute module paths, `shell: false`, minimal environment, bounded output, and deadlines. +- [ ] Write only exact-request-bound, exact-image-bound JSON to the pre-created result path. +- [ ] Add realistic tests for modifications, creation, deletion, multiple hunks, no-final-newline, malformed/truncated/context-mismatch patches, hostile paths, unsupported metadata, file-system objects, quotas, child failures, timeout, output overflow, and atomic result behavior. +- [ ] Include runtime production code in the root 100% coverage gate. + +### Task 3: Digest-pinned image + +**Files:** +- Create: `Dockerfile.patch-validator` +- Create: `.dockerignore.patch-validator` +- Create: `test/patch-validator-image-contract.test.ts` + +- [ ] Pin Node 24.16.0 Bookworm Slim builder by digest. +- [ ] Pin the signed Distroless Node 24 Debian 13 runtime by digest. +- [ ] Install the lockfile with scripts, audit, and funding calls disabled. +- [ ] Verify required TypeScript, Vitest, and coverage module files during build. +- [ ] Copy only lock-pinned Node modules and the image-owned entrypoint to the runtime. +- [ ] Set numeric non-root user, absolute workdir, and exec-form entrypoint. +- [ ] Add OCI labels for source repository, revision build argument, license, title, description, and documentation. +- [ ] Ensure final image contains no shell, npm, npx, Git client, package manager, source checkout, credential, or Docker socket. +- [ ] Add static contract tests for digest pins, user, entrypoint, copied paths, no mutable tags, no package install in the final stage, and no secret-bearing ARG/ENV. + +### Task 4: Real PR image verification + +**Files:** +- Create: `.github/workflows/patch-validator-image.yml` +- Create: `scripts/verify-patch-validator-image.mjs` +- Create: `test/patch-validator-workflow.test.ts` +- Modify: `package.json` + +- [ ] Trigger on pull requests touching the runtime, Dockerfile, lockfile, host contract, tests, or workflow; also trigger on pushes to `main` for publication. +- [ ] Split read-only PR verification from main-only publication into separate jobs with explicit conditions and least permissions. +- [ ] Checkout without persisted credentials. +- [ ] Install Cosign and Trivy through full-SHA-pinned official actions. +- [ ] Verify the Distroless base signature before building. +- [ ] Build Linux/amd64 image with exact source-revision label and no secrets. +- [ ] Inspect numeric user, entrypoint, architecture, labels, and absence of shell/package managers. +- [ ] Run a real hardened smoke with no network, read-only root, all capabilities dropped, no-new-privileges, seccomp, isolated IPC, bounded resources, read-only source/patch mounts, and one writable result file. +- [ ] Validate the smoke result against exact request/profile/image identity. +- [ ] Generate CycloneDX JSON with Trivy and validate schema/version/subject identity and bounded size. +- [ ] Fail on detected MEDIUM, HIGH, or CRITICAL final-image vulnerabilities without a committed exception. +- [ ] Upload bounded SBOM, scan, metadata, and smoke receipts with pinned artifact actions. +- [ ] Add workflow and verifier tests for events, permissions, pins, PR no-publish contract, scan policy, smoke flags, and receipts. + +### Task 5: Main-only publication, signature, and attestations + +**Files:** +- Modify: `.github/workflows/patch-validator-image.yml` +- Modify: `test/patch-validator-workflow.test.ts` + +- [ ] Grant `packages: write`, `id-token: write`, `attestations: write`, and `artifact-metadata: write` only to the main-only publication job. +- [ ] Authenticate to GHCR using only the main job's scoped `GITHUB_TOKEN`. +- [ ] Push a commit-addressed tag and resolve the immutable registry digest. +- [ ] Re-scan and re-smoke the pushed digest. +- [ ] Sign the exact digest keylessly with Cosign and verify exact workflow identity and GitHub Actions issuer. +- [ ] Use full-SHA-pinned `actions/attest` to create SLSA build provenance and CycloneDX SBOM attestations, pushing registry attestations for the exact digest. +- [ ] Generate a bounded publication receipt containing source SHA, image/base/SBOM digests, signature identity, attestation references, scan result, and smoke result. +- [ ] Do not mutate repository files, variables, environments, branch protection, reviewer configuration, or deployment state. + +### Task 6: Documentation, doctoring, and change traceability + +**Files:** +- Create: `docs/patch-validator-image.md` +- Create: `docs/doctoring/patch-validator-image.md` +- Modify: `docs/quarantined-patch-validation.md` +- Modify: `docs/doctoring/quarantined-patch-validation.md` +- Modify: `ARCHITECTURE.md` +- Modify: `CHANGELOG.md` +- Modify: `README.md` + +- [ ] Document build, PR verification, main publication, digest lock, activation, rotation, rollback, incident response, and evidence interpretation. +- [ ] Document the intentionally restricted first patch language and dependency-change workflow. +- [ ] Add an architecture diagram showing source review, image build/verification, untrusted execution, model judgement, publication, merge, release, and deployment authorities. +- [ ] Record stable CycloneDX 1.7 selection and why SPDX 3.1/ISO edition 2 are not yet the publication baseline. +- [ ] Add APA 7th references for OCI Image 1.1.1, OCI Runtime 1.3.0, SLSA 1.2, NIST SP 800-190, NIST SP 800-218, GitHub attestations, Sigstore/Cosign, Trivy, and CycloneDX. +- [ ] Update `CHANGELOG.md` only after the implementation and exact CI evidence are truthful. +- [ ] Do not claim release, activation, SLSA level, multi-architecture parity, or production readiness that has not been verified. + +### Task 7: Verification and stacked integration + +- [ ] Run exact root typecheck and 100% Vitest coverage. +- [ ] Run exact reviewer tests and 100% docstring gate. +- [ ] Run `npm audit --audit-level=high` outside the untrusted runtime. +- [ ] Run Dockerfile static contract and workflow contract tests. +- [ ] Run actual image build, structure inspection, zero-medium/high/critical Trivy gate, CycloneDX generation, and hardened smoke. +- [ ] Confirm `ci`, `reviewer-ci`, `Security Scan`, and `patch-validator-image` all succeed on the same exact head. +- [ ] Request fresh CodeRabbit, OpenCode, and Noema exact-head review. +- [ ] Address every current finding test-first and resolve all threads. +- [ ] Keep the PR draft and do not enable auto-merge while base PR #65, independent approval, or issue #27 governance remains unresolved. +- [ ] After PR #65 merges, update or retarget this stacked PR without losing exact-head verification. +- [ ] After this slice merges and publishes an attested digest, create a separate digest-lock activation PR and verify the reviewer decision flow end to end. diff --git a/docs/superpowers/specs/2026-08-06-patch-validator-image-supply-chain-design.md b/docs/superpowers/specs/2026-08-06-patch-validator-image-supply-chain-design.md new file mode 100644 index 000000000..0cdbd653a --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-patch-validator-image-supply-chain-design.md @@ -0,0 +1,207 @@ +# Patch-Validator Image Supply-Chain Design + +## Status + +Approved for autonomous implementation as issue #66's first executable slice. This branch is stacked on the exact head of PR #65. It must remain a draft until the image runtime, build gate, security gate, documentation, and exact-head checks are complete. Publication and reviewer-flow activation require PR #65 and this slice to land on protected `main`; they never justify bypassing issues #27 or #29. + +## Buyer-visible gap + +PR #65 establishes a credential-free host boundary for validating a text patch against an authenticated Git tree, but the configured image reference does not yet correspond to a repository-owned, reproducibly described validator image. A buyer cannot independently answer which code ran, which dependencies were present, whether the image was scanned and signed, whether the supplied patch could weaken its own validator, or whether the result was produced by the exact digest recorded in review evidence. + +## Design decision + +Build a dedicated Linux/amd64 patch-validator image at: + +`ghcr.io/contextualwisdomlab/noema-patch-validator` + +The image uses a digest-pinned Node 24 builder and a digest-pinned, shell-free Distroless Node 24 runtime. The builder installs the repository's reviewed lockfile with scripts disabled. The final image receives only the image-owned validator entrypoint and the lock-pinned Node modules needed for fixed typecheck and test commands; it receives no package manager, shell, Git client, repository source, credentials, or Docker socket. + +The first supported validation profile is `node_patch_verify`. It runs image-owned commands rather than a caller-controlled `package.json` script: + +1. copy the authenticated `/input` snapshot into private tmpfs; +2. parse and apply the already host-preflighted text patch again with an image-owned strict unified-diff state machine; +3. run the lock-pinned TypeScript compiler through the image's Node runtime; +4. run lock-pinned Vitest with the reviewed coverage configuration through the image's Node runtime; +5. emit one bounded structured result to `/output/result.json`. + +The profile refuses changes to validator-control and dependency-control paths, including `package.json`, `package-lock.json`, `tsconfig.json`, `vitest.config.ts`, `reviewer/`, `patch-validator/`, and every GitHub workflow/action path. Dependency changes require a separately reviewed image rebuild; a patch being tested cannot install a package, alter the test command, weaken coverage, or replace the validator. + +## Trust and authority separation + +The system preserves these separate authorities: + +- **source review:** decides whether the exact Git revision is acceptable; +- **image build:** creates a digest from reviewed source and lockfiles; +- **image verification:** verifies base identity, final digest, vulnerability results, SBOM, signature, and provenance; +- **patch execution:** runs untrusted source and patch bytes without credentials or network; +- **model judgement:** consumes bounded evidence only; +- **GitHub review publication:** uses the Reviewer App after execution has completed; +- **merge, release, and deployment:** remain protected repository decisions. + +A successful image workflow is not approval. A signed image is not a merge authorization. The image digest recorded in patch-validation evidence must equal the digest selected by the trusted caller; tags and predecessor digests are not admissible evidence. + +## Image construction + +### Builder + +The builder is pinned to the multi-platform index for Node.js 24.16.0 Bookworm Slim: + +`node:24.16.0-bookworm-slim@sha256:2c87ef9bd3c6a3bd4b472b4bec2ce9d16354b0c574f736c476489d09f560a203` + +The builder: + +- copies only `package.json` and `package-lock.json` before dependency installation; +- runs `npm ci --ignore-scripts --no-audit --no-fund`; +- verifies the expected TypeScript, Vitest, and coverage-provider executables exist; +- never receives a registry credential, GitHub token, OIDC token, model credential, or build secret; +- does not execute repository lifecycle scripts. + +Builder packages do not enter the final image except the reviewed Node dependency graph. Builder vulnerabilities are recorded in provenance but the publication gate scans the final image independently. + +### Runtime + +The runtime is pinned to the exact signed Distroless Node 24 Debian 13 digest already exercised by reviewer CI: + +`gcr.io/distroless/nodejs24-debian13@sha256:fbbdda866ea71aef98c4abece17e3d61fbf820cc2ef3961522caa2478716171a` + +The runtime: + +- declares numeric non-root user `65532:65532`; +- uses an exec-form image-owned entrypoint; +- contains no shell or package manager; +- writes only to caller-supplied tmpfs and the single pre-created result file; +- reads `/input` and `/patch/input.patch` only; +- uses no network and no Docker socket at execution time; +- accepts no arbitrary command, script path, package manager option, or validator configuration from the request. + +The first release is explicitly Linux/amd64. An arm64 digest requires its own real build, scan, smoke, signature, SBOM, provenance, parity evidence, and activation decision. + +## Runtime patch contract + +The image-owned patch applier supports ordinary canonical text modifications, creations, and deletions. It revalidates: + +- strict UTF-8 and a bounded patch size; +- canonical repository-relative paths; +- one `diff --git` section per target path; +- complete `---` and `+++` identity; +- exact unified-hunk counts; +- context and removed-line equality against the authenticated snapshot; +- canonical `/dev/null` creation or deletion; +- final-newline markers; +- bounded changed-file and output sizes. + +The first profile rejects rename, copy, binary, symlink, gitlink, mode-only, dependency, governance, validator, and configuration changes. Expanding this language requires new failing tests, a profile version change, documentation, and a new image digest. + +No arbitrary JavaScript is accepted as a tool. The validator executes only its own entrypoint and two fixed Node module paths. Child processes use `process.execPath`, `shell: false`, a minimal environment, explicit working directory, bounded output, and command deadlines. + +## Result contract + +The result repeats: + +- repository full name; +- base SHA; +- head SHA; +- patch SHA-256; +- validation profile; +- fixed command-profile identifier; +- validator image digest; +- terminal status and exit code; +- bounded duration, excerpts, and reason codes. + +The host must reject a result whose image digest differs from the exact `NOEMA_PATCH_SANDBOX_IMAGE` reference. This prevents valid-looking evidence from one image being replayed as evidence for another. + +## Pull-request verification workflow + +Pull requests receive a read-only `patch-validator-image` workflow with no package, attestation, OIDC, reviewer, model, publication, or deployment credential. It must: + +1. check out the exact PR revision without persisted credentials; +2. verify both base references are digest-pinned and the runtime base's Distroless keyless signature is valid; +3. run root and reviewer tests through existing mandatory workflows; +4. build the exact Dockerfile for Linux/amd64; +5. inspect the image for numeric non-root user, exact entrypoint, and absence of shell/package-manager executables; +6. run a real no-network, read-only-root, capability-dropped smoke test that applies a small patch and produces request-bound JSON; +7. generate a CycloneDX 1.7-compatible JSON SBOM with Trivy; +8. fail on any detected MEDIUM, HIGH, or CRITICAL vulnerability in the final image unless a time-bounded reviewed exception is committed; +9. upload only bounded non-secret scan, SBOM, image-metadata, and smoke receipts. + +The pull-request workflow never pushes an image and never signs or attests an unmerged revision. + +## Main publication workflow + +After protected `main` contains the implementation, the same Dockerfile is rebuilt from exact `main` source and pushed under a commit-addressed tag. The trusted main-only publication job: + +- has `contents: read`, `packages: write`, `id-token: write`, `attestations: write`, and `artifact-metadata: write` only; +- uses no pull-request code path and no caller-selected ref; +- rescans the pushed digest, reruns the real no-network smoke, and verifies registry identity; +- generates and attaches CycloneDX SBOM and SLSA v1.2 build-provenance attestations through the pinned `actions/attest` action; +- signs the immutable image digest keylessly with Cosign and verifies the exact workflow identity and GitHub Actions OIDC issuer; +- records source SHA, image digest, base digests, SBOM digest, attestation references, scan receipt, and smoke receipt. + +The workflow does not update repository variables, branch files, or reviewer configuration. Activation is a separate, reviewable digest-lock change after publication evidence exists. + +## Activation + +Reviewer-flow activation must use a committed digest lock or protected repository/environment configuration that names exactly: + +`ghcr.io/contextualwisdomlab/noema-patch-validator@sha256:<64 lowercase hexadecimal characters>` + +Before a trusted workflow passes that digest to `DockerPatchValidationRunner`, it must verify: + +- registry/repository identity; +- Cosign certificate identity and issuer; +- GitHub artifact provenance for this repository and workflow; +- SBOM attestation; +- configured vulnerability policy; +- real no-network smoke evidence; +- exact profile compatibility. + +No fallback tag, latest digest, unsigned local image, predecessor digest, or status-only signal may activate the runner. Activation does not grant the image GitHub, model, OIDC, Cloudflare, package, publication, release, or deployment credentials. + +## Testing and coverage + +Production statement and branch coverage and public API documentation remain 100 percent. Tests cover: + +- allowed text modifications, creation, and deletion; +- malformed and truncated patches; +- context mismatch, duplicate path, traversal, absolute path, and governance path; +- unsupported rename, copy, mode, binary, dependency, and validator changes; +- final-newline behavior and multiple hunks; +- source-copy symlink, special-file, count, per-file, and aggregate limits; +- child launch, timeout, nonzero exit, output overflow, and signal cleanup; +- exact request, profile, command, image-digest, and result rebinding; +- Dockerfile digest pins, numeric user, entrypoint, and no package manager/shell; +- workflow event, permission, action-pin, no-PR-publish, scan, signing, attestation, and smoke contracts; +- actual image build, scan, and real hardened smoke in GitHub Actions. + +## Non-goals + +- This slice does not weaken or satisfy issue #27's repository-governance controls. +- It does not provision the Reviewer or Maintainer Apps tracked by issue #29. +- It does not grant the image network access or dependency installation. +- It does not validate arbitrary ecosystems or arbitrary commands. +- It does not claim multi-architecture parity. +- It does not fabricate production, customer, revenue, acquisition, or deployment evidence. + +## Standards rationale + +OCI Image Specification 1.1.1 defines the portable image format, while OCI Runtime Specification 1.3.0 defines the runtime filesystem, process, namespace, mount, and resource model. SLSA 1.2 distinguishes source provenance from build provenance and requires consumers to verify expected properties rather than treating provenance existence as trust by itself. NIST SP 800-190 emphasizes trusted images, registry controls, vulnerability management, least privilege, isolation, and resource controls. NIST SP 800-218 SSDF 1.1 requires protecting software components, producing release integrity evidence, and responding to vulnerabilities. CycloneDX 1.7 is the current stable CycloneDX specification; SPDX 3.1 remains a release candidate and ISO/IEC 5962 edition 2 remains under development, so this slice uses stable CycloneDX JSON for the publication SBOM. + +## Authoritative references — APA 7th + +GitHub. (2026). *actions/attest*. https://github.com/actions/attest + +GitHub. (2026). *Using artifact attestations to establish provenance for builds*. https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations + +National Institute of Standards and Technology. (2017). *Application container security guide* (NIST SP 800-190). https://doi.org/10.6028/NIST.SP.800-190 + +National Institute of Standards and Technology. (2022). *Secure software development framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST SP 800-218). https://doi.org/10.6028/NIST.SP.800-218 + +Open Container Initiative. (2025, April 2). *OCI image-spec v1.1.1 release notice*. https://opencontainers.org/release-notices/v1-1-1-image-spec/ + +Open Container Initiative. (2025, November 4). *OCI runtime-spec v1.3.0 release notice*. https://opencontainers.org/release-notices/v1-3-0-runtime-spec/ + +OWASP Foundation. (2025, October 21). *CycloneDX specification overview* (Version 1.7). https://cyclonedx.org/specification/overview/ + +SLSA Community. (2025). *SLSA specification* (Version 1.2). https://slsa.dev/spec/v1.2/ + +Sigstore. (2026). *Verifying signatures with Cosign*. https://docs.sigstore.dev/cosign/verifying/verify/ diff --git a/package.json b/package.json index 8ae030a15..2c4e5912b 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "operations:preflight": "node scripts/maintainer-app-readiness.mjs", "production:governance": "node scripts/production-environment-governance-audit.mjs", "production:preflight": "node scripts/production-evidence-preflight.mjs", + "patch-validator:image:verify-receipts": "node scripts/verify-patch-validator-image.mjs", "acquisition:deployment-evidence": "node scripts/acquisition-deployment-evidence-audit.mjs", "acquisition:audit": "node scripts/acquisition-readiness-audit.mjs && npm run acquisition:deployment-evidence", "acquisition:manifest": "node scripts/acquisition-data-room-manifest.mjs", diff --git a/patch-validator/entrypoint.mjs b/patch-validator/entrypoint.mjs new file mode 100644 index 000000000..b9a499439 --- /dev/null +++ b/patch-validator/entrypoint.mjs @@ -0,0 +1,18 @@ +export function buildFailureDiagnostic(result) { + return { + trusted: false, + status: result.status, + exit_code: result.exit_code, + stderr_excerpt: result.stderr_excerpt, + reason_codes: [...result.reason_codes], + }; +} + +export function runEntrypoint({ runCliImpl, writeDiagnostic }) { + const result = runCliImpl(); + if (result.status === "passed") { + return 0; + } + writeDiagnostic(`${JSON.stringify(buildFailureDiagnostic(result))}\n`); + return result.exit_code || 1; +} diff --git a/patch-validator/runtime.mjs b/patch-validator/runtime.mjs new file mode 100644 index 000000000..77edce78e --- /dev/null +++ b/patch-validator/runtime.mjs @@ -0,0 +1,99 @@ +import { O_CREAT, O_EXCL, O_WRONLY } from "node:constants"; +import { spawnSync } from "node:child_process"; +import { + closeSync, + existsSync, + mkdirSync, + openSync, +} from "node:fs"; +import { dirname } from "node:path"; + +import { + runCli as runCoreCli, + runValidationCommands as runCoreValidationCommands, +} from "./validate-patch.mjs"; + +export * from "./validate-patch.mjs"; + +const TYPESCRIPT_MODULE = "/opt/noema/node_modules/typescript/bin/tsc"; +const VITEST_MODULE = "/opt/noema/node_modules/vitest/vitest.mjs"; +const TRUSTED_TYPESCRIPT_CONFIG = "/opt/noema/validator-tsconfig.json"; +const TRUSTED_VITEST_CONFIG = "/opt/noema/validator-vitest.config.mjs"; + +function addRunnerConfigLoader(argumentsList) { + const configIndex = argumentsList.indexOf("--config"); + if (configIndex < 0) { + return argumentsList; + } + return [ + ...argumentsList.slice(0, configIndex), + "--configLoader", + "runner", + ...argumentsList.slice(configIndex), + ]; +} + +function imageOwnedValidationArguments(argumentsList, options) { + const modulePath = argumentsList[0]; + if (modulePath === TYPESCRIPT_MODULE) { + return [ + modulePath, + "--noEmit", + "--project", + TRUSTED_TYPESCRIPT_CONFIG, + ]; + } + if (modulePath === VITEST_MODULE) { + return [ + modulePath, + "run", + "--coverage", + "--root", + options.cwd, + "--configLoader", + "runner", + "--config", + TRUSTED_VITEST_CONFIG, + ]; + } + return addRunnerConfigLoader(argumentsList); +} + +function isolateReadOnlyViteConfiguration(spawnSyncImpl) { + return (command, argumentsList, options) => + spawnSyncImpl( + command, + imageOwnedValidationArguments(argumentsList, options), + options, + ); +} + +function ensurePrivateResultFile(resultPath) { + if (existsSync(resultPath)) { + return; + } + mkdirSync(dirname(resultPath), { recursive: true, mode: 0o700 }); + const descriptor = openSync(resultPath, O_CREAT | O_EXCL | O_WRONLY, 0o600); + closeSync(descriptor); +} + +export function runValidationCommands(workspaceRoot, options = {}) { + const { spawnSyncImpl = spawnSync, ...commandOptions } = options; + return runCoreValidationCommands(workspaceRoot, { + ...commandOptions, + spawnSyncImpl: isolateReadOnlyViteConfiguration(spawnSyncImpl), + }); +} + +export function runCli(options = {}) { + const { spawnSyncImpl = spawnSync, ...runtimeOptions } = options; + const environment = runtimeOptions.env ?? process.env; + const effectiveResultPath = runtimeOptions.resultPath ?? environment.NOEMA_RESULT_PATH; + if (typeof effectiveResultPath === "string") { + ensurePrivateResultFile(effectiveResultPath); + } + return runCoreCli({ + ...runtimeOptions, + spawnSyncImpl: isolateReadOnlyViteConfiguration(spawnSyncImpl), + }); +} diff --git a/patch-validator/validate-patch.mjs b/patch-validator/validate-patch.mjs new file mode 100644 index 000000000..29310cd81 --- /dev/null +++ b/patch-validator/validate-patch.mjs @@ -0,0 +1,784 @@ +import { + O_CREAT, + O_EXCL, + O_NOFOLLOW, + O_TRUNC, + O_WRONLY, +} from "node:constants"; +import { spawnSync } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { + chmodSync, + closeSync, + copyFileSync, + existsSync, + fstatSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { dirname, isAbsolute, join, posix, resolve, sep } from "node:path"; +import { TextDecoder } from "node:util"; + +export const MAX_PATCH_BYTES = 4 * 1024 * 1024; +export const MAX_CHANGED_FILES = 100; +export const MAX_SOURCE_MEMBERS = 20_000; +export const MAX_SOURCE_FILE_BYTES = 64 * 1024 * 1024; +export const MAX_SOURCE_TOTAL_BYTES = 512 * 1024 * 1024; +export const MAX_RESULT_JSON_BYTES = 16 * 1024; +export const MAX_RESULT_EXCERPT_CHARS = 4_000; +export const MAX_RESULT_DURATION_MS = 1_200_000; +export const COMMAND_TIMEOUT_MS = 1_200_000; +export const COMMAND_OUTPUT_BYTES = 4_000; + +const PROFILE = "node_patch_verify"; +const COMMAND_PROFILE = "node_patch_verify_v1"; +const SHA1 = /^[0-9a-f]{40}$/; +const SHA256 = /^[0-9a-f]{64}$/; +const IMAGE_DIGEST = /^sha256:[0-9a-f]{64}$/; +const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +const HUNK_HEADER = + /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?$/; +const INDEX_LINE = /^index [0-9a-fA-F]{4,64}\.\.[0-9a-fA-F]{4,64}(?: 100(?:644|755))?$/; +const NEW_FILE_MODE = /^new file mode (100644|100755)$/; +const DELETED_FILE_MODE = /^deleted file mode (100644|100755)$/; +const FORBIDDEN_PATHS = new Set([ + ".npmrc", + ".node-version", + "Dockerfile.patch-validator", + "Dockerfile.patch-validator.dockerignore", + "package-lock.json", + "package.json", + "tsconfig.json", + "vitest.config.ts", +]); +const FORBIDDEN_PREFIXES = [ + ".git/", + ".github/", + "node_modules/", + "patch-validator/", + "reviewer/", +]; +const UNSUPPORTED_METADATA_PREFIXES = [ + "copy from ", + "copy to ", + "new mode ", + "old mode ", + "rename from ", + "rename to ", + "similarity index ", + "dissimilarity index ", +]; +const decoder = new TextDecoder("utf-8", { fatal: true }); + +function boundedExcerpt(value, maximum = MAX_RESULT_EXCERPT_CHARS) { + const text = String(value ?? ""); + return text.length <= maximum ? text : text.slice(0, maximum); +} + +function decodeUtf8(bytes, label) { + try { + return decoder.decode(bytes); + } catch (error) { + throw new Error(`${label} must be valid UTF-8`, { cause: error }); + } +} + +export function validateRepositoryPath(rawPath) { + if ( + typeof rawPath !== "string" || + rawPath.length === 0 || + isAbsolute(rawPath) || + rawPath.startsWith("/") || + rawPath.includes("\\") || + /[\u0000-\u001f\u007f]/u.test(rawPath) + ) { + throw new Error("patch contains an unsafe repository path"); + } + const parts = rawPath.split("/"); + if ( + parts.some((part) => part === "" || part === "." || part === "..") || + posix.normalize(rawPath) !== rawPath + ) { + throw new Error("patch contains a noncanonical repository path"); + } + if ( + rawPath === ".git" || + rawPath === "node_modules" || + FORBIDDEN_PATHS.has(rawPath) || + FORBIDDEN_PREFIXES.some((prefix) => rawPath.startsWith(prefix)) + ) { + throw new Error(`patch-validator profile forbids path: ${rawPath}`); + } + return rawPath; +} + +function parseDiffHeader(line) { + let match = /^diff --git a\/([^\s]+) b\/([^\s]+)$/u.exec(line); + if (match === null) { + match = /^diff --git "a\/([^"\\]+)" "b\/([^"\\]+)"$/u.exec(line); + } + if (match === null) { + throw new Error("patch contains a malformed diff header"); + } + const sourcePath = validateRepositoryPath(match[1]); + const targetPath = validateRepositoryPath(match[2]); + if (sourcePath !== targetPath) { + throw new Error( + "patch-validator profile does not support path-changing operations", + ); + } + return targetPath; +} + +function parseFileHeader(line, marker, prefix) { + if (!line.startsWith(marker)) { + throw new Error("patch contains incomplete file path metadata"); + } + const rawPath = line.slice(marker.length); + if (rawPath === "/dev/null") { + return null; + } + let candidate = rawPath; + if (candidate.startsWith('"') && candidate.endsWith('"')) { + candidate = candidate.slice(1, -1); + } + if (!candidate.startsWith(prefix)) { + throw new Error("patch contains malformed file path metadata"); + } + return validateRepositoryPath(candidate.slice(prefix.length)); +} + +function parseHunk(lines, startIndex) { + const match = HUNK_HEADER.exec(lines[startIndex]); + if (match === null) { + throw new Error("patch contains a malformed hunk header"); + } + const oldStart = Number(match[1]); + const oldCount = Number(match[2] ?? "1"); + const newStart = Number(match[3]); + const newCount = Number(match[4] ?? "1"); + let oldRemaining = oldCount; + let newRemaining = newCount; + let index = startIndex + 1; + const hunkLines = []; + + while (oldRemaining > 0 || newRemaining > 0) { + const line = lines[index]; + if (line === undefined || line.length === 0) { + throw new Error("patch hunk ended before its declared line counts"); + } + const marker = line[0]; + let kind; + if (marker === " ") { + kind = "context"; + oldRemaining -= 1; + newRemaining -= 1; + } else if (marker === "-") { + kind = "delete"; + oldRemaining -= 1; + } else if (marker === "+") { + kind = "add"; + newRemaining -= 1; + } else { + throw new Error("patch contains a malformed hunk body"); + } + if (oldRemaining < 0 || newRemaining < 0) { + throw new Error("patch hunk contains more lines than declared"); + } + const parsedLine = { + kind, + text: line.slice(1), + oldNoNewline: false, + newNoNewline: false, + }; + index += 1; + if (lines[index] === "\\ No newline at end of file") { + parsedLine.oldNoNewline = kind !== "add"; + parsedLine.newNoNewline = kind !== "delete"; + index += 1; + } + hunkLines.push(parsedLine); + } + + return { + hunk: { + oldStart, + oldCount, + newStart, + newCount, + lines: hunkLines, + }, + nextIndex: index, + }; +} + +export function parseUnifiedPatch(patchBytes) { + if (!Buffer.isBuffer(patchBytes) || patchBytes.length === 0) { + throw new Error("patch must be a nonempty byte buffer"); + } + if (patchBytes.length > MAX_PATCH_BYTES) { + throw new Error("patch exceeds its byte limit"); + } + const text = decodeUtf8(patchBytes, "patch"); + if (text.includes("GIT binary patch") || text.includes("Binary files ")) { + throw new Error("patch contains an unsupported binary payload"); + } + const lines = text.split("\n"); + if (lines.at(-1) === "") { + lines.pop(); + } + const patches = []; + const observedPaths = new Set(); + let index = 0; + + while (index < lines.length) { + const path = parseDiffHeader(lines[index]); + if (observedPaths.has(path)) { + throw new Error(`patch repeats duplicate target path: ${path}`); + } + observedPaths.add(path); + if (observedPaths.size > MAX_CHANGED_FILES) { + throw new Error("patch changes too many files"); + } + index += 1; + + let newMode = null; + let deletedMode = null; + while (index < lines.length && !lines[index].startsWith("--- ")) { + const metadata = lines[index]; + if (INDEX_LINE.test(metadata)) { + index += 1; + continue; + } + const newModeMatch = NEW_FILE_MODE.exec(metadata); + if (newModeMatch !== null) { + newMode = newModeMatch[1]; + index += 1; + continue; + } + const deletedModeMatch = DELETED_FILE_MODE.exec(metadata); + if (deletedModeMatch !== null) { + deletedMode = deletedModeMatch[1]; + index += 1; + continue; + } + if ( + UNSUPPORTED_METADATA_PREFIXES.some((prefix) => + metadata.startsWith(prefix), + ) + ) { + throw new Error("patch contains unsupported profile metadata"); + } + throw new Error("patch contains unbound metadata before file headers"); + } + + const oldPath = parseFileHeader(lines[index] ?? "", "--- ", "a/"); + index += 1; + const newPath = parseFileHeader(lines[index] ?? "", "+++ ", "b/"); + index += 1; + if (oldPath !== null && oldPath !== path) { + throw new Error("patch source header does not match its primary path"); + } + if (newPath !== null && newPath !== path) { + throw new Error("patch target header does not match its primary path"); + } + if (oldPath === null && newPath === null) { + throw new Error("patch cannot create and delete the same null path"); + } + + let operation = "modify"; + let mode = null; + if (oldPath === null) { + operation = "create"; + mode = newMode ?? "100644"; + if (deletedMode !== null) { + throw new Error("patch contains conflicting creation metadata"); + } + } else if (newPath === null) { + operation = "delete"; + mode = deletedMode; + if (newMode !== null) { + throw new Error("patch contains conflicting deletion metadata"); + } + } else if (newMode !== null || deletedMode !== null) { + throw new Error("patch contains misplaced file-mode metadata"); + } + + const hunks = []; + while (index < lines.length && lines[index].startsWith("@@")) { + const parsed = parseHunk(lines, index); + hunks.push(parsed.hunk); + index = parsed.nextIndex; + } + if (hunks.length === 0) { + throw new Error("patch file section contains no hunks"); + } + if (index < lines.length && !lines[index].startsWith("diff --git ")) { + throw new Error("patch contains unbound trailing syntax"); + } + patches.push({ path, operation, mode, hunks }); + } + + if (patches.length === 0) { + throw new Error("patch contains no diff headers"); + } + return patches; +} + +function splitFileText(text) { + if (text.length === 0) { + return []; + } + const chunks = text.split("\n"); + const hasFinalNewline = text.endsWith("\n"); + if (hasFinalNewline) { + chunks.pop(); + } + return chunks.map((chunk, index) => ({ + text: chunk, + newline: hasFinalNewline || index < chunks.length - 1, + })); +} + +function joinFileLines(lines) { + return lines.map((line) => `${line.text}${line.newline ? "\n" : ""}`).join(""); +} + +function safeTargetPath(root, repositoryPath, createParents) { + const canonical = validateRepositoryPath(repositoryPath); + const absoluteRoot = resolve(root); + const target = resolve(root, canonical); + if (!target.startsWith(`${absoluteRoot}${sep}`)) { + throw new Error("patch target escapes the private source root"); + } + let current = absoluteRoot; + const parents = canonical.split("/").slice(0, -1); + for (const component of parents) { + current = join(current, component); + if (!existsSync(current)) { + if (!createParents) { + throw new Error("patch source has a missing parent directory"); + } + mkdirSync(current, { mode: 0o700 }); + continue; + } + const metadata = lstatSync(current); + if (metadata.isSymbolicLink()) { + throw new Error("patch target parent must not be a symlink"); + } + if (!metadata.isDirectory()) { + throw new Error("patch target parent must be a directory"); + } + } + return target; +} + +function readSourceFile(path) { + if (!existsSync(path)) { + throw new Error("patch operation has a missing source file"); + } + const metadata = lstatSync(path); + if (metadata.isSymbolicLink() || !metadata.isFile()) { + throw new Error("patch source must be a regular non-symlink file"); + } + if (metadata.size > MAX_SOURCE_FILE_BYTES) { + throw new Error("patch source file exceeds its byte limit"); + } + const bytes = readFileSync(path); + return { + lines: splitFileText(decodeUtf8(bytes, "patch source file")), + mode: metadata.mode & 0o111 ? 0o755 : 0o644, + }; +} + +function applyHunks(sourceLines, hunks) { + const output = []; + let sourceCursor = 0; + for (const hunk of hunks) { + const oldIndex = hunk.oldStart === 0 ? 0 : hunk.oldStart - 1; + const newIndex = hunk.newStart === 0 ? 0 : hunk.newStart - 1; + if (oldIndex < sourceCursor || oldIndex > sourceLines.length) { + throw new Error("patch hunk old range is inconsistent with the source"); + } + output.push(...sourceLines.slice(sourceCursor, oldIndex)); + if (output.length !== newIndex) { + throw new Error("patch hunk new range is inconsistent with prior hunks"); + } + sourceCursor = oldIndex; + + for (const line of hunk.lines) { + if (line.kind === "add") { + output.push({ text: line.text, newline: !line.newNoNewline }); + continue; + } + const sourceLine = sourceLines[sourceCursor]; + if (sourceLine === undefined || sourceLine.text !== line.text) { + throw new Error("patch context does not match the authenticated source"); + } + if (sourceLine.newline !== !line.oldNoNewline) { + throw new Error("patch newline marker does not match the source"); + } + sourceCursor += 1; + if (line.kind === "context") { + output.push({ text: line.text, newline: !line.newNoNewline }); + } + } + } + output.push(...sourceLines.slice(sourceCursor)); + return output; +} + +function writeAtomicFile(path, bytes, mode) { + const temporary = `${path}.noema-${randomUUID()}`; + let descriptor; + try { + descriptor = openSync(temporary, O_CREAT | O_EXCL | O_WRONLY, mode); + writeFileSync(descriptor, bytes); + fsyncSync(descriptor); + closeSync(descriptor); + descriptor = undefined; + chmodSync(temporary, mode); + renameSync(temporary, path); + } catch (error) { + if (descriptor !== undefined) { + closeSync(descriptor); + } + rmSync(temporary, { force: true }); + throw error; + } +} + +export function applyPatchSet(root, patches) { + for (const patch of patches) { + const createParents = patch.operation === "create"; + const target = safeTargetPath(root, patch.path, createParents); + if (patch.operation === "create") { + if (existsSync(target)) { + throw new Error("patch create target already exists"); + } + const output = applyHunks([], patch.hunks); + const bytes = Buffer.from(joinFileLines(output), "utf8"); + if (bytes.length > MAX_SOURCE_FILE_BYTES) { + throw new Error("patched file exceeds its byte limit"); + } + writeAtomicFile(target, bytes, patch.mode === "100755" ? 0o755 : 0o644); + continue; + } + + const source = readSourceFile(target); + const output = applyHunks(source.lines, patch.hunks); + if (patch.operation === "delete") { + if (output.length !== 0) { + throw new Error("patch deletion did not consume the complete source file"); + } + unlinkSync(target); + continue; + } + const bytes = Buffer.from(joinFileLines(output), "utf8"); + if (bytes.length > MAX_SOURCE_FILE_BYTES) { + throw new Error("patched file exceeds its byte limit"); + } + writeAtomicFile(target, bytes, source.mode); + } +} + +export function copySourceTree( + sourceRoot, + destinationRoot, + { + maximumMembers = MAX_SOURCE_MEMBERS, + maximumFileBytes = MAX_SOURCE_FILE_BYTES, + maximumTotalBytes = MAX_SOURCE_TOTAL_BYTES, + } = {}, +) { + mkdirSync(destinationRoot, { recursive: true, mode: 0o700 }); + let members = 0; + let totalBytes = 0; + + function copyDirectory(source, destination, isRoot) { + for (const name of readdirSync(source).sort()) { + if (isRoot && name === ".git") { + continue; + } + if (isRoot && name === "node_modules") { + throw new Error("source tree must not contain node_modules"); + } + const sourcePath = join(source, name); + const destinationPath = join(destination, name); + const metadata = lstatSync(sourcePath); + members += 1; + if (members > maximumMembers) { + throw new Error("source tree exceeds its member limit"); + } + if (metadata.isSymbolicLink()) { + throw new Error("source tree must not contain symlinks"); + } + if (metadata.isDirectory()) { + mkdirSync(destinationPath, { mode: 0o700 }); + copyDirectory(sourcePath, destinationPath, false); + continue; + } + if (!metadata.isFile()) { + throw new Error("source tree contains a non-regular filesystem object"); + } + if (metadata.size > maximumFileBytes) { + throw new Error("source tree file exceeds its byte limit"); + } + totalBytes += metadata.size; + if (totalBytes > maximumTotalBytes) { + throw new Error("source tree exceeds its aggregate byte limit"); + } + copyFileSync(sourcePath, destinationPath); + chmodSync(destinationPath, metadata.mode & 0o111 ? 0o755 : 0o644); + } + } + + copyDirectory(sourceRoot, destinationRoot, true); + return { members, totalBytes }; +} + +export function runFixedCommand({ + modulePath, + args, + cwd, + timeoutMs = COMMAND_TIMEOUT_MS, + maximumOutputBytes = COMMAND_OUTPUT_BYTES, + spawnSyncImpl = spawnSync, +}) { + const completed = spawnSyncImpl(process.execPath, [modulePath, ...args], { + cwd, + env: { + PATH: dirname(process.execPath), + HOME: join(cwd, ".home"), + XDG_CACHE_HOME: join(cwd, ".cache"), + CI: "1", + NO_COLOR: "1", + }, + shell: false, + encoding: "utf8", + timeout: timeoutMs, + maxBuffer: maximumOutputBytes, + killSignal: "SIGKILL", + windowsHide: true, + }); + const stdoutExcerpt = boundedExcerpt(completed.stdout); + const stderrExcerpt = boundedExcerpt(completed.stderr || completed.error?.message); + + if (completed.error !== undefined) { + if (completed.error.code === "ETIMEDOUT") { + return { + exitCode: 124, + stdoutExcerpt, + stderrExcerpt, + reasonCodes: ["command_timeout"], + }; + } + if (completed.error.code === "ENOBUFS") { + return { + exitCode: 125, + stdoutExcerpt, + stderrExcerpt, + reasonCodes: ["command_output_limit"], + }; + } + return { + exitCode: 126, + stdoutExcerpt, + stderrExcerpt, + reasonCodes: ["command_launch_failed"], + }; + } + if (completed.status !== 0 || completed.signal !== null) { + return { + exitCode: Number.isInteger(completed.status) ? completed.status : 128, + stdoutExcerpt, + stderrExcerpt, + reasonCodes: ["command_failed"], + }; + } + return { exitCode: 0, stdoutExcerpt, stderrExcerpt, reasonCodes: [] }; +} + +export function runValidationCommands( + cwd, + { + spawnSyncImpl = spawnSync, + typescriptModule = "/opt/noema/node_modules/typescript/bin/tsc", + vitestModule = "/opt/noema/node_modules/vitest/vitest.mjs", + } = {}, +) { + const typecheck = runFixedCommand({ + modulePath: typescriptModule, + args: ["--noEmit", "--project", join(cwd, "tsconfig.json")], + cwd, + spawnSyncImpl, + }); + if (typecheck.exitCode !== 0) { + return typecheck; + } + return runFixedCommand({ + modulePath: vitestModule, + args: ["run", "--coverage", "--config", join(cwd, "vitest.config.ts")], + cwd, + spawnSyncImpl, + }); +} + +export function readEnvironment(env) { + const values = { + resultPath: env.NOEMA_RESULT_PATH, + repositoryFullName: env.NOEMA_REPOSITORY, + baseSha: env.NOEMA_BASE_SHA, + headSha: env.NOEMA_HEAD_SHA, + patchSha256: env.NOEMA_PATCH_SHA256, + profile: env.NOEMA_PATCH_PROFILE, + commandProfile: env.NOEMA_COMMAND_PROFILE, + validatorImageDigest: env.NOEMA_VALIDATOR_IMAGE_DIGEST, + }; + if ( + typeof values.resultPath !== "string" || + !values.resultPath.startsWith("/") || + !REPOSITORY.test(values.repositoryFullName ?? "") || + !SHA1.test(values.baseSha ?? "") || + !SHA1.test(values.headSha ?? "") || + !SHA256.test(values.patchSha256 ?? "") || + values.profile !== PROFILE || + values.commandProfile !== COMMAND_PROFILE || + !IMAGE_DIGEST.test(values.validatorImageDigest ?? "") + ) { + throw new Error("patch-validator environment is incomplete or malformed"); + } + return values; +} + +function readBoundedRegularFile(path, maximumBytes, label) { + let metadata; + try { + metadata = lstatSync(path); + } catch (error) { + throw new Error(`${label} is unavailable`, { cause: error }); + } + if (metadata.isSymbolicLink() || !metadata.isFile()) { + throw new Error(`${label} must be a regular non-symlink file`); + } + if (metadata.size <= 0 || metadata.size > maximumBytes) { + throw new Error(`${label} has an invalid byte length`); + } + return readFileSync(path); +} + +export function writeResultFile( + resultPath, + result, + maximumBytes = MAX_RESULT_JSON_BYTES, +) { + const bytes = Buffer.from(JSON.stringify(result), "utf8"); + if (bytes.length === 0 || bytes.length > maximumBytes) { + throw new Error("patch-validator result exceeds its byte limit"); + } + let linked; + try { + linked = lstatSync(resultPath); + } catch (error) { + throw new Error("patch-validator result file is unavailable", { cause: error }); + } + if (linked.isSymbolicLink() || !linked.isFile()) { + throw new Error("patch-validator result file must be a regular non-symlink file"); + } + let descriptor; + try { + descriptor = openSync(resultPath, O_WRONLY | O_TRUNC | O_NOFOLLOW); + const opened = fstatSync(descriptor); + if (opened.dev !== linked.dev || opened.ino !== linked.ino) { + throw new Error("patch-validator result file changed during validation"); + } + writeFileSync(descriptor, bytes); + fsyncSync(descriptor); + } finally { + if (descriptor !== undefined) { + closeSync(descriptor); + } + } +} + +function createResult(identity, commandResult, status, durationMs) { + return { + status, + repository_full_name: identity.repositoryFullName, + base_sha: identity.baseSha, + head_sha: identity.headSha, + patch_sha256: identity.patchSha256, + profile: identity.profile, + command_profile: identity.commandProfile, + validator_image_digest: identity.validatorImageDigest, + exit_code: commandResult.exitCode, + duration_ms: Math.max(0, Math.min(MAX_RESULT_DURATION_MS, durationMs)), + stdout_excerpt: boundedExcerpt(commandResult.stdoutExcerpt), + stderr_excerpt: boundedExcerpt(commandResult.stderrExcerpt), + reason_codes: commandResult.reasonCodes.slice(0, 20), + }; +} + +export function runCli({ + env = process.env, + inputRoot = "/input", + patchPath = "/patch/input.patch", + workspaceRoot = "/workspace", + nodeModulesPath = "/opt/noema/node_modules", + resultPath, + now = Date.now, + spawnSyncImpl = spawnSync, +} = {}) { + const identity = readEnvironment(env); + const effectiveResultPath = resultPath ?? identity.resultPath; + const startedAt = now(); + let commandResult; + let status; + + try { + const patchBytes = readBoundedRegularFile( + patchPath, + MAX_PATCH_BYTES, + "patch input", + ); + const observedDigest = createHash("sha256").update(patchBytes).digest("hex"); + if (observedDigest !== identity.patchSha256) { + throw new Error("patch digest does not match the exact request"); + } + const sourceRoot = join(workspaceRoot, "source"); + copySourceTree(inputRoot, sourceRoot); + applyPatchSet(sourceRoot, parseUnifiedPatch(patchBytes)); + const workspaceNodeModules = join(sourceRoot, "node_modules"); + if (existsSync(workspaceNodeModules)) { + throw new Error("private source unexpectedly contains node_modules"); + } + const nodeModulesMetadata = lstatSync(nodeModulesPath); + if (nodeModulesMetadata.isSymbolicLink() || !nodeModulesMetadata.isDirectory()) { + throw new Error("image node_modules must be a regular directory"); + } + symlinkSync(nodeModulesPath, workspaceNodeModules, "dir"); + commandResult = runValidationCommands(sourceRoot, { spawnSyncImpl }); + status = commandResult.exitCode === 0 ? "passed" : "failed"; + } catch (error) { + commandResult = { + exitCode: 1, + stdoutExcerpt: "", + stderrExcerpt: boundedExcerpt(error instanceof Error ? error.message : error), + reasonCodes: ["patch_blocked"], + }; + status = "blocked"; + } + + const result = createResult(identity, commandResult, status, now() - startedAt); + writeResultFile(effectiveResultPath, result); + return result; +} diff --git a/patch-validator/validator-tsconfig.json b/patch-validator/validator-tsconfig.json new file mode 100644 index 000000000..eb758dbab --- /dev/null +++ b/patch-validator/validator-tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "skipLibCheck": false, + "forceConsistentCasingInFileNames": true, + "types": ["vitest/globals"] + }, + "include": [ + "../../workspace/source/src/**/*.ts", + "../../workspace/source/test/**/*.ts" + ], + "exclude": [ + "../../workspace/source/node_modules" + ] +} diff --git a/patch-validator/validator-vitest.config.mjs b/patch-validator/validator-vitest.config.mjs new file mode 100644 index 000000000..fef25267d --- /dev/null +++ b/patch-validator/validator-vitest.config.mjs @@ -0,0 +1,64 @@ +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const ts = require("typescript"); +const TYPESCRIPT_PATH_PATTERN = /\.[cm]?tsx?$/; +const TRUSTED_COMPILER_OPTIONS = Object.freeze({ + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + jsx: ts.JsxEmit.ReactJSX, + isolatedModules: true, + verbatimModuleSyntax: true, + sourceMap: false, +}); + +/** + * Transpile one TypeScript-family module with the image-owned compiler policy. + * + * Vite's built-in OXC transform discovers the nearest source-tree tsconfig for + * every transformed file. The validator disables that transform and installs + * this fixed pre-transform instead, so a repository-controlled tsconfig cannot + * change syntax, JSX, module, or emit behavior during validation. + * + * @param {string} source - Untrusted module source text supplied by Vite. + * @param {string} identifier - Vite module identifier, possibly with a query. + * @returns {{code: string, map: null} | null} Fixed-policy output for + * TypeScript-family modules, or `null` for ordinary JavaScript modules. + */ +export function trustedTypeScriptTransform(source, identifier) { + const fileName = identifier.split("?", 1)[0]; + if (!TYPESCRIPT_PATH_PATTERN.test(fileName)) { + return null; + } + const transformed = ts.transpileModule(source, { + fileName, + compilerOptions: TRUSTED_COMPILER_OPTIONS, + reportDiagnostics: false, + }); + return { code: transformed.outputText, map: null }; +} + +const trustedTypeScriptPlugin = Object.freeze({ + name: "noema-trusted-typescript-transform", + enforce: "pre", + transform: trustedTypeScriptTransform, +}); + +export default { + oxc: false, + plugins: [trustedTypeScriptPlugin], + test: { + include: ["test/**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + passWithNoTests: false, + watch: false, + coverage: { + enabled: true, + provider: "v8", + include: ["src/**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + reporter: ["text", "json-summary"], + thresholds: { + 100: true, + }, + }, + }, +}; diff --git a/reviewer/noema_reviewer/__init__.py b/reviewer/noema_reviewer/__init__.py index 6b918278c..02e6bb78f 100644 --- a/reviewer/noema_reviewer/__init__.py +++ b/reviewer/noema_reviewer/__init__.py @@ -13,6 +13,14 @@ from .agent import PydanticAIReviewAgent, ReviewAgent, build_agent from .manifest import ReviewManifest from .models import Confidence, Finding, ReviewVerdict, Severity, Verdict +from .patch_image_validation import ( + DockerPatchValidatorImageRunner, + PatchValidatorImageProfile, + PatchValidatorImageRequest, + PatchValidatorImageResult, + PatchValidatorImageStatus, + inspect_patch_for_image, +) from .patch_validation import ( DockerPatchValidationRunner, PatchValidationProfile, @@ -26,11 +34,16 @@ __all__ = [ "Confidence", "DockerPatchValidationRunner", + "DockerPatchValidatorImageRunner", "Finding", "PatchValidationProfile", "PatchValidationRequest", "PatchValidationResult", "PatchValidationStatus", + "PatchValidatorImageProfile", + "PatchValidatorImageRequest", + "PatchValidatorImageResult", + "PatchValidatorImageStatus", "PydanticAIReviewAgent", "ReviewAgent", "ReviewManifest", @@ -39,4 +52,5 @@ "Verdict", "build_agent", "inspect_patch_bytes", + "inspect_patch_for_image", ] diff --git a/reviewer/noema_reviewer/patch_image_validation.py b/reviewer/noema_reviewer/patch_image_validation.py new file mode 100644 index 000000000..6278e3abb --- /dev/null +++ b/reviewer/noema_reviewer/patch_image_validation.py @@ -0,0 +1,396 @@ +"""Repository-owned patch-validator image contracts and hardened Docker runner. + +The older :mod:`noema_reviewer.patch_validation` module supplies the exact Git +source, archive, patch-file, and descriptor-safe boundaries introduced by PR +#65. This module composes those proven boundaries with a narrower, versioned +image profile whose trusted host result is additionally bound to the immutable +validator-image digest. Untrusted image code never writes the host evidence +that the reviewer consumes. +""" + +from __future__ import annotations + +import hashlib +import os +import shlex +import subprocess +import tempfile +import time +from collections.abc import Callable +from enum import Enum +from pathlib import Path +from typing import Annotated, Any, Self + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from . import patch_validation as base + + +IMAGE_DIGEST_PATTERN = r"^sha256:[0-9a-f]{64}$" +IMAGE_PROFILE_FORBIDDEN_PATHS = frozenset( + { + ".npmrc", + ".node-version", + "Dockerfile.patch-validator", + "Dockerfile.patch-validator.dockerignore", + "package-lock.json", + "package.json", + "tsconfig.json", + "vitest.config.ts", + } +) +IMAGE_PROFILE_FORBIDDEN_PREFIXES = ( + ".github/", + "patch-validator/", + "reviewer/", +) +IMAGE_PROFILE_UNSUPPORTED_METADATA_PREFIXES = ( + "copy from ", + "copy to ", + "new mode ", + "old mode ", + "rename from ", + "rename to ", +) + +ImageProcessRunner = Callable[..., subprocess.CompletedProcess[str]] +ImageNameFactory = Callable[[], str] +ImageClock = Callable[[], int] +ImageReasonCode = Annotated[ + str, + Field(min_length=1, max_length=64, pattern=base.REASON_CODE_PATTERN), +] + + +class PatchValidatorImageProfile(str, Enum): + """Versioned command profiles owned by the immutable validator image.""" + + NODE_PATCH_VERIFY = "node_patch_verify" + + +class PatchValidatorImageStatus(str, Enum): + """Terminal outcomes represented in trusted patch-image evidence.""" + + PASSED = "passed" + FAILED = "failed" + BLOCKED = "blocked" + + +IMAGE_PROFILE_COMMANDS: dict[PatchValidatorImageProfile, str] = { + PatchValidatorImageProfile.NODE_PATCH_VERIFY: "node_patch_verify_v1", +} + + +class PatchValidatorImageRequest(BaseModel): + """Exact source, patch, and image-profile identity admitted to the sandbox.""" + + model_config = ConfigDict(extra="forbid") + + repository_full_name: str = Field(pattern=base.REPOSITORY_PATTERN) + base_sha: str = Field(pattern=base.SHA1_PATTERN) + head_sha: str = Field(pattern=base.SHA1_PATTERN) + patch_sha256: str = Field(pattern=base.SHA256_PATTERN) + profile: PatchValidatorImageProfile + + +class PatchValidatorImageResult(BaseModel): + """Bounded host evidence tied to one request and immutable image digest.""" + + model_config = ConfigDict(extra="forbid") + + status: PatchValidatorImageStatus + repository_full_name: str = Field(pattern=base.REPOSITORY_PATTERN) + base_sha: str = Field(pattern=base.SHA1_PATTERN) + head_sha: str = Field(pattern=base.SHA1_PATTERN) + patch_sha256: str = Field(pattern=base.SHA256_PATTERN) + profile: PatchValidatorImageProfile + command_profile: str = Field(min_length=1, max_length=64) + validator_image_digest: str = Field(pattern=IMAGE_DIGEST_PATTERN) + exit_code: int = Field(ge=0, le=255) + duration_ms: int = Field(ge=0, le=base.MAX_RESULT_DURATION_MS) + stdout_excerpt: str = Field(max_length=base.MAX_RESULT_EXCERPT_CHARS) + stderr_excerpt: str = Field(max_length=base.MAX_RESULT_EXCERPT_CHARS) + reason_codes: list[ImageReasonCode] = Field(default_factory=list, max_length=20) + + @model_validator(mode="after") + def require_successful_exit_for_passed_status(self) -> Self: + """Reject evidence claiming success for a nonzero fixed-command exit.""" + if self.status is PatchValidatorImageStatus.PASSED and self.exit_code != 0: + raise ValueError("passed patch-validator image result requires exit_code 0") + return self + + +def inspect_patch_for_image(patch_bytes: bytes) -> tuple[str, ...]: + """Validate the common patch grammar plus the first image profile policy.""" + changed_paths = base.inspect_patch_bytes(patch_bytes) + text = patch_bytes.decode("utf-8", errors="strict") + lines = text.splitlines() + + for line in lines: + if not line.startswith("diff --git "): + continue + parts = shlex.split(line) + source_path = base._validated_patch_path(parts[2], "a/") + target_path = base._validated_patch_path(parts[3], "b/") + if source_path != target_path: + raise ValueError( + "patch-validator image profile does not support path-changing operations" + ) + + if any( + line.startswith(IMAGE_PROFILE_UNSUPPORTED_METADATA_PREFIXES) + for line in lines + ): + raise ValueError( + "patch-validator image profile does not support rename, copy, or mode operations" + ) + + for path in changed_paths: + if path in IMAGE_PROFILE_FORBIDDEN_PATHS or path.startswith( + IMAGE_PROFILE_FORBIDDEN_PREFIXES + ): + raise ValueError(f"patch-validator image profile forbids path: {path}") + return changed_paths + + +def _default_image_container_name() -> str: + """Return one unpredictable Docker-safe validator container name.""" + return base._default_name().replace("noema-patch-", "noema-patch-image-", 1) + + +def _image_digest(image_reference: str) -> str: + """Extract the already-regex-validated digest from an immutable reference.""" + return image_reference.rsplit("@", 1)[1] + + +def _result_matches_request( + result: PatchValidatorImageResult, + request: PatchValidatorImageRequest, + validator_image_digest: str, +) -> bool: + """Return whether evidence repeats every request and image-owned identity.""" + observed = ( + result.repository_full_name, + result.base_sha, + result.head_sha, + result.patch_sha256, + result.profile, + result.command_profile, + result.validator_image_digest, + ) + expected = ( + request.repository_full_name, + request.base_sha, + request.head_sha, + request.patch_sha256, + request.profile, + IMAGE_PROFILE_COMMANDS[request.profile], + validator_image_digest, + ) + return observed == expected + + +class DockerPatchValidatorImageRunner: + """Run one image-profile patch in the exact-source hardened Docker boundary.""" + + def __init__( + self, + *, + command_runner: ImageProcessRunner = subprocess.run, + cleanup_runner: ImageProcessRunner = subprocess.run, + name_factory: ImageNameFactory = _default_image_container_name, + clock: ImageClock = time.monotonic_ns, + file_system: Any = base.DEFAULT_PATCH_FILE_SYSTEM, + ) -> None: + """Initialize injectable Docker, cleanup, naming, clock, and file adapters.""" + self._command_runner = command_runner + self._cleanup_runner = cleanup_runner + self._name_factory = name_factory + self._clock = clock + self._file_system = file_system + + def validate( + self, + *, + request: PatchValidatorImageRequest, + source_root: str | Path, + patch_path: str | Path, + ) -> PatchValidatorImageResult: + """Return host-produced evidence for a successful exact-image execution.""" + source = base._validated_directory(source_root, "source root") + _resolved_patch, patch_bytes = base._read_regular_patch( + patch_path, + file_system=self._file_system, + ) + inspect_patch_for_image(patch_bytes) + if hashlib.sha256(patch_bytes).hexdigest() != request.patch_sha256: + raise RuntimeError( + "patch file digest does not match the image validation request" + ) + + image = base._verified_image_reference() + validator_image_digest = _image_digest(image) + metadata_kind = base._git_metadata_kind(source) + base._verify_source_head(source, request.head_sha, metadata_kind) + if metadata_kind is None: + raise RuntimeError( + "source Git metadata is required for exact-head image validation" + ) + + uid = os.getuid() + gid = os.getgid() + if uid <= 0 or gid <= 0: + raise RuntimeError( + "patch-validator image requires a non-root runner UID and GID" + ) + container_name = self._name_factory() + child_environment = {"PATH": os.environ.get("PATH", os.defpath)} + + with tempfile.TemporaryDirectory( + prefix="noema-patch-validator-image-" + ) as staging: + staging_root = base._validated_docker_mount_path( + Path(staging), + "staging root", + ) + source_mount = base._materialize_committed_source( + source, + request.head_sha, + staging_root, + metadata_kind, + ) + staged_patch = base._write_private_patch_copy(staging_root, patch_bytes) + git_metadata_mask = base._create_git_metadata_mask( + staging_root, + metadata_kind, + ) + if git_metadata_mask is None: + raise RuntimeError("source Git metadata mask could not be created") + + command = [ + "docker", + "run", + "--rm", + f"--name={container_name}", + "--pull=never", + "--network=none", + "--read-only", + "--cap-drop=ALL", + "--security-opt=no-new-privileges=true", + "--security-opt=seccomp=builtin", + "--pids-limit=256", + "--memory=2g", + "--memory-swap=2g", + "--cpus=2", + "--ipc=none", + "--ulimit=nofile=1024:1024", + "--ulimit=nproc=256:256", + "--ulimit=core=0:0", + ( + "--ulimit=fsize=" + f"{base.MAX_SOURCE_ARCHIVE_FILE_BYTES}:" + f"{base.MAX_SOURCE_ARCHIVE_FILE_BYTES}" + ), + f"--user={uid}:{gid}", + ( + "--tmpfs=/workspace:" + f"rw,nosuid,nodev,size=1073741824,mode=0700,uid={uid},gid={gid}" + ), + "--tmpfs=/tmp:rw,noexec,nosuid,nodev,size=67108864,mode=1777", + f"--mount=type=bind,src={source_mount},dst=/input,readonly", + ( + "--mount=type=bind," + f"src={git_metadata_mask},dst=/input/.git,readonly" + ), + ( + "--mount=type=bind," + f"src={staged_patch},dst=/patch/input.patch,readonly" + ), + "--workdir=/workspace", + "--env=HOME=/workspace/home", + "--env=XDG_CACHE_HOME=/workspace/cache", + "--env=NOEMA_RESULT_PATH=/workspace/result.json", + f"--env=NOEMA_REPOSITORY={request.repository_full_name}", + f"--env=NOEMA_BASE_SHA={request.base_sha}", + f"--env=NOEMA_HEAD_SHA={request.head_sha}", + f"--env=NOEMA_PATCH_SHA256={request.patch_sha256}", + f"--env=NOEMA_PATCH_PROFILE={request.profile.value}", + ( + "--env=NOEMA_COMMAND_PROFILE=" + f"{IMAGE_PROFILE_COMMANDS[request.profile]}" + ), + f"--env=NOEMA_VALIDATOR_IMAGE_DIGEST={validator_image_digest}", + image, + ] + + started_at_ns = self._clock() + try: + completed = self._command_runner( + command, + text=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + shell=False, + timeout=base.PATCH_SANDBOX_WALL_TIMEOUT_SECONDS, + env=child_environment, + ) + except subprocess.TimeoutExpired as exc: + self._cleanup_runner( + ["docker", "rm", "-f", container_name], + text=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + shell=False, + timeout=30, + env=child_environment, + ) + raise RuntimeError( + "patch-validator image timed out after " + f"{base.PATCH_SANDBOX_WALL_TIMEOUT_SECONDS} seconds" + ) from exc + except OSError as exc: + raise RuntimeError( + f"patch-validator image could not start Docker: {exc}" + ) from exc + + if completed.returncode != 0: + stderr = getattr(completed, "stderr", "") or "" + stdout = getattr(completed, "stdout", "") or "" + detail = base._bounded_detail(stderr or stdout) + raise RuntimeError( + f"patch-validator image exited {completed.returncode}: {detail}" + ) + + elapsed_ms = max( + 0, + min( + base.MAX_RESULT_DURATION_MS, + (self._clock() - started_at_ns) // 1_000_000, + ), + ) + result = PatchValidatorImageResult( + status=PatchValidatorImageStatus.PASSED, + repository_full_name=request.repository_full_name, + base_sha=request.base_sha, + head_sha=request.head_sha, + patch_sha256=request.patch_sha256, + profile=request.profile, + command_profile=IMAGE_PROFILE_COMMANDS[request.profile], + validator_image_digest=validator_image_digest, + exit_code=0, + duration_ms=elapsed_ms, + stdout_excerpt="", + stderr_excerpt="", + reason_codes=[], + ) + if not _result_matches_request( + result, + request, + validator_image_digest, + ): + raise RuntimeError( + "trusted host result does not match the image validation request" + ) + return result diff --git a/reviewer/tests/test_patch_validation_image_control_paths.py b/reviewer/tests/test_patch_validation_image_control_paths.py new file mode 100644 index 000000000..708d3a851 --- /dev/null +++ b/reviewer/tests/test_patch_validation_image_control_paths.py @@ -0,0 +1,28 @@ +"""Regression tests for immutable patch-validator build-control paths.""" + +from __future__ import annotations + +import pytest + +from noema_reviewer.patch_image_validation import inspect_patch_for_image + + +def _ordinary_patch(path: str) -> bytes: + """Return one ordinary same-path text modification for ``path``.""" + return ( + f"diff --git a/{path} b/{path}\n" + "index 1111111..2222222 100644\n" + f"--- a/{path}\n" + f"+++ b/{path}\n" + "@@ -1 +1 @@\n" + "-old value\n" + "+new value\n" + ).encode("utf-8") + + +def test_image_profile_rejects_patch_validator_dockerignore() -> None: + """A proposal cannot alter the active image build-context allowlist.""" + with pytest.raises(ValueError, match="profile forbids path"): + inspect_patch_for_image( + _ordinary_patch("Dockerfile.patch-validator.dockerignore") + ) diff --git a/reviewer/tests/test_patch_validation_image_create_delete.py b/reviewer/tests/test_patch_validation_image_create_delete.py new file mode 100644 index 000000000..a3b261b8a --- /dev/null +++ b/reviewer/tests/test_patch_validation_image_create_delete.py @@ -0,0 +1,52 @@ +"""Create/delete parity tests for the patch-validator image profile.""" + +from __future__ import annotations + +import pytest + +from noema_reviewer.patch_image_validation import inspect_patch_for_image + + +def _creation_patch(mode: str) -> bytes: + """Return one canonical regular-file creation with ``mode``.""" + return ( + "diff --git a/src/new.ts b/src/new.ts\n" + f"new file mode {mode}\n" + "index 0000000..1111111\n" + "--- /dev/null\n" + "+++ b/src/new.ts\n" + "@@ -0,0 +1 @@\n" + "+created\n" + ).encode("utf-8") + + +def _deletion_patch(mode: str) -> bytes: + """Return one canonical regular-file deletion with ``mode``.""" + return ( + "diff --git a/src/old.ts b/src/old.ts\n" + f"deleted file mode {mode}\n" + "index 1111111..0000000\n" + "--- a/src/old.ts\n" + "+++ /dev/null\n" + "@@ -1 +0,0 @@\n" + "-obsolete\n" + ).encode("utf-8") + + +@pytest.mark.parametrize("mode", ("100644", "100755")) +def test_image_profile_accepts_canonical_regular_file_creation(mode: str) -> None: + """Creation metadata is allowed when it matches the canonical file operation.""" + assert inspect_patch_for_image(_creation_patch(mode)) == ("src/new.ts",) + + +@pytest.mark.parametrize("mode", ("100644", "100755")) +def test_image_profile_accepts_canonical_regular_file_deletion(mode: str) -> None: + """Deletion metadata is allowed when it matches the canonical file operation.""" + assert inspect_patch_for_image(_deletion_patch(mode)) == ("src/old.ts",) + + +@pytest.mark.parametrize("mode", ("120000", "160000")) +def test_image_profile_rejects_symlink_and_gitlink_creation(mode: str) -> None: + """Allowing create/delete does not admit symlinks or Git submodule objects.""" + with pytest.raises(ValueError, match="mode"): + inspect_patch_for_image(_creation_patch(mode)) diff --git a/reviewer/tests/test_patch_validation_image_profile.py b/reviewer/tests/test_patch_validation_image_profile.py new file mode 100644 index 000000000..4e6f2b7e2 --- /dev/null +++ b/reviewer/tests/test_patch_validation_image_profile.py @@ -0,0 +1,277 @@ +"""Test-first contract for the repository-owned patch-validator image profile.""" + +from __future__ import annotations + +import hashlib +import subprocess +from pathlib import Path +from types import SimpleNamespace + +import pytest +from pydantic import ValidationError + +from noema_reviewer import patch_image_validation, patch_validation +from noema_reviewer.patch_image_validation import ( + IMAGE_PROFILE_COMMANDS, + DockerPatchValidatorImageRunner, + PatchValidatorImageProfile, + PatchValidatorImageRequest, + PatchValidatorImageResult, + PatchValidatorImageStatus, + inspect_patch_for_image, +) + + +TEST_IMAGE_DIGEST = "sha256:" + "a" * 64 +TEST_IMAGE = f"{patch_validation.TRUSTED_PATCH_IMAGE_REPOSITORY}@{TEST_IMAGE_DIGEST}" + + +def _ordinary_patch(path: str = "src/example.ts") -> bytes: + """Return one same-path ordinary text modification.""" + return ( + f"diff --git a/{path} b/{path}\n" + "index 1111111..2222222 100644\n" + f"--- a/{path}\n" + f"+++ b/{path}\n" + "@@ -1 +1 @@\n" + "-old value\n" + "+new value\n" + ).encode("utf-8") + + +def _request(patch_bytes: bytes, head_sha: str) -> PatchValidatorImageRequest: + """Build an exact request for the image-owned Node profile.""" + return PatchValidatorImageRequest( + repository_full_name="ContextualWisdomLab/noema", + base_sha="1" * 40, + head_sha=head_sha, + patch_sha256=hashlib.sha256(patch_bytes).hexdigest(), + profile=PatchValidatorImageProfile.NODE_PATCH_VERIFY, + ) + + +def _run_git(source: Path, *arguments: str) -> str: + """Run one bounded non-shell Git fixture command.""" + completed = subprocess.run( + [patch_validation.TRUSTED_GIT_EXECUTABLE, "-C", str(source), *arguments], + check=True, + shell=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=30, + ) + return completed.stdout.strip() + + +def _inputs(tmp_path: Path) -> tuple[Path, Path, bytes, str]: + """Create one clean authenticated source and ordinary patch.""" + source = tmp_path / "source" + source.mkdir() + _run_git(source, "init", "-q") + _run_git(source, "config", "user.name", "Noema Test") + _run_git(source, "config", "user.email", "noema-test@example.invalid") + source_file = source / "src" / "example.ts" + source_file.parent.mkdir() + source_file.write_text("old value\n", encoding="utf-8") + _run_git(source, "add", "--all") + _run_git(source, "commit", "-qm", "fixture") + head_sha = _run_git(source, "rev-parse", "HEAD") + patch_bytes = _ordinary_patch() + patch_path = tmp_path / "proposal.patch" + patch_path.write_bytes(patch_bytes) + return source, patch_path, patch_bytes, head_sha + + +def _result_json( + request: PatchValidatorImageRequest, + *, + validator_image_digest: str = TEST_IMAGE_DIGEST, + status: PatchValidatorImageStatus = PatchValidatorImageStatus.PASSED, + exit_code: int = 0, +) -> str: + """Return exact-request and exact-image-bound structured evidence.""" + return PatchValidatorImageResult( + status=status, + repository_full_name=request.repository_full_name, + base_sha=request.base_sha, + head_sha=request.head_sha, + patch_sha256=request.patch_sha256, + profile=request.profile, + command_profile="node_patch_verify_v1", + validator_image_digest=validator_image_digest, + exit_code=exit_code, + duration_ms=1, + stdout_excerpt="passed", + stderr_excerpt="", + reason_codes=[], + ).model_dump_json() + + +def test_node_image_profile_is_a_closed_non_shell_contract() -> None: + """The image profile is enumerated and names an image-owned command contract.""" + assert PatchValidatorImageProfile.NODE_PATCH_VERIFY.value == "node_patch_verify" + assert ( + IMAGE_PROFILE_COMMANDS[PatchValidatorImageProfile.NODE_PATCH_VERIFY] + == "node_patch_verify_v1" + ) + assert "npm" not in IMAGE_PROFILE_COMMANDS[ + PatchValidatorImageProfile.NODE_PATCH_VERIFY + ] + assert " " not in IMAGE_PROFILE_COMMANDS[ + PatchValidatorImageProfile.NODE_PATCH_VERIFY + ] + + +def test_node_image_result_requires_an_immutable_image_digest() -> None: + """Image-backed evidence cannot omit or forge its immutable validator digest.""" + patch_bytes = _ordinary_patch() + request = _request(patch_bytes, "2" * 40) + valid = _result_json(request) + assert TEST_IMAGE_DIGEST in valid + + values = PatchValidatorImageResult.model_validate_json(valid).model_dump() + for invalid_digest in (None, "a" * 64, "sha256:short", "sha512:" + "a" * 128): + candidate = dict(values) + candidate["validator_image_digest"] = invalid_digest + with pytest.raises(ValidationError): + PatchValidatorImageResult.model_validate(candidate) + + +def test_node_image_result_rejects_claimed_success_with_nonzero_exit() -> None: + """The image cannot claim a passed result for a failing fixed command.""" + request = _request(_ordinary_patch(), "2" * 40) + with pytest.raises(ValidationError, match="requires exit_code 0"): + PatchValidatorImageResult.model_validate_json( + _result_json( + request, + status=PatchValidatorImageStatus.FAILED, + exit_code=1, + ).replace('"status":"failed"', '"status":"passed"') + ) + + +def test_node_image_profile_accepts_ordinary_source_and_test_changes() -> None: + """The first image profile accepts ordinary reviewed source and test patches.""" + for path in ("src/example.ts", "test/example.test.ts", "docs/example.md"): + assert inspect_patch_for_image(_ordinary_patch(path)) == (path,) + + +@pytest.mark.parametrize( + "path", + ( + "package.json", + "package-lock.json", + "tsconfig.json", + "vitest.config.ts", + ".npmrc", + ".node-version", + "Dockerfile.patch-validator", + "Dockerfile.patch-validator.dockerignore", + "reviewer/noema_reviewer/agent.py", + "patch-validator/validate-patch.mjs", + ".github/codegraph/package.json", + ), +) +def test_node_image_profile_rejects_dependency_validator_and_config_paths( + path: str, +) -> None: + """A patch cannot alter the image dependency graph or its own validation controls.""" + with pytest.raises(ValueError, match="profile forbids path"): + inspect_patch_for_image(_ordinary_patch(path)) + + +@pytest.mark.parametrize( + "patch_bytes", + ( + ( + "diff --git a/src/old.ts b/src/new.ts\n" + "similarity index 100%\n" + "rename from src/old.ts\n" + "rename to src/new.ts\n" + ).encode(), + ( + "diff --git a/src/old.ts b/src/new.ts\n" + "similarity index 100%\n" + "copy from src/old.ts\n" + "copy to src/new.ts\n" + ).encode(), + ( + "diff --git a/src/example.ts b/src/example.ts\n" + "old mode 100644\n" + "new mode 100755\n" + ).encode(), + ), +) +def test_node_image_profile_rejects_unsupported_metadata( + patch_bytes: bytes, +) -> None: + """The first runtime patch language excludes rename, copy, and mode operations.""" + with pytest.raises(ValueError, match="profile does not support"): + inspect_patch_for_image(patch_bytes) + + +def test_runner_synthesizes_exact_image_bound_result_on_trusted_host( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A zero container exit becomes exact host evidence without trusting output.""" + source, patch_path, patch_bytes, head_sha = _inputs(tmp_path) + request = _request(patch_bytes, head_sha) + monkeypatch.setenv("NOEMA_PATCH_SANDBOX_IMAGE", TEST_IMAGE) + captured_command: list[str] = [] + + def successful(command, **_kwargs): + """Capture the hardened boundary and return one successful container exit.""" + command_list = list(command) + captured_command.extend(command_list) + assert "--network=none" in command_list + assert "--read-only" in command_list + assert "--cap-drop=ALL" in command_list + assert "--env=NOEMA_VALIDATOR_IMAGE_DIGEST=" + TEST_IMAGE_DIGEST in command_list + return SimpleNamespace( + returncode=0, + stdout='{"validator_image_digest":"sha256:' + "b" * 64 + '"}', + stderr="forged container evidence", + ) + + result = DockerPatchValidatorImageRunner(command_runner=successful).validate( + request=request, + source_root=source, + patch_path=patch_path, + ) + + assert result.status is PatchValidatorImageStatus.PASSED + assert result.repository_full_name == request.repository_full_name + assert result.base_sha == request.base_sha + assert result.head_sha == request.head_sha + assert result.patch_sha256 == request.patch_sha256 + assert result.validator_image_digest == TEST_IMAGE_DIGEST + assert result.stdout_excerpt == "" + assert result.stderr_excerpt == "" + assert "--env=NOEMA_RESULT_PATH=/workspace/result.json" in captured_command + assert not any("dst=/output/result.json" in value for value in captured_command) + + +def test_runner_fails_closed_when_trusted_result_binding_is_inconsistent( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The defensive host re-binding check remains fail-closed if it disagrees.""" + source, patch_path, patch_bytes, head_sha = _inputs(tmp_path) + request = _request(patch_bytes, head_sha) + monkeypatch.setenv("NOEMA_PATCH_SANDBOX_IMAGE", TEST_IMAGE) + monkeypatch.setattr( + patch_image_validation, + "_result_matches_request", + lambda *_args: False, + ) + + with pytest.raises(RuntimeError, match="trusted host result does not match"): + DockerPatchValidatorImageRunner( + command_runner=lambda *_args, **_kwargs: SimpleNamespace(returncode=0) + ).validate( + request=request, + source_root=source, + patch_path=patch_path, + ) diff --git a/reviewer/tests/test_patch_validation_image_result_isolation.py b/reviewer/tests/test_patch_validation_image_result_isolation.py new file mode 100644 index 000000000..0262f7f0f --- /dev/null +++ b/reviewer/tests/test_patch_validation_image_result_isolation.py @@ -0,0 +1,166 @@ +"""Regressions that keep untrusted image code outside host evidence production.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from noema_reviewer import patch_image_validation, patch_validation +from noema_reviewer.patch_image_validation import ( + DockerPatchValidatorImageRunner, + PatchValidatorImageProfile, + PatchValidatorImageRequest, + PatchValidatorImageStatus, +) + + +TEST_IMAGE_DIGEST = "sha256:" + "a" * 64 +TEST_IMAGE = f"{patch_validation.TRUSTED_PATCH_IMAGE_REPOSITORY}@{TEST_IMAGE_DIGEST}" +PATCH_BYTES = ( + b"diff --git a/src/example.ts b/src/example.ts\n" + b"--- a/src/example.ts\n" + b"+++ b/src/example.ts\n" + b"@@ -1 +1 @@\n" + b"-old value\n" + b"+new value\n" +) + + +def _request() -> PatchValidatorImageRequest: + """Return one exact image-validation request for isolation tests.""" + return PatchValidatorImageRequest( + repository_full_name="ContextualWisdomLab/noema", + base_sha="1" * 40, + head_sha="2" * 40, + patch_sha256=hashlib.sha256(PATCH_BYTES).hexdigest(), + profile=PatchValidatorImageProfile.NODE_PATCH_VERIFY, + ) + + +def _install_host_boundaries( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> tuple[Path, Path]: + """Install deterministic exact-source adapters without trusting image output.""" + source = tmp_path / "source" + source.mkdir() + patch_path = tmp_path / "proposal.patch" + patch_path.write_bytes(PATCH_BYTES) + + monkeypatch.setattr( + patch_validation, + "_validated_directory", + lambda _path, _label: source, + ) + monkeypatch.setattr( + patch_validation, + "_read_regular_patch", + lambda _path, **_kwargs: (patch_path, PATCH_BYTES), + ) + monkeypatch.setattr( + patch_validation, + "_verified_image_reference", + lambda: TEST_IMAGE, + ) + monkeypatch.setattr( + patch_validation, + "_git_metadata_kind", + lambda _source: "directory", + ) + monkeypatch.setattr( + patch_validation, + "_verify_source_head", + lambda *_args, **_kwargs: None, + ) + + def materialize(_source, _head_sha, staging_root, _metadata_kind): + """Create one private source mount for the captured Docker command.""" + source_mount = staging_root / "source" + source_mount.mkdir() + (source_mount / ".git").mkdir() + return source_mount + + monkeypatch.setattr( + patch_validation, + "_materialize_committed_source", + materialize, + ) + + def stage_patch(staging_root, patch_bytes): + """Create the private staged patch expected by the image runner.""" + staged_patch = staging_root / "input.patch" + staged_patch.write_bytes(patch_bytes) + return staged_patch + + monkeypatch.setattr( + patch_validation, + "_write_private_patch_copy", + stage_patch, + ) + + def metadata_mask(staging_root, _metadata_kind): + """Create the empty nested Git metadata mask required by the runner.""" + mask = staging_root / "git-mask" + mask.mkdir() + return mask + + monkeypatch.setattr( + patch_validation, + "_create_git_metadata_mask", + metadata_mask, + ) + + def reject_container_result(*_args, **_kwargs): + """Prove the trusted host never parses attacker-writable image output.""" + raise AssertionError("container result must not be trusted as host evidence") + + monkeypatch.setattr( + patch_validation, + "_read_result_payload", + reject_container_result, + ) + monkeypatch.setattr(patch_image_validation.os, "getuid", lambda: 1000) + monkeypatch.setattr(patch_image_validation.os, "getgid", lambda: 1000) + return source, patch_path + + +def test_runner_synthesizes_passed_evidence_without_host_writable_result_mount( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A successful container exit becomes host evidence without trusting its files.""" + source, patch_path = _install_host_boundaries(tmp_path, monkeypatch) + captured_command: list[str] = [] + + def successful_container(command, **_kwargs): + """Capture the exact Docker boundary and report one successful exit.""" + captured_command.extend(command) + return SimpleNamespace(returncode=0) + + request = _request() + result = DockerPatchValidatorImageRunner( + command_runner=successful_container, + name_factory=lambda: "fixed-image-container", + ).validate( + request=request, + source_root=source, + patch_path=patch_path, + ) + + assert result.status is PatchValidatorImageStatus.PASSED + assert result.repository_full_name == request.repository_full_name + assert result.base_sha == request.base_sha + assert result.head_sha == request.head_sha + assert result.patch_sha256 == request.patch_sha256 + assert result.profile is request.profile + assert result.command_profile == "node_patch_verify_v1" + assert result.validator_image_digest == TEST_IMAGE_DIGEST + assert result.exit_code == 0 + assert result.stdout_excerpt == "" + assert result.stderr_excerpt == "" + assert result.reason_codes == [] + assert "--env=NOEMA_RESULT_PATH=/workspace/result.json" in captured_command + assert not any("dst=/output/result.json" in argument for argument in captured_command) diff --git a/reviewer/tests/test_patch_validation_image_runner_edges.py b/reviewer/tests/test_patch_validation_image_runner_edges.py new file mode 100644 index 000000000..ab27c653a --- /dev/null +++ b/reviewer/tests/test_patch_validation_image_runner_edges.py @@ -0,0 +1,299 @@ +"""Branch-complete failure tests for the image-bound patch-validation runner.""" + +from __future__ import annotations + +import hashlib +import subprocess +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from noema_reviewer import patch_image_validation, patch_validation +from noema_reviewer.patch_image_validation import ( + DockerPatchValidatorImageRunner, + PatchValidatorImageProfile, + PatchValidatorImageRequest, + PatchValidatorImageStatus, +) + + +TEST_IMAGE_DIGEST = "sha256:" + "a" * 64 +TEST_IMAGE = f"{patch_validation.TRUSTED_PATCH_IMAGE_REPOSITORY}@{TEST_IMAGE_DIGEST}" +PATCH_BYTES = ( + b"diff --git a/src/example.ts b/src/example.ts\n" + b"--- a/src/example.ts\n" + b"+++ b/src/example.ts\n" + b"@@ -1 +1 @@\n" + b"-old value\n" + b"+new value\n" +) + + +def _request(*, patch_sha256: str | None = None) -> PatchValidatorImageRequest: + """Return one exact request for deterministic runner-edge tests.""" + return PatchValidatorImageRequest( + repository_full_name="ContextualWisdomLab/noema", + base_sha="1" * 40, + head_sha="2" * 40, + patch_sha256=patch_sha256 or hashlib.sha256(PATCH_BYTES).hexdigest(), + profile=PatchValidatorImageProfile.NODE_PATCH_VERIFY, + ) + + +def _install_host_boundaries( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> tuple[Path, Path]: + """Replace inherited Git boundaries with deterministic local test adapters.""" + source = tmp_path / "source" + source.mkdir() + patch_path = tmp_path / "proposal.patch" + patch_path.write_bytes(PATCH_BYTES) + + monkeypatch.setattr( + patch_validation, + "_validated_directory", + lambda _path, _label: source, + ) + monkeypatch.setattr( + patch_validation, + "_read_regular_patch", + lambda _path, **_kwargs: (patch_path, PATCH_BYTES), + ) + monkeypatch.setattr( + patch_validation, + "_verified_image_reference", + lambda: TEST_IMAGE, + ) + monkeypatch.setattr( + patch_validation, + "_git_metadata_kind", + lambda _source: "directory", + ) + monkeypatch.setattr( + patch_validation, + "_verify_source_head", + lambda *_args, **_kwargs: None, + ) + + def materialize(_source, _head_sha, staging_root, _metadata_kind): + """Create one minimal private source mount inside the active staging root.""" + source_mount = staging_root / "source" + source_mount.mkdir() + (source_mount / ".git").mkdir() + return source_mount + + monkeypatch.setattr( + patch_validation, + "_materialize_committed_source", + materialize, + ) + + def stage_patch(staging_root, patch_bytes): + """Create the private patch copy expected by the Docker command.""" + staged_patch = staging_root / "input.patch" + staged_patch.write_bytes(patch_bytes) + return staged_patch + + monkeypatch.setattr( + patch_validation, + "_write_private_patch_copy", + stage_patch, + ) + + def metadata_mask(staging_root, _metadata_kind): + """Create a deterministic empty Git metadata mask.""" + mask = staging_root / "git-mask" + mask.mkdir() + return mask + + monkeypatch.setattr( + patch_validation, + "_create_git_metadata_mask", + metadata_mask, + ) + monkeypatch.setattr(patch_image_validation.os, "getuid", lambda: 1000) + monkeypatch.setattr(patch_image_validation.os, "getgid", lambda: 1000) + return source, patch_path + + +def test_runner_rejects_patch_digest_mismatch( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Patch bytes must match the exact digest before image or Git processing.""" + source, patch_path = _install_host_boundaries(tmp_path, monkeypatch) + + with pytest.raises(RuntimeError, match="digest does not match"): + DockerPatchValidatorImageRunner().validate( + request=_request(patch_sha256="0" * 64), + source_root=source, + patch_path=patch_path, + ) + + +def test_runner_rejects_missing_git_metadata_after_preflight( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A missing metadata shape cannot become exact-head image evidence.""" + source, patch_path = _install_host_boundaries(tmp_path, monkeypatch) + monkeypatch.setattr(patch_validation, "_git_metadata_kind", lambda _source: None) + + with pytest.raises(RuntimeError, match="Git metadata is required"): + DockerPatchValidatorImageRunner().validate( + request=_request(), + source_root=source, + patch_path=patch_path, + ) + + +@pytest.mark.parametrize(("uid", "gid"), ((0, 1000), (1000, 0))) +def test_runner_rejects_root_identity( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + uid: int, + gid: int, +) -> None: + """Both root UID and root GID are independently rejected before Docker.""" + source, patch_path = _install_host_boundaries(tmp_path, monkeypatch) + monkeypatch.setattr(patch_image_validation.os, "getuid", lambda: uid) + monkeypatch.setattr(patch_image_validation.os, "getgid", lambda: gid) + + with pytest.raises(RuntimeError, match="non-root"): + DockerPatchValidatorImageRunner().validate( + request=_request(), + source_root=source, + patch_path=patch_path, + ) + + +def test_runner_rejects_missing_metadata_mask( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Docker cannot receive source until its nested Git metadata mask exists.""" + source, patch_path = _install_host_boundaries(tmp_path, monkeypatch) + monkeypatch.setattr( + patch_validation, + "_create_git_metadata_mask", + lambda *_args, **_kwargs: None, + ) + + with pytest.raises(RuntimeError, match="mask could not be created"): + DockerPatchValidatorImageRunner().validate( + request=_request(), + source_root=source, + patch_path=patch_path, + ) + + +def test_runner_timeout_forces_bounded_cleanup( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A timed-out validator is force-removed with the same minimal environment.""" + source, patch_path = _install_host_boundaries(tmp_path, monkeypatch) + cleanup_calls: list[tuple[list[str], dict[str, object]]] = [] + + def timeout(*_args, **_kwargs): + """Emulate a container exceeding the fixed wall-time contract.""" + raise subprocess.TimeoutExpired("docker", 1) + + def cleanup(command, **kwargs): + """Record bounded Docker cleanup without invoking a real daemon.""" + cleanup_calls.append((list(command), kwargs)) + return SimpleNamespace(returncode=0) + + with pytest.raises(RuntimeError, match="timed out"): + DockerPatchValidatorImageRunner( + command_runner=timeout, + cleanup_runner=cleanup, + name_factory=lambda: "fixed-image-container", + ).validate( + request=_request(), + source_root=source, + patch_path=patch_path, + ) + + assert cleanup_calls[0][0] == [ + "docker", + "rm", + "-f", + "fixed-image-container", + ] + assert cleanup_calls[0][1]["timeout"] == 30 + + +def test_runner_wraps_docker_launch_error( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An operating-system launch error is not mistaken for validation evidence.""" + source, patch_path = _install_host_boundaries(tmp_path, monkeypatch) + + def fail_launch(*_args, **_kwargs): + """Emulate Docker being unavailable on the trusted host.""" + raise OSError("docker unavailable") + + with pytest.raises(RuntimeError, match="could not start Docker"): + DockerPatchValidatorImageRunner(command_runner=fail_launch).validate( + request=_request(), + source_root=source, + patch_path=patch_path, + ) + + +@pytest.mark.parametrize( + "completed", + ( + SimpleNamespace(returncode=7, stderr="validator failed", stdout=""), + SimpleNamespace(returncode=8, stderr="", stdout="stdout failure"), + SimpleNamespace(returncode=9), + ), +) +def test_runner_rejects_nonzero_container_exit( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + completed: SimpleNamespace, +) -> None: + """Nonzero image exits fail visibly with bounded available diagnostics.""" + source, patch_path = _install_host_boundaries(tmp_path, monkeypatch) + + with pytest.raises(RuntimeError, match=f"exited {completed.returncode}"): + DockerPatchValidatorImageRunner( + command_runner=lambda *_args, **_kwargs: completed + ).validate( + request=_request(), + source_root=source, + patch_path=patch_path, + ) + + +def test_runner_ignores_container_text_and_measures_on_trusted_host( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Successful host evidence excludes attacker-controlled container text.""" + source, patch_path = _install_host_boundaries(tmp_path, monkeypatch) + times = iter((1_000_000_000, 1_010_000_000)) + + result = DockerPatchValidatorImageRunner( + command_runner=lambda *_args, **_kwargs: SimpleNamespace( + returncode=0, + stdout='{"status":"passed","forged":true}', + stderr="forged diagnostics", + ), + clock=lambda: next(times), + ).validate( + request=_request(), + source_root=source, + patch_path=patch_path, + ) + + assert result.status is PatchValidatorImageStatus.PASSED + assert result.duration_ms == 10 + assert result.stdout_excerpt == "" + assert result.stderr_excerpt == "" + assert result.reason_codes == [] diff --git a/scripts/lib/patch-validator-image-receipts.mjs b/scripts/lib/patch-validator-image-receipts.mjs new file mode 100644 index 000000000..403fd77a7 --- /dev/null +++ b/scripts/lib/patch-validator-image-receipts.mjs @@ -0,0 +1,314 @@ +import { O_NOFOLLOW, O_RDONLY } from "node:constants"; +import { + closeSync, + fstatSync, + lstatSync, + openSync, + readSync, +} from "node:fs"; +import { hasDuplicateJsonObjectKeys } from "../normalize-commercial-readiness-evidence.mjs"; + +export const MAX_RECEIPT_BYTES = 8 * 1024 * 1024; + +const SHA1 = /^[0-9a-f]{40}$/; +const IMAGE_DIGEST = /^sha256:[0-9a-f]{64}$/; +const SUPPORTED_CYCLONEDX_VERSIONS = new Set(["1.5", "1.6", "1.7"]); +const EXPECTED_SOURCE_LABEL = "https://github.com/ContextualWisdomLab/noema"; +const fatalUtf8Decoder = new TextDecoder("utf-8", { fatal: true }); +const EXPECTED_ENTRYPOINT = [ + "/nodejs/bin/node", + "--input-type=module", + "--eval", + "import { runCli } from '/opt/noema/runtime.mjs'; import { runEntrypoint } from '/opt/noema/entrypoint.mjs'; process.exitCode = runEntrypoint({ runCliImpl: runCli, writeDiagnostic: (message) => process.stderr.write(message) });", +]; +const DEFAULT_RECEIPT_FILE_SYSTEM = Object.freeze({ + closeSync, + fstatSync, + lstatSync, + openSync, + readSync, +}); + +function requireCondition(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function requireRecord(value, label) { + requireCondition( + Object.prototype.toString.call(value) === "[object Object]", + `${label} must be a JSON record`, + ); + return value; +} + +function receiptByteLengthIsValid(size, maximumBytes) { + return size > 0 && size <= maximumBytes; +} + +export function readBoundedJson( + path, + maximumBytes = MAX_RECEIPT_BYTES, + fileSystem = DEFAULT_RECEIPT_FILE_SYSTEM, +) { + const pathMetadata = fileSystem.lstatSync(path); + requireCondition(pathMetadata.isFile(), "receipt must be a regular file"); + requireCondition( + receiptByteLengthIsValid(pathMetadata.size, maximumBytes), + "receipt has an invalid byte length", + ); + + const descriptor = fileSystem.openSync(path, O_RDONLY | O_NOFOLLOW); + try { + const before = fileSystem.fstatSync(descriptor); + requireCondition( + receiptByteLengthIsValid(before.size, maximumBytes), + "receipt has an invalid byte length", + ); + requireCondition( + pathMetadata.dev === before.dev && pathMetadata.ino === before.ino, + "receipt changed while it was being opened", + ); + + const buffer = Buffer.alloc(before.size + 1); + let offset = 0; + while (offset < buffer.length) { + const bytesRead = fileSystem.readSync( + descriptor, + buffer, + offset, + buffer.length - offset, + null, + ); + if (bytesRead === 0) { + break; + } + offset += bytesRead; + } + + const after = fileSystem.fstatSync(descriptor); + requireCondition( + before.dev === after.dev && + before.ino === after.ino && + before.size === after.size, + "receipt changed while it was being read", + ); + requireCondition( + offset === before.size, + "receipt changed while it was being read", + ); + + try { + const text = fatalUtf8Decoder.decode(buffer.subarray(0, offset)); + requireCondition( + !hasDuplicateJsonObjectKeys(text), + "receipt must not contain duplicate decoded JSON keys", + ); + return JSON.parse(text); + } catch (error) { + throw new Error("receipt must contain valid JSON", { cause: error }); + } + } finally { + fileSystem.closeSync(descriptor); + } +} + +function expectedImageReference(expectedSourceRevision) { + return `noema-patch-validator:${expectedSourceRevision}`; +} + +function verifyCycloneDxReceipt( + sbom, + expectedImageDigest, + expectedSourceRevision, +) { + const cyclonedx = requireRecord(sbom, "CycloneDX record"); + requireCondition( + cyclonedx.bomFormat === "CycloneDX", + "CycloneDX format is invalid", + ); + requireCondition( + SUPPORTED_CYCLONEDX_VERSIONS.has(String(cyclonedx.specVersion)), + "CycloneDX version is unsupported", + ); + requireCondition( + Array.isArray(cyclonedx.components), + "CycloneDX components must be an array", + ); + const cyclonedxMetadata = requireRecord( + cyclonedx.metadata, + "CycloneDX metadata record", + ); + const subject = requireRecord( + cyclonedxMetadata.component, + "CycloneDX component record", + ); + requireCondition( + subject.type === "container", + "CycloneDX component type must be container", + ); + requireCondition( + subject.name === expectedImageReference(expectedSourceRevision), + "CycloneDX image reference does not match", + ); + requireCondition( + Array.isArray(subject.properties), + "CycloneDX properties must be an array", + ); + const imageIdentity = subject.properties.find( + (property) => property?.name === "aquasecurity:trivy:ImageID", + ); + requireCondition( + imageIdentity?.value === expectedImageDigest, + "CycloneDX image digest does not match", + ); + return cyclonedx; +} + +function verifyVulnerabilityReceipt( + vulnerabilityScan, + expectedImageDigest, + expectedSourceRevision, +) { + const scan = requireRecord( + vulnerabilityScan, + "vulnerability scan record", + ); + requireCondition( + scan.ArtifactType === "container_image", + "vulnerability artifact type does not match", + ); + requireCondition( + scan.ArtifactName === expectedImageReference(expectedSourceRevision), + "vulnerability image reference does not match", + ); + const scanMetadata = requireRecord( + scan.Metadata, + "vulnerability metadata record", + ); + requireCondition( + scanMetadata.ImageID === expectedImageDigest, + "vulnerability image digest does not match", + ); + requireCondition( + Array.isArray(scan.Results) && scan.Results.length > 0, + "vulnerability results must be a non-empty array", + ); + + let detectedVulnerabilityCount = 0; + for (const rawResult of scan.Results) { + const result = requireRecord(rawResult, "vulnerability result record"); + requireCondition( + result.Vulnerabilities == null || Array.isArray(result.Vulnerabilities), + "vulnerability result entries are invalid", + ); + if (Array.isArray(result.Vulnerabilities)) { + detectedVulnerabilityCount += result.Vulnerabilities.length; + } + } + requireCondition( + detectedVulnerabilityCount === 0, + "detected vulnerabilities are not allowed", + ); + return { + resultCount: scan.Results.length, + detectedVulnerabilityCount, + }; +} + +export function verifyPatchValidatorReceipts({ + metadata, + smokeResult, + sbom, + vulnerabilityScan, + expectedImageDigest, + expectedSourceRevision, +}) { + requireCondition( + IMAGE_DIGEST.test(String(expectedImageDigest)), + "expected image digest is invalid", + ); + requireCondition( + SHA1.test(String(expectedSourceRevision)), + "expected source revision is invalid", + ); + + const imageMetadata = requireRecord(metadata, "metadata record"); + requireCondition( + imageMetadata.schema_version === "noema.patch-validator-image-metadata.v1", + "metadata schema is invalid", + ); + requireCondition( + imageMetadata.source_revision === expectedSourceRevision, + "source revision does not match", + ); + requireCondition( + imageMetadata.validator_image_digest === expectedImageDigest, + "image digest does not match", + ); + requireCondition(imageMetadata.os === "linux", "image OS must be Linux"); + requireCondition( + imageMetadata.architecture === "amd64", + "image architecture must be amd64", + ); + requireCondition( + imageMetadata.user === "65532:65532", + "image must use the expected non-root user", + ); + requireCondition( + JSON.stringify(imageMetadata.entrypoint) === JSON.stringify(EXPECTED_ENTRYPOINT), + "image entrypoint does not match", + ); + const labels = requireRecord(imageMetadata.labels, "labels record"); + requireCondition( + labels["org.opencontainers.image.source"] === EXPECTED_SOURCE_LABEL, + "image source label does not match", + ); + requireCondition( + labels["org.opencontainers.image.revision"] === expectedSourceRevision, + "image revision label does not match", + ); + + const smoke = requireRecord(smokeResult, "smoke record"); + requireCondition(smoke.status === "passed", "smoke status is not passed"); + requireCondition(smoke.exit_code === 0, "smoke exit code is not zero"); + requireCondition( + smoke.validator_image_digest === expectedImageDigest, + "smoke image digest does not match", + ); + requireCondition( + smoke.head_sha === expectedSourceRevision, + "smoke source revision does not match", + ); + requireCondition( + smoke.profile === "node_patch_verify", + "smoke profile does not match", + ); + requireCondition( + smoke.command_profile === "node_patch_verify_v1", + "smoke command profile does not match", + ); + + const cyclonedx = verifyCycloneDxReceipt( + sbom, + expectedImageDigest, + expectedSourceRevision, + ); + const vulnerability = verifyVulnerabilityReceipt( + vulnerabilityScan, + expectedImageDigest, + expectedSourceRevision, + ); + + return { + schema_version: "noema.patch-validator-image-verification.v1", + status: "passed", + source_revision: expectedSourceRevision, + validator_image_digest: expectedImageDigest, + cyclonedx_spec_version: cyclonedx.specVersion, + component_count: cyclonedx.components.length, + vulnerability_result_count: vulnerability.resultCount, + detected_vulnerability_count: vulnerability.detectedVulnerabilityCount, + }; +} diff --git a/scripts/lib/patch-validator-smoke-diagnostic.mjs b/scripts/lib/patch-validator-smoke-diagnostic.mjs new file mode 100644 index 000000000..3a98c3183 --- /dev/null +++ b/scripts/lib/patch-validator-smoke-diagnostic.mjs @@ -0,0 +1,96 @@ +import { lstatSync, mkdirSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +import { readBoundedJson } from "./patch-validator-image-receipts.mjs"; + +export const MAX_DIAGNOSTIC_BYTES = 16 * 1024; + +const DIAGNOSTIC_STATUSES = new Set(["passed", "failed", "blocked"]); +const REASON_CODE = /^[a-z][a-z0-9_]{0,63}$/; +const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g; +const MAX_STDERR_EXCERPT_CHARACTERS = 2_048; +const MAX_REASON_CODES = 20; +const WORKFLOW_DIAGNOSTIC_NAME = "patch-validator-untrusted-diagnostic.json"; +const RETAINED_DIAGNOSTIC_NAME = "smoke-diagnostic.json"; + +function isRecord(value) { + return Object.prototype.toString.call(value) === "[object Object]"; +} + +function retainWorkflowDiagnostic(path, diagnostic) { + const runnerTemp = String(process.env.RUNNER_TEMP ?? "").trim(); + if (!runnerTemp) { + return; + } + + const trustedRunnerTemp = resolve(runnerTemp); + const expectedDiagnosticPath = join(trustedRunnerTemp, WORKFLOW_DIAGNOSTIC_NAME); + if (resolve(path) !== expectedDiagnosticPath) { + return; + } + + const evidenceDirectory = join(trustedRunnerTemp, "patch-validator-evidence"); + mkdirSync(evidenceDirectory, { recursive: true, mode: 0o700 }); + const directoryStat = lstatSync(evidenceDirectory); + if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) { + throw new Error("smoke diagnostic evidence directory is unsafe"); + } + + const evidencePath = join(evidenceDirectory, RETAINED_DIAGNOSTIC_NAME); + writeFileSync(evidencePath, `${JSON.stringify(diagnostic, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + flag: "wx", + }); +} + +export function readPatchValidatorDiagnostic(path) { + let value; + try { + value = readBoundedJson(path, MAX_DIAGNOSTIC_BYTES); + } catch (error) { + const detail = String(error); + throw new Error(`smoke diagnostic is unavailable or unsafe: ${detail}`, { + cause: error, + }); + } + + if (!isRecord(value)) { + throw new Error("smoke diagnostic fields are invalid"); + } + if (!DIAGNOSTIC_STATUSES.has(value.status)) { + throw new Error("smoke diagnostic fields are invalid"); + } + if ( + !Number.isInteger(value.exit_code) || + value.exit_code < 0 || + value.exit_code > 255 + ) { + throw new Error("smoke diagnostic fields are invalid"); + } + if (typeof value.stderr_excerpt !== "string") { + throw new Error("smoke diagnostic fields are invalid"); + } + if ( + !Array.isArray(value.reason_codes) || + value.reason_codes.length > MAX_REASON_CODES || + !value.reason_codes.every( + (reasonCode) => + typeof reasonCode === "string" && REASON_CODE.test(reasonCode), + ) + ) { + throw new Error("smoke diagnostic fields are invalid"); + } + + const diagnostic = { + trusted: false, + status: value.status, + exit_code: value.exit_code, + stderr_excerpt: value.stderr_excerpt + .replace(CONTROL_CHARACTERS, "") + .slice(0, MAX_STDERR_EXCERPT_CHARACTERS), + reason_codes: [...value.reason_codes], + }; + retainWorkflowDiagnostic(path, diagnostic); + return diagnostic; +} diff --git a/scripts/lib/patch-validator-static-runtime-evidence.mjs b/scripts/lib/patch-validator-static-runtime-evidence.mjs new file mode 100644 index 000000000..502ee9121 --- /dev/null +++ b/scripts/lib/patch-validator-static-runtime-evidence.mjs @@ -0,0 +1,750 @@ +const IMAGE_DIGEST = /^sha256:[0-9a-f]{64}$/; +const RFC3339_TIMESTAMP = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; +const GRYPE_DATABASE_SCHEMA = /^v\d+\.\d+\.\d+$/; +const PROVIDER_NAME = /^[a-z0-9][a-z0-9_.-]{0,63}$/; +const EXPECTED_NODE_VERSION = "24.19.0"; +const EXPECTED_NODE_CPE = + `cpe:2.3:a:nodejs:node.js:${EXPECTED_NODE_VERSION}:*:*:*:*:*:*:*`; +const EMBEDDED_INVENTORY_SCHEMA = + "noema.patch-validator-embedded-runtime-inventory.v1"; +const EMBEDDED_SCAN_SCHEMA = + "noema.patch-validator-embedded-runtime-vulnerability-scan.v1"; +const EMBEDDED_COMPONENT_LIMIT = 128; +const COMPONENT_KEY = /^[a-z0-9][a-z0-9_-]{0,63}$/; +const BLOCKING_SEVERITIES = new Set(["MEDIUM", "HIGH", "CRITICAL", "UNKNOWN"]); +const ALLOWED_SEVERITIES = new Set([ + "NEGLIGIBLE", + "LOW", + "MEDIUM", + "HIGH", + "CRITICAL", + "UNKNOWN", +]); +const RUNTIME_METADATA_REASONS = new Map([ + ["modules", "Node.js native module ABI version"], + ["napi", "Node-API compatibility level"], +]); +const REVIEWED_COMPONENT_IDENTITIES = new Map([ + [ + "acorn", + { name: "acorn", identityType: "npm", npmPackage: "acorn" }, + ], + [ + "amaro", + { name: "amaro", identityType: "npm", npmPackage: "amaro" }, + ], + [ + "undici", + { name: "undici", identityType: "npm", npmPackage: "undici" }, + ], + [ + "openssl", + { + name: "openssl", + identityType: "cpe", + cpeVendor: "openssl", + cpeProduct: "openssl", + }, + ], + [ + "ngtcp2", + { + name: "ngtcp2", + identityType: "cpe", + cpeVendor: "nghttp2", + cpeProduct: "ngtcp2", + }, + ], +]); +const NGTCP2_FIXED_VERSION = "1.22.1"; +const NGTCP2_FIXED_VERSION_PARTS = [1n, 22n, 1n]; + +function requireCondition(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function requireRecord(value, label) { + requireCondition( + Object.prototype.toString.call(value) === "[object Object]", + `${label} must be a JSON record`, + ); + return value; +} + +function normalizedSeverity(value) { + requireCondition( + typeof value === "string", + "binary vulnerability severity must be a string", + ); + const severity = value.toUpperCase(); + requireCondition( + ALLOWED_SEVERITIES.has(severity), + "binary vulnerability severity is unsupported", + ); + return severity; +} + +function countBlockingMatches(matches, label) { + requireCondition(Array.isArray(matches), `${label} matches must be an array`); + let blocking = 0; + for (const rawMatch of matches) { + const match = requireRecord(rawMatch, `${label} match`); + const vulnerability = requireRecord( + match.vulnerability, + `${label} vulnerability`, + ); + if (BLOCKING_SEVERITIES.has(normalizedSeverity(vulnerability.severity))) { + blocking += 1; + } + } + return blocking; +} + +function imageIdFromSyftSource(source) { + const metadata = requireRecord(source.metadata, "Syft source metadata"); + return metadata.imageID ?? metadata.imageId ?? null; +} + +function packageHasNodeBinaryLocation(pkg) { + return ( + Array.isArray(pkg.locations) && + pkg.locations.some((location) => { + const record = requireRecord(location, "Syft package location"); + return ( + record.path === "/nodejs/bin/node" || + record.accessPath === "/nodejs/bin/node" + ); + }) + ); +} + +function packageHasNodeCpe(pkg) { + return ( + Array.isArray(pkg.cpes) && + pkg.cpes.some((candidate) => { + if (typeof candidate === "string") { + return candidate === EXPECTED_NODE_CPE; + } + const record = requireRecord(candidate, "Syft package CPE"); + return record.cpe === EXPECTED_NODE_CPE; + }) + ); +} + +function reviewedIdentityDefinition(component) { + const definition = REVIEWED_COMPONENT_IDENTITIES.get(component.key); + requireCondition( + definition !== undefined, + `embedded runtime component ${String(component.key)} has no reviewed vulnerability identity in the identity catalog`, + ); + requireCondition( + component.name === definition.name, + `embedded runtime component ${component.key} name does not match the reviewed identity catalog`, + ); + return definition; +} + +function reviewedNpmPurl(component, purl, definition) { + requireCondition( + definition.identityType === "npm", + `embedded runtime component ${component.key} vulnerability identity type does not match the reviewed identity catalog`, + ); + const expectedPurl = `pkg:npm/${definition.npmPackage}@${component.version}`; + requireCondition( + purl === expectedPurl, + `embedded runtime component ${component.key} has no supported vulnerability identity; npm PURL must bind the exact reviewed package name and version`, + ); + return purl; +} + +function reviewedApplicationCpe(component, cpe, definition) { + requireCondition( + definition.identityType === "cpe", + `embedded runtime component ${component.key} vulnerability identity type does not match the reviewed identity catalog`, + ); + const fields = cpe.split(":"); + requireCondition( + fields.length === 13 && + fields[0] === "cpe" && + fields[1] === "2.3" && + fields[2] === "a", + `embedded runtime component ${component.key} has no supported vulnerability identity; CPE must be a complete CPE 2.3 application identity`, + ); + requireCondition( + fields[3] === definition.cpeVendor, + `embedded runtime component ${component.key} CPE vendor must match the reviewed identity catalog`, + ); + requireCondition( + fields[4] === definition.cpeProduct && fields[4] === component.name, + `embedded runtime component ${component.key} CPE product name must match the reviewed identity catalog and component`, + ); + requireCondition( + fields[5] === component.version, + `embedded runtime component ${component.key} CPE version must match process.versions`, + ); + return cpe; +} + +function componentIdentity(component) { + const definition = reviewedIdentityDefinition(component); + const cpe = component.cpe; + const purl = component.purl; + const hasCpe = typeof cpe === "string" && cpe.length > 0 && cpe.length <= 512; + const hasPurl = typeof purl === "string" && purl.length > 0 && purl.length <= 512; + requireCondition( + hasCpe !== hasPurl, + `embedded runtime component ${String(component.key)} has no supported vulnerability identity; declare exactly one reviewed CPE or npm PURL`, + ); + requireCondition( + !hasPurl || purl.startsWith("pkg:npm/"), + `embedded runtime component ${String(component.key)} has no supported vulnerability identity; only canonical npm PURLs or reviewed application CPEs are allowed`, + ); + return hasPurl + ? reviewedNpmPurl(component, purl, definition) + : reviewedApplicationCpe(component, cpe, definition); +} + +function expectedScannerSourceType(identity) { + return identity.startsWith("pkg:") ? "purl" : "cpe"; +} + +function verifyGrypeDatabaseEvidence(descriptor, componentKey) { + const database = requireRecord( + descriptor.db, + `embedded runtime component ${componentKey} vulnerability database evidence`, + ); + const status = requireRecord( + database.status, + `embedded runtime component ${componentKey} database status evidence`, + ); + requireCondition( + GRYPE_DATABASE_SCHEMA.test(String(status.schemaVersion)), + `embedded runtime component ${componentKey} database schema evidence is invalid`, + ); + requireCondition( + RFC3339_TIMESTAMP.test(String(status.built)), + `embedded runtime component ${componentKey} database build timestamp is invalid`, + ); + requireCondition( + status.valid === true, + `embedded runtime component ${componentKey} vulnerability database must be valid`, + ); + requireCondition( + status.error == null || status.error === "", + `embedded runtime component ${componentKey} vulnerability database error is not allowed`, + ); + + const providers = requireRecord( + database.providers, + `embedded runtime component ${componentKey} database providers evidence`, + ); + const providerEntries = Object.entries(providers); + requireCondition( + providerEntries.length > 0 && providerEntries.length <= EMBEDDED_COMPONENT_LIMIT, + `embedded runtime component ${componentKey} database providers evidence must be a bounded non-empty record`, + ); + const normalizedProviders = []; + for (const [providerName, rawProvider] of providerEntries) { + requireCondition( + PROVIDER_NAME.test(providerName), + `embedded runtime component ${componentKey} database provider name is invalid`, + ); + const provider = requireRecord( + rawProvider, + `embedded runtime component ${componentKey} database provider evidence`, + ); + requireCondition( + RFC3339_TIMESTAMP.test(String(provider.captured)), + `embedded runtime component ${componentKey} provider capture timestamp is invalid`, + ); + requireCondition( + IMAGE_DIGEST.test(String(provider.input)), + `embedded runtime component ${componentKey} provider input digest is invalid`, + ); + normalizedProviders.push([ + providerName, + { + captured: provider.captured, + input: provider.input, + }, + ]); + } + normalizedProviders.sort(([left], [right]) => left.localeCompare(right)); + return JSON.stringify({ + schema_version: status.schemaVersion, + built_at: status.built, + providers: normalizedProviders, + }); +} + +function artifactCpeValue(candidate) { + if (typeof candidate === "string") return candidate; + const record = requireRecord(candidate, "embedded runtime match artifact CPE"); + return record.cpe; +} + +function verifyEmbeddedMatchArtifact(match, component, expectedIdentity) { + const artifact = requireRecord( + match.artifact, + `embedded runtime component ${component.key} match artifact`, + ); + requireCondition( + artifact.version === component.version, + `embedded runtime component ${component.key} match artifact version does not match the reviewed component`, + ); + if (artifact.name != null) { + requireCondition( + artifact.name === component.name, + `embedded runtime component ${component.key} match artifact name does not match the reviewed component`, + ); + } + + if (expectedIdentity.startsWith("pkg:")) { + requireCondition( + artifact.name === component.name && artifact.purl === expectedIdentity, + `embedded runtime component ${component.key} match artifact identity does not match the reviewed npm component`, + ); + return; + } + + requireCondition( + Array.isArray(artifact.cpes) && + artifact.cpes.some((candidate) => artifactCpeValue(candidate) === expectedIdentity), + `embedded runtime component ${component.key} match artifact identity does not match the reviewed CPE component`, + ); +} + +function verifyEmbeddedScannerOutput(componentScan, expectedIdentity, component) { + const componentKey = componentScan.key; + const rawScanner = requireRecord( + componentScan.scanner_output, + `embedded runtime component ${componentKey} raw scanner evidence`, + ); + requireCondition( + componentScan.assessment == null && + componentScan.matches == null && + componentScan.ignoredMatches == null, + `embedded runtime component ${componentKey} synthetic assessment fields are not allowed`, + ); + const descriptor = requireRecord( + rawScanner.descriptor, + `embedded runtime component ${componentKey} raw scanner descriptor`, + ); + requireCondition( + descriptor.name === "grype", + `embedded runtime component ${componentKey} raw scanner must be produced by Grype`, + ); + requireCondition( + descriptor.version === "0.116.1", + `embedded runtime component ${componentKey} raw scanner version does not match`, + ); + const source = requireRecord( + rawScanner.source, + `embedded runtime component ${componentKey} raw scanner source`, + ); + requireCondition( + source.type === expectedScannerSourceType(expectedIdentity), + `embedded runtime component ${componentKey} raw scanner source type does not match`, + ); + requireCondition( + source.target === expectedIdentity, + `embedded runtime component ${componentKey} raw scanner source target does not match`, + ); + const databaseIdentity = verifyGrypeDatabaseEvidence(descriptor, componentKey); + requireCondition( + rawScanner.ignoredMatches == null || + (Array.isArray(rawScanner.ignoredMatches) && + rawScanner.ignoredMatches.length === 0), + "ignored embedded runtime component matches are not allowed", + ); + requireCondition( + Array.isArray(rawScanner.matches), + `embedded runtime component ${componentKey} matches must be an array`, + ); + const blocking = countBlockingMatches( + rawScanner.matches, + `embedded runtime component ${componentKey}`, + ); + for (const rawMatch of rawScanner.matches) { + const match = requireRecord( + rawMatch, + `embedded runtime component ${componentKey} match`, + ); + verifyEmbeddedMatchArtifact(match, component, expectedIdentity); + } + return { + blocking, + databaseIdentity, + matchCount: rawScanner.matches.length, + }; +} + +/** + * Return true only for stable numeric ngtcp2 releases at or above the reviewed + * CVE-2026-40170 fixed floor. Scanner-negative evidence is not sufficient for + * this component because the vendored native dependency can lack a reliable + * ecosystem/CPE match. Ambiguous pre-release or non-numeric versions therefore + * fail closed instead of being interpreted as newer than the fixed release. + */ +function ngtcp2MeetsSecurityFloor(version) { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version); + if (!match) { + return false; + } + const candidate = match.slice(1).map((part) => BigInt(part)); + for (let index = 0; index < NGTCP2_FIXED_VERSION_PARTS.length; index += 1) { + if (candidate[index] > NGTCP2_FIXED_VERSION_PARTS[index]) { + return true; + } + if (candidate[index] < NGTCP2_FIXED_VERSION_PARTS[index]) { + return false; + } + } + return true; +} + +function verifyEmbeddedRuntimeEvidence({ + embeddedRuntimeInventory, + embeddedVulnerabilityScan, + expectedImageDigest, +}) { + const inventory = requireRecord( + embeddedRuntimeInventory, + "embedded runtime inventory", + ); + requireCondition( + inventory.schema_version === EMBEDDED_INVENTORY_SCHEMA, + "embedded runtime inventory schema does not match", + ); + requireCondition( + inventory.validator_image_digest === expectedImageDigest, + "embedded runtime inventory image digest does not match", + ); + requireCondition( + inventory.node_version === EXPECTED_NODE_VERSION, + "embedded runtime inventory Node version does not match", + ); + requireCondition( + Array.isArray(inventory.components) && + inventory.components.length > 0 && + inventory.components.length <= EMBEDDED_COMPONENT_LIMIT, + "embedded runtime inventory components must be a bounded non-empty array", + ); + + const scan = requireRecord( + embeddedVulnerabilityScan, + "embedded runtime vulnerability scan", + ); + requireCondition( + scan.schema_version === EMBEDDED_SCAN_SCHEMA, + "embedded runtime vulnerability scan schema does not match", + ); + requireCondition( + scan.validator_image_digest === expectedImageDigest, + "embedded runtime vulnerability scan image digest does not match", + ); + requireCondition( + scan.scanner === "grype@0.116.1", + "embedded runtime vulnerability scanner does not match", + ); + requireCondition( + scan.ignoredMatches == null || + (Array.isArray(scan.ignoredMatches) && scan.ignoredMatches.length === 0), + "ignored embedded runtime vulnerability matches are not allowed", + ); + + // Older RED fixtures used one aggregate match list. Inspect it first so a + // known blocking advisory can never become non-blocking merely because newer + // completeness fields are absent. Clean aggregate-only evidence is still + // rejected below because per-component scan evidence is mandatory. + if (Array.isArray(scan.matches)) { + const aggregateBlocking = countBlockingMatches( + scan.matches, + "embedded runtime vulnerability scan", + ); + requireCondition( + aggregateBlocking === 0, + "blocking embedded runtime vulnerabilities are not allowed", + ); + } + + const processVersions = requireRecord( + inventory.process_versions, + "embedded runtime process.versions", + ); + requireCondition( + processVersions.node === EXPECTED_NODE_VERSION, + "embedded runtime process.versions Node version does not match", + ); + const expectedKeys = Object.keys(processVersions) + .filter((key) => key !== "node") + .sort(); + requireCondition( + expectedKeys.length > 0 && expectedKeys.length <= EMBEDDED_COMPONENT_LIMIT, + "embedded runtime process.versions dependencies must be a bounded non-empty set", + ); + + const componentByKey = new Map(); + const scannableComponentByKey = new Map(); + for (const rawComponent of inventory.components) { + const component = requireRecord(rawComponent, "embedded runtime component"); + requireCondition( + typeof component.key === "string" && COMPONENT_KEY.test(component.key), + "embedded runtime component key is invalid", + ); + requireCondition( + !componentByKey.has(component.key), + "embedded runtime component keys must be unique", + ); + requireCondition( + typeof component.name === "string" && + component.name.length > 0 && + component.name.length <= 128, + `embedded runtime component ${component.key} name is invalid`, + ); + requireCondition( + typeof component.version === "string" && + component.version.length > 0 && + component.version.length <= 128 && + processVersions[component.key] === component.version, + `embedded runtime component ${component.key} version does not match process.versions`, + ); + + let identity = null; + if (component.classification === "bundled_dependency") { + identity = componentIdentity(component); + if (component.key === "ngtcp2") { + requireCondition( + ngtcp2MeetsSecurityFloor(component.version), + `known vulnerable embedded runtime dependency ngtcp2 ${component.version}; CVE-2026-40170 is fixed in ${NGTCP2_FIXED_VERSION}`, + ); + } + scannableComponentByKey.set(component.key, { identity, component }); + } else { + requireCondition( + component.classification === "runtime_metadata", + `embedded runtime component ${component.key} must be classified as a bundled dependency or approved runtime metadata`, + ); + const expectedReason = RUNTIME_METADATA_REASONS.get(component.key); + requireCondition( + expectedReason !== undefined, + `embedded runtime component ${component.key} runtime metadata classification is not allowed`, + ); + requireCondition( + component.reason === expectedReason, + `embedded runtime component ${component.key} runtime metadata reason does not match`, + ); + requireCondition( + component.cpe == null && component.purl == null, + `embedded runtime component ${component.key} runtime metadata must not declare a vulnerability identity`, + ); + } + componentByKey.set(component.key, { identity, component }); + } + + const actualKeys = [...componentByKey.keys()].sort(); + requireCondition( + actualKeys.length === expectedKeys.length && + actualKeys.every((key, index) => key === expectedKeys[index]), + "embedded runtime component set must exactly match process.versions dependencies", + ); + + requireCondition( + Array.isArray(scan.components) && + scan.components.length === scannableComponentByKey.size, + "embedded runtime vulnerability scan must contain one result per component; one result per bundled dependency is required", + ); + const scannedKeys = new Set(); + let embeddedMatchCount = 0; + let embeddedBlockingCount = 0; + let vulnerabilityDatabaseIdentity; + for (const rawComponentScan of scan.components) { + const componentScan = requireRecord( + rawComponentScan, + "embedded runtime component scan", + ); + requireCondition( + typeof componentScan.key === "string" && + scannableComponentByKey.has(componentScan.key), + "embedded runtime component scan references an unknown component", + ); + requireCondition( + !scannedKeys.has(componentScan.key), + "embedded runtime component scan keys must be unique", + ); + scannedKeys.add(componentScan.key); + const expectedComponent = scannableComponentByKey.get(componentScan.key); + requireCondition( + componentScan.identity === expectedComponent.identity, + `embedded runtime component ${componentScan.key} scan identity does not match`, + ); + const scannerResult = verifyEmbeddedScannerOutput( + componentScan, + expectedComponent.identity, + expectedComponent.component, + ); + if (vulnerabilityDatabaseIdentity === undefined) { + vulnerabilityDatabaseIdentity = scannerResult.databaseIdentity; + } else { + requireCondition( + scannerResult.databaseIdentity === vulnerabilityDatabaseIdentity, + "embedded runtime component scans must use the same vulnerability database identity and provider snapshot", + ); + } + embeddedBlockingCount += scannerResult.blocking; + embeddedMatchCount += scannerResult.matchCount; + } + requireCondition( + scannedKeys.size === scannableComponentByKey.size, + "embedded runtime vulnerability scan did not evaluate every component", + ); + requireCondition( + vulnerabilityDatabaseIdentity !== undefined, + "embedded runtime vulnerability scan must retain a shared database identity", + ); + requireCondition( + embeddedBlockingCount === 0, + "blocking embedded runtime vulnerabilities are not allowed", + ); + + return { + embedded_runtime_component_count: inventory.components.length, + embedded_runtime_vulnerability_match_count: embeddedMatchCount, + embedded_runtime_vulnerability_database_identity: vulnerabilityDatabaseIdentity, + blocked_embedded_runtime_vulnerability_count: embeddedBlockingCount, + }; +} + +/** + * Verify the self-compiled static Node runtime and every dependency exposed by + * that runtime's exact `process.versions` record before accepting vulnerability + * evidence for the immutable image. + * + * Syft/Grype image scanning authenticates the Node executable itself. A fully + * static Node build also bundles native dependencies into that executable, so + * the verifier separately requires an exact-image-bound dependency inventory + * whose component set equals `process.versions` (excluding Node itself). + * `modules` and `napi` remain in that exhaustive inventory as reviewed ABI + * metadata, while every actual bundled dependency must be present in the + * bounded repository-owned identity catalog and carry exactly one version-bound + * reviewed CPE or canonical npm PURL. CPE vendor/product and npm package names + * come from that catalog rather than from untrusted receipt fields. Each + * dependency is accepted only when raw Grype JSON names the pinned scanner, + * binds the exact PURL or CPE as its source target, carries the same + * vulnerability-database/provider snapshot as every sibling scan, and binds + * every reported match to the exact reviewed artifact identity. The retained + * verification result includes that canonical shared database identity. + * Synthetic local completion flags and generic PURLs are rejected. Known + * advisory floors cover scanner identity gaps; unknown identities, omitted + * components, ignored matches, malformed database provenance, cross-package + * matches, and medium-or-higher or unknown-severity advisories fail closed. + */ +export function verifyStaticRuntimeBinaryEvidence({ + binarySbom, + binaryVulnerabilityScan, + embeddedRuntimeInventory, + embeddedVulnerabilityScan, + expectedImageDigest, +}) { + requireCondition( + IMAGE_DIGEST.test(String(expectedImageDigest)), + "expected static-runtime image digest is invalid", + ); + + const syft = requireRecord(binarySbom, "Syft SBOM record"); + const syftDescriptor = requireRecord(syft.descriptor, "Syft descriptor"); + requireCondition( + syftDescriptor.name === "syft", + "binary SBOM must be produced by Syft", + ); + requireCondition( + syftDescriptor.version === "1.50.0", + "binary SBOM Syft version does not match", + ); + const syftSource = requireRecord(syft.source, "Syft source"); + requireCondition(syftSource.type === "image", "Syft source must be an image"); + requireCondition( + imageIdFromSyftSource(syftSource) === expectedImageDigest, + "Syft image digest does not match", + ); + requireCondition( + Array.isArray(syft.artifacts), + "Syft artifacts must be an array", + ); + + const nodePackages = syft.artifacts.filter((rawPackage) => { + const pkg = requireRecord(rawPackage, "Syft package"); + return ( + pkg.name === "node" && + pkg.version === EXPECTED_NODE_VERSION && + packageHasNodeBinaryLocation(pkg) && + packageHasNodeCpe(pkg) + ); + }); + requireCondition( + nodePackages.length === 1, + "Syft must identify exactly one expected static Node runtime", + ); + + const grype = requireRecord( + binaryVulnerabilityScan, + "Grype vulnerability record", + ); + const grypeDescriptor = requireRecord(grype.descriptor, "Grype descriptor"); + requireCondition( + grypeDescriptor.name === "grype", + "binary scan must be produced by Grype", + ); + requireCondition( + grypeDescriptor.version === "0.116.1", + "binary scan Grype version does not match", + ); + const grypeSource = requireRecord(grype.source, "Grype source"); + requireCondition( + grypeSource.type === "image", + "Grype source must be an image", + ); + const grypeTarget = requireRecord( + grypeSource.target, + "Grype image target", + ); + requireCondition( + grypeTarget.imageID === expectedImageDigest, + "Grype image digest does not match", + ); + requireCondition( + Array.isArray(grype.matches), + "Grype matches must be an array", + ); + requireCondition( + grype.ignoredMatches == null || + (Array.isArray(grype.ignoredMatches) && + grype.ignoredMatches.length === 0), + "ignored binary vulnerability matches are not allowed", + ); + + const blockingMatchCount = countBlockingMatches(grype.matches, "Grype"); + requireCondition( + blockingMatchCount === 0, + "blocking static-runtime vulnerabilities are not allowed", + ); + + const embedded = verifyEmbeddedRuntimeEvidence({ + embeddedRuntimeInventory, + embeddedVulnerabilityScan, + expectedImageDigest, + }); + + return { + binary_cataloger: "syft@1.50.0", + binary_vulnerability_scanner: "grype@0.116.1", + node_runtime_version: EXPECTED_NODE_VERSION, + binary_package_count: syft.artifacts.length, + binary_vulnerability_match_count: grype.matches.length, + blocked_binary_vulnerability_count: blockingMatchCount, + ...embedded, + }; +} diff --git a/scripts/verify-patch-validator-image.mjs b/scripts/verify-patch-validator-image.mjs new file mode 100644 index 000000000..cc62854e2 --- /dev/null +++ b/scripts/verify-patch-validator-image.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node + +import { + MAX_RECEIPT_BYTES, + readBoundedJson, + verifyPatchValidatorReceipts, +} from "./lib/patch-validator-image-receipts.mjs"; +import { verifyStaticRuntimeBinaryEvidence } from "./lib/patch-validator-static-runtime-evidence.mjs"; + +export { + MAX_RECEIPT_BYTES, + verifyPatchValidatorReceipts, + verifyStaticRuntimeBinaryEvidence, +}; + +function parseArguments(args) { + const values = new Map(); + for (let index = 0; index < args.length; index += 2) { + const flag = args[index]; + const value = args[index + 1]; + if (!flag?.startsWith("--") || value === undefined) { + throw new Error("patch-validator receipt verifier arguments are incomplete"); + } + if (values.has(flag)) { + throw new Error(`duplicate patch-validator verifier argument: ${flag}`); + } + values.set(flag, value); + } + const required = [ + "--metadata", + "--smoke", + "--sbom", + "--vulnerability-scan", + "--binary-sbom", + "--binary-vulnerability-scan", + "--embedded-runtime-inventory", + "--embedded-vulnerability-scan", + "--expected-image-digest", + "--expected-source-revision", + ]; + for (const flag of required) { + if (!values.has(flag)) { + throw new Error(`missing patch-validator verifier argument: ${flag}`); + } + } + if (values.size !== required.length) { + throw new Error("unknown patch-validator verifier argument"); + } + return values; +} + +export function main(args = process.argv.slice(2)) { + const values = parseArguments(args); + const expectedImageDigest = values.get("--expected-image-digest"); + const receipt = verifyPatchValidatorReceipts({ + metadata: readBoundedJson(values.get("--metadata")), + smokeResult: readBoundedJson(values.get("--smoke")), + sbom: readBoundedJson(values.get("--sbom"), MAX_RECEIPT_BYTES), + vulnerabilityScan: readBoundedJson( + values.get("--vulnerability-scan"), + MAX_RECEIPT_BYTES, + ), + expectedImageDigest, + expectedSourceRevision: values.get("--expected-source-revision"), + }); + const staticRuntimeReceipt = verifyStaticRuntimeBinaryEvidence({ + binarySbom: readBoundedJson( + values.get("--binary-sbom"), + MAX_RECEIPT_BYTES, + ), + binaryVulnerabilityScan: readBoundedJson( + values.get("--binary-vulnerability-scan"), + MAX_RECEIPT_BYTES, + ), + embeddedRuntimeInventory: readBoundedJson( + values.get("--embedded-runtime-inventory"), + MAX_RECEIPT_BYTES, + ), + embeddedVulnerabilityScan: readBoundedJson( + values.get("--embedded-vulnerability-scan"), + MAX_RECEIPT_BYTES, + ), + expectedImageDigest, + }); + process.stdout.write( + `${JSON.stringify({ ...receipt, ...staticRuntimeReceipt }, null, 2)}\n`, + ); +} + +main(); diff --git a/test/patch-validator-control-plane-isolation.test.mjs b/test/patch-validator-control-plane-isolation.test.mjs new file mode 100644 index 000000000..ec0d97d5a --- /dev/null +++ b/test/patch-validator-control-plane-isolation.test.mjs @@ -0,0 +1,79 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +import { runValidationCommands } from "../patch-validator/runtime.mjs"; + +const TYPESCRIPT_MODULE = "/opt/noema/node_modules/typescript/bin/tsc"; +const VITEST_MODULE = "/opt/noema/node_modules/vitest/vitest.mjs"; +const TRUSTED_TYPESCRIPT_CONFIG = "/opt/noema/validator-tsconfig.json"; +const TRUSTED_VITEST_CONFIG = "/opt/noema/validator-vitest.config.mjs"; + +describe("patch-validator control-plane isolation", () => { + it("never loads validation policy from the untrusted source tree", () => { + const sourceRoot = "/workspace/source"; + const invocations = []; + + const result = runValidationCommands(sourceRoot, { + spawnSyncImpl: (command, argumentsList, options) => { + invocations.push({ command, argumentsList, options }); + return { + status: 0, + signal: null, + stdout: "", + stderr: "", + error: undefined, + }; + }, + }); + + expect(result.exitCode).toBe(0); + expect(invocations).toHaveLength(2); + expect(invocations[0].argumentsList).toEqual([ + TYPESCRIPT_MODULE, + "--noEmit", + "--project", + TRUSTED_TYPESCRIPT_CONFIG, + ]); + expect(invocations[1].argumentsList).toEqual([ + VITEST_MODULE, + "run", + "--coverage", + "--root", + sourceRoot, + "--configLoader", + "runner", + "--config", + TRUSTED_VITEST_CONFIG, + ]); + for (const invocation of invocations) { + expect(invocation.argumentsList).not.toContain( + `${sourceRoot}/tsconfig.json`, + ); + expect(invocation.argumentsList).not.toContain( + `${sourceRoot}/vitest.config.ts`, + ); + } + }); + + it("copies immutable image-owned validation configurations into the runtime", () => { + const dockerfile = readFileSync("Dockerfile.patch-validator", "utf8"); + const dockerignore = readFileSync( + "Dockerfile.patch-validator.dockerignore", + "utf8", + ); + + expect(dockerfile).toContain( + "COPY --chown=65532:65532 patch-validator/validator-tsconfig.json /opt/noema/validator-tsconfig.json", + ); + expect(dockerfile).toContain( + "COPY --chown=65532:65532 patch-validator/validator-vitest.config.mjs /opt/noema/validator-vitest.config.mjs", + ); + expect(dockerignore).toContain( + "!patch-validator/validator-tsconfig.json", + ); + expect(dockerignore).toContain( + "!patch-validator/validator-vitest.config.mjs", + ); + }); +}); diff --git a/test/patch-validator-embedded-runtime-assessment.test.ts b/test/patch-validator-embedded-runtime-assessment.test.ts new file mode 100644 index 000000000..fc2a612e5 --- /dev/null +++ b/test/patch-validator-embedded-runtime-assessment.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; + +import { verifyStaticRuntimeBinaryEvidence } from "../scripts/lib/patch-validator-static-runtime-evidence.mjs"; + +const imageDigest = `sha256:${"7".repeat(64)}`; +const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"; +const opensslCpe = "cpe:2.3:a:openssl:openssl:3.5.2:*:*:*:*:*:*:*"; + +function inputWithUnassessedZeroMatchComponent(): any { + return { + expectedImageDigest: imageDigest, + binarySbom: { + descriptor: { name: "syft", version: "1.50.0" }, + source: { type: "image", metadata: { imageID: imageDigest } }, + artifacts: [ + { + name: "node", + version: "24.19.0", + locations: [{ path: "/nodejs/bin/node" }], + cpes: [nodeCpe], + }, + ], + }, + binaryVulnerabilityScan: { + descriptor: { name: "grype", version: "0.116.1" }, + source: { type: "image", target: { imageID: imageDigest } }, + matches: [], + ignoredMatches: [], + }, + embeddedRuntimeInventory: { + schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", + validator_image_digest: imageDigest, + node_version: "24.19.0", + process_versions: { + node: "24.19.0", + openssl: "3.5.2", + }, + components: [ + { + key: "openssl", + name: "openssl", + version: "3.5.2", + classification: "bundled_dependency", + cpe: opensslCpe, + }, + ], + }, + embeddedVulnerabilityScan: { + schema_version: "noema.patch-validator-embedded-runtime-vulnerability-scan.v1", + validator_image_digest: imageDigest, + scanner: "grype@0.116.1", + components: [ + { + key: "openssl", + identity: opensslCpe, + matches: [], + ignoredMatches: [], + }, + ], + ignoredMatches: [], + }, + }; +} + +function inputWithUnsupportedGenericSelfAssessment(): any { + const input = inputWithUnassessedZeroMatchComponent(); + const genericPurl = "pkg:generic/openssl@3.5.2"; + input.embeddedRuntimeInventory.components[0] = { + key: "openssl", + name: "openssl", + version: "3.5.2", + classification: "bundled_dependency", + purl: genericPurl, + }; + input.embeddedVulnerabilityScan.components[0] = { + key: "openssl", + identity: genericPurl, + matches: [], + ignoredMatches: [], + assessment: { + status: "completed", + scanner: "grype@0.116.1", + identity: genericPurl, + }, + }; + return input; +} + +function inputWithSyntheticCompletedAssessment(): any { + const input = inputWithUnassessedZeroMatchComponent(); + input.embeddedVulnerabilityScan.components[0].assessment = { + status: "completed", + scanner: "grype@0.116.1", + identity: opensslCpe, + }; + return input; +} + +describe("embedded runtime scanner assessment evidence", () => { + it("rejects a zero-match component without raw scanner evidence for its reviewed identity", () => { + expect(() => + verifyStaticRuntimeBinaryEvidence(inputWithUnassessedZeroMatchComponent()), + ).toThrow(/raw scanner evidence/i); + }); + + it("rejects a locally completed zero-match assessment for an unsupported generic identity", () => { + expect(() => + verifyStaticRuntimeBinaryEvidence(inputWithUnsupportedGenericSelfAssessment()), + ).toThrow(/supported vulnerability identity/i); + }); + + it("rejects a synthetic completed assessment that is not bound to raw scanner evidence", () => { + expect(() => + verifyStaticRuntimeBinaryEvidence(inputWithSyntheticCompletedAssessment()), + ).toThrow(/raw scanner evidence/i); + }); +}); diff --git a/test/patch-validator-embedded-runtime-workflow.test.ts b/test/patch-validator-embedded-runtime-workflow.test.ts new file mode 100644 index 000000000..91f980178 --- /dev/null +++ b/test/patch-validator-embedded-runtime-workflow.test.ts @@ -0,0 +1,26 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +const workflow = readFileSync( + new URL("../.github/workflows/patch-validator-image.yml", import.meta.url), + "utf8", +); + +describe("patch-validator embedded-runtime workflow", () => { + it("never fabricates generic package identities or local completion assessments", () => { + expect(workflow).not.toContain("pkg:generic/"); + expect(workflow).not.toContain("assessment:"); + }); + + it("updates the vulnerability database once and disables per-component auto-update", () => { + expect(workflow).toContain('"$SCANNER_BIN_DIR/grype" db update'); + expect(workflow).toContain("GRYPE_DB_AUTO_UPDATE=false"); + }); + + it("scans each reviewed PURL or CPE directly and retains the raw scanner record", () => { + expect(workflow).not.toContain('"sbom:$sbom_path"'); + expect(workflow).toContain('"$SCANNER_BIN_DIR/grype" --config /dev/null "$identity"'); + expect(workflow).toContain("scanner_output: raw"); + }); +}); diff --git a/test/patch-validator-entrypoint.test.mjs b/test/patch-validator-entrypoint.test.mjs new file mode 100644 index 000000000..693f91e18 --- /dev/null +++ b/test/patch-validator-entrypoint.test.mjs @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + buildFailureDiagnostic, + runEntrypoint, +} from "../patch-validator/entrypoint.mjs"; + +function failedResult(overrides = {}) { + return { + status: "failed", + repository_full_name: "ContextualWisdomLab/noema", + base_sha: "1".repeat(40), + head_sha: "2".repeat(40), + patch_sha256: "3".repeat(64), + profile: "node_patch_verify", + command_profile: "node_patch_verify_v1", + validator_image_digest: `sha256:${"4".repeat(64)}`, + exit_code: 2, + duration_ms: 10, + stdout_excerpt: "ignored output", + stderr_excerpt: "typecheck failed", + reason_codes: ["command_failed"], + ...overrides, + }; +} + +describe("patch-validator image entrypoint", () => { + it("does not emit a diagnostic for successful validation", () => { + const write = vi.fn(); + + expect( + runEntrypoint({ + runCliImpl: () => failedResult({ status: "passed", exit_code: 0 }), + writeDiagnostic: write, + }), + ).toBe(0); + expect(write).not.toHaveBeenCalled(); + }); + + it("emits one bounded non-authoritative failure diagnostic", () => { + const write = vi.fn(); + const result = failedResult(); + + expect( + runEntrypoint({ + runCliImpl: () => result, + writeDiagnostic: write, + }), + ).toBe(2); + expect(buildFailureDiagnostic(result)).toEqual({ + trusted: false, + status: "failed", + exit_code: 2, + stderr_excerpt: "typecheck failed", + reason_codes: ["command_failed"], + }); + expect(write).toHaveBeenCalledWith( + `${JSON.stringify(buildFailureDiagnostic(result))}\n`, + ); + expect(write.mock.calls[0][0]).not.toContain("repository_full_name"); + expect(write.mock.calls[0][0]).not.toContain("validator_image_digest"); + }); + + it("uses a fail-closed exit when a blocked result has no failing exit code", () => { + const write = vi.fn(); + + expect( + runEntrypoint({ + runCliImpl: () => failedResult({ status: "blocked", exit_code: 0 }), + writeDiagnostic: write, + }), + ).toBe(1); + expect(write).toHaveBeenCalledOnce(); + }); +}); diff --git a/test/patch-validator-image-contract.test.ts b/test/patch-validator-image-contract.test.ts new file mode 100644 index 000000000..a71be4ce3 --- /dev/null +++ b/test/patch-validator-image-contract.test.ts @@ -0,0 +1,152 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const repositoryRoot = resolve(import.meta.dirname, ".."); +const dockerfilePath = resolve(repositoryRoot, "Dockerfile.patch-validator"); +const packageJsonPath = resolve(repositoryRoot, "package.json"); +const ignorefilePath = resolve( + repositoryRoot, + "Dockerfile.patch-validator.dockerignore", +); +const obsoleteIgnorefilePath = resolve( + repositoryRoot, + ".dockerignore.patch-validator", +); + +function readRequiredFile(path: string): string { + expect(existsSync(path), `${path} must exist`).toBe(true); + return readFileSync(path, "utf8"); +} + +describe("patch-validator image contract", () => { + it("defines a source-pinned, static, shell-free, non-root image with a minimal context", () => { + const dockerfile = readRequiredFile(dockerfilePath); + const packageJson = JSON.parse(readRequiredFile(packageJsonPath)) as Record; + const ignorefile = readRequiredFile(ignorefilePath); + const fromLines = dockerfile + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("FROM ")); + + expect(fromLines).toEqual([ + "FROM alpine:3.24.1@sha256:79ff19e9084a00eece421b2523fb93e22d730e2c0e525905de047e848e56d95f AS node_builder", + "FROM node:24.18.0-alpine3.24@sha256:4ba75f835bb8802193e4c114572113d4b26f95f6f094f4b5229d2a77773e0afc AS dependencies", + "FROM scratch AS runtime", + ]); + expect( + fromLines + .filter((line) => line !== "FROM scratch AS runtime") + .every((line) => /@sha256:[0-9a-f]{64}(?:\s|$)/.test(line)), + ).toBe(true); + + expect(dockerfile).toContain("ARG NODE_VERSION=24.19.0"); + expect(dockerfile).toContain( + "ARG NODE_SOURCE_SHA256=f6d95e10a0431ee1067fc6aabe9f762908b4716dd35324e1ddb4b1466b76659f", + ); + expect(dockerfile).toContain("--fully-static"); + expect(dockerfile).toContain("--without-npm"); + expect(dockerfile).toContain("--without-corepack"); + expect(dockerfile).toContain("readelf -l /opt/node/bin/node"); + expect(dockerfile).toContain("readelf -d /opt/node/bin/node"); + + expect(dockerfile).toContain("COPY package.json package-lock.json ./"); + expect(dockerfile).toContain( + "npm ci --include=optional --ignore-scripts --no-audit --no-fund", + ); + expect(dockerfile).toContain("node_modules/typescript/bin/tsc"); + expect(dockerfile).toContain("node_modules/vitest/vitest.mjs"); + expect(dockerfile).toContain("node_modules/@vitest/coverage-v8/package.json"); + expect(dockerfile).toContain("node_modules/@rolldown/binding-wasm32-wasi/package.json"); + + const runtimeStage = dockerfile.slice(dockerfile.indexOf(fromLines[2])); + expect(runtimeStage).not.toMatch(/^RUN\b/m); + expect(runtimeStage).not.toMatch(/^ADD\b/m); + expect(runtimeStage).not.toContain("COPY . "); + expect(runtimeStage).toContain("ENV NAPI_RS_FORCE_WASI=error"); + expect(runtimeStage).toContain("USER 65532:65532"); + expect(runtimeStage).toContain("WORKDIR /workspace"); + expect(runtimeStage).toContain( + 'ENTRYPOINT ["/nodejs/bin/node", "--input-type=module", "--eval", "import { runCli } from \'/opt/noema/runtime.mjs\'; import { runEntrypoint } from \'/opt/noema/entrypoint.mjs\'; process.exitCode = runEntrypoint({ runCliImpl: runCli, writeDiagnostic: (message) => process.stderr.write(message) });"]', + ); + expect(runtimeStage).toContain( + "COPY --from=node_builder --chown=65532:65532 /opt/node/bin/node /nodejs/bin/node", + ); + expect(runtimeStage).toContain( + "COPY --from=dependencies --chown=65532:65532 /build/node_modules /opt/noema/node_modules", + ); + expect(runtimeStage).toContain( + "COPY --chown=65532:65532 patch-validator/entrypoint.mjs /opt/noema/entrypoint.mjs", + ); + expect(runtimeStage).toContain( + "COPY --chown=65532:65532 patch-validator/validate-patch.mjs /opt/noema/validate-patch.mjs", + ); + expect(runtimeStage).toContain( + "COPY --chown=65532:65532 patch-validator/runtime.mjs /opt/noema/runtime.mjs", + ); + expect(runtimeStage).toContain( + "COPY --chown=65532:65532 patch-validator/validator-tsconfig.json /opt/noema/validator-tsconfig.json", + ); + expect(runtimeStage).toContain( + "COPY --chown=65532:65532 patch-validator/validator-vitest.config.mjs /opt/noema/validator-vitest.config.mjs", + ); + + expect(runtimeStage).toContain( + 'org.opencontainers.image.source="https://github.com/ContextualWisdomLab/noema"', + ); + expect(runtimeStage).toContain('org.opencontainers.image.revision="${SOURCE_REVISION}"'); + expect(packageJson.private).toBe(true); + expect(packageJson).not.toHaveProperty("license"); + expect(runtimeStage).not.toContain("org.opencontainers.image.licenses="); + expect(runtimeStage).toContain('org.opencontainers.image.title="Noema Patch Validator"'); + expect(runtimeStage).toContain( + 'org.opencontainers.image.documentation="https://github.com/ContextualWisdomLab/noema/blob/main/docs/patch-validator-image.md"', + ); + + const argumentAndEnvironmentLines = dockerfile + .split("\n") + .filter((line) => /^(?:ARG|ENV)\b/.test(line.trim())); + expect(argumentAndEnvironmentLines.join("\n")).not.toMatch( + /(token|secret|password|credential|private[_-]?key|github|nvidia|cloudflare)/i, + ); + + const ignoreEntries = ignorefile + .split("\n") + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")); + expect(ignoreEntries).toEqual([ + "*", + "!package.json", + "!package-lock.json", + "!patch-validator/", + "patch-validator/*", + "!patch-validator/entrypoint.mjs", + "!patch-validator/validate-patch.mjs", + "!patch-validator/runtime.mjs", + "!patch-validator/validator-tsconfig.json", + "!patch-validator/validator-vitest.config.mjs", + ]); + expect(existsSync(obsoleteIgnorefilePath)).toBe(false); + }); + + it("removes Worker-only tooling and native addons before copying runtime dependencies", () => { + const dockerfile = readRequiredFile(dockerfilePath); + + expect(dockerfile).toContain("npm_config_os=wasip1-threads"); + expect(dockerfile).toContain("npm_config_cpu=wasm32"); + expect(dockerfile).toContain( + "npm pkg delete devDependencies.@cloudflare/workers-types devDependencies.wrangler", + ); + expect(dockerfile).toContain( + "npm prune --include=optional --ignore-scripts --no-audit --no-fund", + ); + expect(dockerfile).toContain( + 'test -z "$(find node_modules -type f -name \'*.node\' -print -quit)"', + ); + expect(dockerfile).toContain("test ! -e node_modules/@cloudflare/workers-types"); + expect(dockerfile).toContain("test ! -e node_modules/wrangler"); + expect(dockerfile).toContain("test ! -e node_modules/workerd"); + expect(dockerfile).toContain("test ! -e node_modules/miniflare"); + }); +}); diff --git a/test/patch-validator-image-documentation-current.test.ts b/test/patch-validator-image-documentation-current.test.ts new file mode 100644 index 000000000..8402b36c1 --- /dev/null +++ b/test/patch-validator-image-documentation-current.test.ts @@ -0,0 +1,51 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +function read(path: string): string { + return readFileSync(resolve(process.cwd(), path), "utf8"); +} + +describe("patch-validator image documentation", () => { + it("describes the current static runtime and embedded dependency evidence", () => { + const changelog = read("CHANGELOG.md"); + const publicDoc = read("docs/patch-validator-image.md"); + const doctoring = read("docs/doctoring/patch-validator-image.md"); + const assessmentDoctoring = read( + "docs/doctoring/patch-validator-embedded-scan-assessment.md", + ); + + const imageEntry = changelog + .split("\n") + .find((line) => line.startsWith("- repository-owned patch-validator image")); + + expect(imageEntry).toBeDefined(); + expect(imageEntry).toContain("Node.js 24.19.0"); + expect(imageEntry).toContain("`scratch`"); + expect(imageEntry).toContain("`process.versions`"); + expect(imageEntry).not.toContain("Distroless runtime digest"); + + expect(publicDoc).toContain("`process.versions`"); + expect(publicDoc).toContain("embedded-runtime-inventory.json"); + expect(publicDoc).toContain("embedded-runtime-vulnerability-scan.json"); + expect(publicDoc).toContain("`modules` and `napi`"); + + expect(doctoring).toContain("`process.versions`"); + expect(doctoring).toContain("one result per bundled dependency"); + expect(doctoring).not.toContain( + "statically linked third-party code that is not independently surfaced as a separate package", + ); + + expect(assessmentDoctoring).toContain("reviewed identity catalog"); + expect(assessmentDoctoring).toMatch(/raw Grype/i); + expect(assessmentDoctoring).toMatch( + /same vulnerability database|shared vulnerability database/i, + ); + expect(assessmentDoctoring).not.toMatch( + /assessment\.status\s*=\s*["'`]completed/i, + ); + expect(assessmentDoctoring).not.toMatch(/positive assessment record/i); + expect(assessmentDoctoring).toContain("National Vulnerability Database"); + expect(assessmentDoctoring).toContain("Supported scan targets"); + }); +}); diff --git a/test/patch-validator-package-note-identity.test.ts b/test/patch-validator-package-note-identity.test.ts new file mode 100644 index 000000000..87332ea26 --- /dev/null +++ b/test/patch-validator-package-note-identity.test.ts @@ -0,0 +1,35 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const repositoryRoot = resolve(import.meta.dirname, ".."); +const dockerfilePath = resolve(repositoryRoot, "Dockerfile.patch-validator"); +const publicDocumentationPath = resolve(repositoryRoot, "docs", "patch-validator-image.md"); +const changelogPath = resolve(repositoryRoot, "CHANGELOG.md"); + +describe("patch-validator package note identity", () => { + it("does not embed a generic package URL in the static Node package note", () => { + const dockerfile = readFileSync(dockerfilePath, "utf8"); + + expect(dockerfile).not.toContain("pkg:generic/"); + expect(dockerfile).toContain( + '"cpe":"cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"', + ); + }); + + it("documents the retained raw per-component Grype evidence instead of superseded synthetic artifacts", () => { + const publicDocumentation = readFileSync(publicDocumentationPath, "utf8"); + const changelog = readFileSync(changelogPath, "utf8"); + const unreleasedChangelog = changelog.split("\n## ", 3)[1] ?? ""; + + expect(publicDocumentation).not.toContain("status `completed`"); + expect(publicDocumentation).not.toContain("embedded-runtime-sbom.cdx.json"); + expect(publicDocumentation).not.toContain("positive assessment"); + expect(publicDocumentation).toContain("raw per-component Grype"); + expect(unreleasedChangelog).not.toContain( + "각 bundled dependency는 `status=completed`", + ); + expect(unreleasedChangelog).toContain("raw per-component Grype"); + }); +}); diff --git a/test/patch-validator-receipt-json-integrity.test.ts b/test/patch-validator-receipt-json-integrity.test.ts new file mode 100644 index 000000000..6a615ed57 --- /dev/null +++ b/test/patch-validator-receipt-json-integrity.test.ts @@ -0,0 +1,39 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { readBoundedJson } from "../scripts/lib/patch-validator-image-receipts.mjs"; + +const roots: string[] = []; + +/** Create one isolated directory for hostile receipt fixtures. */ +function temporaryRoot(): string { + const root = mkdtempSync(join(tmpdir(), "noema-image-json-integrity-")); + roots.push(root); + return root; +} + +afterEach(() => { + while (roots.length > 0) { + rmSync(roots.pop()!, { recursive: true, force: true }); + } +}); + +describe("patch-validator receipt JSON integrity", () => { + it("rejects duplicate decoded object keys instead of accepting last-key-wins evidence", () => { + const path = join(temporaryRoot(), "duplicate.json"); + writeFileSync(path, '{"status":"failed","st\\u0061tus":"passed"}'); + + expect(() => readBoundedJson(path, 128)).toThrow(/valid JSON|duplicate/i); + }); + + it("rejects malformed UTF-8 instead of normalizing invalid evidence bytes", () => { + const path = join(temporaryRoot(), "invalid-utf8.json"); + writeFileSync( + path, + Buffer.from([0x7b, 0x22, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x3a, 0x22, 0xc3, 0x28, 0x22, 0x7d]), + ); + + expect(() => readBoundedJson(path, 128)).toThrow(/valid JSON|UTF-8/i); + }); +}); diff --git a/test/patch-validator-receipt-verifier.test.ts b/test/patch-validator-receipt-verifier.test.ts new file mode 100644 index 000000000..a8ada764f --- /dev/null +++ b/test/patch-validator-receipt-verifier.test.ts @@ -0,0 +1,227 @@ +import { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + readBoundedJson, + verifyPatchValidatorReceipts, +} from "../scripts/lib/patch-validator-image-receipts.mjs"; + +const roots: string[] = []; +const sourceRevision = "1".repeat(40); +const imageDigest = `sha256:${"2".repeat(64)}`; +const imageReference = `noema-patch-validator:${sourceRevision}`; +const entrypoint = [ + "/nodejs/bin/node", + "--input-type=module", + "--eval", + "import { runCli } from '/opt/noema/runtime.mjs'; import { runEntrypoint } from '/opt/noema/entrypoint.mjs'; process.exitCode = runEntrypoint({ runCliImpl: runCli, writeDiagnostic: (message) => process.stderr.write(message) });", +]; + +function temporaryRoot(): string { + const root = mkdtempSync(join(tmpdir(), "noema-image-receipts-")); + roots.push(root); + return root; +} + +function validInput(): any { + return { + metadata: { + schema_version: "noema.patch-validator-image-metadata.v1", + source_revision: sourceRevision, + validator_image_digest: imageDigest, + os: "linux", + architecture: "amd64", + user: "65532:65532", + entrypoint, + labels: { + "org.opencontainers.image.source": + "https://github.com/ContextualWisdomLab/noema", + "org.opencontainers.image.revision": sourceRevision, + }, + }, + smokeResult: { + status: "passed", + repository_full_name: "ContextualWisdomLab/noema", + base_sha: "0".repeat(40), + head_sha: sourceRevision, + patch_sha256: "3".repeat(64), + profile: "node_patch_verify", + command_profile: "node_patch_verify_v1", + validator_image_digest: imageDigest, + exit_code: 0, + duration_ms: 10, + stdout_excerpt: "", + stderr_excerpt: "", + reason_codes: [], + }, + sbom: { + bomFormat: "CycloneDX", + specVersion: "1.6", + serialNumber: "urn:uuid:00000000-0000-4000-8000-000000000001", + version: 1, + metadata: { + component: { + type: "container", + name: imageReference, + properties: [ + { + name: "aquasecurity:trivy:ImageID", + value: imageDigest, + }, + ], + }, + }, + components: [{ type: "library", name: "typescript", version: "5.9.3" }], + }, + vulnerabilityScan: { + SchemaVersion: 2, + ArtifactName: imageReference, + ArtifactType: "container_image", + Metadata: { + ImageID: imageDigest, + }, + Results: [ + { + Target: imageReference, + Class: "os-pkgs", + Type: "debian", + Vulnerabilities: null, + }, + ], + }, + expectedImageDigest: imageDigest, + expectedSourceRevision: sourceRevision, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); + while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true }); +}); + +describe("patch-validator image receipt verifier", () => { + it("returns exact image, source, SBOM, and vulnerability evidence", () => { + expect(verifyPatchValidatorReceipts(validInput())).toEqual({ + schema_version: "noema.patch-validator-image-verification.v1", + status: "passed", + source_revision: sourceRevision, + validator_image_digest: imageDigest, + cyclonedx_spec_version: "1.6", + component_count: 1, + vulnerability_result_count: 1, + detected_vulnerability_count: 0, + }); + }); + + const invalidCases: Array<[string, (input: any) => void]> = [ + ["metadata record", (x) => { x.metadata = null; }], + ["metadata schema", (x) => { x.metadata.schema_version = "wrong"; }], + ["source revision", (x) => { x.metadata.source_revision = "f".repeat(40); }], + ["image digest", (x) => { x.metadata.validator_image_digest = `sha256:${"f".repeat(64)}`; }], + ["Linux", (x) => { x.metadata.os = "windows"; }], + ["amd64", (x) => { x.metadata.architecture = "arm64"; }], + ["non-root user", (x) => { x.metadata.user = "0:0"; }], + ["entrypoint", (x) => { x.metadata.entrypoint = ["unexpected"]; }], + ["labels record", (x) => { x.metadata.labels = null; }], + ["source label", (x) => { x.metadata.labels["org.opencontainers.image.source"] = "other"; }], + ["revision label", (x) => { x.metadata.labels["org.opencontainers.image.revision"] = "other"; }], + ["smoke record", (x) => { x.smokeResult = null; }], + ["smoke status", (x) => { x.smokeResult.status = "failed"; }], + ["smoke exit", (x) => { x.smokeResult.exit_code = 1; }], + ["smoke image digest", (x) => { x.smokeResult.validator_image_digest = `sha256:${"f".repeat(64)}`; }], + ["smoke source revision", (x) => { x.smokeResult.head_sha = "f".repeat(40); }], + ["smoke profile", (x) => { x.smokeResult.profile = "other"; }], + ["smoke command profile", (x) => { x.smokeResult.command_profile = "other"; }], + ["CycloneDX record", (x) => { x.sbom = null; }], + ["CycloneDX format", (x) => { x.sbom.bomFormat = "SPDX"; }], + ["CycloneDX version", (x) => { x.sbom.specVersion = "1.4"; }], + ["CycloneDX components", (x) => { x.sbom.components = null; }], + ["CycloneDX metadata", (x) => { x.sbom.metadata = null; }], + ["CycloneDX component", (x) => { x.sbom.metadata.component = null; }], + ["CycloneDX image reference", (x) => { x.sbom.metadata.component.name = "other"; }], + ["CycloneDX properties", (x) => { x.sbom.metadata.component.properties = null; }], + ["CycloneDX image digest", (x) => { x.sbom.metadata.component.properties[0].value = `sha256:${"f".repeat(64)}`; }], + ["vulnerability scan record", (x) => { x.vulnerabilityScan = null; }], + ["vulnerability artifact type", (x) => { x.vulnerabilityScan.ArtifactType = "filesystem"; }], + ["vulnerability image reference", (x) => { x.vulnerabilityScan.ArtifactName = "other"; }], + ["vulnerability metadata", (x) => { x.vulnerabilityScan.Metadata = null; }], + ["vulnerability image digest", (x) => { x.vulnerabilityScan.Metadata.ImageID = `sha256:${"f".repeat(64)}`; }], + ["vulnerability results", (x) => { x.vulnerabilityScan.Results = null; }], + ["detected vulnerabilities", (x) => { x.vulnerabilityScan.Results[0].Vulnerabilities = [{ VulnerabilityID: "CVE-2099-0001" }]; }], + ]; + + it.each(invalidCases)("rejects mismatched %s evidence", (message, mutate) => { + const input = validInput(); + mutate(input); + expect(() => verifyPatchValidatorReceipts(input)).toThrow( + new RegExp(message, "i"), + ); + }); + + it("reads bounded regular JSON and rejects unsafe evidence files", () => { + const root = temporaryRoot(); + const validPath = join(root, "valid.json"); + writeFileSync(validPath, '{"value":1}'); + expect(readBoundedJson(validPath, 64)).toEqual({ value: 1 }); + + const emptyPath = join(root, "empty.json"); + writeFileSync(emptyPath, ""); + expect(() => readBoundedJson(emptyPath, 64)).toThrow(/byte length/); + + const oversizedPath = join(root, "oversized.json"); + writeFileSync(oversizedPath, "12345"); + expect(() => readBoundedJson(oversizedPath, 4)).toThrow(/byte length/); + + const invalidPath = join(root, "invalid.json"); + writeFileSync(invalidPath, "{"); + expect(() => readBoundedJson(invalidPath, 64)).toThrow(/valid JSON/); + + const directoryPath = join(root, "directory.json"); + mkdirSync(directoryPath); + expect(() => readBoundedJson(directoryPath, 64)).toThrow(/regular file/); + + const symlinkPath = join(root, "link.json"); + symlinkSync(validPath, symlinkPath); + expect(() => readBoundedJson(symlinkPath, 64)).toThrow(/regular file/); + }); + + it("rechecks the descriptor byte bound before reading a raced receipt", () => { + const root = temporaryRoot(); + const validPath = join(root, "valid.json"); + writeFileSync(validPath, '{"value":1}'); + const readSync = vi.fn(() => { + throw new Error("unbounded descriptor read attempted"); + }); + const closeSync = vi.fn(); + const racedFileSystem = { + lstatSync: () => ({ + isFile: () => true, + size: 11, + dev: 1, + ino: 2, + }), + openSync: () => 42, + fstatSync: () => ({ + isFile: () => true, + size: 65, + dev: 1, + ino: 2, + }), + readSync, + closeSync, + }; + + expect(() => readBoundedJson(validPath, 64, racedFileSystem)).toThrow( + /byte length/, + ); + expect(readSync).not.toHaveBeenCalled(); + expect(closeSync).toHaveBeenCalledWith(42); + }); +}); diff --git a/test/patch-validator-runtime-bom.test.mjs b/test/patch-validator-runtime-bom.test.mjs new file mode 100644 index 000000000..6221f2cb1 --- /dev/null +++ b/test/patch-validator-runtime-bom.test.mjs @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; + +import { parseUnifiedPatch } from "../patch-validator/runtime.mjs"; + +describe("UTF-8 BOM-only patch input", () => { + it("rejects decoded input that contains no diff headers", () => { + expect(() => + parseUnifiedPatch(Buffer.from([0xef, 0xbb, 0xbf])), + ).toThrow(/no diff headers/); + }); +}); diff --git a/test/patch-validator-runtime-branch-coverage.test.mjs b/test/patch-validator-runtime-branch-coverage.test.mjs new file mode 100644 index 000000000..e236f5736 --- /dev/null +++ b/test/patch-validator-runtime-branch-coverage.test.mjs @@ -0,0 +1,202 @@ +import { createHash } from "node:crypto"; +import { + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + applyPatchSet, + parseUnifiedPatch, + readEnvironment, + runCli, +} from "../patch-validator/runtime.mjs"; + +const roots = []; + +function temporaryRoot() { + const root = mkdtempSync(join(tmpdir(), "noema-patch-branch-coverage-")); + roots.push(root); + return root; +} + +function modificationPatch() { + return Buffer.from( + "diff --git a/src/example.ts b/src/example.ts\n" + + "--- a/src/example.ts\n" + + "+++ b/src/example.ts\n" + + "@@ -1 +1 @@\n" + + "-old value\n" + + "+new value\n", + ); +} + +function digest(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +function environment(patchBytes, overrides = {}) { + return { + NOEMA_RESULT_PATH: "/output/result.json", + NOEMA_REPOSITORY: "ContextualWisdomLab/noema", + NOEMA_BASE_SHA: "1".repeat(40), + NOEMA_HEAD_SHA: "2".repeat(40), + NOEMA_PATCH_SHA256: digest(patchBytes), + NOEMA_PATCH_PROFILE: "node_patch_verify", + NOEMA_COMMAND_PROFILE: "node_patch_verify_v1", + NOEMA_VALIDATOR_IMAGE_DIGEST: `sha256:${"4".repeat(64)}`, + ...overrides, + }; +} + +function runtimeFixture() { + const root = temporaryRoot(); + const patchBytes = modificationPatch(); + const inputRoot = join(root, "input"); + const workspaceRoot = join(root, "workspace"); + const nodeModulesPath = join(root, "image-node-modules"); + const patchPath = join(root, "input.patch"); + const resultPath = join(root, "result.json"); + mkdirSync(join(inputRoot, "src"), { recursive: true }); + mkdirSync(nodeModulesPath); + writeFileSync(join(inputRoot, "src/example.ts"), "old value\n"); + writeFileSync(join(inputRoot, "package.json"), '{"type":"module"}\n'); + writeFileSync(join(inputRoot, "package-lock.json"), "{}\n"); + writeFileSync(join(inputRoot, "tsconfig.json"), "{}\n"); + writeFileSync(join(inputRoot, "vitest.config.ts"), "export default {};\n"); + writeFileSync(patchPath, patchBytes); + writeFileSync(resultPath, ""); + return { + patchBytes, + inputRoot, + workspaceRoot, + nodeModulesPath, + patchPath, + resultPath, + }; +} + +function successfulCommand() { + return { + status: 0, + signal: null, + stdout: "", + stderr: "", + error: undefined, + }; +} + +afterEach(() => { + vi.doUnmock("node:fs"); + vi.resetModules(); + while (roots.length > 0) { + rmSync(roots.pop(), { recursive: true, force: true }); + } +}); + +describe("remaining runtime branches", () => { + it("rejects an omitted new file header", () => { + const patch = Buffer.from( + "diff --git a/src/x.ts b/src/x.ts\n" + + "--- a/src/x.ts\n", + ); + expect(() => parseUnifiedPatch(patch)).toThrow(/incomplete file path metadata/); + }); + + it("defaults an unannotated created file to mode 100644", () => { + const patch = Buffer.from( + "diff --git a/src/default.ts b/src/default.ts\n" + + "--- /dev/null\n" + + "+++ b/src/default.ts\n" + + "@@ -0,0 +1 @@\n" + + "+created\n", + ); + expect(parseUnifiedPatch(patch)[0]).toMatchObject({ + operation: "create", + mode: "100644", + }); + }); + + it("applies the executable branch for mode 100755", () => { + const root = temporaryRoot(); + mkdirSync(join(root, "bin")); + const patch = Buffer.from( + "diff --git a/bin/tool.mjs b/bin/tool.mjs\n" + + "new file mode 100755\n" + + "--- /dev/null\n" + + "+++ b/bin/tool.mjs\n" + + "@@ -0,0 +1 @@\n" + + "+export {};\n", + ); + applyPatchSet(root, parseUnifiedPatch(patch)); + expect(lstatSync(join(root, "bin/tool.mjs")).mode & 0o111).not.toBe(0); + }); + + it("rejects an omitted validator image digest after all prior identity fields pass", () => { + const patchBytes = modificationPatch(); + const raw = environment(patchBytes); + delete raw.NOEMA_VALIDATOR_IMAGE_DIGEST; + expect(() => readEnvironment(raw)).toThrow(/environment/); + }); + + it("closes no descriptor when opening the result channel fails", async () => { + const actual = await vi.importActual("node:fs"); + vi.resetModules(); + vi.doMock("node:fs", () => ({ + ...actual, + openSync() { + throw new Error("forced result open failure"); + }, + })); + const { writeResultFile } = await import("../patch-validator/validate-patch.mjs"); + const root = temporaryRoot(); + const resultPath = join(root, "result.json"); + writeFileSync(resultPath, ""); + expect(() => writeResultFile(resultPath, { status: "passed" })).toThrow( + /forced result open failure/, + ); + }); + + it("uses the identity result path when no explicit override is supplied", () => { + const fixture = runtimeFixture(); + const result = runCli({ + env: environment(fixture.patchBytes, { + NOEMA_RESULT_PATH: fixture.resultPath, + }), + inputRoot: fixture.inputRoot, + workspaceRoot: fixture.workspaceRoot, + nodeModulesPath: fixture.nodeModulesPath, + patchPath: fixture.patchPath, + spawnSyncImpl: successfulCommand, + }); + expect(result.status).toBe("passed"); + expect(JSON.parse(readFileSync(fixture.resultPath, "utf8"))).toEqual(result); + }); + + it("bounds a non-Error validation failure without losing its message", () => { + const fixture = runtimeFixture(); + const result = runCli({ + env: environment(fixture.patchBytes), + inputRoot: fixture.inputRoot, + workspaceRoot: fixture.workspaceRoot, + nodeModulesPath: fixture.nodeModulesPath, + patchPath: fixture.patchPath, + resultPath: fixture.resultPath, + spawnSyncImpl() { + throw "non-error validation failure"; + }, + }); + expect(result).toMatchObject({ + status: "blocked", + reason_codes: ["patch_blocked"], + stderr_excerpt: "non-error validation failure", + }); + }); +}); diff --git a/test/patch-validator-runtime-coverage.test.mjs b/test/patch-validator-runtime-coverage.test.mjs new file mode 100644 index 000000000..204f0b980 --- /dev/null +++ b/test/patch-validator-runtime-coverage.test.mjs @@ -0,0 +1,464 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + truncateSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + MAX_CHANGED_FILES, + MAX_PATCH_BYTES, + MAX_RESULT_DURATION_MS, + MAX_SOURCE_FILE_BYTES, + applyPatchSet, + copySourceTree, + parseUnifiedPatch, + runCli, + runFixedCommand, + validateRepositoryPath, +} from "../patch-validator/runtime.mjs"; + +const roots = []; + +function temporaryRoot() { + const root = mkdtempSync(join(tmpdir(), "noema-patch-coverage-")); + roots.push(root); + return root; +} + +function modificationPatch({ + path = "src/example.ts", + oldText = "old value", + newText = "new value", + oldStart = 1, + newStart = 1, + oldCount = 1, + newCount = 1, + trailingNewline = true, +} = {}) { + return Buffer.from( + `diff --git a/${path} b/${path}\n` + + `--- a/${path}\n` + + `+++ b/${path}\n` + + `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@\n` + + `-${oldText}\n` + + `+${newText}${trailingNewline ? "\n" : ""}`, + ); +} + +function creationPatch(path, text = "created") { + return Buffer.from( + `diff --git a/${path} b/${path}\n` + + "new file mode 100644\n" + + "--- /dev/null\n" + + `+++ b/${path}\n` + + "@@ -0,0 +1,1 @@\n" + + `+${text}\n`, + ); +} + +function deletionPatch(path, text = "obsolete") { + return Buffer.from( + `diff --git a/${path} b/${path}\n` + + "deleted file mode 100644\n" + + `--- a/${path}\n` + + "+++ /dev/null\n" + + "@@ -1,1 +0,0 @@\n" + + `-${text}\n`, + ); +} + +function digest(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +function environment(patchBytes, overrides = {}) { + return { + NOEMA_RESULT_PATH: "/output/result.json", + NOEMA_REPOSITORY: "ContextualWisdomLab/noema", + NOEMA_BASE_SHA: "1".repeat(40), + NOEMA_HEAD_SHA: "2".repeat(40), + NOEMA_PATCH_SHA256: digest(patchBytes), + NOEMA_PATCH_PROFILE: "node_patch_verify", + NOEMA_COMMAND_PROFILE: "node_patch_verify_v1", + NOEMA_VALIDATOR_IMAGE_DIGEST: `sha256:${"4".repeat(64)}`, + ...overrides, + }; +} + +function runtimeFixture(patchBytes = modificationPatch()) { + const root = temporaryRoot(); + const inputRoot = join(root, "input"); + const workspaceRoot = join(root, "workspace"); + const nodeModulesPath = join(root, "image-node-modules"); + const patchPath = join(root, "input.patch"); + const resultPath = join(root, "result.json"); + mkdirSync(join(inputRoot, "src"), { recursive: true }); + mkdirSync(nodeModulesPath); + writeFileSync(join(inputRoot, "src/example.ts"), "old value\n"); + writeFileSync(join(inputRoot, "package.json"), '{"type":"module"}\n'); + writeFileSync(join(inputRoot, "package-lock.json"), "{}\n"); + writeFileSync(join(inputRoot, "tsconfig.json"), "{}\n"); + writeFileSync(join(inputRoot, "vitest.config.ts"), "export default {};\n"); + writeFileSync(patchPath, patchBytes); + writeFileSync(resultPath, ""); + return { + root, + patchBytes, + inputRoot, + workspaceRoot, + nodeModulesPath, + patchPath, + resultPath, + }; +} + +function successfulCommand() { + return { + status: 0, + signal: null, + stdout: "", + stderr: "", + error: undefined, + }; +} + +afterEach(() => { + vi.doUnmock("node:fs"); + vi.resetModules(); + while (roots.length > 0) { + rmSync(roots.pop(), { recursive: true, force: true }); + } +}); + +describe("remaining strict parser boundaries", () => { + it("rejects non-string paths and the alternate binary marker", () => { + expect(() => validateRepositoryPath(42)).toThrow(/unsafe/); + expect(() => + parseUnifiedPatch(Buffer.from("diff --git a/x b/x\nBinary files a/x and b/x differ")), + ).toThrow(/binary/); + }); + + it("accepts a patch whose final hunk line has no transport newline", () => { + const [parsed] = parseUnifiedPatch(modificationPatch({ trailingNewline: false })); + expect(parsed.hunks[0].lines.at(-1)).toMatchObject({ kind: "add", text: "new value" }); + }); + + it("rejects changed-file overflow before parsing a 101st body", () => { + const patches = Array.from( + { length: MAX_CHANGED_FILES + 1 }, + (_, index) => creationPatch(`src/generated-${index}.ts`), + ); + expect(() => parseUnifiedPatch(Buffer.concat(patches))).toThrow(/too many files/); + }); + + it.each([ + Buffer.from( + "diff --git a/src/x.ts b/src/x.ts\nunexpected metadata\n--- a/src/x.ts\n+++ b/src/x.ts\n@@ -1 +1 @@\n-x\n+y\n", + ), + Buffer.from( + 'diff --git a/src/x.ts b/src/x.ts\n--- "x/src/x.ts"\n+++ b/src/x.ts\n@@ -1 +1 @@\n-x\n+y\n', + ), + Buffer.from( + "diff --git a/src/x.ts b/src/x.ts\n--- a/src/x.ts\n+++ b/src/x.ts\n@@ -0,0 +0,0 @@\n+extra\n", + ), + ])("rejects additional malformed grammar", (patch) => { + expect(() => parseUnifiedPatch(patch)).toThrow(/patch/); + }); + + it("creates an empty file from a zero-count hunk", () => { + const root = temporaryRoot(); + mkdirSync(join(root, "src")); + const patch = Buffer.from( + "diff --git a/src/empty.ts b/src/empty.ts\n" + + "new file mode 100644\n" + + "--- /dev/null\n" + + "+++ b/src/empty.ts\n" + + "@@ -0,0 +0,0 @@\n", + ); + applyPatchSet(root, parseUnifiedPatch(patch)); + expect(readFileSync(join(root, "src/empty.ts"), "utf8")).toBe(""); + }); +}); + +describe("remaining patch application boundaries", () => { + it("rejects a missing parent for modification and a non-directory creation parent", () => { + const root = temporaryRoot(); + expect(() => + applyPatchSet(root, parseUnifiedPatch(modificationPatch({ path: "missing/x.ts" }))), + ).toThrow(/missing parent/); + + writeFileSync(join(root, "parent"), "not a directory"); + expect(() => + applyPatchSet(root, parseUnifiedPatch(creationPatch("parent/x.ts"))), + ).toThrow(/directory/); + }); + + it("rejects symlink, directory, and oversized source files", () => { + const root = temporaryRoot(); + mkdirSync(join(root, "src")); + writeFileSync(join(root, "target.ts"), "old value\n"); + symlinkSync(join(root, "target.ts"), join(root, "src/link.ts")); + expect(() => + applyPatchSet(root, parseUnifiedPatch(modificationPatch({ path: "src/link.ts" }))), + ).toThrow(/regular non-symlink/); + + mkdirSync(join(root, "src/directory.ts")); + expect(() => + applyPatchSet(root, parseUnifiedPatch(modificationPatch({ path: "src/directory.ts" }))), + ).toThrow(/regular non-symlink/); + + const oversized = join(root, "src/oversized.ts"); + writeFileSync(oversized, ""); + truncateSync(oversized, MAX_SOURCE_FILE_BYTES + 1); + expect(() => + applyPatchSet(root, parseUnifiedPatch(modificationPatch({ path: "src/oversized.ts" }))), + ).toThrow(/byte limit/); + }); + + it("rejects deletion that leaves authenticated source content", () => { + const root = temporaryRoot(); + mkdirSync(join(root, "src")); + writeFileSync(join(root, "src/old.ts"), "obsolete\nretained\n"); + expect(() => + applyPatchSet(root, parseUnifiedPatch(deletionPatch("src/old.ts"))), + ).toThrow(/complete source/); + }); + + it("rejects a non-regular source-tree object", () => { + const source = temporaryRoot(); + const destination = temporaryRoot(); + const fifo = join(source, "named-pipe"); + const completed = spawnSync("mkfifo", [fifo], { shell: false }); + expect(completed.status).toBe(0); + expect(() => copySourceTree(source, destination)).toThrow(/non-regular/); + }); +}); + +describe("remaining subprocess and orchestration boundaries", () => { + it("normalizes absent subprocess output", () => { + expect( + runFixedCommand({ + modulePath: "/opt/noema/tool.mjs", + args: [], + cwd: "/workspace/source", + spawnSyncImpl: () => ({ status: 0, signal: null, error: undefined }), + }), + ).toEqual({ + exitCode: 0, + stdoutExcerpt: "", + stderrExcerpt: "", + reasonCodes: [], + }); + }); + + it.each([ + ["missing", "unavailable", (fixture) => rmSync(fixture.patchPath)], + ["empty", "invalid byte length", (fixture) => writeFileSync(fixture.patchPath, "")], + [ + "directory", + "regular non-symlink", + (fixture) => { + rmSync(fixture.patchPath); + mkdirSync(fixture.patchPath); + }, + ], + [ + "symlink", + "regular non-symlink", + (fixture) => { + const target = join(fixture.root, "patch-target"); + writeFileSync(target, fixture.patchBytes); + rmSync(fixture.patchPath); + symlinkSync(target, fixture.patchPath); + }, + ], + [ + "oversized", + "invalid byte length", + (fixture) => truncateSync(fixture.patchPath, MAX_PATCH_BYTES + 1), + ], + ])("emits blocked evidence for a %s patch file", (_name, message, mutate) => { + const fixture = runtimeFixture(); + mutate(fixture); + const result = runCli({ + env: environment(fixture.patchBytes), + ...fixture, + spawnSyncImpl: vi.fn(), + }); + expect(result).toMatchObject({ status: "blocked", reason_codes: ["patch_blocked"] }); + expect(result.stderr_excerpt).toMatch(new RegExp(message)); + }); + + it("rejects a pre-existing private node_modules path", () => { + const fixture = runtimeFixture(); + mkdirSync(join(fixture.workspaceRoot, "source/node_modules"), { recursive: true }); + const result = runCli({ + env: environment(fixture.patchBytes), + ...fixture, + spawnSyncImpl: vi.fn(), + }); + expect(result.status).toBe("blocked"); + expect(result.stderr_excerpt).toMatch(/unexpectedly contains node_modules/); + }); + + it.each(["file", "symlink", "missing"])( + "rejects an invalid image node_modules %s", + (kind) => { + const fixture = runtimeFixture(); + if (kind === "file") { + rmSync(fixture.nodeModulesPath, { recursive: true }); + writeFileSync(fixture.nodeModulesPath, "not a directory"); + } else if (kind === "symlink") { + const target = temporaryRoot(); + rmSync(fixture.nodeModulesPath, { recursive: true }); + symlinkSync(target, fixture.nodeModulesPath, "dir"); + } else { + rmSync(fixture.nodeModulesPath, { recursive: true }); + } + const result = runCli({ + env: environment(fixture.patchBytes), + ...fixture, + spawnSyncImpl: vi.fn(), + }); + expect(result.status).toBe("blocked"); + expect(result.stderr_excerpt).toMatch(/node_modules|no such file/i); + }, + ); + + it("clamps negative and excessive durations", () => { + const negative = runtimeFixture(); + const negativeTimes = [100, 50]; + expect( + runCli({ + env: environment(negative.patchBytes), + ...negative, + now: () => negativeTimes.shift(), + spawnSyncImpl: successfulCommand, + }).duration_ms, + ).toBe(0); + + const excessive = runtimeFixture(); + const excessiveTimes = [0, MAX_RESULT_DURATION_MS + 1]; + expect( + runCli({ + env: environment(excessive.patchBytes), + ...excessive, + now: () => excessiveTimes.shift(), + spawnSyncImpl: successfulCommand, + }).duration_ms, + ).toBe(MAX_RESULT_DURATION_MS); + }); +}); + +describe("descriptor-race and atomic-write defenses", () => { + it("rejects a result inode change after opening", async () => { + const actual = await vi.importActual("node:fs"); + vi.doMock("node:fs", () => ({ + ...actual, + fstatSync(descriptor) { + const metadata = actual.fstatSync(descriptor); + return { ...metadata, ino: metadata.ino + 1 }; + }, + })); + const { writeResultFile } = await import("../patch-validator/validate-patch.mjs"); + const root = temporaryRoot(); + const resultPath = join(root, "result.json"); + writeFileSync(resultPath, ""); + expect(() => writeResultFile(resultPath, { status: "passed" })).toThrow(/changed/); + }); + + it("cleans an opened temporary file when its descriptor write fails", async () => { + const actual = await vi.importActual("node:fs"); + vi.doMock("node:fs", () => ({ + ...actual, + writeFileSync(target, ...args) { + if (typeof target === "number") { + throw new Error("forced descriptor failure"); + } + return actual.writeFileSync(target, ...args); + }, + })); + const { applyPatchSet: applyWithFault } = await import( + "../patch-validator/validate-patch.mjs" + ); + const root = temporaryRoot(); + mkdirSync(join(root, "src")); + expect(() => + applyWithFault(root, [ + { + path: "src/new.ts", + operation: "create", + mode: "100644", + hunks: [ + { + oldStart: 0, + oldCount: 0, + newStart: 1, + newCount: 1, + lines: [ + { + kind: "add", + text: "new", + oldNoNewline: false, + newNoNewline: false, + }, + ], + }, + ], + }, + ]), + ).toThrow(/forced descriptor failure/); + expect(existsSync(join(root, "src/new.ts"))).toBe(false); + }); + + it("handles a temporary-file open failure before a descriptor exists", async () => { + const actual = await vi.importActual("node:fs"); + vi.doMock("node:fs", () => ({ + ...actual, + openSync() { + throw new Error("forced open failure"); + }, + })); + const { applyPatchSet: applyWithFault } = await import( + "../patch-validator/validate-patch.mjs" + ); + const root = temporaryRoot(); + mkdirSync(join(root, "src")); + expect(() => + applyWithFault(root, [ + { + path: "src/new.ts", + operation: "create", + mode: "100644", + hunks: [ + { + oldStart: 0, + oldCount: 0, + newStart: 1, + newCount: 1, + lines: [ + { + kind: "add", + text: "new", + oldNoNewline: false, + newNoNewline: false, + }, + ], + }, + ], + }, + ]), + ).toThrow(/forced open failure/); + }); +}); diff --git a/test/patch-validator-runtime-entrypoint.test.mjs b/test/patch-validator-runtime-entrypoint.test.mjs new file mode 100644 index 000000000..b1077deb0 --- /dev/null +++ b/test/patch-validator-runtime-entrypoint.test.mjs @@ -0,0 +1,37 @@ +import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const runtimePath = resolve( + import.meta.dirname, + "../patch-validator/runtime.mjs", +); +const runtimeUrl = pathToFileURL(runtimePath).href; +const executableSource = [ + `import { runCli } from ${JSON.stringify(runtimeUrl)};`, + "const result = runCli();", + 'if (result.status !== "passed") {', + " process.exitCode = Number.isInteger(result.exit_code) && result.exit_code > 0", + " ? result.exit_code", + " : 1;", + "}", +].join("\n"); + +describe("patch-validator executable entrypoint", () => { + it("executes fail-closed validation instead of silently exiting", () => { + const completed = spawnSync( + process.execPath, + ["--input-type=module", "--eval", executableSource], + { + encoding: "utf8", + env: {}, + shell: false, + }, + ); + + expect(completed.status).not.toBe(0); + expect(completed.stderr).toMatch(/environment/); + }); +}); diff --git a/test/patch-validator-runtime-final-coverage.test.mjs b/test/patch-validator-runtime-final-coverage.test.mjs new file mode 100644 index 000000000..9b3cb0688 --- /dev/null +++ b/test/patch-validator-runtime-final-coverage.test.mjs @@ -0,0 +1,191 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + MAX_SOURCE_FILE_BYTES, + applyPatchSet, + parseUnifiedPatch, +} from "../patch-validator/runtime.mjs"; + +const roots = []; + +function temporaryRoot() { + const root = mkdtempSync(join(tmpdir(), "noema-patch-final-coverage-")); + roots.push(root); + return root; +} + +function addedLine(text) { + return { + kind: "add", + text, + oldNoNewline: false, + newNoNewline: false, + }; +} + +afterEach(() => { + while (roots.length > 0) { + rmSync(roots.pop(), { recursive: true, force: true }); + } +}); + +describe("final strict-patch coverage", () => { + it("rejects an omitted old file header", () => { + const patch = Buffer.from("diff --git a/src/x.ts b/src/x.ts\n"); + expect(() => parseUnifiedPatch(patch)).toThrow(/incomplete file path metadata/); + }); + + it("rejects a context line that exceeds a zero old count", () => { + const patch = Buffer.from( + "diff --git a/src/x.ts b/src/x.ts\n" + + "--- a/src/x.ts\n" + + "+++ b/src/x.ts\n" + + "@@ -1,0 +1,1 @@\n" + + " context\n", + ); + expect(() => parseUnifiedPatch(patch)).toThrow(/more lines than declared/); + }); + + it("rejects new-file metadata on a deletion", () => { + const patch = Buffer.from( + "diff --git a/src/x.ts b/src/x.ts\n" + + "new file mode 100644\n" + + "--- a/src/x.ts\n" + + "+++ /dev/null\n" + + "@@ -1 +0,0 @@\n" + + "-x\n", + ); + expect(() => parseUnifiedPatch(patch)).toThrow(/conflicting deletion metadata/); + }); + + it("modifies an authenticated empty file", () => { + const root = temporaryRoot(); + mkdirSync(join(root, "src")); + writeFileSync(join(root, "src/empty.ts"), ""); + const patch = Buffer.from( + "diff --git a/src/empty.ts b/src/empty.ts\n" + + "--- a/src/empty.ts\n" + + "+++ b/src/empty.ts\n" + + "@@ -0,0 +1,1 @@\n" + + "+filled\n", + ); + applyPatchSet(root, parseUnifiedPatch(patch)); + expect(readFileSync(join(root, "src/empty.ts"), "utf8")).toBe("filled\n"); + }); + + it("rejects a root path whose normalized target cannot satisfy confinement", () => { + expect(() => + applyPatchSet("/", [ + { + path: "src/noema-never-created.ts", + operation: "create", + mode: "100644", + hunks: [ + { + oldStart: 0, + oldCount: 0, + newStart: 1, + newCount: 1, + lines: [addedLine("never")], + }, + ], + }, + ]), + ).toThrow(/escapes the private source root/); + }); + + it("creates missing private parent directories", () => { + const root = temporaryRoot(); + applyPatchSet(root, [ + { + path: "new/deep/file.ts", + operation: "create", + mode: "100644", + hunks: [ + { + oldStart: 0, + oldCount: 0, + newStart: 1, + newCount: 1, + lines: [addedLine("created")], + }, + ], + }, + ]); + expect(readFileSync(join(root, "new/deep/file.ts"), "utf8")).toBe("created\n"); + }); + + it("preserves an authenticated context line while modifying the next line", () => { + const root = temporaryRoot(); + mkdirSync(join(root, "src")); + writeFileSync(join(root, "src/example.ts"), "one\ntwo\n"); + const patch = Buffer.from( + "diff --git a/src/example.ts b/src/example.ts\n" + + "--- a/src/example.ts\n" + + "+++ b/src/example.ts\n" + + "@@ -1,2 +1,2 @@\n" + + " one\n" + + "-two\n" + + "+TWO\n", + ); + applyPatchSet(root, parseUnifiedPatch(patch)); + expect(readFileSync(join(root, "src/example.ts"), "utf8")).toBe("one\nTWO\n"); + }); + + it("rejects an oversized created file even when supplied through the internal contract", () => { + const root = temporaryRoot(); + mkdirSync(join(root, "src")); + expect(() => + applyPatchSet(root, [ + { + path: "src/oversized.ts", + operation: "create", + mode: "100644", + hunks: [ + { + oldStart: 0, + oldCount: 0, + newStart: 1, + newCount: 1, + lines: [addedLine("x".repeat(MAX_SOURCE_FILE_BYTES + 1))], + }, + ], + }, + ]), + ).toThrow(/patched file exceeds/); + }); + + it("rejects a modification whose output crosses the source-file byte ceiling", () => { + const root = temporaryRoot(); + mkdirSync(join(root, "src")); + writeFileSync(join(root, "src/full.ts"), "a".repeat(MAX_SOURCE_FILE_BYTES)); + expect(() => + applyPatchSet(root, [ + { + path: "src/full.ts", + operation: "modify", + mode: null, + hunks: [ + { + oldStart: 1, + oldCount: 0, + newStart: 1, + newCount: 1, + lines: [addedLine("x")], + }, + ], + }, + ]), + ).toThrow(/patched file exceeds/); + }); +}); diff --git a/test/patch-validator-runtime-result-isolation.test.mjs b/test/patch-validator-runtime-result-isolation.test.mjs new file mode 100644 index 000000000..fb3dcd45d --- /dev/null +++ b/test/patch-validator-runtime-result-isolation.test.mjs @@ -0,0 +1,160 @@ +import { createHash } from "node:crypto"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { runCli } from "../patch-validator/runtime.mjs"; + +const roots = []; + +function temporaryRoot() { + const root = mkdtempSync(join(tmpdir(), "noema-result-isolation-")); + roots.push(root); + return root; +} + +afterEach(() => { + vi.unstubAllEnvs(); + while (roots.length > 0) { + rmSync(roots.pop(), { recursive: true, force: true }); + } +}); + +function modificationPatch() { + return Buffer.from( + "diff --git a/src/example.ts b/src/example.ts\n" + + "index 1111111..2222222 100644\n" + + "--- a/src/example.ts\n" + + "+++ b/src/example.ts\n" + + "@@ -1 +1 @@\n" + + "-old value\n" + + "+new value\n", + ); +} + +function exactEnvironment(patchBytes, resultPath) { + return { + NOEMA_RESULT_PATH: resultPath, + NOEMA_REPOSITORY: "ContextualWisdomLab/noema", + NOEMA_BASE_SHA: "1".repeat(40), + NOEMA_HEAD_SHA: "2".repeat(40), + NOEMA_PATCH_SHA256: createHash("sha256").update(patchBytes).digest("hex"), + NOEMA_PATCH_PROFILE: "node_patch_verify", + NOEMA_COMMAND_PROFILE: "node_patch_verify_v1", + NOEMA_VALIDATOR_IMAGE_DIGEST: `sha256:${"4".repeat(64)}`, + }; +} + +function runtimeFixture() { + const root = temporaryRoot(); + const inputRoot = join(root, "input"); + const workspaceRoot = join(root, "workspace"); + const nodeModulesPath = join(root, "image-node-modules"); + const patchPath = join(root, "input.patch"); + const patchBytes = modificationPatch(); + + mkdirSync(join(inputRoot, "src"), { recursive: true }); + mkdirSync(join(inputRoot, ".git")); + mkdirSync(nodeModulesPath); + writeFileSync(join(inputRoot, "src/example.ts"), "old value\n"); + writeFileSync(join(inputRoot, "package.json"), '{"type":"module"}\n'); + writeFileSync(join(inputRoot, "package-lock.json"), "{}\n"); + writeFileSync(join(inputRoot, "tsconfig.json"), "{}\n"); + writeFileSync(join(inputRoot, "vitest.config.ts"), "export default {};\n"); + writeFileSync(patchPath, patchBytes); + + return { + root, + inputRoot, + workspaceRoot, + nodeModulesPath, + patchPath, + patchBytes, + }; +} + +function successfulCommand() { + return { + status: 0, + signal: null, + stdout: "", + stderr: "", + error: undefined, + }; +} + +describe("internal result isolation", () => { + it("creates its tmpfs result file without a host-writable mount", () => { + const fixture = runtimeFixture(); + const resultPath = join(fixture.workspaceRoot, "result.json"); + + expect(existsSync(resultPath)).toBe(false); + const result = runCli({ + env: exactEnvironment(fixture.patchBytes, resultPath), + inputRoot: fixture.inputRoot, + patchPath: fixture.patchPath, + workspaceRoot: fixture.workspaceRoot, + nodeModulesPath: fixture.nodeModulesPath, + spawnSyncImpl: successfulCommand, + }); + + expect(result.status).toBe("passed"); + expect(JSON.parse(readFileSync(resultPath, "utf8"))).toEqual(result); + }); + + it("prefers an explicit private result path over an environment path", () => { + const fixture = runtimeFixture(); + const environmentResultPath = join(fixture.root, "untrusted-result.json"); + const explicitResultPath = join(fixture.workspaceRoot, "trusted-result.json"); + + const result = runCli({ + env: exactEnvironment(fixture.patchBytes, environmentResultPath), + resultPath: explicitResultPath, + inputRoot: fixture.inputRoot, + patchPath: fixture.patchPath, + workspaceRoot: fixture.workspaceRoot, + nodeModulesPath: fixture.nodeModulesPath, + spawnSyncImpl: successfulCommand, + }); + + expect(result.status).toBe("passed"); + expect(existsSync(environmentResultPath)).toBe(false); + expect(JSON.parse(readFileSync(explicitResultPath, "utf8"))).toEqual(result); + }); + + it("uses the process environment when no environment map is supplied", () => { + const fixture = runtimeFixture(); + const resultPath = join(fixture.workspaceRoot, "process-environment-result.json"); + for (const [name, value] of Object.entries( + exactEnvironment(fixture.patchBytes, resultPath), + )) { + vi.stubEnv(name, value); + } + + const result = runCli({ + inputRoot: fixture.inputRoot, + patchPath: fixture.patchPath, + workspaceRoot: fixture.workspaceRoot, + nodeModulesPath: fixture.nodeModulesPath, + spawnSyncImpl: successfulCommand, + }); + + expect(result.status).toBe("passed"); + expect(JSON.parse(readFileSync(resultPath, "utf8"))).toEqual(result); + }); + + it("does not precreate a non-string result path before environment validation", () => { + expect(() => runCli({ env: {}, resultPath: 42 })).toThrow( + /environment is incomplete or malformed/, + ); + }); +}); diff --git a/test/patch-validator-runtime.test.mjs b/test/patch-validator-runtime.test.mjs new file mode 100644 index 000000000..2c5363ce4 --- /dev/null +++ b/test/patch-validator-runtime.test.mjs @@ -0,0 +1,656 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + MAX_PATCH_BYTES, + MAX_RESULT_JSON_BYTES, + applyPatchSet, + copySourceTree, + parseUnifiedPatch, + readEnvironment, + runCli, + runFixedCommand, + runValidationCommands, + validateRepositoryPath, + writeResultFile, +} from "../patch-validator/runtime.mjs"; + +const roots = []; + +function temporaryRoot() { + const root = mkdtempSync(join(tmpdir(), "noema-patch-validator-")); + roots.push(root); + return root; +} + +afterEach(() => { + vi.restoreAllMocks(); + while (roots.length > 0) { + rmSync(roots.pop(), { recursive: true, force: true }); + } +}); + +function modificationPatch({ + path = "src/example.ts", + oldText = "old value", + newText = "new value", + oldStart = 1, + newStart = 1, + oldCount = 1, + newCount = 1, + finalNewline = true, +} = {}) { + const marker = finalNewline ? "" : "\\ No newline at end of file\n"; + return Buffer.from( + `diff --git a/${path} b/${path}\n` + + "index 1111111..2222222 100644\n" + + `--- a/${path}\n` + + `+++ b/${path}\n` + + `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@\n` + + `-${oldText}\n${marker}` + + `+${newText}\n${marker}`, + ); +} + +function creationPatch(path = "src/new.ts", text = "created", mode = "100644") { + return Buffer.from( + `diff --git a/${path} b/${path}\n` + + `new file mode ${mode}\n` + + "--- /dev/null\n" + + `+++ b/${path}\n` + + "@@ -0,0 +1,1 @@\n" + + `+${text}\n`, + ); +} + +function deletionPatch(path = "src/old.ts", text = "obsolete") { + return Buffer.from( + `diff --git a/${path} b/${path}\n` + + "deleted file mode 100644\n" + + `--- a/${path}\n` + + "+++ /dev/null\n" + + "@@ -1,1 +0,0 @@\n" + + `-${text}\n`, + ); +} + +function digest(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +function environment(patchBytes = modificationPatch(), overrides = {}) { + return { + NOEMA_RESULT_PATH: "/output/result.json", + NOEMA_REPOSITORY: "ContextualWisdomLab/noema", + NOEMA_BASE_SHA: "1".repeat(40), + NOEMA_HEAD_SHA: "2".repeat(40), + NOEMA_PATCH_SHA256: digest(patchBytes), + NOEMA_PATCH_PROFILE: "node_patch_verify", + NOEMA_COMMAND_PROFILE: "node_patch_verify_v1", + NOEMA_VALIDATOR_IMAGE_DIGEST: `sha256:${"4".repeat(64)}`, + ...overrides, + }; +} + +function runtimeFixture(patchBytes = modificationPatch()) { + const root = temporaryRoot(); + const inputRoot = join(root, "input"); + const workspaceRoot = join(root, "workspace"); + const nodeModulesPath = join(root, "image-node-modules"); + const patchPath = join(root, "input.patch"); + const resultPath = join(root, "result.json"); + mkdirSync(join(inputRoot, "src"), { recursive: true }); + mkdirSync(join(inputRoot, ".git")); + mkdirSync(nodeModulesPath); + writeFileSync(join(inputRoot, "src/example.ts"), "old value\n"); + writeFileSync(join(inputRoot, "package.json"), '{"type":"module"}\n'); + writeFileSync(join(inputRoot, "package-lock.json"), "{}\n"); + writeFileSync(join(inputRoot, "tsconfig.json"), "{}\n"); + writeFileSync(join(inputRoot, "vitest.config.ts"), "export default {};\n"); + writeFileSync(patchPath, patchBytes); + writeFileSync(resultPath, ""); + return { + patchBytes, + inputRoot, + workspaceRoot, + nodeModulesPath, + patchPath, + resultPath, + }; +} + +describe("environment identity", () => { + it("accepts the exact request and immutable image digest", () => { + const raw = environment(); + expect(readEnvironment(raw)).toEqual({ + resultPath: raw.NOEMA_RESULT_PATH, + repositoryFullName: raw.NOEMA_REPOSITORY, + baseSha: raw.NOEMA_BASE_SHA, + headSha: raw.NOEMA_HEAD_SHA, + patchSha256: raw.NOEMA_PATCH_SHA256, + profile: raw.NOEMA_PATCH_PROFILE, + commandProfile: raw.NOEMA_COMMAND_PROFILE, + validatorImageDigest: raw.NOEMA_VALIDATOR_IMAGE_DIGEST, + }); + }); + + it.each([ + ["NOEMA_RESULT_PATH", "relative.json"], + ["NOEMA_REPOSITORY", "single"], + ["NOEMA_BASE_SHA", "A".repeat(40)], + ["NOEMA_HEAD_SHA", "2".repeat(39)], + ["NOEMA_PATCH_SHA256", "3".repeat(63)], + ["NOEMA_PATCH_PROFILE", "arbitrary"], + ["NOEMA_COMMAND_PROFILE", "npm run test"], + ["NOEMA_VALIDATOR_IMAGE_DIGEST", "4".repeat(64)], + ])("rejects malformed %s", (key, value) => { + expect(() => readEnvironment(environment(undefined, { [key]: value }))).toThrow( + /environment/, + ); + }); + + it("rejects an omitted identity field", () => { + const raw = environment(); + delete raw.NOEMA_HEAD_SHA; + expect(() => readEnvironment(raw)).toThrow(/environment/); + }); +}); + +describe("path policy", () => { + it.each(["src/example.ts", "test/file name.test.ts", "docs/한국어.md"])( + "accepts canonical path %s", + (path) => expect(validateRepositoryPath(path)).toBe(path), + ); + + it.each([ + "", + "/absolute", + "../outside", + "src/../outside", + "src//double", + "src/./dot", + "src/trailing/", + "src\\windows", + "src/\u0001control", + ".git", + ".git/config", + "node_modules", + "node_modules/pkg/index.js", + "package.json", + "reviewer/agent.py", + "patch-validator/runtime.mjs", + ".github/workflows/pwn.yml", + ])("rejects unsafe or controlled path %s", (path) => { + expect(() => validateRepositoryPath(path)).toThrow(/path/); + }); +}); + +describe("strict patch parser", () => { + it("parses modification, creation, deletion, and executable creation", () => { + const patches = parseUnifiedPatch( + Buffer.concat([ + modificationPatch(), + creationPatch(), + deletionPatch(), + creationPatch("bin/tool.mjs", "run", "100755"), + ]), + ); + expect(patches.map(({ operation, path, mode }) => [operation, path, mode])).toEqual([ + ["modify", "src/example.ts", null], + ["create", "src/new.ts", "100644"], + ["delete", "src/old.ts", "100644"], + ["create", "bin/tool.mjs", "100755"], + ]); + }); + + it("parses quoted paths, multiple hunks, context, and no-newline markers", () => { + const patch = Buffer.from( + 'diff --git "a/src/example.ts" "b/src/example.ts"\n' + + '--- "a/src/example.ts"\n' + + '+++ "b/src/example.ts"\n' + + "@@ -1,2 +1,2 @@\n" + + "-one\n" + + "+ONE\n" + + " two\n" + + "@@ -3,1 +3,1 @@ trailing\n" + + "-three\n" + + "\\ No newline at end of file\n" + + "+THREE\n" + + "\\ No newline at end of file\n", + ); + const [parsed] = parseUnifiedPatch(patch); + expect(parsed.hunks).toHaveLength(2); + expect(parsed.hunks[0].lines[2]).toMatchObject({ kind: "context", text: "two" }); + expect(parsed.hunks[1].lines[0].oldNoNewline).toBe(true); + expect(parsed.hunks[1].lines[1].newNoNewline).toBe(true); + }); + + it.each([ + Buffer.alloc(0), + new Uint8Array([1]), + Buffer.from([0xff]), + Buffer.from("ordinary text\n"), + Buffer.from("diff --git malformed\n"), + Buffer.from( + "diff --git a/src/old.ts b/src/new.ts\n--- a/src/old.ts\n+++ b/src/new.ts\n@@ -1 +1 @@\n-old\n+new\n", + ), + Buffer.from( + "diff --git a/src/x.ts b/src/x.ts\nrename from src/x.ts\nrename to src/y.ts\n", + ), + Buffer.from( + "diff --git a/src/x.ts b/src/x.ts\nold mode 100644\nnew mode 100755\n", + ), + Buffer.from( + "diff --git a/src/x.ts b/src/x.ts\nGIT binary patch\n", + ), + Buffer.from( + "diff --git a/src/x.ts b/src/x.ts\n--- a/src/y.ts\n+++ b/src/x.ts\n@@ -1 +1 @@\n-old\n+new\n", + ), + Buffer.from( + "diff --git a/src/x.ts b/src/x.ts\n--- a/src/x.ts\n+++ b/src/y.ts\n@@ -1 +1 @@\n-old\n+new\n", + ), + Buffer.from( + "diff --git a/src/x.ts b/src/x.ts\n--- /dev/null\n+++ /dev/null\n@@ -0,0 +0,0 @@\n", + ), + Buffer.from( + "diff --git a/src/x.ts b/src/x.ts\nnew file mode 100644\ndeleted file mode 100644\n--- /dev/null\n+++ b/src/x.ts\n@@ -0,0 +1 @@\n+x\n", + ), + Buffer.from( + "diff --git a/src/x.ts b/src/x.ts\nnew file mode 100644\n--- a/src/x.ts\n+++ b/src/x.ts\n@@ -1 +1 @@\n-old\n+new\n", + ), + Buffer.from( + "diff --git a/src/x.ts b/src/x.ts\n--- a/src/x.ts\n+++ b/src/x.ts\n@@ malformed\n", + ), + Buffer.from( + "diff --git a/src/x.ts b/src/x.ts\n--- a/src/x.ts\n+++ b/src/x.ts\n@@ -1,2 +1,1 @@\n-old\n+new\n", + ), + Buffer.from( + "diff --git a/src/x.ts b/src/x.ts\n--- a/src/x.ts\n+++ b/src/x.ts\n@@ -1 +1 @@\n\\ No newline at end of file\n-old\n+new\n", + ), + Buffer.from( + "diff --git a/src/x.ts b/src/x.ts\n--- a/src/x.ts\n+++ b/src/x.ts\nnot a hunk\n", + ), + Buffer.from( + "diff --git a/src/x.ts b/src/x.ts\n--- a/src/x.ts\n+++ b/src/x.ts\n@@ -1 +1 @@\n-old\n+new\ntrailing\n", + ), + ])("rejects malformed or unsupported input", (patch) => { + expect(() => parseUnifiedPatch(patch)).toThrow(/patch/); + }); + + it("rejects oversized and duplicate patches", () => { + expect(() => parseUnifiedPatch(Buffer.alloc(MAX_PATCH_BYTES + 1))).toThrow( + /byte limit/, + ); + expect(() => + parseUnifiedPatch(Buffer.concat([modificationPatch(), modificationPatch()])), + ).toThrow(/duplicate/); + }); +}); + +describe("patch application", () => { + it("applies modifications, creations, deletions, and preserves executable mode", () => { + const root = temporaryRoot(); + mkdirSync(join(root, "src")); + writeFileSync(join(root, "src/example.ts"), "old value\n"); + writeFileSync(join(root, "src/old.ts"), "obsolete\n"); + chmodSync(join(root, "src/example.ts"), 0o755); + + applyPatchSet( + root, + parseUnifiedPatch( + Buffer.concat([modificationPatch(), creationPatch(), deletionPatch()]), + ), + ); + + expect(readFileSync(join(root, "src/example.ts"), "utf8")).toBe("new value\n"); + expect(lstatSync(join(root, "src/example.ts")).mode & 0o111).not.toBe(0); + expect(readFileSync(join(root, "src/new.ts"), "utf8")).toBe("created\n"); + expect(existsSync(join(root, "src/old.ts"))).toBe(false); + }); + + it("applies separated hunks and exact final-newline state", () => { + const root = temporaryRoot(); + mkdirSync(join(root, "src")); + writeFileSync(join(root, "src/example.ts"), "one\ntwo\nthree"); + const patch = Buffer.from( + "diff --git a/src/example.ts b/src/example.ts\n" + + "--- a/src/example.ts\n" + + "+++ b/src/example.ts\n" + + "@@ -1,1 +1,1 @@\n-one\n+ONE\n" + + "@@ -3,1 +3,1 @@\n-three\n\\ No newline at end of file\n+THREE\n\\ No newline at end of file\n", + ); + applyPatchSet(root, parseUnifiedPatch(patch)); + expect(readFileSync(join(root, "src/example.ts"), "utf8")).toBe("ONE\ntwo\nTHREE"); + }); + + it("rejects invalid operation state", () => { + const root = temporaryRoot(); + mkdirSync(join(root, "src")); + expect(() => applyPatchSet(root, parseUnifiedPatch(modificationPatch()))).toThrow( + /missing source/, + ); + writeFileSync(join(root, "src/new.ts"), "exists\n"); + expect(() => applyPatchSet(root, parseUnifiedPatch(creationPatch()))).toThrow( + /already exists/, + ); + expect(() => applyPatchSet(root, parseUnifiedPatch(deletionPatch()))).toThrow( + /missing source/, + ); + }); + + it("rejects context, newline, hunk-range, and symlink-parent mismatches", () => { + const root = temporaryRoot(); + mkdirSync(join(root, "src")); + writeFileSync(join(root, "src/example.ts"), "different\n"); + expect(() => applyPatchSet(root, parseUnifiedPatch(modificationPatch()))).toThrow( + /context/, + ); + + writeFileSync(join(root, "src/example.ts"), "old value"); + expect(() => applyPatchSet(root, parseUnifiedPatch(modificationPatch()))).toThrow( + /newline/, + ); + + writeFileSync(join(root, "src/example.ts"), "old value\n"); + expect(() => + applyPatchSet( + root, + parseUnifiedPatch(modificationPatch({ oldStart: 3, newStart: 1 })), + ), + ).toThrow(/old range/); + expect(() => + applyPatchSet( + root, + parseUnifiedPatch(modificationPatch({ oldStart: 1, newStart: 2 })), + ), + ).toThrow(/new range/); + + const outside = temporaryRoot(); + symlinkSync(outside, join(root, "linked"), "dir"); + expect(() => + applyPatchSet(root, parseUnifiedPatch(creationPatch("linked/new.ts"))), + ).toThrow(/symlink/); + }); +}); + +describe("source materialization", () => { + it("copies regular files, preserves mode, and omits .git", () => { + const source = temporaryRoot(); + const destination = temporaryRoot(); + mkdirSync(join(source, "src")); + mkdirSync(join(source, ".git")); + writeFileSync(join(source, "src/script.js"), "console.log('safe');\n"); + chmodSync(join(source, "src/script.js"), 0o755); + expect(copySourceTree(source, destination)).toEqual({ members: 2, totalBytes: 21 }); + expect(existsSync(join(destination, ".git"))).toBe(false); + expect(lstatSync(join(destination, "src/script.js")).mode & 0o111).not.toBe(0); + }); + + it("rejects node_modules, symlinks, member, file, and aggregate limits", () => { + const source = temporaryRoot(); + const destination = temporaryRoot(); + mkdirSync(join(source, "node_modules")); + expect(() => copySourceTree(source, destination)).toThrow(/node_modules/); + rmSync(join(source, "node_modules"), { recursive: true }); + + writeFileSync(join(source, "file.txt"), "1234"); + symlinkSync(join(source, "file.txt"), join(source, "link.txt")); + expect(() => copySourceTree(source, destination)).toThrow(/symlink/); + rmSync(join(source, "link.txt")); + + expect(() => + copySourceTree(source, destination, { + maximumMembers: 0, + maximumFileBytes: 10, + maximumTotalBytes: 10, + }), + ).toThrow(/member limit/); + expect(() => + copySourceTree(source, destination, { + maximumMembers: 10, + maximumFileBytes: 3, + maximumTotalBytes: 10, + }), + ).toThrow(/file exceeds/); + expect(() => + copySourceTree(source, destination, { + maximumMembers: 10, + maximumFileBytes: 10, + maximumTotalBytes: 3, + }), + ).toThrow(/aggregate/); + }); +}); + +describe("fixed command execution", () => { + it("runs Node without a shell and returns bounded success", () => { + const spawnSyncImpl = vi.fn(() => ({ + status: 0, + signal: null, + stdout: "ok", + stderr: "", + error: undefined, + })); + expect( + runFixedCommand({ + modulePath: "/opt/noema/tool.mjs", + args: ["run"], + cwd: "/workspace/source", + timeoutMs: 1000, + maximumOutputBytes: 32, + spawnSyncImpl, + }), + ).toEqual({ + exitCode: 0, + stdoutExcerpt: "ok", + stderrExcerpt: "", + reasonCodes: [], + }); + expect(spawnSyncImpl.mock.calls[0][0]).toBe(process.execPath); + expect(spawnSyncImpl.mock.calls[0][2]).toMatchObject({ + shell: false, + cwd: "/workspace/source", + timeout: 1000, + maxBuffer: 32, + }); + }); + + it.each([ + [{ status: 2, signal: null, stdout: "", stderr: "failed" }, 2, "command_failed"], + [ + { + status: null, + signal: "SIGKILL", + stdout: "", + stderr: "", + error: Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }), + }, + 124, + "command_timeout", + ], + [ + { + status: null, + signal: null, + stdout: "x".repeat(5000), + stderr: "", + error: Object.assign(new Error("overflow"), { code: "ENOBUFS" }), + }, + 125, + "command_output_limit", + ], + [ + { + status: null, + signal: null, + stdout: "", + stderr: "", + error: Object.assign(new Error("launch"), { code: "ENOENT" }), + }, + 126, + "command_launch_failed", + ], + [{ status: null, signal: "SIGTERM", stdout: "", stderr: "" }, 128, "command_failed"], + ])("classifies failure", (completed, exitCode, reasonCode) => { + const result = runFixedCommand({ + modulePath: "/opt/noema/tool.mjs", + args: [], + cwd: "/workspace/source", + spawnSyncImpl: () => completed, + }); + expect(result.exitCode).toBe(exitCode); + expect(result.reasonCodes).toEqual([reasonCode]); + expect(result.stdoutExcerpt.length).toBeLessThanOrEqual(4000); + }); + + it("runs Vitest only after TypeScript succeeds", () => { + const calls = []; + const result = runValidationCommands("/workspace/source", { + spawnSyncImpl: (...args) => { + calls.push(args); + return { status: 0, signal: null, stdout: "ok", stderr: "" }; + }, + typescriptModule: "/opt/noema/tsc", + vitestModule: "/opt/noema/vitest", + }); + expect(result.exitCode).toBe(0); + expect(calls.map((call) => call[1][0])).toEqual([ + "/opt/noema/tsc", + "/opt/noema/vitest", + ]); + + calls.length = 0; + expect( + runValidationCommands("/workspace/source", { + spawnSyncImpl: (...args) => { + calls.push(args); + return { status: 1, signal: null, stdout: "", stderr: "type error" }; + }, + typescriptModule: "/opt/noema/tsc", + vitestModule: "/opt/noema/vitest", + }).exitCode, + ).toBe(1); + expect(calls).toHaveLength(1); + }); +}); + +describe("bounded result channel", () => { + it("writes JSON to a pre-created stable regular file", () => { + const root = temporaryRoot(); + const resultPath = join(root, "result.json"); + writeFileSync(resultPath, ""); + writeResultFile(resultPath, { status: "passed", value: "safe" }); + expect(JSON.parse(readFileSync(resultPath, "utf8"))).toEqual({ + status: "passed", + value: "safe", + }); + }); + + it("rejects missing, symlinked, and oversized channels", () => { + const root = temporaryRoot(); + expect(() => writeResultFile(join(root, "missing.json"), { status: "passed" })).toThrow( + /result file/, + ); + const target = join(root, "target.json"); + const link = join(root, "link.json"); + writeFileSync(target, ""); + symlinkSync(target, link); + expect(() => writeResultFile(link, { status: "passed" })).toThrow(/result file/); + expect(() => + writeResultFile(target, { data: "x".repeat(MAX_RESULT_JSON_BYTES) }), + ).toThrow(/byte limit/); + }); +}); + +describe("runtime orchestration", () => { + it("materializes, patches, validates, and emits exact passed evidence", () => { + const fixture = runtimeFixture(); + const times = [1000, 1010]; + const result = runCli({ + env: environment(fixture.patchBytes), + ...fixture, + now: () => times.shift(), + spawnSyncImpl: () => ({ + status: 0, + signal: null, + stdout: "passed", + stderr: "", + error: undefined, + }), + }); + expect(result).toMatchObject({ + status: "passed", + exit_code: 0, + duration_ms: 10, + validator_image_digest: environment().NOEMA_VALIDATOR_IMAGE_DIGEST, + }); + expect(JSON.parse(readFileSync(fixture.resultPath, "utf8"))).toEqual(result); + expect(readFileSync(join(fixture.workspaceRoot, "source/src/example.ts"), "utf8")).toBe( + "new value\n", + ); + expect(lstatSync(join(fixture.workspaceRoot, "source/node_modules")).isSymbolicLink()).toBe( + true, + ); + }); + + it("emits failed evidence for fixed validation failure", () => { + const fixture = runtimeFixture(); + const result = runCli({ + env: environment(fixture.patchBytes), + ...fixture, + spawnSyncImpl: () => ({ + status: 7, + signal: null, + stdout: "", + stderr: "failed", + error: undefined, + }), + }); + expect(result).toMatchObject({ + status: "failed", + exit_code: 7, + reason_codes: ["command_failed"], + }); + }); + + it.each([ + [Buffer.from("not a patch"), {}, /patch/], + [modificationPatch(), { NOEMA_PATCH_SHA256: "0".repeat(64) }, /digest/], + ])("emits blocked evidence for hostile input", (patchBytes, overrides, message) => { + const fixture = runtimeFixture(patchBytes); + const result = runCli({ + env: environment(patchBytes, overrides), + ...fixture, + spawnSyncImpl: vi.fn(), + }); + expect(result.status).toBe("blocked"); + expect(result.reason_codes).toEqual(["patch_blocked"]); + expect(result.stderr_excerpt).toMatch(message); + }); + + it("rejects invalid environment before touching result", () => { + const fixture = runtimeFixture(); + expect(() => + runCli({ + env: environment(fixture.patchBytes, { NOEMA_PATCH_PROFILE: "arbitrary" }), + ...fixture, + }), + ).toThrow(/environment/); + expect(readFileSync(fixture.resultPath, "utf8")).toBe(""); + }); +}); diff --git a/test/patch-validator-smoke-diagnostic-workflow.test.mjs b/test/patch-validator-smoke-diagnostic-workflow.test.mjs new file mode 100644 index 000000000..721129b08 --- /dev/null +++ b/test/patch-validator-smoke-diagnostic-workflow.test.mjs @@ -0,0 +1,94 @@ +import { + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { readPatchValidatorDiagnostic } from "../scripts/lib/patch-validator-smoke-diagnostic.mjs"; + +describe("patch-validator smoke diagnostic workflow", () => { + it("retains only the bounded sanitized diagnostic that the workflow already uploads", () => { + const workflow = readFileSync( + ".github/workflows/patch-validator-image.yml", + "utf8", + ); + const vitest = readFileSync("vitest.config.ts", "utf8"); + + expect(workflow).not.toContain("docker run --rm \\"); + expect(workflow).toContain( + 'container_name="noema-patch-smoke-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"', + ); + expect(workflow).toContain('docker rm -f "$container_name"'); + expect(workflow).toContain( + 'diagnostic_path="$RUNNER_TEMP/patch-validator-untrusted-diagnostic.json"', + ); + expect(workflow).toContain( + '"$IMAGE_TAG" >/dev/null 2>"$diagnostic_path"', + ); + expect(workflow).not.toContain("docker cp "); + expect(workflow).toContain("readPatchValidatorDiagnostic"); + expect(workflow).toContain('rm -f "$diagnostic_path"'); + expect(workflow).not.toContain( + "$RUNNER_TEMP/patch-validator-untrusted-diagnostic.json\n path:", + ); + expect(workflow).toContain( + "path: ${{ runner.temp }}/patch-validator-evidence", + ); + expect(vitest).toContain( + '"scripts/lib/patch-validator-smoke-diagnostic.mjs"', + ); + + const runnerTemp = mkdtempSync(join(tmpdir(), "noema-smoke-workflow-")); + const diagnosticPath = join( + runnerTemp, + "patch-validator-untrusted-diagnostic.json", + ); + const evidencePath = join( + runnerTemp, + "patch-validator-evidence", + "smoke-diagnostic.json", + ); + const previousRunnerTemp = process.env.RUNNER_TEMP; + process.env.RUNNER_TEMP = runnerTemp; + + try { + writeFileSync( + diagnosticPath, + JSON.stringify({ + status: "failed", + exit_code: 2, + stderr_excerpt: "typecheck failed\u0007", + reason_codes: ["command_failed"], + repository_full_name: "attacker/controlled", + }), + ); + + expect(readPatchValidatorDiagnostic(diagnosticPath)).toEqual({ + trusted: false, + status: "failed", + exit_code: 2, + stderr_excerpt: "typecheck failed", + reason_codes: ["command_failed"], + }); + expect(JSON.parse(readFileSync(evidencePath, "utf8"))).toEqual({ + trusted: false, + status: "failed", + exit_code: 2, + stderr_excerpt: "typecheck failed", + reason_codes: ["command_failed"], + }); + } finally { + if (previousRunnerTemp === undefined) { + delete process.env.RUNNER_TEMP; + } else { + process.env.RUNNER_TEMP = previousRunnerTemp; + } + rmSync(runnerTemp, { recursive: true, force: true }); + } + }); +}); diff --git a/test/patch-validator-smoke-diagnostic.test.mjs b/test/patch-validator-smoke-diagnostic.test.mjs new file mode 100644 index 000000000..8cf4d1751 --- /dev/null +++ b/test/patch-validator-smoke-diagnostic.test.mjs @@ -0,0 +1,163 @@ +import { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + MAX_DIAGNOSTIC_BYTES, + readPatchValidatorDiagnostic, +} from "../scripts/lib/patch-validator-smoke-diagnostic.mjs"; + +const roots = []; + +function temporaryRoot() { + const root = mkdtempSync(join(tmpdir(), "noema-smoke-diagnostic-")); + roots.push(root); + return root; +} + +function validDiagnostic() { + return { + status: "failed", + exit_code: 2, + stderr_excerpt: "typecheck failed", + reason_codes: ["command_failed"], + }; +} + +function withRunnerTemp(value, callback) { + const previousRunnerTemp = process.env.RUNNER_TEMP; + if (value === undefined) { + delete process.env.RUNNER_TEMP; + } else { + process.env.RUNNER_TEMP = value; + } + try { + return callback(); + } finally { + if (previousRunnerTemp === undefined) { + delete process.env.RUNNER_TEMP; + } else { + process.env.RUNNER_TEMP = previousRunnerTemp; + } + } +} + +afterEach(() => { + while (roots.length > 0) { + rmSync(roots.pop(), { recursive: true, force: true }); + } +}); + +describe("patch-validator smoke diagnostics", () => { + it("returns only bounded non-authoritative fields from the private result", () => { + const root = temporaryRoot(); + const resultPath = join(root, "result.json"); + writeFileSync( + resultPath, + JSON.stringify({ + status: "failed", + exit_code: 2, + stderr_excerpt: "typecheck failed\nwith control\u0007", + reason_codes: ["command_failed", "extra"], + repository_full_name: "attacker/controlled", + validator_image_digest: "sha256:" + "f".repeat(64), + }), + ); + + expect(readPatchValidatorDiagnostic(resultPath)).toEqual({ + trusted: false, + status: "failed", + exit_code: 2, + stderr_excerpt: "typecheck failed\nwith control", + reason_codes: ["command_failed", "extra"], + }); + }); + + it("returns the bounded diagnostic without retaining workflow evidence when RUNNER_TEMP is absent", () => { + const root = temporaryRoot(); + const resultPath = join(root, "result.json"); + writeFileSync(resultPath, JSON.stringify(validDiagnostic())); + + expect( + withRunnerTemp(undefined, () => readPatchValidatorDiagnostic(resultPath)), + ).toEqual({ + trusted: false, + status: "failed", + exit_code: 2, + stderr_excerpt: "typecheck failed", + reason_codes: ["command_failed"], + }); + }); + + it("rejects a symlinked workflow evidence directory before retaining diagnostics", () => { + const runnerTemp = temporaryRoot(); + const diagnosticPath = join( + runnerTemp, + "patch-validator-untrusted-diagnostic.json", + ); + const targetDirectory = join(runnerTemp, "evidence-target"); + mkdirSync(targetDirectory); + symlinkSync( + targetDirectory, + join(runnerTemp, "patch-validator-evidence"), + "dir", + ); + writeFileSync(diagnosticPath, JSON.stringify(validDiagnostic())); + + expect(() => + withRunnerTemp(runnerTemp, () => + readPatchValidatorDiagnostic(diagnosticPath), + ), + ).toThrow(/evidence directory is unsafe/); + }); + + it("rejects missing, oversized, malformed, and non-file diagnostics", () => { + const root = temporaryRoot(); + expect(() => readPatchValidatorDiagnostic(join(root, "missing.json"))).toThrow( + /unavailable/, + ); + + const oversizedPath = join(root, "oversized.json"); + writeFileSync(oversizedPath, "x".repeat(MAX_DIAGNOSTIC_BYTES + 1)); + expect(() => readPatchValidatorDiagnostic(oversizedPath)).toThrow(/byte length/); + + const malformedPath = join(root, "malformed.json"); + writeFileSync(malformedPath, "{"); + expect(() => readPatchValidatorDiagnostic(malformedPath)).toThrow(/valid JSON/); + + const directoryPath = join(root, "directory.json"); + mkdirSync(directoryPath); + expect(() => readPatchValidatorDiagnostic(directoryPath)).toThrow(/regular file/); + }); + + const invalidCases = [ + ["record", null], + ["status", { ...validDiagnostic(), status: "unknown" }], + ["exit code", { ...validDiagnostic(), exit_code: -1 }], + ["exit code", { ...validDiagnostic(), exit_code: 256 }], + ["stderr", { ...validDiagnostic(), stderr_excerpt: 4 }], + ["reason codes", { ...validDiagnostic(), reason_codes: "command_failed" }], + ["reason codes", { ...validDiagnostic(), reason_codes: ["bad reason"] }], + [ + "reason codes", + { ...validDiagnostic(), reason_codes: Array.from({ length: 21 }, () => "extra") }, + ], + ]; + + it.each(invalidCases)("rejects invalid diagnostic %s fields", (_label, value) => { + const root = temporaryRoot(); + const resultPath = join(root, "invalid-fields.json"); + writeFileSync(resultPath, JSON.stringify(value)); + expect(() => readPatchValidatorDiagnostic(resultPath)).toThrow( + /diagnostic fields/, + ); + }); +}); diff --git a/test/patch-validator-static-binary-vulnerability-scan.test.ts b/test/patch-validator-static-binary-vulnerability-scan.test.ts new file mode 100644 index 000000000..6ceb57b63 --- /dev/null +++ b/test/patch-validator-static-binary-vulnerability-scan.test.ts @@ -0,0 +1,48 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +const workflow = readFileSync( + ".github/workflows/patch-validator-image.yml", + "utf8", +); +const verifier = readFileSync("scripts/verify-patch-validator-image.mjs", "utf8"); +const staticRuntimeVerifier = readFileSync( + "scripts/lib/patch-validator-static-runtime-evidence.mjs", + "utf8", +); + +/** + * Regression contract for the self-compiled static Node runtime. + * + * Trivy documents that third-party/self-compiled binaries are not covered by + * its OS-package vulnerability scanner. A scratch image therefore needs an + * independent binary inventory and vulnerability gate that proves the Node + * executable was actually classified before a clean scan can be accepted. + */ +describe("patch-validator static binary vulnerability boundary", () => { + it("pins and verifies Syft and Grype before scanning the exact local image", () => { + expect(workflow).toContain("SYFT_VERSION: 1.50.0"); + expect(workflow).toContain( + "SYFT_CHECKSUMS_SHA256: bb8824a06c27c625fc103db5d7e9d7131ba2cc6e7c7a79318ee71686ede3c3f0", + ); + expect(workflow).toContain("GRYPE_VERSION: 0.116.1"); + expect(workflow).toContain( + "GRYPE_CHECKSUMS_SHA256: 38ffeb0fbdf1955e46ebfb3cb7369b78888168954a77df02985c0c06505f85e9", + ); + expect(workflow).toContain("image-binary-sbom.syft.json"); + expect(workflow).toContain("image-binary-vulnerability-scan.json"); + expect(workflow).toContain("syft\" scan"); + expect(workflow).toContain("grype\" --config /dev/null"); + expect(workflow).toContain("--fail-on medium"); + }); + + it("requires binary inventory and scan receipts in exact-image verification", () => { + expect(verifier).toContain("--binary-sbom"); + expect(verifier).toContain("--binary-vulnerability-scan"); + expect(staticRuntimeVerifier).toContain("binarySbom"); + expect(staticRuntimeVerifier).toContain("binaryVulnerabilityScan"); + expect(staticRuntimeVerifier).toContain("cpe:2.3:a:nodejs:node.js:"); + expect(staticRuntimeVerifier).toContain("24.19.0"); + }); +}); diff --git a/test/patch-validator-static-runtime-evidence.test.ts b/test/patch-validator-static-runtime-evidence.test.ts new file mode 100644 index 000000000..7de4b9347 --- /dev/null +++ b/test/patch-validator-static-runtime-evidence.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, it } from "vitest"; + +import { verifyStaticRuntimeBinaryEvidence } from "../scripts/lib/patch-validator-static-runtime-evidence.mjs"; + +const imageDigest = `sha256:${"2".repeat(64)}`; +const providerDigest = `sha256:${"a".repeat(64)}`; +const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"; +const opensslCpe = "cpe:2.3:a:openssl:openssl:3.5.2:*:*:*:*:*:*:*"; +const undiciPurl = "pkg:npm/undici@7.13.0"; + +function rawScannerOutput( + identity: string, + matches: any[] = [], + ignoredMatches: any[] | null = [], +): any { + return { + descriptor: { + name: "grype", + version: "0.116.1", + db: { + status: { + schemaVersion: "v6.0.2", + built: "2026-08-07T00:00:00Z", + valid: true, + }, + providers: { + nvd: { + captured: "2026-08-06T00:00:00Z", + input: providerDigest, + }, + }, + }, + }, + source: { + type: identity.startsWith("pkg:") ? "purl" : "cpe", + target: identity, + }, + matches, + ignoredMatches, + }; +} + +function cleanComponentScan(key: string, identity: string): any { + return { + key, + identity, + scanner_output: rawScannerOutput(identity), + }; +} + +function validInput(): any { + return { + expectedImageDigest: imageDigest, + binarySbom: { + descriptor: { name: "syft", version: "1.50.0" }, + source: { + type: "image", + metadata: { imageID: imageDigest }, + }, + artifacts: [ + { + name: "node", + version: "24.19.0", + locations: [{ path: "/nodejs/bin/node" }], + cpes: [{ cpe: nodeCpe, source: "syft-generated" }], + }, + { + name: "typescript", + version: "5.9.3", + locations: [{ accessPath: "/opt/noema/node_modules/typescript/package.json" }], + cpes: [], + }, + ], + }, + binaryVulnerabilityScan: { + matches: [ + { + vulnerability: { id: "CVE-2099-0001", severity: "Low" }, + }, + { + vulnerability: { id: "CVE-2099-0002", severity: "Negligible" }, + }, + ], + source: { + type: "image", + target: { imageID: imageDigest }, + }, + descriptor: { name: "grype", version: "0.116.1" }, + }, + embeddedRuntimeInventory: { + schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", + validator_image_digest: imageDigest, + node_version: "24.19.0", + process_versions: { + node: "24.19.0", + openssl: "3.5.2", + undici: "7.13.0", + }, + components: [ + { + key: "openssl", + name: "openssl", + version: "3.5.2", + classification: "bundled_dependency", + cpe: opensslCpe, + }, + { + key: "undici", + name: "undici", + version: "7.13.0", + classification: "bundled_dependency", + purl: undiciPurl, + }, + ], + }, + embeddedVulnerabilityScan: { + schema_version: "noema.patch-validator-embedded-runtime-vulnerability-scan.v1", + validator_image_digest: imageDigest, + scanner: "grype@0.116.1", + components: [ + cleanComponentScan("openssl", opensslCpe), + { + key: "undici", + identity: undiciPurl, + scanner_output: rawScannerOutput( + undiciPurl, + [ + { + artifact: { + name: "undici", + version: "7.13.0", + purl: undiciPurl, + }, + vulnerability: { id: "GHSA-2099-0001", severity: "Low" }, + }, + ], + null, + ), + }, + ], + ignoredMatches: [], + }, + }; +} + +describe("static runtime binary evidence verifier", () => { + it("returns exact binary inventory and independent scan evidence", () => { + expect(verifyStaticRuntimeBinaryEvidence(validInput())).toEqual({ + binary_cataloger: "syft@1.50.0", + binary_vulnerability_scanner: "grype@0.116.1", + node_runtime_version: "24.19.0", + binary_package_count: 2, + binary_vulnerability_match_count: 2, + blocked_binary_vulnerability_count: 0, + embedded_runtime_component_count: 2, + embedded_runtime_vulnerability_match_count: 1, + embedded_runtime_vulnerability_database_identity: expect.stringContaining(providerDigest), + blocked_embedded_runtime_vulnerability_count: 0, + }); + }); + + it("accepts Syft imageId spelling, accessPath, and string CPE serialization", () => { + const input = validInput(); + input.binarySbom.source.metadata = { imageId: imageDigest }; + input.binarySbom.artifacts[0].locations = [{ accessPath: "/nodejs/bin/node" }]; + input.binarySbom.artifacts[0].cpes = [nodeCpe]; + expect(verifyStaticRuntimeBinaryEvidence(input).node_runtime_version).toBe("24.19.0"); + }); + + it("rejects a blocking advisory on an embedded runtime dependency even when the Node CPE lane is clean", () => { + const input = validInput(); + input.embeddedRuntimeInventory = { + schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", + validator_image_digest: imageDigest, + node_version: "24.19.0", + components: [ + { + key: "openssl", + name: "openssl", + version: "3.5.2", + classification: "bundled_dependency", + cpe: opensslCpe, + }, + ], + }; + input.embeddedVulnerabilityScan = { + schema_version: "noema.patch-validator-embedded-runtime-vulnerability-scan.v1", + validator_image_digest: imageDigest, + scanner: "grype@0.116.1", + matches: [ + { + artifact: { name: "openssl", version: "3.5.2", cpes: [opensslCpe] }, + vulnerability: { id: "CVE-2099-4242", severity: "High" }, + }, + ], + ignoredMatches: [], + }; + expect(() => verifyStaticRuntimeBinaryEvidence(input)).toThrow( + /blocking embedded runtime vulnerabilities/i, + ); + }); + + const invalidCases: Array<[string, (input: any) => void]> = [ + ["static-runtime image digest", (x) => { x.expectedImageDigest = "latest"; }], + ["Syft SBOM record", (x) => { x.binarySbom = null; }], + ["Syft descriptor", (x) => { x.binarySbom.descriptor = null; }], + ["produced by Syft", (x) => { x.binarySbom.descriptor.name = "other"; }], + ["Syft version", (x) => { x.binarySbom.descriptor.version = "1.49.0"; }], + ["Syft source", (x) => { x.binarySbom.source = null; }], + ["Syft source must be an image", (x) => { x.binarySbom.source.type = "directory"; }], + ["Syft source metadata", (x) => { x.binarySbom.source.metadata = null; }], + ["Syft image digest", (x) => { x.binarySbom.source.metadata = {}; }], + ["Syft image digest", (x) => { x.binarySbom.source.metadata.imageID = `sha256:${"3".repeat(64)}`; }], + ["Syft artifacts", (x) => { x.binarySbom.artifacts = null; }], + ["Syft package", (x) => { x.binarySbom.artifacts[0] = null; }], + ["exactly one expected static Node", (x) => { x.binarySbom.artifacts[0].name = "nodejs"; }], + ["exactly one expected static Node", (x) => { x.binarySbom.artifacts[0].version = "24.18.0"; }], + ["exactly one expected static Node", (x) => { x.binarySbom.artifacts[0].locations = null; }], + ["Syft package location", (x) => { x.binarySbom.artifacts[0].locations = [null]; }], + ["exactly one expected static Node", (x) => { x.binarySbom.artifacts[0].locations = [{ path: "/other" }]; }], + ["exactly one expected static Node", (x) => { x.binarySbom.artifacts[0].cpes = null; }], + ["Syft package CPE", (x) => { x.binarySbom.artifacts[0].cpes = [42]; }], + ["exactly one expected static Node", (x) => { x.binarySbom.artifacts[0].cpes = [{ cpe: "cpe:2.3:a:other:node:24.19.0:*:*:*:*:*:*:*" }]; }], + ["exactly one expected static Node", (x) => { x.binarySbom.artifacts[0].cpes = ["cpe:2.3:a:other:node:24.19.0:*:*:*:*:*:*:*"]; }], + ["exactly one expected static Node", (x) => { x.binarySbom.artifacts.push({ ...x.binarySbom.artifacts[0] }); }], + ["Grype vulnerability record", (x) => { x.binaryVulnerabilityScan = null; }], + ["Grype descriptor", (x) => { x.binaryVulnerabilityScan.descriptor = null; }], + ["produced by Grype", (x) => { x.binaryVulnerabilityScan.descriptor.name = "other"; }], + ["Grype version", (x) => { x.binaryVulnerabilityScan.descriptor.version = "0.115.0"; }], + ["Grype source", (x) => { x.binaryVulnerabilityScan.source = null; }], + ["Grype source must be an image", (x) => { x.binaryVulnerabilityScan.source.type = "directory"; }], + ["Grype image target", (x) => { x.binaryVulnerabilityScan.source.target = null; }], + ["Grype image digest", (x) => { x.binaryVulnerabilityScan.source.target.imageID = `sha256:${"4".repeat(64)}`; }], + ["Grype matches", (x) => { x.binaryVulnerabilityScan.matches = null; }], + ["ignored binary vulnerability", (x) => { x.binaryVulnerabilityScan.ignoredMatches = "invalid"; }], + ["ignored binary vulnerability", (x) => { x.binaryVulnerabilityScan.ignoredMatches = [{}]; }], + ["Grype match", (x) => { x.binaryVulnerabilityScan.matches[0] = null; }], + ["Grype vulnerability", (x) => { x.binaryVulnerabilityScan.matches[0].vulnerability = null; }], + ["severity must be a string", (x) => { x.binaryVulnerabilityScan.matches[0].vulnerability.severity = 3; }], + ["severity is unsupported", (x) => { x.binaryVulnerabilityScan.matches[0].vulnerability.severity = "Important"; }], + ["blocking static-runtime vulnerabilities", (x) => { x.binaryVulnerabilityScan.matches[0].vulnerability.severity = "Medium"; }], + ["blocking static-runtime vulnerabilities", (x) => { x.binaryVulnerabilityScan.matches[0].vulnerability.severity = "High"; }], + ["blocking static-runtime vulnerabilities", (x) => { x.binaryVulnerabilityScan.matches[0].vulnerability.severity = "Critical"; }], + ["blocking static-runtime vulnerabilities", (x) => { x.binaryVulnerabilityScan.matches[0].vulnerability.severity = "Unknown"; }], + ["embedded runtime inventory", (x) => { x.embeddedRuntimeInventory = null; }], + ["inventory schema", (x) => { x.embeddedRuntimeInventory.schema_version = "wrong"; }], + ["inventory image digest", (x) => { x.embeddedRuntimeInventory.validator_image_digest = `sha256:${"4".repeat(64)}`; }], + ["inventory Node version", (x) => { x.embeddedRuntimeInventory.node_version = "24.18.0"; }], + ["components must be", (x) => { x.embeddedRuntimeInventory.components = null; }], + ["components must be", (x) => { x.embeddedRuntimeInventory.components = []; }], + ["components must be", (x) => { x.embeddedRuntimeInventory.components = Array.from({ length: 129 }, () => x.embeddedRuntimeInventory.components[0]); }], + ["embedded runtime vulnerability scan", (x) => { x.embeddedVulnerabilityScan = null; }], + ["scan schema", (x) => { x.embeddedVulnerabilityScan.schema_version = "wrong"; }], + ["scan image digest", (x) => { x.embeddedVulnerabilityScan.validator_image_digest = `sha256:${"5".repeat(64)}`; }], + ["scanner does not match", (x) => { x.embeddedVulnerabilityScan.scanner = "grype@0.1.0"; }], + ["ignored embedded runtime vulnerability", (x) => { x.embeddedVulnerabilityScan.ignoredMatches = "invalid"; }], + ["ignored embedded runtime vulnerability", (x) => { x.embeddedVulnerabilityScan.ignoredMatches = [{}]; }], + ["process.versions", (x) => { x.embeddedRuntimeInventory.process_versions = null; }], + ["process.versions Node version", (x) => { x.embeddedRuntimeInventory.process_versions.node = "24.18.0"; }], + ["dependencies must be", (x) => { x.embeddedRuntimeInventory.process_versions = { node: "24.19.0" }; }], + ["dependencies must be", (x) => { + x.embeddedRuntimeInventory.process_versions = { node: "24.19.0" }; + for (let index = 0; index < 129; index += 1) x.embeddedRuntimeInventory.process_versions[`dep${index}`] = "1"; + }], + ["embedded runtime component", (x) => { x.embeddedRuntimeInventory.components[0] = null; }], + ["component key", (x) => { x.embeddedRuntimeInventory.components[0].key = "../openssl"; }], + ["keys must be unique", (x) => { x.embeddedRuntimeInventory.components[1].key = "openssl"; }], + ["classified as a bundled dependency", (x) => { x.embeddedRuntimeInventory.components[0].classification = "metadata"; }], + ["name is invalid", (x) => { x.embeddedRuntimeInventory.components[0].name = ""; }], + ["version does not match", (x) => { x.embeddedRuntimeInventory.components[0].version = "0"; }], + ["no supported vulnerability identity", (x) => { delete x.embeddedRuntimeInventory.components[0].cpe; }], + ["component set must exactly match", (x) => { x.embeddedRuntimeInventory.components.pop(); }], + ["one result per component", (x) => { x.embeddedVulnerabilityScan.components = null; }], + ["one result per component", (x) => { x.embeddedVulnerabilityScan.components.pop(); }], + ["embedded runtime component scan", (x) => { x.embeddedVulnerabilityScan.components[0] = null; }], + ["unknown component", (x) => { x.embeddedVulnerabilityScan.components[0].key = "other"; }], + ["scan keys must be unique", (x) => { x.embeddedVulnerabilityScan.components[1].key = "openssl"; x.embeddedVulnerabilityScan.components[1].identity = opensslCpe; }], + ["scan identity does not match", (x) => { x.embeddedVulnerabilityScan.components[0].identity = "pkg:npm/other@1"; }], + ["raw scanner evidence", (x) => { x.embeddedVulnerabilityScan.components[0].scanner_output = null; }], + ["raw scanner descriptor", (x) => { x.embeddedVulnerabilityScan.components[0].scanner_output.descriptor = null; }], + ["raw scanner must be produced by Grype", (x) => { x.embeddedVulnerabilityScan.components[0].scanner_output.descriptor.name = "other"; }], + ["raw scanner version", (x) => { x.embeddedVulnerabilityScan.components[0].scanner_output.descriptor.version = "0.115.0"; }], + ["raw scanner source", (x) => { x.embeddedVulnerabilityScan.components[0].scanner_output.source = null; }], + ["raw scanner source type", (x) => { x.embeddedVulnerabilityScan.components[0].scanner_output.source.type = "sbom-file"; }], + ["raw scanner source target", (x) => { x.embeddedVulnerabilityScan.components[0].scanner_output.source.target = "other"; }], + ["vulnerability database", (x) => { x.embeddedVulnerabilityScan.components[0].scanner_output.descriptor.db = null; }], + ["database status", (x) => { x.embeddedVulnerabilityScan.components[0].scanner_output.descriptor.db.status = null; }], + ["database schema", (x) => { x.embeddedVulnerabilityScan.components[0].scanner_output.descriptor.db.status.schemaVersion = ""; }], + ["database build timestamp", (x) => { x.embeddedVulnerabilityScan.components[0].scanner_output.descriptor.db.status.built = "yesterday"; }], + ["database must be valid", (x) => { x.embeddedVulnerabilityScan.components[0].scanner_output.descriptor.db.status.valid = false; }], + ["database error", (x) => { x.embeddedVulnerabilityScan.components[0].scanner_output.descriptor.db.status.error = "checksum mismatch"; }], + ["database providers", (x) => { x.embeddedVulnerabilityScan.components[0].scanner_output.descriptor.db.providers = null; }], + ["database provider name", (x) => { x.embeddedVulnerabilityScan.components[0].scanner_output.descriptor.db.providers = { "../nvd": { captured: "2026-08-06T00:00:00Z", input: providerDigest } }; }], + ["database provider evidence", (x) => { x.embeddedVulnerabilityScan.components[0].scanner_output.descriptor.db.providers.nvd = null; }], + ["provider capture timestamp", (x) => { x.embeddedVulnerabilityScan.components[0].scanner_output.descriptor.db.providers.nvd.captured = "yesterday"; }], + ["provider input digest", (x) => { x.embeddedVulnerabilityScan.components[0].scanner_output.descriptor.db.providers.nvd.input = "latest"; }], + ["ignored embedded runtime component", (x) => { x.embeddedVulnerabilityScan.components[0].scanner_output.ignoredMatches = "invalid"; }], + ["ignored embedded runtime component", (x) => { x.embeddedVulnerabilityScan.components[0].scanner_output.ignoredMatches = [{}]; }], + ["component openssl matches", (x) => { x.embeddedVulnerabilityScan.components[0].scanner_output.matches = null; }], + ["component openssl match", (x) => { x.embeddedVulnerabilityScan.components[0].scanner_output.matches = [null]; }], + ["component openssl vulnerability", (x) => { x.embeddedVulnerabilityScan.components[0].scanner_output.matches = [{ vulnerability: null }]; }], + ["blocking embedded runtime vulnerabilities", (x) => { + x.embeddedVulnerabilityScan.components[0].scanner_output.matches = [{ + artifact: { name: "openssl", version: "3.5.2", cpes: [opensslCpe] }, + vulnerability: { severity: "Medium" }, + }]; + }], + ]; + + it.each(invalidCases)("rejects %s", (message, mutate) => { + const input = validInput(); + mutate(input); + expect(() => verifyStaticRuntimeBinaryEvidence(input)).toThrow( + new RegExp(message, "i"), + ); + }); + + it("accepts explicit empty ignored lists and a clean aggregate compatibility list", () => { + const input = validInput(); + input.binaryVulnerabilityScan.ignoredMatches = []; + input.embeddedVulnerabilityScan.matches = []; + expect(verifyStaticRuntimeBinaryEvidence(input).blocked_binary_vulnerability_count).toBe(0); + }); +}); diff --git a/test/patch-validator-static-runtime-identity-binding.test.ts b/test/patch-validator-static-runtime-identity-binding.test.ts new file mode 100644 index 000000000..b0e71d401 --- /dev/null +++ b/test/patch-validator-static-runtime-identity-binding.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, it } from "vitest"; + +import { verifyStaticRuntimeBinaryEvidence } from "../scripts/lib/patch-validator-static-runtime-evidence.mjs"; + +const imageDigest = `sha256:${"4".repeat(64)}`; +const providerDigestA = `sha256:${"a".repeat(64)}`; +const providerDigestB = `sha256:${"b".repeat(64)}`; +const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"; +const opensslCpe = "cpe:2.3:a:openssl:openssl:3.5.2:*:*:*:*:*:*:*"; +const undiciPurl = "pkg:npm/undici@7.13.0"; + +function rawScannerOutput( + identity: string, + providerInput = providerDigestA, + matches: any[] = [], +): any { + return { + descriptor: { + name: "grype", + version: "0.116.1", + db: { + status: { + schemaVersion: "v6.0.2", + built: "2026-08-07T00:00:00Z", + valid: true, + }, + providers: { + nvd: { + captured: "2026-08-06T00:00:00Z", + input: providerInput, + }, + }, + }, + }, + source: { + type: identity.startsWith("pkg:") ? "purl" : "cpe", + target: identity, + }, + matches, + ignoredMatches: [], + }; +} + +function validInput(): any { + return { + expectedImageDigest: imageDigest, + binarySbom: { + descriptor: { name: "syft", version: "1.50.0" }, + source: { type: "image", metadata: { imageID: imageDigest } }, + artifacts: [ + { + name: "node", + version: "24.19.0", + locations: [{ path: "/nodejs/bin/node" }], + cpes: [nodeCpe], + }, + ], + }, + binaryVulnerabilityScan: { + descriptor: { name: "grype", version: "0.116.1" }, + source: { type: "image", target: { imageID: imageDigest } }, + matches: [], + ignoredMatches: [], + }, + embeddedRuntimeInventory: { + schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", + validator_image_digest: imageDigest, + node_version: "24.19.0", + process_versions: { + node: "24.19.0", + openssl: "3.5.2", + undici: "7.13.0", + }, + components: [ + { + key: "openssl", + name: "openssl", + version: "3.5.2", + classification: "bundled_dependency", + cpe: opensslCpe, + }, + { + key: "undici", + name: "undici", + version: "7.13.0", + classification: "bundled_dependency", + purl: undiciPurl, + }, + ], + }, + embeddedVulnerabilityScan: { + schema_version: "noema.patch-validator-embedded-runtime-vulnerability-scan.v1", + validator_image_digest: imageDigest, + scanner: "grype@0.116.1", + components: [ + { + key: "openssl", + identity: opensslCpe, + scanner_output: rawScannerOutput(opensslCpe), + }, + { + key: "undici", + identity: undiciPurl, + scanner_output: rawScannerOutput(undiciPurl), + }, + ], + ignoredMatches: [], + }, + }; +} + +describe("embedded runtime identity binding", () => { + it("retains the exact shared Grype database identity in verification evidence", () => { + const result = verifyStaticRuntimeBinaryEvidence(validInput()); + expect(result.embedded_runtime_vulnerability_database_identity).toContain("v6.0.2"); + expect(result.embedded_runtime_vulnerability_database_identity).toContain(providerDigestA); + }); + + it("canonicalizes a multi-provider Grype database snapshot before comparing component scans", () => { + const input = validInput(); + for (const componentScan of input.embeddedVulnerabilityScan.components) { + componentScan.scanner_output.descriptor.db.providers.github = { + captured: "2026-08-06T01:00:00Z", + input: providerDigestB, + }; + } + + const result = verifyStaticRuntimeBinaryEvidence(input); + expect(result.embedded_runtime_vulnerability_database_identity).toContain("github"); + expect(result.embedded_runtime_vulnerability_database_identity).toContain(providerDigestB); + }); + + it("rejects an incomplete npm PURL that does not bind package and version", () => { + const input = validInput(); + input.embeddedRuntimeInventory.components[1].purl = "pkg:npm/"; + input.embeddedVulnerabilityScan.components[1].identity = "pkg:npm/"; + input.embeddedVulnerabilityScan.components[1].scanner_output = rawScannerOutput("pkg:npm/"); + + expect(() => verifyStaticRuntimeBinaryEvidence(input)).toThrow( + /supported vulnerability identity|canonical.*purl|purl.*version/i, + ); + }); + + it("rejects a CPE whose product version disagrees with process.versions", () => { + const input = validInput(); + const mismatchedCpe = "cpe:2.3:a:openssl:openssl:9.9.9:*:*:*:*:*:*:*"; + input.embeddedRuntimeInventory.components[0].cpe = mismatchedCpe; + input.embeddedVulnerabilityScan.components[0].identity = mismatchedCpe; + input.embeddedVulnerabilityScan.components[0].scanner_output = rawScannerOutput(mismatchedCpe); + + expect(() => verifyStaticRuntimeBinaryEvidence(input)).toThrow( + /cpe.*version|vulnerability identity.*version|identity.*process\.versions/i, + ); + }); + + it("rejects a CPE whose product name disagrees with the reviewed component", () => { + const input = validInput(); + const mismatchedCpe = "cpe:2.3:a:openssl:not-openssl:3.5.2:*:*:*:*:*:*:*"; + input.embeddedRuntimeInventory.components[0].cpe = mismatchedCpe; + input.embeddedVulnerabilityScan.components[0].identity = mismatchedCpe; + input.embeddedVulnerabilityScan.components[0].scanner_output = rawScannerOutput(mismatchedCpe); + + expect(() => verifyStaticRuntimeBinaryEvidence(input)).toThrow( + /cpe.*name|cpe.*product|identity.*component/i, + ); + }); + + it("rejects a wildcard CPE vendor instead of treating it as a reviewed identity", () => { + const input = validInput(); + const wildcardCpe = "cpe:2.3:a:*:openssl:3.5.2:*:*:*:*:*:*:*"; + input.embeddedRuntimeInventory.components[0].cpe = wildcardCpe; + input.embeddedVulnerabilityScan.components[0].identity = wildcardCpe; + input.embeddedVulnerabilityScan.components[0].scanner_output = rawScannerOutput(wildcardCpe); + + expect(() => verifyStaticRuntimeBinaryEvidence(input)).toThrow( + /reviewed.*cpe|cpe.*vendor|identity catalog/i, + ); + }); + + it("rejects a same-name CPE from a vendor outside the reviewed identity catalog", () => { + const input = validInput(); + const substitutedCpe = "cpe:2.3:a:example:openssl:3.5.2:*:*:*:*:*:*:*"; + input.embeddedRuntimeInventory.components[0].cpe = substitutedCpe; + input.embeddedVulnerabilityScan.components[0].identity = substitutedCpe; + input.embeddedVulnerabilityScan.components[0].scanner_output = rawScannerOutput(substitutedCpe); + + expect(() => verifyStaticRuntimeBinaryEvidence(input)).toThrow( + /reviewed.*cpe|cpe.*vendor|identity catalog/i, + ); + }); + + it("rejects a bundled dependency key that is not in the reviewed identity catalog", () => { + const input = validInput(); + input.embeddedRuntimeInventory.process_versions.openssl_alias = "3.5.2"; + input.embeddedRuntimeInventory.components[0].key = "openssl_alias"; + input.embeddedVulnerabilityScan.components[0].key = "openssl_alias"; + + expect(() => verifyStaticRuntimeBinaryEvidence(input)).toThrow( + /identity catalog|no reviewed vulnerability identity/i, + ); + }); + + it("rejects a vulnerability match whose artifact belongs to another package", () => { + const input = validInput(); + input.embeddedVulnerabilityScan.components[0].scanner_output = rawScannerOutput( + opensslCpe, + providerDigestA, + [ + { + artifact: { + name: "zlib", + version: "1.3.1", + cpes: ["cpe:2.3:a:zlib:zlib:1.3.1:*:*:*:*:*:*:*"] + }, + vulnerability: { id: "CVE-2099-1000", severity: "Low" }, + }, + ], + ); + + expect(() => verifyStaticRuntimeBinaryEvidence(input)).toThrow( + /artifact.*identity|artifact.*component|match.*artifact/i, + ); + }); + + it("accepts canonical structured CPE artifact evidence bound to the reviewed component", () => { + const input = validInput(); + input.embeddedVulnerabilityScan.components[0].scanner_output = rawScannerOutput( + opensslCpe, + providerDigestA, + [ + { + artifact: { + name: "openssl", + version: "3.5.2", + cpes: [{ cpe: opensslCpe }], + }, + vulnerability: { id: "CVE-2099-1002", severity: "Low" }, + }, + ], + ); + + const result = verifyStaticRuntimeBinaryEvidence(input); + expect(result.embedded_runtime_vulnerability_match_count).toBe(1); + expect(result.blocked_embedded_runtime_vulnerability_count).toBe(0); + }); + + it("rejects a CPE match whose optional artifact name contradicts the reviewed component", () => { + const input = validInput(); + input.embeddedVulnerabilityScan.components[0].scanner_output = rawScannerOutput( + opensslCpe, + providerDigestA, + [ + { + artifact: { + name: "zlib", + version: "3.5.2", + cpes: [opensslCpe], + }, + vulnerability: { id: "CVE-2099-1001", severity: "Low" }, + }, + ], + ); + + expect(() => verifyStaticRuntimeBinaryEvidence(input)).toThrow( + /artifact.*name|artifact.*component/i, + ); + }); + + it("rejects vulnerability matches that omit evaluated artifact identity", () => { + const input = validInput(); + input.embeddedVulnerabilityScan.components[1].scanner_output = rawScannerOutput( + undiciPurl, + providerDigestA, + [{ vulnerability: { id: "GHSA-2099-1000", severity: "Low" } }], + ); + + expect(() => verifyStaticRuntimeBinaryEvidence(input)).toThrow( + /artifact.*identity|artifact.*record|match.*artifact/i, + ); + }); + + it("rejects component scans captured from different vulnerability database snapshots", () => { + const input = validInput(); + input.embeddedVulnerabilityScan.components[1].scanner_output = rawScannerOutput( + undiciPurl, + providerDigestB, + ); + + expect(() => verifyStaticRuntimeBinaryEvidence(input)).toThrow( + /database.*identity|database.*snapshot|same.*database/i, + ); + }); +}); diff --git a/test/patch-validator-static-runtime-metadata.test.ts b/test/patch-validator-static-runtime-metadata.test.ts new file mode 100644 index 000000000..9f6099c06 --- /dev/null +++ b/test/patch-validator-static-runtime-metadata.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vitest"; + +import { verifyStaticRuntimeBinaryEvidence } from "../scripts/lib/patch-validator-static-runtime-evidence.mjs"; + +const imageDigest = `sha256:${"7".repeat(64)}`; +const providerDigest = `sha256:${"8".repeat(64)}`; +const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"; +const opensslCpe = "cpe:2.3:a:openssl:openssl:3.5.2:*:*:*:*:*:*:*"; + +function rawScannerOutput(identity: string): any { + return { + descriptor: { + name: "grype", + version: "0.116.1", + db: { + status: { + schemaVersion: "v6.0.2", + built: "2026-08-07T00:00:00Z", + valid: true, + }, + providers: { + nvd: { + captured: "2026-08-06T00:00:00Z", + input: providerDigest, + }, + }, + }, + }, + source: { + type: identity.startsWith("pkg:") ? "purl" : "cpe", + target: identity, + }, + matches: [], + ignoredMatches: [], + }; +} + +function inputWithRuntimeMetadata(): any { + return { + expectedImageDigest: imageDigest, + binarySbom: { + descriptor: { name: "syft", version: "1.50.0" }, + source: { type: "image", metadata: { imageID: imageDigest } }, + artifacts: [ + { + name: "node", + version: "24.19.0", + locations: [{ path: "/nodejs/bin/node" }], + cpes: [nodeCpe], + }, + ], + }, + binaryVulnerabilityScan: { + descriptor: { name: "grype", version: "0.116.1" }, + source: { type: "image", target: { imageID: imageDigest } }, + matches: [], + ignoredMatches: [], + }, + embeddedRuntimeInventory: { + schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", + validator_image_digest: imageDigest, + node_version: "24.19.0", + process_versions: { + node: "24.19.0", + modules: "137", + napi: "10", + openssl: "3.5.2", + }, + components: [ + { + key: "modules", + name: "node_modules_abi", + version: "137", + classification: "runtime_metadata", + reason: "Node.js native module ABI version", + }, + { + key: "napi", + name: "node_api_level", + version: "10", + classification: "runtime_metadata", + reason: "Node-API compatibility level", + }, + { + key: "openssl", + name: "openssl", + version: "3.5.2", + classification: "bundled_dependency", + cpe: opensslCpe, + }, + ], + }, + embeddedVulnerabilityScan: { + schema_version: "noema.patch-validator-embedded-runtime-vulnerability-scan.v1", + validator_image_digest: imageDigest, + scanner: "grype@0.116.1", + components: [ + { + key: "openssl", + identity: opensslCpe, + scanner_output: rawScannerOutput(opensslCpe), + }, + ], + ignoredMatches: [], + }, + }; +} + +function addNgtcp2(input: any, version: string): void { + const cpe = `cpe:2.3:a:nghttp2:ngtcp2:${version}:*:*:*:*:*:*:*`; + input.embeddedRuntimeInventory.process_versions.ngtcp2 = version; + input.embeddedRuntimeInventory.components.push({ + key: "ngtcp2", + name: "ngtcp2", + version, + classification: "bundled_dependency", + cpe, + }); + input.embeddedVulnerabilityScan.components.push({ + key: "ngtcp2", + identity: cpe, + scanner_output: rawScannerOutput(cpe), + }); +} + +describe("static runtime metadata classification", () => { + it("keeps ABI metadata exhaustive without pretending it is a vulnerable package", () => { + expect(verifyStaticRuntimeBinaryEvidence(inputWithRuntimeMetadata())).toMatchObject({ + embedded_runtime_component_count: 3, + embedded_runtime_vulnerability_match_count: 0, + blocked_embedded_runtime_vulnerability_count: 0, + }); + }); + + it("rejects runtime-metadata classification for a real bundled dependency", () => { + const input = inputWithRuntimeMetadata(); + input.embeddedRuntimeInventory.components[2] = { + key: "openssl", + name: "openssl", + version: "3.5.2", + classification: "runtime_metadata", + reason: "Node.js native module ABI version", + }; + input.embeddedVulnerabilityScan.components = []; + expect(() => verifyStaticRuntimeBinaryEvidence(input)).toThrow( + /runtime metadata classification is not allowed/i, + ); + }); + + it("requires the reviewed explanation for each metadata field", () => { + const input = inputWithRuntimeMetadata(); + input.embeddedRuntimeInventory.components[0].reason = "metadata"; + expect(() => verifyStaticRuntimeBinaryEvidence(input)).toThrow( + /runtime metadata reason does not match/i, + ); + }); + + it("rejects package identities on metadata-only fields", () => { + const input = inputWithRuntimeMetadata(); + input.embeddedRuntimeInventory.components[1].purl = "pkg:generic/napi@10"; + expect(() => verifyStaticRuntimeBinaryEvidence(input)).toThrow( + /runtime metadata must not declare a vulnerability identity/i, + ); + }); + + it("rejects vulnerability scans that masquerade metadata as a package", () => { + const input = inputWithRuntimeMetadata(); + input.embeddedVulnerabilityScan.components.push({ + key: "modules", + identity: "pkg:generic/node-modules-abi@137", + scanner_output: rawScannerOutput("pkg:generic/node-modules-abi@137"), + }); + expect(() => verifyStaticRuntimeBinaryEvidence(input)).toThrow( + /one result per bundled dependency/i, + ); + }); + + it("does not let a scanner blind spot clear the bundled ngtcp2 version from Node 24.19.0", () => { + const input = inputWithRuntimeMetadata(); + addNgtcp2(input, "1.15.1"); + + expect(() => verifyStaticRuntimeBinaryEvidence(input)).toThrow( + /known vulnerable embedded runtime dependency.*ngtcp2.*1\.15\.1.*1\.22\.1/i, + ); + }); + + it("fails closed when the bundled ngtcp2 version is not a stable numeric release", () => { + const input = inputWithRuntimeMetadata(); + addNgtcp2(input, "1.22.1-rc.1"); + + expect(() => verifyStaticRuntimeBinaryEvidence(input)).toThrow( + /known vulnerable embedded runtime dependency.*ngtcp2.*1\.22\.1-rc\.1.*1\.22\.1/i, + ); + }); + + it.each(["1.22.1", "1.22.2", "1.23.0", "2.0.0"])( + "accepts ngtcp2 %s at or above the reviewed fixed floor when scanner evidence is clean", + (version) => { + const input = inputWithRuntimeMetadata(); + addNgtcp2(input, version); + expect(() => verifyStaticRuntimeBinaryEvidence(input)).not.toThrow(); + }, + ); +}); diff --git a/test/patch-validator-static-runtime.test.ts b/test/patch-validator-static-runtime.test.ts new file mode 100644 index 000000000..c5eda79c0 --- /dev/null +++ b/test/patch-validator-static-runtime.test.ts @@ -0,0 +1,72 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +const dockerfile = readFileSync("Dockerfile.patch-validator", "utf8"); +const workflow = readFileSync( + ".github/workflows/patch-validator-image.yml", + "utf8", +); + +const alpineBuilder = + "alpine:3.24.1@sha256:79ff19e9084a00eece421b2523fb93e22d730e2c0e525905de047e848e56d95f"; +const nodeSourceSha256 = + "f6d95e10a0431ee1067fc6aabe9f762908b4716dd35324e1ddb4b1466b76659f"; +const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"; + +describe("patch-validator static scratch runtime", () => { + it("builds and inventories the checksum-pinned current Node 24 source", () => { + expect(dockerfile).toContain(`FROM ${alpineBuilder} AS node_builder`); + expect(dockerfile).toContain("ARG NODE_VERSION=24.19.0"); + expect(dockerfile).toContain(`ARG NODE_SOURCE_SHA256=${nodeSourceSha256}`); + expect(dockerfile).toContain( + "ADD --checksum=sha256:${NODE_SOURCE_SHA256} https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}.tar.xz /tmp/node.tar.xz", + ); + expect(dockerfile).toContain("--fully-static"); + expect(dockerfile).not.toContain("--with-intl=none"); + expect(dockerfile).toContain("--with-intl=small-icu"); + expect(dockerfile).toContain("--without-npm"); + expect(dockerfile).toContain("--without-corepack"); + expect(dockerfile).toContain( + "test \"$(/opt/node/bin/node --version)\" = \"v${NODE_VERSION}\"", + ); + expect(dockerfile).toContain( + "/opt/node/bin/node --input-type=module --eval=\"new RegExp('\\\\p{ID_Continue}', 'u')\"", + ); + expect(dockerfile).toContain("readelf -l /opt/node/bin/node"); + expect(dockerfile).toContain("readelf -d /opt/node/bin/node"); + expect(dockerfile).toContain( + "--add-section .note.package=/tmp/node-package-note.json", + ); + expect(dockerfile).toContain( + "--set-section-flags .note.package=noload,readonly", + ); + expect(dockerfile).toContain(`\"cpe\":\"${nodeCpe}\"`); + expect(dockerfile).toContain( + "readelf -p .note.package /opt/node/bin/node", + ); + }); + + it("ships only the static runtime and approved validator payload in scratch", () => { + expect(dockerfile).toContain("FROM scratch AS runtime"); + expect(dockerfile).toContain( + "COPY --from=node_builder --chown=65532:65532 /opt/node/bin/node /nodejs/bin/node", + ); + expect(dockerfile).toContain("USER 65532:65532"); + expect(dockerfile).not.toContain("gcr.io/distroless"); + expect(dockerfile).not.toContain("debian13"); + }); + + it("verifies the repository-built runtime without external base-image trust claims", () => { + expect(workflow).not.toContain("DISTROLESS_IMAGE"); + expect(workflow).not.toContain("sigstore/cosign-installer"); + expect(workflow).not.toContain("cosign verify"); + expect(workflow).not.toContain("keyless@distroless.iam.gserviceaccount.com"); + expect(workflow).toContain("Verify static Node runtime identity"); + expect(workflow).toContain( + 'test "$(docker run --rm --pull=never --entrypoint=/nodejs/bin/node "$IMAGE_TAG" --version)" = "v24.19.0"', + ); + expect(workflow).toContain("--severity MEDIUM,HIGH,CRITICAL"); + expect(workflow).toContain("--exit-code 1"); + }); +}); diff --git a/test/patch-validator-trusted-typescript-transform.test.mjs b/test/patch-validator-trusted-typescript-transform.test.mjs new file mode 100644 index 000000000..279c57c0a --- /dev/null +++ b/test/patch-validator-trusted-typescript-transform.test.mjs @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import * as trustedConfiguration from "../patch-validator/validator-vitest.config.mjs"; + +function trustedTransform() { + const transform = trustedConfiguration.trustedTypeScriptTransform; + expect(transform).toBeTypeOf("function"); + return transform; +} + +describe("image-owned TypeScript transform", () => { + it("disables Vite OXC tsconfig discovery and installs the trusted transform", () => { + const configuration = trustedConfiguration.default; + + expect(configuration.oxc).toBe(false); + expect(configuration.plugins).toHaveLength(1); + expect(configuration.plugins[0]).toMatchObject({ + name: "noema-trusted-typescript-transform", + enforce: "pre", + transform: trustedConfiguration.trustedTypeScriptTransform, + }); + }); + + it("transpiles TypeScript without consulting a source-owned tsconfig", () => { + const transformed = trustedTransform()( + 'export const validatedValue: string = "new";\n', + "/workspace/source/src/value.ts?import", + ); + + expect(transformed).toEqual({ + code: 'export const validatedValue = "new";\n', + map: null, + }); + }); + + it("leaves non-TypeScript modules to Vite's normal JavaScript pipeline", () => { + expect( + trustedTransform()( + 'export const validatedValue = "new";\n', + "/workspace/source/src/value.js", + ), + ).toBeNull(); + }); +}); diff --git a/test/patch-validator-vite-config-loader.test.mjs b/test/patch-validator-vite-config-loader.test.mjs new file mode 100644 index 000000000..3fc324d08 --- /dev/null +++ b/test/patch-validator-vite-config-loader.test.mjs @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from "vitest"; + +import { runValidationCommands } from "../patch-validator/runtime.mjs"; + +describe("patch-validator Vitest config isolation", () => { + it("uses the runner loader and image-owned config without mutating dependencies", () => { + const spawnSyncImpl = vi.fn(() => ({ + status: 0, + signal: null, + stdout: "", + stderr: "", + error: undefined, + })); + + const result = runValidationCommands("/workspace/source", { + spawnSyncImpl, + typescriptModule: "/opt/noema/node_modules/typescript/bin/tsc", + vitestModule: "/opt/noema/node_modules/vitest/vitest.mjs", + }); + + expect(result.exitCode).toBe(0); + expect(spawnSyncImpl).toHaveBeenCalledTimes(2); + expect(spawnSyncImpl.mock.calls[1][1]).toEqual([ + "/opt/noema/node_modules/vitest/vitest.mjs", + "run", + "--coverage", + "--root", + "/workspace/source", + "--configLoader", + "runner", + "--config", + "/opt/noema/validator-vitest.config.mjs", + ]); + }); +}); diff --git a/test/patch-validator-vulnerability-policy.test.mjs b/test/patch-validator-vulnerability-policy.test.mjs new file mode 100644 index 000000000..78db5a8c0 --- /dev/null +++ b/test/patch-validator-vulnerability-policy.test.mjs @@ -0,0 +1,16 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +describe("patch-validator vulnerability policy", () => { + it("fails on every detected medium, high, or critical vulnerability", () => { + const workflow = readFileSync( + ".github/workflows/patch-validator-image.yml", + "utf8", + ); + + expect(workflow).toContain("--severity MEDIUM,HIGH,CRITICAL"); + expect(workflow).toContain("--exit-code 1"); + expect(workflow).not.toContain("--ignore-unfixed"); + }); +}); diff --git a/test/patch-validator-workflow.test.ts b/test/patch-validator-workflow.test.ts new file mode 100644 index 000000000..4ba3ee0ee --- /dev/null +++ b/test/patch-validator-workflow.test.ts @@ -0,0 +1,181 @@ +import { existsSync, readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +const workflowPath = ".github/workflows/patch-validator-image.yml"; +const verifierPath = "scripts/verify-patch-validator-image.mjs"; +const verifierLibraryPath = "scripts/lib/patch-validator-image-receipts.mjs"; +const dockerfilePath = "Dockerfile.patch-validator"; +const dockerfileFrontend = + "# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e"; + +function readRequiredFile(path: string): string { + expect(existsSync(path), `${path} must exist`).toBe(true); + return readFileSync(path, "utf8"); +} + +describe("patch-validator pull-request image verification", () => { + it("builds and verifies every exact PR head without publication authority", () => { + const workflow = readRequiredFile(workflowPath); + + expect(workflow).toContain("name: patch-validator-image"); + const pullRequestStart = workflow.indexOf(" pull_request:"); + const workflowDispatchStart = workflow.indexOf(" workflow_dispatch:"); + expect(pullRequestStart).toBeGreaterThanOrEqual(0); + expect(workflowDispatchStart).toBeGreaterThan(pullRequestStart); + expect( + workflow.slice(pullRequestStart, workflowDispatchStart).trim(), + ).toBe("pull_request:"); + expect(workflow).toContain("permissions:\n contents: read"); + expect(workflow).not.toContain("contents: write"); + expect(workflow).not.toContain("packages: write"); + expect(workflow).not.toContain("id-token: write"); + expect(workflow).not.toContain("attestations: write"); + expect(workflow).not.toContain("artifact-metadata: write"); + + expect(workflow).toContain( + "SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }}", + ); + expect(workflow).toContain( + "PR_NUMBER: ${{ github.event.pull_request.number || '' }}", + ); + expect(workflow).toContain("ref: ${{ env.SOURCE_SHA }}"); + expect(workflow).toContain("timeout-minutes: 90"); + expect(workflow).toContain("Refuse stale pull-request head before verification"); + expect(workflow).toContain("Refuse stale pull-request head after verification"); + expect(workflow).toContain( + 'gh api --method GET "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq ".head.sha"', + ); + expect(workflow.match(/test "\$live_head" = "\$SOURCE_SHA"/g)).toHaveLength(2); + expect(workflow).toContain("GH_TOKEN: ${{ github.token }}"); + expect(workflow).toContain( + "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683", + ); + expect(workflow).toContain("persist-credentials: false"); + expect(workflow).toContain( + "aquasecurity/setup-trivy@81e514348e19b6112ce2a7e3ecbafe19c1e1f567", + ); + expect(workflow).toContain("version: v0.73.0"); + expect(workflow).toContain( + "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02", + ); + + expect(workflow).not.toContain("DISTROLESS_IMAGE"); + expect(workflow).not.toContain("sigstore/cosign-installer"); + expect(workflow).not.toContain("cosign verify"); + expect(workflow).not.toContain("keyless@distroless.iam.gserviceaccount.com"); + expect(workflow).toContain("docker build"); + expect(workflow).toContain("--platform=linux/amd64"); + expect(workflow).toContain("--file=Dockerfile.patch-validator"); + expect(workflow).toContain("--build-arg=SOURCE_REVISION=${SOURCE_SHA}"); + expect(workflow).toContain("Verify static Node runtime identity"); + expect(workflow).toContain( + 'test "$(docker run --rm --pull=never --entrypoint=/nodejs/bin/node "$IMAGE_TAG" --version)" = "v24.19.0"', + ); + expect(workflow).toContain("readelf -l \"$node_binary\""); + expect(workflow).toContain("readelf -d \"$node_binary\""); + expect(workflow).toContain("grep -Eq '\\.(node|so)(\\.|$)'"); + expect(workflow).not.toContain("(?:"); + expect(workflow).toContain("contains a native addon or shared library"); + + for (const hardeningFlag of [ + "--network=none", + "--read-only", + "--cap-drop=ALL", + "--security-opt=no-new-privileges=true", + "--pids-limit=256", + "--memory=2g", + "--memory-swap=2g", + "--cpus=2", + "--ipc=none", + "--ulimit=nofile=1024:1024", + "--ulimit=nproc=256:256", + "--ulimit=core=0:0", + "--tmpfs=/workspace:", + "--tmpfs=/tmp:", + ]) { + expect(workflow).toContain(hardeningFlag); + } + expect(workflow).toContain("dst=/input,readonly"); + expect(workflow).toContain("dst=/patch/input.patch,readonly"); + expect(workflow).not.toContain("dst=/output/result.json"); + expect(workflow).toContain("--env=NOEMA_RESULT_PATH=/workspace/result.json"); + expect(workflow).toContain( + 'diagnostic_path="$RUNNER_TEMP/patch-validator-untrusted-diagnostic.json"', + ); + expect(workflow).toContain('"$IMAGE_TAG" >/dev/null 2>"$diagnostic_path"'); + expect(workflow).not.toContain( + 'docker cp "$container_name:/workspace/result.json" "$diagnostic_path"', + ); + expect(workflow).not.toContain('"$IMAGE_TAG" >/dev/null 2>&1'); + expect(workflow).toContain("const smokeResult = {"); + expect(workflow).toContain('flag: "wx"'); + expect(workflow).toContain( + "printf 'SOURCE_TSCONFIG_MUST_NOT_BE_PARSED\\n' >\"$source_dir/tsconfig.json\"", + ); + expect(workflow).toContain( + 'throw new Error("source Vitest config must not execute");', + ); + + expect(workflow).toContain("--format cyclonedx"); + expect(workflow).toContain("--severity MEDIUM,HIGH,CRITICAL"); + expect(workflow).toContain("--exit-code 1"); + expect(workflow).toContain( + "Generate embedded static-runtime dependency inventory and vulnerability receipt", + ); + expect(workflow).toContain("process.versions"); + expect(workflow).toContain("embedded-runtime-inventory.json"); + expect(workflow).toContain("embedded-runtime-vulnerability-scan.json"); + expect(workflow).toContain("node scripts/verify-patch-validator-image.mjs"); + expect(workflow).toContain( + '--vulnerability-scan "$evidence_dir/image-vulnerability-scan.json"', + ); + expect(workflow).toContain( + '--embedded-runtime-inventory "$evidence_dir/embedded-runtime-inventory.json"', + ); + expect(workflow).toContain( + '--embedded-vulnerability-scan "$evidence_dir/embedded-runtime-vulnerability-scan.json"', + ); + expect(workflow).toContain("retention-days: 90"); + + expect(workflow).not.toContain("docker push"); + expect(workflow).not.toContain("docker/login-action"); + expect(workflow).not.toContain("cosign sign"); + expect(workflow).not.toContain("actions/attest"); + expect(workflow).not.toContain("NVIDIA_NIM_API_KEY"); + expect(workflow.toLowerCase()).not.toContain("copilot"); + }); + + it("pins the Dockerfile frontend by immutable digest", () => { + const dockerfile = readRequiredFile(dockerfilePath); + expect(dockerfile.split("\n", 1)[0]).toBe(dockerfileFrontend); + }); + + it("ships a bounded verifier covered by the root test gate", () => { + const verifier = readRequiredFile(verifierPath); + const verifierLibrary = readRequiredFile(verifierLibraryPath); + const vitest = readRequiredFile("vitest.config.ts"); + const packageJson = JSON.parse(readRequiredFile("package.json")) as { + scripts?: Record; + }; + + expect(verifier).toContain("verifyPatchValidatorReceipts"); + expect(verifier).toContain('"--vulnerability-scan"'); + expect(verifier).toContain('"--embedded-runtime-inventory"'); + expect(verifier).toContain('"--embedded-vulnerability-scan"'); + expect(verifierLibrary).toContain( + "export function verifyPatchValidatorReceipts", + ); + expect(verifierLibrary).toContain("MAX_RECEIPT_BYTES"); + expect(verifierLibrary).toContain("CycloneDX"); + expect(verifierLibrary).toContain("vulnerabilityScan"); + expect(verifierLibrary).toContain("validator_image_digest"); + expect(verifierLibrary).toContain("source_revision"); + expect(vitest).toContain( + '"scripts/lib/patch-validator-image-receipts.mjs"', + ); + expect(packageJson.scripts?.["patch-validator:image:verify-receipts"]).toBe( + "node scripts/verify-patch-validator-image.mjs", + ); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 258654b41..dafc97a74 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,10 +2,17 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { + include: ["test/**/*.test.ts", "test/**/*.test.mjs"], coverage: { - reporter: ["json-summary", "text"], + reporter: ["json-summary", ["text", { maxCols: 240 }]], include: [ "src/**/*.ts", + "patch-validator/entrypoint.mjs", + "patch-validator/validate-patch.mjs", + "patch-validator/runtime.mjs", + "scripts/lib/patch-validator-image-receipts.mjs", + "scripts/lib/patch-validator-smoke-diagnostic.mjs", + "scripts/lib/patch-validator-static-runtime-evidence.mjs", "scripts/normalize-commercial-readiness-evidence.mjs", "scripts/prepare-agent-pr-message.mjs", ],