diff --git a/.github/workflows/patch-validator-image.yml b/.github/workflows/patch-validator-image.yml index d707ee75e..e3ea128da 100644 --- a/.github/workflows/patch-validator-image.yml +++ b/.github/workflows/patch-validator-image.yml @@ -185,21 +185,26 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - - name: Build exact-head patch-validator image + - name: Build exact-head patch-validator image with authenticated GitHub Actions cache + timeout-minutes: 150 + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 + with: + context: . + file: Dockerfile.patch-validator + platforms: linux/amd64 + load: true + build-contexts: | + validator_deps=${{ env.VALIDATOR_DEPS_CONTEXT }} + build-args: | + SOURCE_REVISION=${{ env.SOURCE_SHA }} + tags: ${{ env.IMAGE_TAG }} + cache-from: type=gha,scope=noema-patch-validator-image + cache-to: type=gha,mode=max,scope=noema-patch-validator-image + + - name: Capture exact-head patch-validator image digest shell: bash run: | set -euo pipefail - test -d "$VALIDATOR_DEPS_CONTEXT/node_modules" - timeout --signal=TERM --kill-after=30s 150m docker buildx build \ - --load \ - --cache-from=type=gha,scope=noema-patch-validator-image \ - --cache-to=type=gha,mode=max,scope=noema-patch-validator-image \ - --build-context "validator_deps=${VALIDATOR_DEPS_CONTEXT}" \ - --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:????????????????????????????????????????????????????????????????) ;; @@ -676,4 +681,4 @@ jobs: name: patch-validator-image-verification-${{ env.SOURCE_SHA }} path: ${{ runner.temp }}/patch-validator-evidence if-no-files-found: error - retention-days: 90 + retention-days: 90 \ No newline at end of file diff --git a/Dockerfile.patch-validator b/Dockerfile.patch-validator index 0f07059c1..caf1ab9e1 100644 --- a/Dockerfile.patch-validator +++ b/Dockerfile.patch-validator @@ -6,6 +6,8 @@ ARG NODE_VERSION=24.19.0 ARG NODE_SOURCE_SHA256=f6d95e10a0431ee1067fc6aabe9f762908b4716dd35324e1ddb4b1466b76659f ARG OPENSSL_VERSION=3.5.8 ARG OPENSSL_SOURCE_SHA256=a8f84a39918ec6415ce765d9b429d313ba97b8143169c172e734b9514464f5b2 +ARG CARES_VERSION=1.34.8 +ARG CARES_SOURCE_SHA256=c222b6d681096f9444d2c4863d2c1174019e27cacca0a4a5c114d36dd7d7bf78 RUN apk add --no-cache \ binutils-gold \ @@ -46,11 +48,34 @@ RUN set -eu; \ download_exact \ "https://github.com/openssl/openssl/releases/download/openssl-${OPENSSL_VERSION}/openssl-${OPENSSL_VERSION}.tar.gz" \ "$OPENSSL_SOURCE_SHA256" \ - /tmp/openssl.tar.gz + /tmp/openssl.tar.gz; \ + download_exact \ + "https://github.com/c-ares/c-ares/releases/download/v${CARES_VERSION}/c-ares-${CARES_VERSION}.tar.gz" \ + "$CARES_SOURCE_SHA256" \ + /tmp/cares.tar.gz +# Node's canonical c-ares updater replaces the upstream payload while retaining +# Node-owned config/GYP/GN integration. Preserve only that build glue; the +# reviewed c-ares source tree itself is replaced atomically. RUN mkdir -p /usr/src/node /usr/src/openssl \ && tar -xJf /tmp/node.tar.xz --strip-components=1 -C /usr/src/node \ - && tar -xzf /tmp/openssl.tar.gz --strip-components=1 -C /usr/src/openssl + && tar -xzf /tmp/openssl.tar.gz --strip-components=1 -C /usr/src/openssl \ + && mkdir -p /tmp/node-cares-integration /tmp/cares-reviewed \ + && cp -R /usr/src/node/deps/cares/config /tmp/node-cares-integration/config \ + && cp /usr/src/node/deps/cares/.gitignore /tmp/node-cares-integration/.gitignore \ + && cp /usr/src/node/deps/cares/cares.gyp /tmp/node-cares-integration/cares.gyp \ + && cp /usr/src/node/deps/cares/*.gn /usr/src/node/deps/cares/*.gni /tmp/node-cares-integration/ \ + && tar -xzf /tmp/cares.tar.gz --strip-components=1 -C /tmp/cares-reviewed \ + && rm -rf /tmp/cares-reviewed/test \ + && rm -rf /tmp/cares-reviewed/config \ + && cp -R /tmp/node-cares-integration/config /tmp/cares-reviewed/config \ + && cp /tmp/node-cares-integration/.gitignore /tmp/cares-reviewed/.gitignore \ + && cp /tmp/node-cares-integration/cares.gyp /tmp/cares-reviewed/cares.gyp \ + && cp /tmp/node-cares-integration/*.gn /tmp/node-cares-integration/*.gni /tmp/cares-reviewed/ \ + && rm -rf /usr/src/node/deps/cares \ + && mv /tmp/cares-reviewed /usr/src/node/deps/cares \ + && test -f /usr/src/node/deps/cares/config/linux/ares_config.h \ + && grep -Fq "#define ARES_VERSION_STR \"${CARES_VERSION}\"" /usr/src/node/deps/cares/include/ares_version.h WORKDIR /usr/src/openssl @@ -84,6 +109,7 @@ RUN ./configure \ && make install \ && test "$(/opt/node/bin/node --version)" = "v${NODE_VERSION}" \ && test "$(/opt/node/bin/npm --version)" = "11.17.0" \ + && CARES_VERSION="${CARES_VERSION}" /opt/node/bin/node --input-type=module --eval='if (process.versions.ares !== process.env.CARES_VERSION) throw new Error(`unexpected c-ares ${process.versions.ares}`)' \ && OPENSSL_VERSION="${OPENSSL_VERSION}" /opt/node/bin/node --input-type=module --eval='if (process.versions.openssl !== process.env.OPENSSL_VERSION) throw new Error(`unexpected OpenSSL ${process.versions.openssl}`)' \ && /opt/node/bin/node --input-type=module --eval='/\p{ID_Continue}/u.test("a")' \ && ! readelf -l /opt/node/bin/node | grep -q 'Requesting program interpreter' \ @@ -99,6 +125,7 @@ RUN ./configure \ && 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}" \ + && CARES_VERSION="${CARES_VERSION}" /opt/node/bin/node --input-type=module --eval='if (process.versions.ares !== process.env.CARES_VERSION) throw new Error(`unexpected c-ares ${process.versions.ares}`)' \ && OPENSSL_VERSION="${OPENSSL_VERSION}" /opt/node/bin/node --input-type=module --eval='if (process.versions.openssl !== process.env.OPENSSL_VERSION) throw new Error(`unexpected OpenSSL ${process.versions.openssl}`)' \ && /opt/node/bin/node --input-type=module --eval='/\p{ID_Continue}/u.test("a")' \ && ! readelf -l /opt/node/bin/node | grep -q 'Requesting program interpreter' \ @@ -131,4 +158,4 @@ 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) });"] \ No newline at end of file +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/docs/doctoring/patch-validator-embedded-runtime-applicability-2026-09-09.md b/docs/doctoring/patch-validator-embedded-runtime-applicability-2026-09-09.md new file mode 100644 index 000000000..2fb01b3f4 --- /dev/null +++ b/docs/doctoring/patch-validator-embedded-runtime-applicability-2026-09-09.md @@ -0,0 +1,69 @@ +# Patch-validator embedded-runtime applicability review — 2026-09-09 + +## Problem and exact evidence + +Noema patch-validator image run `34328891694` on exact source `b1c33d1c0d3ff0dc6d3d5fae6d4d8dd1ea56270d` completed authenticated BuildKit cache import/hit/export, exact image build, static-runtime inspection, isolated smoke execution, SBOM generation, and raw binary/embedded-runtime scans before the final exact-receipt verifier failed. The retained verification artifact is `10095371779` with artifact digest `sha256:21d9d7a7403ae4be1af21b4a769baa23d77714b2239cbc4c7975ac498728c7b4`. + +The raw Grype receipt contained five distinct classes that must not be handled as one generic scanner exception: + +- c-ares `1.34.6` → `CVE-2026-33630`; +- SQLite `3.53.3` → `BIT-sqlite-2024-0232`, `BIT-sqlite-2025-29088`, and `BIT-sqlite-2025-6965`; +- V8 `13.6.233.17-node.51`, normalized to reviewed CPE version `13.6.233.17` → `CVE-2026-85046` plus three already reviewed legacy advisories; +- zlib `1.3.2.1-motley-3246f1b` → `GHSA-g857-hhfv-j68w`; +- nghttp2 `1.69.0` → the already reviewed nghttpx-only `CVE-2026-58055`. + +Raw scanner evidence remains retained. Applicability review creates a derived receipt and is permitted only when exact component identity, CPE provenance, scanner identity, finding ID, and independently reviewed evidence all agree. + +## Decisions + +### c-ares: repair the vulnerable component; do not suppress the finding + +Upstream c-ares 1.34.7 is explicitly a security release fixing `CVE-2026-33630`, a remotely triggerable use-after-free/double-free in `ares_getaddrinfo()` over TCP. The exact validator runtime contains c-ares 1.34.6, so the finding is applicable. c-ares 1.34.8 retains the security fix and restores API compatibility after 1.34.7's unintended callback-signature break. + +The validator therefore keeps Node 24.19.0 as its reviewed executable identity but overlays the official c-ares 1.34.8 release source into Node's vendored `deps/cares` tree before compilation. The release tarball is authenticated by SHA-256 `c222b6d681096f9444d2c4863d2c1174019e27cacca0a4a5c114d36dd7d7bf78`, and the built runtime must report `process.versions.ares === "1.34.8"`. This avoids converting an actually vulnerable component into an applicability exception. + +Rejected alternatives: ignoring the finding because the smoke container runs with `--network none`; weakening the severity threshold; or accepting c-ares 1.34.6 based on elapsed isolation evidence. Those alternatives make deployment configuration part of binary vulnerability truth and are not accepted. + +### SQLite: exact runtime is outside all three reviewed vulnerable ranges + +The embedded runtime is SQLite 3.53.3. Reviewed records bound the findings as follows: + +- `CVE-2024-0232` / `BIT-sqlite-2024-0232`: affected SQLite 3.43.0 through 3.43.1; fixed in 3.43.2. +- `CVE-2025-29088` / `BIT-sqlite-2025-29088`: SQLite 3.49.0 before 3.49.1. +- `CVE-2025-6965` / `BIT-sqlite-2025-6965`: SQLite versions before 3.50.2. + +For this exact 3.53.3 component/CPE, those scanner matches are version-range false positives. The derived applicability receipt may remove only those three exact finding IDs and only when the component remains exactly SQLite 3.53.3 with the reviewed CPE and NVD-CPE Grype provenance. Any other SQLite finding remains blocking. + +### zlib: scanner matched an advisory for a different product/ecosystem + +`GHSA-g857-hhfv-j68w` / `CVE-2026-27820` is published by `ruby/zlib` for the RubyGems package `zlib`; the affected code is Ruby's `Zlib::GzipReader` wrapper. Noema's runtime component is Node's embedded upstream C zlib library and is represented by a zlib CPE, not a RubyGems PURL. The derived applicability receipt may remove only this exact GHSA when the exact Node zlib component/CPE and NVD-CPE Grype provenance match. Other zlib findings remain blocking. + +### V8 CVE-2026-85046: exact Node branch lacks the vulnerable implementation path + +The primary V8 fix for Chromium issue 542403045 changes `JSCallReducer::ReduceArraySort` and `MaglevGraphBuilder::TryReduceArrayPrototypeSort` so mixed receiver element kinds are not used by the inlined `Array.prototype.sort` path. The exact signed Node.js v24.19.0 release commit is `cdc1b38d40cb567b7ad0b39c86addf830a0af0ae`. Inspection of that release's vendored V8 `deps/v8/src/compiler/js-call-reducer.cc` and `deps/v8/src/maglev/maglev-graph-builder.cc` finds neither affected reducer. Thus the generic V8 CPE range is not sufficient evidence that Node 24.19.0's `13.6.233.17-node.51` branch contains the vulnerable path. + +The derived applicability receipt may remove `CVE-2026-85046` only for exact Node 24.19.0, exact V8 `13.6.233.17-node.51`, reviewed CPE `cpe:2.3:a:google:v8:13.6.233.17:*:*:*:*:*:*:*`, normalized scanner artifact version `13.6.233.17`, Grype 0.116.1, and NVD-CPE provenance. A Node/V8/CPE/scanner/finding mismatch must fail closed. + +## Risk and follow-up + +Applicability review is deliberately exact-version rather than an open-ended semantic-version rule. A future Node, V8, SQLite, zlib, scanner, or CPE change loses the exception automatically and must be reviewed again. c-ares remains a source repair, not an exception. The image lane must rebuild from the new exact head and prove the patched c-ares version, raw scan retention, derived reviewed receipt, strict final verifier, isolation smoke, SBOM, and unchanged image/source binding before integration. + +Noema does not take ownership of scanner authority, quarantine policy, outbound control, or provider routing through this review. It consumes scanner evidence and applies only its own exact runtime applicability decision at the validator acceptance boundary. + +## Traceability / references + +c-ares. (2026, July 6). *c-ares version 1.34.7 — security release*. https://c-ares.org/changelog.html + +c-ares. (2026, July 7). *c-ares version 1.34.8*. https://github.com/c-ares/c-ares/releases/tag/v1.34.8 + +GitHub. (2026, April 16). *Buffer overflow vulnerability in Zlib::GzipReader (GHSA-g857-hhfv-j68w)*. https://github.com/ruby/zlib/security/advisories/GHSA-g857-hhfv-j68w + +National Institute of Standards and Technology. (2024, January 16; modified 2026, June 17). *CVE-2024-0232*. National Vulnerability Database. https://nvd.nist.gov/vuln/detail/CVE-2024-0232 + +Open Source Vulnerabilities. (2025). *BIT-sqlite-2025-29088*. https://osv.dev/vulnerability/BIT-sqlite-2025-29088 + +Open Source Vulnerabilities. (2025; modified 2026). *BIT-sqlite-2025-6965*. https://osv.dev/vulnerability/BIT-sqlite-2025-6965 + +V8 Project. (2026, August 7). *[compiler] Don't inline Array.prototype.sort on mixed elements kinds* (commit `e0562d87ad9c17042b581582c99237d798572e67`). https://github.com/v8/v8/commit/e0562d87ad9c17042b581582c99237d798572e67 + +Node.js. (2026, August 3). *Node.js v24.19.0 Krypton (LTS) release* (signed tag; commit `cdc1b38d40cb567b7ad0b39c86addf830a0af0ae`). https://github.com/nodejs/node/releases/tag/v24.19.0 diff --git a/scripts/lib/patch-validator-embedded-runtime-applicability.mjs b/scripts/lib/patch-validator-embedded-runtime-applicability.mjs index 094e3ae14..79686f16d 100644 --- a/scripts/lib/patch-validator-embedded-runtime-applicability.mjs +++ b/scripts/lib/patch-validator-embedded-runtime-applicability.mjs @@ -5,14 +5,27 @@ const LEGACY_V8_CVES = new Set([ "CVE-2011-5037", "CVE-2011-3886", ]); +const V8_ARRAY_SORT_CVES = new Set(["CVE-2026-85046"]); +const SQLITE_FIXED_RANGE_FINDINGS = new Set([ + "BIT-sqlite-2024-0232", + "BIT-sqlite-2025-29088", + "BIT-sqlite-2025-6965", +]); +const ZLIB_RUBYGEM_FINDINGS = new Set(["GHSA-g857-hhfv-j68w"]); const QUIC_DISABLED_REASON = "QUIC transport dependency disabled in this build"; const HTTP3_DISABLED_REASON = "HTTP/3 dependency disabled in this build"; const OPENSSL_NON_APPLICABLE_REASON = "Node runtime proves QUIC and HTTP/3 dependencies are disabled"; const NGHTTP2_NON_APPLICABLE_REASON = "CVE affects the nghttpx proxy, not Node's embedded libnghttp2 runtime"; -const V8_NON_APPLICABLE_REASON = +const LEGACY_V8_NON_APPLICABLE_REASON = "Exact Node 24.19.0 V8 runtime is newer than the reviewed affected legacy V8 releases"; +const V8_ARRAY_SORT_NON_APPLICABLE_REASON = + "Exact Node 24.19.0 V8 branch lacks the vulnerable inlined Array.prototype.sort reducers"; +const SQLITE_NON_APPLICABLE_REASON = + "Exact SQLite 3.53.3 runtime is newer than the reviewed affected SQLite ranges"; +const ZLIB_NON_APPLICABLE_REASON = + "Advisory applies to the Ruby zlib gem GzipReader wrapper, not Node's embedded C zlib runtime"; const EXPECTED_NODE_VERSION = "24.19.0"; const EXPECTED_NGHTTP2_VERSION = "1.69.0"; const EXPECTED_NGHTTP2_CPE = @@ -21,6 +34,12 @@ const EXPECTED_V8_VERSION = "13.6.233.17-node.51"; const EXPECTED_V8_SCANNER_VERSION = "13.6.233.17"; const EXPECTED_V8_CPE = "cpe:2.3:a:google:v8:13.6.233.17:*:*:*:*:*:*:*"; +const EXPECTED_SQLITE_VERSION = "3.53.3"; +const EXPECTED_SQLITE_CPE = + "cpe:2.3:a:sqlite:sqlite:3.53.3:*:*:*:*:*:*:*"; +const EXPECTED_ZLIB_VERSION = "1.3.2.1-motley-3246f1b"; +const EXPECTED_ZLIB_CPE = + "cpe:2.3:a:zlib:zlib:1.3.2.1-motley-3246f1b:*:*:*:*:*:*:*"; function isRecord(value) { return Object.prototype.toString.call(value) === "[object Object]"; @@ -113,20 +132,6 @@ function matchUsesExactReviewedCpe( }); } -function scanMatchesExactComponent(rawComponentScan, component) { - return ( - isRecord(rawComponentScan) - && rawComponentScan.key === component.key - && rawComponentScan.identity === component.cpe - && isRecord(rawComponentScan.scanner_output) - && rawComponentScan.scanner_output.source?.type === "cpe" - && rawComponentScan.scanner_output.source?.target === component.cpe - && rawComponentScan.scanner_output.descriptor?.name === "grype" - && rawComponentScan.scanner_output.descriptor?.version === "0.116.1" - && Array.isArray(rawComponentScan.scanner_output.matches) - ); -} - function filterReviewedMatches( rawComponentScan, component, @@ -181,23 +186,43 @@ function filterReviewedMatches( }; } +function scanMatchesExactComponent(rawComponentScan, component) { + return ( + isRecord(rawComponentScan) + && rawComponentScan.key === component.key + && rawComponentScan.identity === component.cpe + && isRecord(rawComponentScan.scanner_output) + && rawComponentScan.scanner_output.source?.type === "cpe" + && rawComponentScan.scanner_output.source?.target === component.cpe + && rawComponentScan.scanner_output.descriptor?.name === "grype" + && rawComponentScan.scanner_output.descriptor?.version === "0.116.1" + && Array.isArray(rawComponentScan.scanner_output.matches) + ); +} + /** * Apply narrowly reviewed applicability evidence without mutating the retained - * raw scanner receipt. Exceptions are exact-CVE, exact-component, exact-CPE, - * Grype-0.116.1 NVD-CPE matches only: + * raw scanner receipt. Exceptions are exact-finding, exact-component, + * exact-CPE, Grype-0.116.1 NVD-CPE matches only: * * - OpenSSL CVE-2026-14456 requires exact evidence that both QUIC transport * dependencies are disabled in this Node build. * - nghttp2 CVE-2026-58055 describes the nghttpx proxy request-forwarding * behavior; the exact Node 24.19.0 runtime inventories libnghttp2 as a * statically bundled dependency, not the nghttpx proxy executable. - * - the three legacy V8 advisories are bounded to historical affected V8/Node - * releases that predate the exact Node 24.19.0 / V8 13.6.233.17-node.51 - * runtime retained in the inventory. Grype reports the normalized CPE - * artifact version 13.6.233.17, which is accepted only for this exact - * reviewed runtime/CPE pairing. + * - legacy V8 advisories are bounded to historical affected V8/Node releases + * that predate the exact Node 24.19.0 / V8 13.6.233.17-node.51 runtime. + * - CVE-2026-85046 is tied to V8's inlined Array.prototype.sort reducers. The + * exact signed Node 24.19.0 V8 branch contains neither affected reducer, so + * the generic V8 CPE range is not execution-path evidence for this runtime. + * - the three SQLite findings have reviewed vulnerable ranges ending no later + * than 3.50.1, while this exact embedded runtime is SQLite 3.53.3. + * - GHSA-g857-hhfv-j68w is a RubyGems zlib/GzipReader advisory, not an + * upstream C zlib-library advisory; the exact Node component is the latter. * - * Every other scanner match remains untouched for the strict verifier. + * Grype reports the normalized V8 CPE artifact version 13.6.233.17, which is + * accepted only for the exact reviewed runtime/CPE pairing. Every other + * scanner match remains untouched for the strict verifier. */ export function applyReviewedEmbeddedRuntimeApplicability({ inventory, scan }) { const nonApplicableMatches = []; @@ -234,6 +259,26 @@ export function applyReviewedEmbeddedRuntimeApplicability({ inventory, scan }) { EXPECTED_V8_CPE, ) : null; + const sqlite = exactNodeRuntime + && inventory.process_versions.sqlite === EXPECTED_SQLITE_VERSION + ? exactBundledCpeComponent( + inventory, + "sqlite", + "sqlite", + EXPECTED_SQLITE_VERSION, + EXPECTED_SQLITE_CPE, + ) + : null; + const zlib = exactNodeRuntime + && inventory.process_versions.zlib === EXPECTED_ZLIB_VERSION + ? exactBundledCpeComponent( + inventory, + "zlib", + "zlib", + EXPECTED_ZLIB_VERSION, + EXPECTED_ZLIB_CPE, + ) + : null; let changed = false; const reviewedComponents = scan.components.map((rawComponentScan) => { @@ -259,16 +304,47 @@ export function applyReviewedEmbeddedRuntimeApplicability({ inventory, scan }) { current = nghttp2Result.componentScan; changed ||= nghttp2Result.changed; - const v8Result = filterReviewedMatches( + const legacyV8Result = filterReviewedMatches( current, v8, LEGACY_V8_CVES, - V8_NON_APPLICABLE_REASON, + LEGACY_V8_NON_APPLICABLE_REASON, nonApplicableMatches, EXPECTED_V8_SCANNER_VERSION, ); - current = v8Result.componentScan; - changed ||= v8Result.changed; + current = legacyV8Result.componentScan; + changed ||= legacyV8Result.changed; + + const arraySortV8Result = filterReviewedMatches( + current, + v8, + V8_ARRAY_SORT_CVES, + V8_ARRAY_SORT_NON_APPLICABLE_REASON, + nonApplicableMatches, + EXPECTED_V8_SCANNER_VERSION, + ); + current = arraySortV8Result.componentScan; + changed ||= arraySortV8Result.changed; + + const sqliteResult = filterReviewedMatches( + current, + sqlite, + SQLITE_FIXED_RANGE_FINDINGS, + SQLITE_NON_APPLICABLE_REASON, + nonApplicableMatches, + ); + current = sqliteResult.componentScan; + changed ||= sqliteResult.changed; + + const zlibResult = filterReviewedMatches( + current, + zlib, + ZLIB_RUBYGEM_FINDINGS, + ZLIB_NON_APPLICABLE_REASON, + nonApplicableMatches, + ); + current = zlibResult.componentScan; + changed ||= zlibResult.changed; return current; }); diff --git a/scripts/lib/patch-validator-static-runtime-evidence.mjs b/scripts/lib/patch-validator-static-runtime-evidence.mjs index f7bfd40b1..513d07d72 100644 --- a/scripts/lib/patch-validator-static-runtime-evidence.mjs +++ b/scripts/lib/patch-validator-static-runtime-evidence.mjs @@ -148,6 +148,33 @@ function expectedScannerSourceType(identity) { return identity.startsWith("pkg:") ? "purl" : "cpe"; } +/** + * Return the version representation Grype is expected to attach to a match + * artifact for the repository-reviewed identity. PURL scans retain the runtime + * component version verbatim. CPE scans are keyed by the normalized version in + * the exact reviewed CPE; this matters for Node's patched V8 version, where + * `process.versions.v8` carries a `-node.N` suffix but the NVD CPE does not. + */ +export function expectedScannerArtifactVersion(expectedIdentity, componentVersion) { + requireCondition( + typeof expectedIdentity === "string" && expectedIdentity.length > 0, + "embedded runtime scanner identity is invalid", + ); + requireCondition( + typeof componentVersion === "string" && componentVersion.length > 0, + "embedded runtime component version is invalid", + ); + if (expectedIdentity.startsWith("pkg:")) { + return componentVersion; + } + const fields = expectedIdentity.split(":"); + requireCondition( + fields.length === 13 && fields[0] === "cpe" && fields[1] === "2.3" && fields[5].length > 0, + "embedded runtime reviewed CPE identity is invalid", + ); + return fields[5]; +} + function verifyGrypeDatabaseEvidence(descriptor, componentKey) { const database = requireRecord( descriptor.db, @@ -229,8 +256,8 @@ function verifyEmbeddedMatchArtifact(match, component, expectedIdentity) { `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`, + artifact.version === expectedScannerArtifactVersion(expectedIdentity, component.version), + `embedded runtime component ${component.key} match artifact version does not match the reviewed component identity`, ); if (artifact.name != null) { requireCondition( diff --git a/test/patch-validator-cares-source-replacement.test.ts b/test/patch-validator-cares-source-replacement.test.ts new file mode 100644 index 000000000..c0ca75834 --- /dev/null +++ b/test/patch-validator-cares-source-replacement.test.ts @@ -0,0 +1,65 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const dockerfile = readFileSync("Dockerfile.patch-validator", "utf8"); + +describe("patch-validator c-ares source replacement", () => { + it("replaces reviewed upstream sources atomically while preserving Node-owned integration glue", () => { + const preserveConfig = + "cp -R /usr/src/node/deps/cares/config /tmp/node-cares-integration/config"; + const preserveGitignore = + "cp /usr/src/node/deps/cares/.gitignore /tmp/node-cares-integration/.gitignore"; + const preserveGyp = + "cp /usr/src/node/deps/cares/cares.gyp /tmp/node-cares-integration/cares.gyp"; + const preserveGn = + "cp /usr/src/node/deps/cares/*.gn /usr/src/node/deps/cares/*.gni /tmp/node-cares-integration/"; + const extractReviewedTree = + "tar -xzf /tmp/cares.tar.gz --strip-components=1 -C /tmp/cares-reviewed"; + const removeReviewedTests = "rm -rf /tmp/cares-reviewed/test"; + const replaceReviewedConfig = "rm -rf /tmp/cares-reviewed/config"; + const restoreNodeConfig = + "cp -R /tmp/node-cares-integration/config /tmp/cares-reviewed/config"; + const removeVendoredTree = "rm -rf /usr/src/node/deps/cares"; + const installReviewedTree = "mv /tmp/cares-reviewed /usr/src/node/deps/cares"; + const assertLinuxConfig = + "test -f /usr/src/node/deps/cares/config/linux/ares_config.h"; + + for (const required of [ + preserveConfig, + preserveGitignore, + preserveGyp, + preserveGn, + extractReviewedTree, + removeReviewedTests, + replaceReviewedConfig, + restoreNodeConfig, + removeVendoredTree, + installReviewedTree, + assertLinuxConfig, + ]) { + expect(dockerfile).toContain(required); + } + + expect(dockerfile.indexOf(extractReviewedTree)).toBeLessThan( + dockerfile.indexOf(replaceReviewedConfig), + ); + expect(dockerfile.indexOf(replaceReviewedConfig)).toBeLessThan( + dockerfile.indexOf(restoreNodeConfig), + ); + expect(dockerfile.indexOf(preserveGyp)).toBeLessThan( + dockerfile.indexOf(removeVendoredTree), + ); + expect(dockerfile.indexOf(extractReviewedTree)).toBeLessThan( + dockerfile.indexOf(removeVendoredTree), + ); + expect(dockerfile.indexOf(removeVendoredTree)).toBeLessThan( + dockerfile.indexOf(installReviewedTree), + ); + expect(dockerfile.indexOf(installReviewedTree)).toBeLessThan( + dockerfile.indexOf(assertLinuxConfig), + ); + expect(dockerfile).not.toContain( + "tar -xzf /tmp/cares.tar.gz --strip-components=1 -C /usr/src/node/deps/cares", + ); + }); +}); diff --git a/test/patch-validator-image-build-cache.test.ts b/test/patch-validator-image-build-cache.test.ts index 2f6f51334..77b5c17a1 100644 --- a/test/patch-validator-image-build-cache.test.ts +++ b/test/patch-validator-image-build-cache.test.ts @@ -8,18 +8,24 @@ const workflow = readFileSync( ); describe("patch-validator image build cache", () => { - it("reuses content-addressed BuildKit layers across successive exact PR heads", () => { - expect(workflow).toContain("docker buildx build"); - expect(workflow).toContain("--load"); + it("uses an authenticated GitHub Actions cache transport for successive exact PR heads", () => { expect(workflow).toContain( - "--cache-from=type=gha,scope=noema-patch-validator-image", + "uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294", ); + expect(workflow).toContain("load: true"); expect(workflow).toContain( - "--cache-to=type=gha,mode=max,scope=noema-patch-validator-image", + "cache-from: type=gha,scope=noema-patch-validator-image", ); - expect(workflow).not.toContain( - "timeout --signal=TERM --kill-after=30s 150m docker build \\", + expect(workflow).toContain( + "cache-to: type=gha,mode=max,scope=noema-patch-validator-image", + ); + expect(workflow).toContain("build-contexts: |"); + expect(workflow).toContain( + "validator_deps=${{ env.VALIDATOR_DEPS_CONTEXT }}", ); + expect(workflow).toContain("SOURCE_REVISION=${{ env.SOURCE_SHA }}"); + expect(workflow).toContain("tags: ${{ env.IMAGE_TAG }}"); + expect(workflow).not.toContain("docker buildx build"); }); it("seeds the shared BuildKit cache from protected main for sibling PR branches", () => { diff --git a/test/patch-validator-image-build-regression.test.ts b/test/patch-validator-image-build-regression.test.ts index 180db2e9c..89570bfda 100644 --- a/test/patch-validator-image-build-regression.test.ts +++ b/test/patch-validator-image-build-regression.test.ts @@ -5,9 +5,20 @@ const dockerfile = readFileSync("Dockerfile.patch-validator", "utf8"); const imageWorkflow = readFileSync(".github/workflows/patch-validator-image.yml", "utf8"); describe("patch-validator exact-toolchain image build regression", () => { - it("builds the static runtime with the exact Node/npm toolchain declared by devEngines", () => { + it("builds the static runtime with the exact Node/npm toolchain and patched c-ares source", () => { expect(dockerfile).toContain("ARG NODE_VERSION=24.19.0"); expect(dockerfile).toContain('test "$(/opt/node/bin/npm --version)" = "11.17.0"'); + expect(dockerfile).toContain("ARG CARES_VERSION=1.34.8"); + expect(dockerfile).toContain( + "ARG CARES_SOURCE_SHA256=c222b6d681096f9444d2c4863d2c1174019e27cacca0a4a5c114d36dd7d7bf78", + ); + expect(dockerfile).toContain("c-ares-${CARES_VERSION}.tar.gz"); + expect(dockerfile).toContain( + "tar -xzf /tmp/cares.tar.gz --strip-components=1 -C /tmp/cares-reviewed", + ); + expect(dockerfile).toContain( + "if (process.versions.ares !== process.env.CARES_VERSION) throw new Error", + ); expect(dockerfile).not.toContain("FROM validator_deps"); expect(dockerfile).not.toContain("FROM node:24.18.0-alpine3.24"); expect(dockerfile).not.toContain("--without-npm"); @@ -28,7 +39,13 @@ describe("patch-validator exact-toolchain image build regression", () => { expect(imageWorkflow).toContain('node-version: "24.19.0"'); expect(imageWorkflow).toContain('test "$(npm --version)" = "11.17.0"'); expect(imageWorkflow).toContain("npm ci --include=optional --ignore-scripts --no-audit --no-fund"); - expect(imageWorkflow).toContain('--build-context "validator_deps=${VALIDATOR_DEPS_CONTEXT}"'); + expect(imageWorkflow).toContain( + "uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294", + ); + expect(imageWorkflow).toContain("build-contexts: |"); + expect(imageWorkflow).toContain( + "validator_deps=${{ env.VALIDATOR_DEPS_CONTEXT }}", + ); expect(dockerfile).toContain( "COPY --from=validator_deps --chown=65532:65532 /node_modules /opt/noema/node_modules", ); @@ -44,8 +61,10 @@ describe("patch-validator exact-toolchain image build regression", () => { expect(dockerfile).toContain("timeout --signal=TERM --kill-after=30s 5m"); expect(dockerfile).toContain("node-v${NODE_VERSION}.tar.xz"); expect(dockerfile).toContain("openssl-${OPENSSL_VERSION}.tar.gz"); + expect(dockerfile).toContain("c-ares-${CARES_VERSION}.tar.gz"); expect(dockerfile).toContain("NODE_SOURCE_SHA256"); expect(dockerfile).toContain("OPENSSL_SOURCE_SHA256"); + expect(dockerfile).toContain("CARES_SOURCE_SHA256"); expect(dockerfile).toContain("sha256sum"); expect(dockerfile).not.toContain("ADD --checksum"); }); @@ -114,4 +133,4 @@ describe("patch-validator exact-toolchain image build regression", () => { expect(dockerfile).toContain("WORKDIR /usr/src/node"); expect(dockerfile).not.toContain("&& cd /usr/src/node"); }); -}); \ No newline at end of file +}); diff --git a/test/patch-validator-image-contract.test.ts b/test/patch-validator-image-contract.test.ts index 10d698699..453b99d6e 100644 --- a/test/patch-validator-image-contract.test.ts +++ b/test/patch-validator-image-contract.test.ts @@ -69,7 +69,11 @@ describe("patch-validator image contract", () => { expect(imageWorkflow).toContain("node_modules/@vitest/coverage-v8/package.json"); expect(imageWorkflow).toContain("node_modules/@rolldown/binding-wasm32-wasi/package.json"); expect(imageWorkflow).toContain( - '--build-context "validator_deps=${VALIDATOR_DEPS_CONTEXT}"', + "uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294", + ); + expect(imageWorkflow).toContain("build-contexts: |"); + expect(imageWorkflow).toContain( + "validator_deps=${{ env.VALIDATOR_DEPS_CONTEXT }}", ); const runtimeStage = dockerfile.slice(dockerfile.indexOf(fromLines[1])); diff --git a/test/patch-validator-image-timeout-contract.test.ts b/test/patch-validator-image-timeout-contract.test.ts new file mode 100644 index 000000000..6f26031b5 --- /dev/null +++ b/test/patch-validator-image-timeout-contract.test.ts @@ -0,0 +1,23 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +const workflow = readFileSync(".github/workflows/patch-validator-image.yml", "utf8"); + +describe("patch-validator image build timeout", () => { + it("preserves the explicit 150-minute build deadline after cache transport repair", () => { + const buildStepStart = workflow.indexOf( + "- name: Build exact-head patch-validator image with authenticated GitHub Actions cache", + ); + const digestStepStart = workflow.indexOf( + "- name: Capture exact-head patch-validator image digest", + buildStepStart, + ); + + expect(buildStepStart).toBeGreaterThanOrEqual(0); + expect(digestStepStart).toBeGreaterThan(buildStepStart); + + const buildStep = workflow.slice(buildStepStart, digestStepStart); + expect(buildStep).toContain("timeout-minutes: 150"); + }); +}); diff --git a/test/patch-validator-reviewed-applicability.test.ts b/test/patch-validator-reviewed-applicability.test.ts index 12dedf896..f5ceb6ce7 100644 --- a/test/patch-validator-reviewed-applicability.test.ts +++ b/test/patch-validator-reviewed-applicability.test.ts @@ -4,6 +4,8 @@ import { applyReviewedEmbeddedRuntimeApplicability } from "../scripts/lib/patch- const nghttp2Cpe = "cpe:2.3:a:nghttp2:nghttp2:1.69.0:*:*:*:*:*:*:*"; const v8Cpe = "cpe:2.3:a:google:v8:13.6.233.17:*:*:*:*:*:*:*"; +const sqliteCpe = "cpe:2.3:a:sqlite:sqlite:3.53.3:*:*:*:*:*:*:*"; +const zlibCpe = "cpe:2.3:a:zlib:zlib:1.3.2.1-motley-3246f1b:*:*:*:*:*:*:*"; function componentScan( key: string, @@ -47,7 +49,9 @@ function inventory() { process_versions: { node: "24.19.0", nghttp2: "1.69.0", + sqlite: "3.53.3", v8: "13.6.233.17-node.51", + zlib: "1.3.2.1-motley-3246f1b", }, components: [ { @@ -57,6 +61,13 @@ function inventory() { classification: "bundled_dependency", cpe: nghttp2Cpe, }, + { + key: "sqlite", + name: "sqlite", + version: "3.53.3", + classification: "bundled_dependency", + cpe: sqliteCpe, + }, { key: "v8", name: "v8", @@ -64,6 +75,13 @@ function inventory() { classification: "bundled_dependency", cpe: v8Cpe, }, + { + key: "zlib", + name: "zlib", + version: "1.3.2.1-motley-3246f1b", + classification: "bundled_dependency", + cpe: zlibCpe, + }, ], }; } @@ -128,16 +146,89 @@ describe("reviewed embedded-runtime applicability", () => { }, ); - it("does not convert a different nghttp2 advisory into an applicability exception", () => { + it("marks CVE-2026-85046 non-applicable only to the exact Node 24.19.0 V8 branch/CPE", () => { const scan = { components: [ componentScan( - "nghttp2", - "nghttp2", - "1.69.0", - "1.69.0", - nghttp2Cpe, - "CVE-2099-4242", + "v8", + "v8", + "13.6.233.17-node.51", + "13.6.233.17", + v8Cpe, + "CVE-2026-85046", + "High", + ), + ], + }; + + const reviewed = applyReviewedEmbeddedRuntimeApplicability({ + inventory: inventory(), + scan, + }); + + expect(reviewed.scan.components[0].scanner_output.matches).toEqual([]); + expect(reviewed.nonApplicableMatches).toContainEqual({ + component_key: "v8", + vulnerability_id: "CVE-2026-85046", + reason: "Exact Node 24.19.0 V8 branch lacks the vulnerable inlined Array.prototype.sort reducers", + }); + + const differentNode = inventory(); + differentNode.node_version = "24.19.1"; + differentNode.process_versions.node = "24.19.1"; + const retained = applyReviewedEmbeddedRuntimeApplicability({ + inventory: differentNode, + scan, + }); + expect(retained.scan).toBe(scan); + expect(retained.nonApplicableMatches).toEqual([]); + }); + + it.each([ + "BIT-sqlite-2024-0232", + "BIT-sqlite-2025-29088", + "BIT-sqlite-2025-6965", + ])( + "marks reviewed fixed-range SQLite finding %s non-applicable to exact SQLite 3.53.3", + (vulnerabilityId) => { + const scan = { + components: [ + componentScan( + "sqlite", + "sqlite", + "3.53.3", + "3.53.3", + sqliteCpe, + vulnerabilityId, + "High", + ), + ], + }; + + const reviewed = applyReviewedEmbeddedRuntimeApplicability({ + inventory: inventory(), + scan, + }); + + expect(reviewed.scan.components[0].scanner_output.matches).toEqual([]); + expect(reviewed.nonApplicableMatches).toContainEqual({ + component_key: "sqlite", + vulnerability_id: vulnerabilityId, + reason: "Exact SQLite 3.53.3 runtime is newer than the reviewed affected SQLite ranges", + }); + }, + ); + + it("rejects the RubyGems zlib advisory as a product mismatch for Node's embedded C zlib", () => { + const scan = { + components: [ + componentScan( + "zlib", + "zlib", + "1.3.2.1-motley-3246f1b", + "1.3.2.1-motley-3246f1b", + zlibCpe, + "GHSA-g857-hhfv-j68w", "Medium", ), ], @@ -148,6 +239,34 @@ describe("reviewed embedded-runtime applicability", () => { scan, }); + expect(reviewed.scan.components[0].scanner_output.matches).toEqual([]); + expect(reviewed.nonApplicableMatches).toContainEqual({ + component_key: "zlib", + vulnerability_id: "GHSA-g857-hhfv-j68w", + reason: "Advisory applies to the Ruby zlib gem GzipReader wrapper, not Node's embedded C zlib runtime", + }); + }); + + it("does not convert an unreviewed advisory into an applicability exception", () => { + const scan = { + components: [ + componentScan( + "sqlite", + "sqlite", + "3.53.3", + "3.53.3", + sqliteCpe, + "CVE-2099-4242", + "High", + ), + ], + }; + + const reviewed = applyReviewedEmbeddedRuntimeApplicability({ + inventory: inventory(), + scan, + }); + expect(reviewed.scan).toBe(scan); expect(reviewed.nonApplicableMatches).toEqual([]); }); diff --git a/test/patch-validator-scanner-artifact-version.test.ts b/test/patch-validator-scanner-artifact-version.test.ts new file mode 100644 index 000000000..16aeabc1d --- /dev/null +++ b/test/patch-validator-scanner-artifact-version.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { expectedScannerArtifactVersion } from "../scripts/lib/patch-validator-static-runtime-evidence.mjs"; + +describe("patch-validator embedded scanner artifact version binding", () => { + it("uses the reviewed upstream CPE version for Node-patched V8", () => { + expect( + expectedScannerArtifactVersion( + "cpe:2.3:a:google:v8:13.6.233.17:*:*:*:*:*:*:*", + "13.6.233.17-node.51", + ), + ).toBe("13.6.233.17"); + }); + + it("keeps ordinary CPE and PURL versions exact", () => { + expect( + expectedScannerArtifactVersion( + "cpe:2.3:a:openssl:openssl:3.5.2:*:*:*:*:*:*:*", + "3.5.2", + ), + ).toBe("3.5.2"); + expect( + expectedScannerArtifactVersion("pkg:npm/undici@7.13.0", "7.13.0"), + ).toBe("7.13.0"); + }); + + it("fails closed instead of accepting an unreviewable CPE shape", () => { + expect(() => + expectedScannerArtifactVersion("cpe:2.3:a:google:v8", "13.6.233.17-node.51"), + ).toThrow(/reviewed CPE identity is invalid/i); + }); +}); diff --git a/test/patch-validator-workflow.test.ts b/test/patch-validator-workflow.test.ts index 366a2d961..cb67fc636 100644 --- a/test/patch-validator-workflow.test.ts +++ b/test/patch-validator-workflow.test.ts @@ -43,7 +43,27 @@ describe("patch-validator pull-request image verification", () => { ); expect(workflow).toContain("ref: ${{ env.SOURCE_SHA }}"); expect(workflow).toContain("timeout-minutes: 180"); - expect(workflow).toContain("timeout --signal=TERM --kill-after=30s 150m docker build"); + expect(workflow).toContain( + "uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294", + ); + expect(workflow).toContain("load: true"); + expect(workflow).toContain("context: ."); + expect(workflow).toContain("file: Dockerfile.patch-validator"); + expect(workflow).toContain("platforms: linux/amd64"); + expect(workflow).toContain("build-contexts: |"); + expect(workflow).toContain( + "validator_deps=${{ env.VALIDATOR_DEPS_CONTEXT }}", + ); + expect(workflow).toContain("build-args: |"); + expect(workflow).toContain("SOURCE_REVISION=${{ env.SOURCE_SHA }}"); + expect(workflow).toContain("tags: ${{ env.IMAGE_TAG }}"); + expect(workflow).toContain( + "cache-from: type=gha,scope=noema-patch-validator-image", + ); + expect(workflow).toContain( + "cache-to: type=gha,mode=max,scope=noema-patch-validator-image", + ); + expect(workflow).not.toContain("docker buildx build"); expect(workflow).toContain("Refuse stale pull-request head before verification"); expect(workflow).toContain("Refuse stale pull-request head after verification"); expect(workflow).toContain( @@ -71,10 +91,6 @@ describe("patch-validator pull-request image verification", () => { 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"', @@ -195,4 +211,4 @@ describe("patch-validator pull-request image verification", () => { "node scripts/verify-patch-validator-image.mjs", ); }); -}); \ No newline at end of file +});