diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24690a78f..7282b641d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -183,6 +183,25 @@ jobs: diagnosticCount += 1; if (diagnosticCount >= 64) break outer; } + const branchMap = coverage?.branchMap ?? {}; + for (const [branchId, hits] of Object.entries(coverage?.b ?? {})) { + if (!Array.isArray(hits)) continue; + const branch = branchMap[branchId]; + const locations = branch?.locations ?? []; + for (const [armIndex, armHits] of hits.entries()) { + if (armHits !== 0) continue; + const location = locations[armIndex] ?? branch?.loc; + const line = branch?.line ?? branch?.loc?.start?.line ?? location?.line ?? location?.start?.line; + if (!Number.isInteger(line)) continue; + const column = branch?.loc?.start?.column ?? location?.start?.column ?? 0; + const path = relative(process.cwd(), file) || file; + console.error( + `Uncovered branch ${path}:${line}:${column} branch=${branchId} arm=${armIndex}`, + ); + diagnosticCount += 1; + if (diagnosticCount >= 64) break outer; + } + } } NODE fi @@ -194,6 +213,9 @@ jobs: - name: release KPI verification run: npm run kpi:verify + - name: release dependency license inventory + run: npm run release:dependency-license-inventory + - name: release acquisition manifest run: npm run acquisition:manifest diff --git a/.github/workflows/patch-validator-image.yml b/.github/workflows/patch-validator-image.yml index b59f3eb49..8a155b759 100644 --- a/.github/workflows/patch-validator-image.yml +++ b/.github/workflows/patch-validator-image.yml @@ -6,7 +6,7 @@ on: concurrency: group: noema-patch-validator-image-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: false + cancel-in-progress: true permissions: contents: read @@ -80,6 +80,20 @@ jobs: scanner_dir="$RUNNER_TEMP/noema-binary-scanners" mkdir -p "$scanner_dir" + download_scanner_asset() { + destination="$1" + url="$2" + timeout --signal=TERM --kill-after=30s 5m \ + curl --proto '=https' --proto-redir '=https' --tlsv1.2 --location --fail --silent --show-error \ + --retry 3 \ + --retry-all-errors \ + --retry-delay 2 \ + --retry-max-time 90 \ + --connect-timeout 20 \ + --max-time 180 \ + --output "$destination" "$url" + } + install_scanner() { scanner="$1" version="$2" @@ -88,12 +102,10 @@ jobs: 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" + download_scanner_asset "$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" + download_scanner_asset "$scanner_dir/$archive" "$release_base/$archive" ( cd "$scanner_dir" grep -E "^[0-9a-f]{64} ${archive}$" "$checksums" | sha256sum --check --strict @@ -108,6 +120,51 @@ jobs: "$scanner_dir/grype" version | grep -Fq "$GRYPE_VERSION" printf 'SCANNER_BIN_DIR=%s\n' "$scanner_dir" >>"$GITHUB_ENV" + - name: Set up exact dependency materialization toolchain + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24.19.0" + cache: npm + + - name: Materialize exact patch-validator dependencies + shell: bash + run: | + set -euo pipefail + test "$(node --version)" = "v24.19.0" + test "$(npm --version)" = "11.17.0" + + deps_dir="$RUNNER_TEMP/patch-validator-deps" + rm -rf "$deps_dir" + mkdir -p "$deps_dir" + cp package.json package-lock.json "$deps_dir/" + source_lock_sha="$(sha256sum package-lock.json | cut -d' ' -f1)" + copied_lock_sha="$(sha256sum "$deps_dir/package-lock.json" | cut -d' ' -f1)" + test "$copied_lock_sha" = "$source_lock_sha" + + ( + cd "$deps_dir" + timeout --signal=TERM --kill-after=30s 10m env \ + 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 + timeout --signal=TERM --kill-after=30s 5m env \ + 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 + ) + + printf 'VALIDATOR_DEPS_CONTEXT=%s\n' "$deps_dir" >>"$GITHUB_ENV" + - name: Set up Docker Buildx uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 @@ -115,10 +172,12 @@ jobs: 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} \ diff --git a/AGENTS.md b/AGENTS.md index 47b2cb218..c66fef094 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,25 +8,28 @@ Worker (npm + `wrangler.toml`); tests run under Vitest. ## Agent guidance (CWL governance) ### Security & review gate -- Every PR that is eligible for the central **Security Scan** must pass that required gate. It runs +- Every PR that is expected to receive the central **Security Scan** must pass that required gate. It runs `osv-scan` + `dependency-review` (diff-scoped) and `trivy-fs` (repo-wide, - fixable `MEDIUM/HIGH/CRITICAL`). The central workflow currently selects pull requests whose base branch is `main`, `master`, or `develop`. - A feature-base stacked PR can therefore have no Security Scan run; absence is non-passing evidence - rather than scanner success. Keep the stack in dependency order, then after its predecessor integrates - refresh or retarget the PR onto an eligible protected base and require a fresh terminal-success Security Scan - on the unchanged exact head before merge. + fixable `MEDIUM/HIGH/CRITICAL`). The current protected central workflow has no + pull-request base-branch filter, so stacked feature-base PRs are expected to + receive the same scanner workflow rather than being exempt by branch name. + An absent, queued, skipped, cancelled, stale, or failed run is non-passing + evidence rather than scanner success. Keep stacks in dependency order and + require a fresh terminal-success Security Scan on the unchanged exact head + before merge; if an expected run is absent, investigate routing instead of + treating the absence as an eligible-base exception. - A failing **`trivy-fs` is a REAL finding, not a flake.** Read the job log — it prints each finding's rule id / severity / file — or the run's SARIF results, then **remediate**: - - For this repo, findings are almost always vulnerable npm dependencies: bump - the package in `package.json` and refresh `package-lock.json` - (`npm update ` or `npm install @`), preferring the transitive - fix. There is no Dockerfile or k8s manifest today; if you add one, `trivy-fs` - will also flag image/IaC misconfig — fix it at the source. + - Vulnerable npm dependencies belong in `package.json`/`package-lock.json`; + refresh the lockfile with the smallest compatible fixed dependency. + Dockerfile/IaC findings, including the patch-validator image definition, + must be fixed at the source rather than hidden behind scanner changes. - Only for a genuine false positive, add a narrow, **documented** `.trivyignore` (or `.trivyignore.yaml`) entry. Never weaken or disable the gate. -- A local scan with a stale DB misses findings. Run `trivy --download-db-only` - first, then scan the **merge ref**, not just the PR head. +- A local scan with a stale DB misses findings. Refresh scanner data before + local diagnosis and keep local evidence separate from the required central + exact-head run; local success never substitutes for the protected workflow. - The org `code_scanning` ruleset is intentionally **CodeQL-only** (multiple code-scanning tools can't converge on one PR ref). Gating is by the Security Scan **job result**, not the `code_scanning` rule — do **not** add tools to diff --git a/Dockerfile.patch-validator b/Dockerfile.patch-validator index d2604df5d..0f07059c1 100644 --- a/Dockerfile.patch-validator +++ b/Dockerfile.patch-validator @@ -4,23 +4,69 @@ FROM alpine:3.24.1@sha256:79ff19e9084a00eece421b2523fb93e22d730e2c0e525905de047e ARG NODE_VERSION=24.19.0 ARG NODE_SOURCE_SHA256=f6d95e10a0431ee1067fc6aabe9f762908b4716dd35324e1ddb4b1466b76659f +ARG OPENSSL_VERSION=3.5.8 +ARG OPENSSL_SOURCE_SHA256=a8f84a39918ec6415ce765d9b429d313ba97b8143169c172e734b9514464f5b2 RUN apk add --no-cache \ binutils-gold \ + coreutils \ + curl \ g++ \ gcc \ libgcc \ libgcc-static \ linux-headers \ make \ + perl \ 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 +RUN set -eu; \ + download_exact() { \ + url="$1"; \ + expected="$2"; \ + output="$3"; \ + timeout --signal=TERM --kill-after=30s 5m \ + curl --fail --location --proto '=https' --proto-redir '=https' --tlsv1.2 \ + --retry 3 \ + --retry-all-errors \ + --retry-delay 2 \ + --retry-max-time 90 \ + --connect-timeout 20 \ + --max-time 180 \ + --output "$output" \ + "$url"; \ + printf '%s %s\n' "$expected" "$output" | sha256sum --check --strict; \ + }; \ + download_exact \ + "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}.tar.xz" \ + "$NODE_SOURCE_SHA256" \ + /tmp/node.tar.xz; \ + download_exact \ + "https://github.com/openssl/openssl/releases/download/openssl-${OPENSSL_VERSION}/openssl-${OPENSSL_VERSION}.tar.gz" \ + "$OPENSSL_SOURCE_SHA256" \ + /tmp/openssl.tar.gz + +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 + +WORKDIR /usr/src/openssl + +RUN ./Configure linux-x86_64 \ + no-shared \ + no-tests \ + --prefix=/opt/openssl \ + --libdir=lib \ + && make -j"$(getconf _NPROCESSORS_ONLN)" \ + && make install_sw \ + && test -f /opt/openssl/include/openssl/opensslv.h \ + && test -f /opt/openssl/lib/libcrypto.a \ + && test -f /opt/openssl/lib/libssl.a \ + && grep -Fq "OPENSSL_VERSION_MAJOR 3" /opt/openssl/include/openssl/opensslv.h \ + && grep -Fq "OPENSSL_VERSION_MINOR 5" /opt/openssl/include/openssl/opensslv.h \ + && grep -Fq "OPENSSL_VERSION_PATCH 8" /opt/openssl/include/openssl/opensslv.h ENV PATH="/opt/node/bin:${PATH}" WORKDIR /usr/src/node @@ -31,10 +77,14 @@ RUN ./configure \ --with-intl=small-icu \ --without-corepack \ --disable-single-executable-application \ + --shared-openssl \ + --shared-openssl-includes=/opt/openssl/include \ + --shared-openssl-libpath=/opt/openssl/lib \ && make -j"$(getconf _NPROCESSORS_ONLN)" V= \ && make install \ && test "$(/opt/node/bin/node --version)" = "v${NODE_VERSION}" \ && test "$(/opt/node/bin/npm --version)" = "11.17.0" \ + && 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' \ && ! readelf -d /opt/node/bin/node | grep -q '(NEEDED)' \ @@ -49,30 +99,11 @@ 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}" \ + && 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' \ && ! readelf -d /opt/node/bin/node | grep -q '(NEEDED)' -FROM node_builder 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 @@ -89,11 +120,15 @@ WORKDIR /workspace COPY --from=node_builder --chown=65532:65532 /opt/node/bin/node /nodejs/bin/node COPY --from=node_builder --chown=65532:65532 --chmod=0444 /usr/src/node/LICENSE /licenses/node/LICENSE -COPY --from=dependencies --chown=65532:65532 /build/node_modules /opt/noema/node_modules +# validator_deps is a local named BuildKit context materialized from this exact +# head's lockfile by the workflow. Copying directly from the named context avoids +# an ambiguous unpinned FROM reference while keeping npm registry resolution out +# of the Docker build authority. +COPY --from=validator_deps --chown=65532:65532 /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) });"] +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 diff --git a/src/index.ts b/src/index.ts index ec0b58efd..c519268e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -815,8 +815,8 @@ async function resolveInstallationId( repository: string, env: Env, ): Promise { - if (env.GITHUB_APP_INSTALLATION_ID) { - const configuredInstallationId = env.GITHUB_APP_INSTALLATION_ID; + const configuredInstallationId = env.GITHUB_APP_INSTALLATION_ID; + if (configuredInstallationId) { if (!/^[1-9]\d*$/.test(configuredInstallationId)) { throw new ApiError("ERR_GITHUB_INSTALLATION", 500, "GitHub App installation id configuration is invalid"); } @@ -824,11 +824,10 @@ async function resolveInstallationId( if (!Number.isSafeInteger(numericInstallationId) || String(numericInstallationId) !== configuredInstallationId) { throw new ApiError("ERR_GITHUB_INSTALLATION", 500, "GitHub App installation id configuration is invalid"); } - return { value: configuredInstallationId, source: "configured" }; } const now = Date.now(); const cacheKey = `${env.GITHUB_API_BASE}:${env.GITHUB_APP_ID}:${repository}`; - const cached = installationIdCache.get(cacheKey); + const cached = configuredInstallationId ? undefined : installationIdCache.get(cacheKey); if (cached && cached.expiresAtMs > now) { return { value: cached.value, source: "cache" }; } @@ -850,11 +849,23 @@ async function resolveInstallationId( throw new ApiError("ERR_GITHUB_API", 502, "GitHub API returned invalid installation response"); } const installationId = String(installation.id); - installationIdCache.set(cacheKey, { + if (configuredInstallationId && installationId !== configuredInstallationId) { + throw new ApiError( + "ERR_GITHUB_INSTALLATION", + 500, + "GitHub App installation id does not match target repository", + ); + } + if (!configuredInstallationId) { + installationIdCache.set(cacheKey, { + value: installationId, + expiresAtMs: now + configuredTtlMs(env.NOEMA_INSTALLATION_CACHE_TTL_SECONDS, 600, 3600), + }); + } + return { value: installationId, - expiresAtMs: now + configuredTtlMs(env.NOEMA_INSTALLATION_CACHE_TTL_SECONDS, 600, 3600), - }); - return { value: installationId, source: "discovery" }; + source: configuredInstallationId ? "configured" : "discovery", + }; } async function createInstallationToken(repository: string, env: Env): Promise { diff --git a/src/outbound-fetch-policy.ts b/src/outbound-fetch-policy.ts index e6f459c2a..f3cf72fdc 100644 --- a/src/outbound-fetch-policy.ts +++ b/src/outbound-fetch-policy.ts @@ -27,6 +27,7 @@ type GitHubApiOperation = | "installation-token"; const TRUSTED_GITHUB_API_ORIGIN = "https://api.github.com"; +const TRUSTED_GITHUB_API_META = "https://api.github.com/meta"; const TRUSTED_GITHUB_OIDC_ORIGIN = "https://token.actions.githubusercontent.com"; const TRUSTED_GITHUB_OIDC_DISCOVERY = "https://token.actions.githubusercontent.com/.well-known/openid-configuration"; @@ -70,25 +71,73 @@ function outboundUrl(input: RequestInfo | URL): URL | undefined { } function outboundMethod(input: RequestInfo | URL, init: RequestInit | undefined): string { - return (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); + return init?.method ?? (input instanceof Request ? input.method : "GET"); } -function outboundHeaders(input: RequestInfo | URL, init: RequestInit | undefined): Headers { - if (init?.headers !== undefined) return new Headers(init.headers); - if (input instanceof Request) return new Headers(input.headers); - return new Headers(); +function outboundHeaders( + input: RequestInfo | URL, + init: RequestInit | undefined, +): Headers | undefined { + try { + if (init?.headers !== undefined) return new Headers(init.headers); + if (input instanceof Request) return new Headers(input.headers); + return new Headers(); + } catch { + return undefined; + } +} + +function hasNoHeaders(headers: Headers): boolean { + let empty = true; + headers.forEach(() => { + empty = false; + }); + return empty; } -function rawAuthorizationHeaderFromInit(headersInit: HeadersInit | undefined): string | null | undefined { +function rawHeaderValueFromInit( + headersInit: HeadersInit | undefined, + headerName: string, +): string | null | undefined { if (headersInit === undefined || headersInit instanceof Headers) return undefined; const entries = Array.isArray(headersInit) ? headersInit : Object.entries(headersInit); - let authorization: string | undefined; + let rawValue: string | undefined; for (const [name, value] of entries) { - if (name.toLowerCase() !== "authorization") continue; - if (authorization !== undefined) return null; - authorization = value; + if (name.toLowerCase() !== headerName) continue; + if (rawValue !== undefined) return null; + rawValue = value; } - return authorization; + return rawValue; +} + +function hasOnlyReviewedGithubApiHeaders( + headers: Headers, + operation: GitHubApiOperation | undefined, + headersInit: HeadersInit | undefined, +): boolean { + let reviewed = true; + headers.forEach((value, name) => { + if (!reviewed || name === "authorization") return; + const rawValue = rawHeaderValueFromInit(headersInit, name); + if (rawValue === null || (rawValue !== undefined && rawValue !== value)) { + reviewed = false; + return; + } + if ( + (name === "accept" && value === "application/vnd.github+json") + || (name === "user-agent" && value === "noema") + || (name === "x-github-api-version" && value === "2022-11-28") + || ( + operation === "installation-token" + && name === "content-type" + && value === "application/json" + ) + ) { + return; + } + reviewed = false; + }); + return reviewed; } function outboundBodyPresent(input: RequestInfo | URL, init: RequestInit | undefined): boolean { @@ -157,11 +206,15 @@ function boundedOutboundSignal( input: RequestInfo | URL, init: RequestInit | undefined, timeoutSignal: AbortSignal, -): AbortSignal { - const signals = [timeoutSignal]; - if (input instanceof Request) signals.push(input.signal); - if (init?.signal) signals.push(init.signal); - return AbortSignal.any(signals); +): AbortSignal | undefined { + try { + const signals = [timeoutSignal]; + if (input instanceof Request) signals.push(input.signal); + if (init?.signal) signals.push(init.signal); + return AbortSignal.any(signals); + } catch { + return undefined; + } } function ignoreCancellationBestEffort(cancel: () => Promise): void { @@ -172,7 +225,57 @@ function ignoreCancellationBestEffort(cancel: () => Promise): void { } } -async function boundedOutboundResponse(response: Response): Promise { +function cancelResponseBodyBestEffort(response: Response, reason: string): void { + if (response.body === null) return; + ignoreCancellationBestEffort(() => response.body!.cancel(reason)); +} + +async function awaitOutboundTransport( + transport: Promise, + signal: AbortSignal, +): Promise { + let onAbort!: () => void; + const abort = new Promise((_resolve, reject) => { + onAbort = () => reject(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + }); + try { + signal.throwIfAborted(); + return await Promise.race([transport, abort]); + } catch (error) { + void transport.then((lateResponse) => { + cancelResponseBodyBestEffort( + lateResponse, + "Noema outbound response arrived after request authority was revoked", + ); + }).catch(() => undefined); + throw error; + } finally { + signal.removeEventListener("abort", onAbort); + } +} + +async function readOutboundChunk( + reader: ReadableStreamDefaultReader, + signal: AbortSignal, +): Promise> { + signal.throwIfAborted(); + let onAbort!: () => void; + const abort = new Promise((_resolve, reject) => { + onAbort = () => reject(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + }); + try { + return await Promise.race([reader.read(), abort]); + } finally { + signal.removeEventListener("abort", onAbort); + } +} + +async function boundedOutboundResponse( + response: Response, + signal: AbortSignal, +): Promise { const declaredLength = response.headers.get("content-length"); if ( declaredLength !== null @@ -194,7 +297,8 @@ async function boundedOutboundResponse(response: Response): Promise { let totalBytes = 0; try { while (true) { - const { done, value } = await reader.read(); + const { done, value } = await readOutboundChunk(reader, signal); + if (signal.aborted) throw signal.reason; if (done) break; totalBytes += value.byteLength; if (totalBytes > MAX_OUTBOUND_RESPONSE_BYTES) { @@ -205,10 +309,11 @@ async function boundedOutboundResponse(response: Response): Promise { } chunks.push(value); } - } catch { + } catch (error) { ignoreCancellationBestEffort(() => reader.cancel( "Noema outbound response body could not be read", )); + if (signal.aborted) throw error; return blockedResponse("response-read"); } @@ -249,6 +354,29 @@ function githubApiOperation(url: URL): GitHubApiOperation | undefined { return undefined; } +function withCanonicalInstallationTokenMediaType( + input: RequestInfo | URL, + init: RequestInit | undefined, +): RequestInit | undefined { + const url = outboundUrl(input); + const parsedHeaders = outboundHeaders(input, init); + if ( + !url + || githubApiOperation(url) !== "installation-token" + || typeof init?.body !== "string" + || !parsedHeaders + || parsedHeaders.has("content-type") + || init.headers instanceof Headers + ) { + return init; + } + + const headers: HeadersInit = Array.isArray(init.headers) + ? [...init.headers, ["content-type", "application/json"]] + : { ...(init.headers ?? {}), "content-type": "application/json" }; + return { ...init, headers }; +} + /** * Checks whether an outbound destination is on the exact HTTPS credential-egress allowlist used by Noema. * Raw string destinations must already equal their parsed URL serialization; the policy never trims, @@ -269,7 +397,8 @@ export function isTrustedCredentialEgress(input: RequestInfo | URL): boolean { } if (url.origin === TRUSTED_GITHUB_API_ORIGIN) { - return true; + return url.href === TRUSTED_GITHUB_API_META + || githubApiOperation(url) !== undefined; } return url.href === TRUSTED_GITHUB_OIDC_DISCOVERY @@ -291,47 +420,42 @@ export function isTrustedCredentialEgressRequest( const url = outboundUrl(input)!; const method = outboundMethod(input, init); const headers = outboundHeaders(input, init); + if (!headers) return false; const bodyPresent = outboundBodyPresent(input, init); if (url.origin === TRUSTED_GITHUB_OIDC_ORIGIN) { - return ( - method === "GET" + return method === "GET" && !bodyPresent - && !headers.has("authorization") - && !headers.has("cookie") - && !headers.has("proxy-authorization") - ); + && hasNoHeaders(headers); } - if ( - headers.has("cookie") - || headers.has("proxy-authorization") - || headers.has("x-http-method-override") - || headers.has("x-method-override") - ) { + const operation = githubApiOperation(url); + if (!hasOnlyReviewedGithubApiHeaders(headers, operation, init?.headers)) { return false; } const authorization = headers.get("authorization"); if (!authorization) { - return method === "GET" && !bodyPresent; + return url.href === TRUSTED_GITHUB_API_META + && method === "GET" + && !bodyPresent; } - const rawAuthorization = rawAuthorizationHeaderFromInit(init?.headers); + const rawAuthorization = rawHeaderValueFromInit(init?.headers, "authorization"); if ( rawAuthorization === undefined || rawAuthorization === null || rawAuthorization !== authorization - || !/^Bearer [\x21-\x7e]+$/i.test(authorization) + || !/^Bearer [\x21-\x7e]+$/.test(authorization) ) { return false; } - const operation = githubApiOperation(url); if (operation === "repository-installation") { return method === "GET" && !bodyPresent; } return operation === "installation-token" && method === "POST" + && headers.get("content-type") === "application/json" && reviewedInstallationTokenBody(input, init); } @@ -345,7 +469,8 @@ export function createFailClosedFetch(rawFetch: FetchLike): FetchLike { if (!isTrustedCredentialEgress(input)) { return blockedResponse("destination"); } - if (!isTrustedCredentialEgressRequest(input, init)) { + const effectiveInit = withCanonicalInstallationTokenMediaType(input, init); + if (!isTrustedCredentialEgressRequest(input, effectiveInit)) { return blockedResponse("request-policy"); } @@ -358,14 +483,33 @@ export function createFailClosedFetch(rawFetch: FetchLike): FetchLike { () => timeoutController.abort(timeoutReason), OUTBOUND_FETCH_TIMEOUT_MS, ); - const signal = boundedOutboundSignal(input, init, timeoutController.signal); + const signal = boundedOutboundSignal(input, effectiveInit, timeoutController.signal); + if (!signal) { + clearTimeout(timeoutHandle); + return blockedResponse("request-policy"); + } + if (signal.aborted) { + clearTimeout(timeoutHandle); + throw signal.reason; + } try { - const response = await rawFetch(input, { - ...(init ?? {}), - redirect: "manual", + const response = await awaitOutboundTransport( + rawFetch(input, { + ...(effectiveInit ?? {}), + redirect: "manual", + signal, + }), signal, - }); + ); + if (signal.aborted) { + if (response.body !== null) { + ignoreCancellationBestEffort(() => response.body!.cancel( + "Noema outbound response arrived after request authority was revoked", + )); + } + throw signal.reason; + } if (response.redirected || (response.status >= 300 && response.status < 400)) { if (response.body !== null) { ignoreCancellationBestEffort(() => response.body!.cancel( @@ -374,15 +518,15 @@ export function createFailClosedFetch(rawFetch: FetchLike): FetchLike { } return blockedResponse("redirect"); } - return await boundedOutboundResponse(response); + return await boundedOutboundResponse(response, signal); } catch (error) { if (signal.aborted && signal.reason === timeoutReason) { return blockedResponse("timeout"); } if (signal.aborted) { - throw error; + throw signal.reason; } - if (outboundHeaders(input, init).has("authorization")) { + if (outboundHeaders(input, effectiveInit)?.has("authorization")) { return blockedResponse("transport"); } throw error; @@ -448,4 +592,4 @@ export function resetGlobalOutboundFetchPolicy( } } installations.delete(key); -} +} \ No newline at end of file diff --git a/test/acquisition-data-room-integrity.test.ts b/test/acquisition-data-room-integrity.test.ts index f99868fa6..9f24b26ea 100644 --- a/test/acquisition-data-room-integrity.test.ts +++ b/test/acquisition-data-room-integrity.test.ts @@ -14,6 +14,7 @@ import { } from "../scripts/lib/acquisition-data-room-integrity.mjs"; const HEAD = "0123456789abcdef0123456789abcdef01234567"; +const ACQUISITION_AUDIT_TEST_TIMEOUT_MS = 35_000; const testCatalog = [ { @@ -165,7 +166,7 @@ describe("acquisition data-room integrity", () => { } finally { rmSync(temp, { recursive: true, force: true }); } - }); + }, ACQUISITION_AUDIT_TEST_TIMEOUT_MS); it("recomputes local hashes and refuses evidence modified after manifest generation", () => { const temp = mkdtempSync(join(tmpdir(), "noema-data-room-mutated-")); diff --git a/test/ci-release-evidence-order.test.ts b/test/ci-release-evidence-order.test.ts new file mode 100644 index 000000000..32dd16710 --- /dev/null +++ b/test/ci-release-evidence-order.test.ts @@ -0,0 +1,27 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +describe("CI release evidence ordering", () => { + it("materializes dependency license evidence before the acquisition manifest", () => { + const workflow = readFileSync(".github/workflows/ci.yml", "utf8"); + const inventoryStep = workflow.indexOf( + " - name: release dependency license inventory\n run: npm run release:dependency-license-inventory", + ); + const manifestStep = workflow.indexOf( + " - name: release acquisition manifest\n run: npm run acquisition:manifest", + ); + + expect(inventoryStep).toBeGreaterThanOrEqual(0); + expect(manifestStep).toBeGreaterThan(inventoryStep); + }); + + it("emits actionable branch diagnostics when coverage fails without uncovered statements", () => { + const workflow = readFileSync(".github/workflows/ci.yml", "utf8"); + + expect(workflow).toContain("const branchMap = coverage?.branchMap ?? {};"); + expect(workflow).toContain("for (const [branchId, hits] of Object.entries(coverage?.b ?? {}))"); + expect(workflow).toContain("const line = branch?.line ?? branch?.loc?.start?.line ?? location?.line ?? location?.start?.line;"); + expect(workflow).toContain("if (!Number.isInteger(line)) continue;"); + expect(workflow).toContain("Uncovered branch ${path}:${line}:${column}"); + }); +}); diff --git a/test/github-api-content-type-authority.test.ts b/test/github-api-content-type-authority.test.ts index 7e1596528..a8dc0e032 100644 --- a/test/github-api-content-type-authority.test.ts +++ b/test/github-api-content-type-authority.test.ts @@ -117,6 +117,9 @@ async function exchangeWithInstallationTokenResponse( if (url === "https://token.actions.githubusercontent.com/.well-known/jwks") { return Response.json({ keys: [jwk] }); } + if (url === "https://api.github.com/repos/ContextualWisdomLab/.github/installation") { + return Response.json({ id: 92345 }); + } if (url === "https://api.github.com/app/installations/92345/access_tokens") { return installationTokenResponse; } diff --git a/test/github-api-malformed-json.test.ts b/test/github-api-malformed-json.test.ts index df4281b55..4a0b066a7 100644 --- a/test/github-api-malformed-json.test.ts +++ b/test/github-api-malformed-json.test.ts @@ -110,6 +110,12 @@ async function exchangeWith( if (url === "https://token.actions.githubusercontent.com/.well-known/jwks") { return Response.json({ keys: [jwk] }); } + if ( + env.GITHUB_APP_INSTALLATION_ID + && url === `https://api.github.com/repos/${targetRepository}/installation` + ) { + return Response.json({ id: Number(env.GITHUB_APP_INSTALLATION_ID) }); + } return githubHandler(url); }); diff --git a/test/github-app-explicit-installation-id-validation.test.ts b/test/github-app-explicit-installation-id-validation.test.ts index 373e64afc..084c33d96 100644 --- a/test/github-app-explicit-installation-id-validation.test.ts +++ b/test/github-app-explicit-installation-id-validation.test.ts @@ -130,6 +130,67 @@ async function exchangeWithConfiguredInstallationId(installationId: string, clie return { response, githubApiCalls }; } +async function exchangeWithRepositoryBoundInstallationId( + configuredInstallationId: string, + discoveredInstallationId: number, + clientIp: string, +) { + const { token, jwk } = await signedOidcToken(); + const githubApiCalls: string[] = []; + const repositoryInstallationUrl = + "https://api.github.com/repos/ContextualWisdomLab/noema/installation"; + const installationTokenUrl = + `https://api.github.com/app/installations/${configuredInstallationId}/access_tokens`; + + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + if (url === "https://token.actions.githubusercontent.com/.well-known/openid-configuration") { + return Response.json({ + jwks_uri: "https://token.actions.githubusercontent.com/.well-known/jwks", + }); + } + if (url === "https://token.actions.githubusercontent.com/.well-known/jwks") { + return Response.json({ keys: [jwk] }); + } + + githubApiCalls.push(url); + if (url === repositoryInstallationUrl) { + return Response.json({ id: discoveredInstallationId }, { status: 200 }); + } + if (url === installationTokenUrl) { + return Response.json({ + token: "ghs_repository_scoped", + expires_at: new Date(Date.now() + 60 * 60_000).toISOString(), + }, { status: 201 }); + } + return new Response("unexpected GitHub API request", { status: 500 }); + }); + + const response = await worker.fetch( + new Request("https://noema.example/exchange", { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + "cf-connecting-ip": clientIp, + }, + body: JSON.stringify({ target_repository: "ContextualWisdomLab/noema" }), + }), + { + ...baseEnv, + GITHUB_APP_PRIVATE_KEY_PEM: appPrivateKeyPem, + GITHUB_APP_INSTALLATION_ID: configuredInstallationId, + }, + ); + + return { + response, + githubApiCalls, + repositoryInstallationUrl, + installationTokenUrl, + }; +} + describe("configured GitHub App installation id", () => { it.each([ ["0", "203.0.113.250"], @@ -149,4 +210,50 @@ describe("configured GitHub App installation id", () => { }); expect(githubApiCalls).toBe(0); }); + + it("rejects a syntactically valid configured id that belongs to a different installation", async () => { + const { + response, + githubApiCalls, + repositoryInstallationUrl, + } = await exchangeWithRepositoryBoundInstallationId( + "12345", + 67890, + "203.0.113.248", + ); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_GITHUB_INSTALLATION", + message: "GitHub App installation id does not match target repository", + }); + expect(githubApiCalls).toEqual([repositoryInstallationUrl]); + }); + + it("mints only after a configured id is verified against the target repository", async () => { + const { + response, + githubApiCalls, + repositoryInstallationUrl, + installationTokenUrl, + } = await exchangeWithRepositoryBoundInstallationId( + "12346", + 12346, + "203.0.113.247", + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + ok: true, + data: { + token: "ghs_repository_scoped", + repository: "ContextualWisdomLab/noema", + }, + }); + expect(githubApiCalls).toEqual([ + repositoryInstallationUrl, + installationTokenUrl, + ]); + }); }); diff --git a/test/github-app-runtime-coverage.test.ts b/test/github-app-runtime-coverage.test.ts index 3bf777848..0f9b90f32 100644 --- a/test/github-app-runtime-coverage.test.ts +++ b/test/github-app-runtime-coverage.test.ts @@ -111,6 +111,12 @@ async function exchange( if (url === "https://token.actions.githubusercontent.com/.well-known/jwks") { return Response.json({ keys: [jwk] }); } + if ( + env.GITHUB_APP_INSTALLATION_ID + && url === `https://api.github.com/repos/${targetRepository}/installation` + ) { + return Response.json({ id: Number(env.GITHUB_APP_INSTALLATION_ID) }); + } return githubHandler(url, init); }); diff --git a/test/github-installation-expiry-defensive-coverage.test.ts b/test/github-installation-expiry-defensive-coverage.test.ts index ea548c2c7..07edea0d2 100644 --- a/test/github-installation-expiry-defensive-coverage.test.ts +++ b/test/github-installation-expiry-defensive-coverage.test.ts @@ -56,6 +56,7 @@ async function exchangeWithTokenResponse(tokenBody: unknown, clientIp: string): const url = String(input); if (url.endsWith("/.well-known/openid-configuration")) return Response.json({ jwks_uri: "https://token.actions.githubusercontent.com/.well-known/jwks" }); if (url.endsWith("/.well-known/jwks")) return Response.json({ keys: [{ ...oidcPublicJwk, kid, kty: "RSA" }] }); + if (url === "https://api.github.com/repos/ContextualWisdomLab/noema/installation") return Response.json({ id: 92345 }); if (url === "https://api.github.com/app/installations/92345/access_tokens") return Response.json(tokenBody, { status: 201 }); return new Response("unexpected", { status: 500 }); }); diff --git a/test/github-installation-token-expiry-calendar-integrity.test.ts b/test/github-installation-token-expiry-calendar-integrity.test.ts index 01c60b151..793fac9e8 100644 --- a/test/github-installation-token-expiry-calendar-integrity.test.ts +++ b/test/github-installation-token-expiry-calendar-integrity.test.ts @@ -110,6 +110,9 @@ describe("GitHub installation-token expiry calendar integrity", () => { if (url === "https://token.actions.githubusercontent.com/.well-known/jwks") { return Response.json({ keys: [jwk] }); } + if (url === "https://api.github.com/repos/ContextualWisdomLab/expiry-calendar/installation") { + return Response.json({ id: 92345 }); + } if (url === "https://api.github.com/app/installations/92345/access_tokens") { return Response.json({ token: "ghs_impossible_calendar_expiry", diff --git a/test/installation-token-scope-policy.test.ts b/test/installation-token-scope-policy.test.ts index 864f07fa8..d0921c775 100644 --- a/test/installation-token-scope-policy.test.ts +++ b/test/installation-token-scope-policy.test.ts @@ -3,7 +3,10 @@ import { isTrustedCredentialEgressRequest } from "../src/outbound-fetch-policy"; const installationTokenUrl = "https://api.github.com/app/installations/12345/access_tokens"; -const authorization = { authorization: "Bearer app-jwt" }; +const authorization = { + authorization: "Bearer app-jwt", + "content-type": "application/json", +}; const leastPrivilegeBody = JSON.stringify({ repositories: ["noema"], permissions: { diff --git a/test/main-governance-audit.test.ts b/test/main-governance-audit.test.ts index a88834cbe..59a087fdc 100644 --- a/test/main-governance-audit.test.ts +++ b/test/main-governance-audit.test.ts @@ -254,11 +254,15 @@ describe("repository governance guidance", () => { const agents = readFileSync(new URL("../AGENTS.md", import.meta.url), "utf8"); expect(agents).not.toContain("It runs on every PR base, **including stacked PRs**."); - expect(agents).toContain( + expect(agents).not.toContain( "The central workflow currently selects pull requests whose base branch is `main`, `master`, or `develop`.", ); + expect(agents).toContain("The current protected central workflow has no"); + expect(agents).toContain( + "pull-request base-branch filter, so stacked feature-base PRs are expected to", + ); expect(agents).toContain( - "A feature-base stacked PR can therefore have no Security Scan run; absence is non-passing evidence", + "An absent, queued, skipped, cancelled, stale, or failed run is non-passing", ); expect(agents).toContain("MEDIUM/HIGH/CRITICAL"); expect(agents).not.toContain("CRITICAL/HIGH, fixable only"); diff --git a/test/outbound-fetch-anonymous-authority.test.ts b/test/outbound-fetch-anonymous-authority.test.ts new file mode 100644 index 000000000..35bcd7cc3 --- /dev/null +++ b/test/outbound-fetch-anonymous-authority.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createFailClosedFetch, + isTrustedCredentialEgress, + isTrustedCredentialEgressRequest, + type FetchLike, +} from "../src/outbound-fetch-policy"; + +describe("anonymous GitHub API egress authority", () => { + it("admits only the exact bodyless /meta diagnostic without credentials", async () => { + const installationUrl = "https://api.github.com/repos/ContextualWisdomLab/noema/installation"; + + expect(isTrustedCredentialEgress("https://api.github.com/meta")).toBe(true); + expect(isTrustedCredentialEgressRequest("https://api.github.com/meta")).toBe(true); + expect(isTrustedCredentialEgress(installationUrl)).toBe(true); + expect(isTrustedCredentialEgressRequest(installationUrl)).toBe(false); + + const rawFetch = vi.fn(async () => new Response(null, { status: 204 })); + const wrapped = createFailClosedFetch(rawFetch); + + const response = await wrapped(installationUrl); + + expect(rawFetch).not.toHaveBeenCalled(); + expect(response.status).toBe(502); + expect(response.headers.get("x-noema-egress-policy")).toBe("blocked-request-policy"); + }); + + it("rejects arbitrary anonymous GitHub REST destinations outside reviewed operations", async () => { + const unreviewedUrl = "https://api.github.com/repos/ContextualWisdomLab/noema/issues"; + + expect(isTrustedCredentialEgress(unreviewedUrl)).toBe(false); + + const rawFetch = vi.fn(); + const wrapped = createFailClosedFetch(rawFetch); + const response = await wrapped(unreviewedUrl); + + expect(rawFetch).not.toHaveBeenCalled(); + expect(response.status).toBe(502); + expect(response.headers.get("x-noema-egress-policy")).toBe("blocked-destination"); + }); +}); diff --git a/test/outbound-fetch-app-installations-authority.test.ts b/test/outbound-fetch-app-installations-authority.test.ts index 47cce1d1f..cc72aafbf 100644 --- a/test/outbound-fetch-app-installations-authority.test.ts +++ b/test/outbound-fetch-app-installations-authority.test.ts @@ -15,7 +15,7 @@ describe("credential egress operation authority", () => { }); expect(response.status).toBe(502); - expect(response.headers.get("x-noema-egress-policy")).toBe("blocked-request-policy"); + expect(response.headers.get("x-noema-egress-policy")).toBe("blocked-destination"); expect(rawFetch).not.toHaveBeenCalled(); }); }); diff --git a/test/outbound-fetch-authorization-canonicality.test.ts b/test/outbound-fetch-authorization-canonicality.test.ts index 6ace265fc..c9a078384 100644 --- a/test/outbound-fetch-authorization-canonicality.test.ts +++ b/test/outbound-fetch-authorization-canonicality.test.ts @@ -7,7 +7,7 @@ describe("credential-egress Authorization framing", () => { it("accepts exactly one ASCII space between Bearer and the credential from raw RequestInit headers", () => { expect(isTrustedCredentialEgressRequest(installationLookup, { method: "GET", - headers: { authorization: "Bearer canonical-token", accept: "application/json" }, + headers: { authorization: "Bearer canonical-token", accept: "application/vnd.github+json" }, })).toBe(true); expect(isTrustedCredentialEgressRequest(installationLookup, { method: "GET", diff --git a/test/outbound-fetch-header-authority.test.ts b/test/outbound-fetch-header-authority.test.ts new file mode 100644 index 000000000..aa70cff3c --- /dev/null +++ b/test/outbound-fetch-header-authority.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createFailClosedFetch, + type FetchLike, +} from "../src/outbound-fetch-policy"; + +describe("outbound header authority", () => { + it.each([ + [ + "credential-bearing GitHub API", + "https://api.github.com/repos/ContextualWisdomLab/noema/installation", + { + headers: { + authorization: "Bearer sensitive", + "x-noema-unreviewed-secret": "sensitive-marker", + }, + }, + ], + [ + "GitHub OIDC metadata", + "https://token.actions.githubusercontent.com/.well-known/openid-configuration", + { + headers: { + "x-noema-unreviewed-secret": "sensitive-marker", + }, + }, + ], + ])("rejects unreviewed caller header authority before %s egress", async (_label, url, init) => { + const rawFetch = vi.fn(async () => new Response("unexpected network")); + const wrapped = createFailClosedFetch(rawFetch); + + const response = await wrapped(url, init); + + expect(rawFetch).not.toHaveBeenCalled(); + expect(response.status).toBe(502); + expect(response.headers.get("x-noema-egress-policy")).toBe("blocked-request-policy"); + expect(await response.text()).toBe(""); + }); + + it("accepts the exact production-reviewed GitHub API header set", async () => { + const rawFetch = vi.fn(async () => Response.json({ id: 12345 })); + const wrapped = createFailClosedFetch(rawFetch); + + const response = await wrapped( + "https://api.github.com/repos/ContextualWisdomLab/noema/installation", + { + method: "GET", + headers: { + accept: "application/vnd.github+json", + authorization: "Bearer sensitive", + "user-agent": "noema", + "x-github-api-version": "2022-11-28", + }, + }, + ); + + expect(response.status).toBe(200); + expect(rawFetch).toHaveBeenCalledOnce(); + }); + + it("rejects a reviewed GitHub API header name with a noncanonical value", async () => { + const rawFetch = vi.fn(async () => new Response("unexpected network")); + const wrapped = createFailClosedFetch(rawFetch); + + const response = await wrapped( + "https://api.github.com/repos/ContextualWisdomLab/noema/installation", + { + method: "GET", + headers: { + authorization: "Bearer sensitive", + accept: "application/json", + }, + }, + ); + + expect(rawFetch).not.toHaveBeenCalled(); + expect(response.status).toBe(502); + expect(response.headers.get("x-noema-egress-policy")).toBe("blocked-request-policy"); + expect(await response.text()).toBe(""); + }); + + it.each([ + ["accept", " application/vnd.github+json "], + ["user-agent", " noema "], + ["x-github-api-version", " 2022-11-28 "], + ])("rejects raw whitespace normalization around reviewed %s authority", async (name, value) => { + const rawFetch = vi.fn(async () => new Response("unexpected network")); + const wrapped = createFailClosedFetch(rawFetch); + + const response = await wrapped( + "https://api.github.com/repos/ContextualWisdomLab/noema/installation", + { + method: "GET", + headers: { + accept: "application/vnd.github+json", + authorization: "Bearer sensitive", + "user-agent": "noema", + "x-github-api-version": "2022-11-28", + [name]: value, + }, + }, + ); + + expect(rawFetch).not.toHaveBeenCalled(); + expect(response.status).toBe(502); + expect(response.headers.get("x-noema-egress-policy")).toBe("blocked-request-policy"); + expect(await response.text()).toBe(""); + }); + + it("rejects raw whitespace normalization around installation-token content type", async () => { + const rawFetch = vi.fn(async () => new Response("unexpected network")); + const wrapped = createFailClosedFetch(rawFetch); + const body = JSON.stringify({ + repositories: ["noema"], + permissions: { + pull_requests: "write", + contents: "read", + checks: "read", + }, + }); + + const response = await wrapped( + "https://api.github.com/app/installations/123/access_tokens", + { + method: "POST", + headers: { + accept: "application/vnd.github+json", + authorization: "Bearer sensitive", + "content-type": " application/json ", + "user-agent": "noema", + "x-github-api-version": "2022-11-28", + }, + body, + }, + ); + + expect(rawFetch).not.toHaveBeenCalled(); + expect(response.status).toBe(502); + expect(response.headers.get("x-noema-egress-policy")).toBe("blocked-request-policy"); + expect(await response.text()).toBe(""); + }); + + it("does not let later reviewed headers rehabilitate an earlier rejected header", async () => { + const rawFetch = vi.fn(async () => new Response("unexpected network")); + const wrapped = createFailClosedFetch(rawFetch); + + const response = await wrapped( + "https://api.github.com/repos/ContextualWisdomLab/noema/installation", + { + method: "GET", + headers: { + "a-unreviewed-header": "forbidden", + accept: "application/vnd.github+json", + authorization: "Bearer sensitive", + }, + }, + ); + + expect(rawFetch).not.toHaveBeenCalled(); + expect(response.status).toBe(502); + expect(response.headers.get("x-noema-egress-policy")).toBe("blocked-request-policy"); + expect(await response.text()).toBe(""); + }); +}); diff --git a/test/outbound-fetch-installation-id-range.test.ts b/test/outbound-fetch-installation-id-range.test.ts index 6f81777a6..a67dd21a9 100644 --- a/test/outbound-fetch-installation-id-range.test.ts +++ b/test/outbound-fetch-installation-id-range.test.ts @@ -25,7 +25,7 @@ function requestFor(id: string) { } describe("credential egress installation-id authority", () => { - it("accepts the maximum canonical safe integer installation id", () => { + it("accepts the maximum canonical safe integer installation id with the exact JSON media type", () => { expect(requestFor(String(Number.MAX_SAFE_INTEGER))).toBe(true); }); diff --git a/test/outbound-fetch-method-canonicality.test.ts b/test/outbound-fetch-method-canonicality.test.ts new file mode 100644 index 000000000..86b4b1115 --- /dev/null +++ b/test/outbound-fetch-method-canonicality.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { isTrustedCredentialEgressRequest } from "../src/outbound-fetch-policy"; + +const installationLookup = "https://api.github.com/repos/ContextualWisdomLab/noema/installation"; +const installationToken = "https://api.github.com/app/installations/123/access_tokens"; + +describe("credential-egress HTTP method canonicality", () => { + it.each(["get", "Get", "gEt"])( + "rejects non-canonical repository-installation method bytes before credential egress: %s", + (method) => { + expect(isTrustedCredentialEgressRequest(installationLookup, { + method, + headers: { authorization: "Bearer canonical-token" }, + })).toBe(false); + }, + ); + + it.each(["post", "Post", "pOsT"])( + "rejects non-canonical installation-token method bytes before credential egress: %s", + (method) => { + expect(isTrustedCredentialEgressRequest(installationToken, { + method, + headers: { + authorization: "Bearer canonical-token", + "content-type": "application/json", + }, + body: JSON.stringify({ + permissions: { checks: "read", contents: "read", pull_requests: "write" }, + repositories: ["noema"], + }), + })).toBe(false); + }, + ); +}); diff --git a/test/outbound-fetch-policy.test.ts b/test/outbound-fetch-policy.test.ts index 155716f3d..25efb0aad 100644 --- a/test/outbound-fetch-policy.test.ts +++ b/test/outbound-fetch-policy.test.ts @@ -14,9 +14,10 @@ describe("credential-bearing outbound fetch policy", () => { vi.restoreAllMocks(); }); - it("allows only GitHub API and pinned GitHub OIDC endpoints", () => { - expect(isTrustedCredentialEgress("https://api.github.com/app/installations")).toBe(true); - expect(isTrustedCredentialEgress(new URL("https://api.github.com/repos/cwl/noema?per_page=100"))).toBe(true); + it("allows only reviewed GitHub API and pinned GitHub OIDC endpoints", () => { + expect(isTrustedCredentialEgress("https://api.github.com/meta")).toBe(true); + expect(isTrustedCredentialEgress("https://api.github.com/repos/cwl/noema/installation")).toBe(true); + expect(isTrustedCredentialEgress("https://api.github.com/app/installations/123/access_tokens")).toBe(true); expect(isTrustedCredentialEgress(new Request( "https://token.actions.githubusercontent.com/.well-known/openid-configuration", ))).toBe(true); @@ -94,7 +95,7 @@ describe("credential-bearing outbound fetch policy", () => { })); const wrapped = createFailClosedFetch(rawFetch); - const response = await wrapped("https://api.github.com/app/installations"); + const response = await wrapped("https://api.github.com/meta"); expect(response.status).toBe(502); expect(response.headers.get("x-noema-egress-policy")).toBe("blocked-redirect"); @@ -110,7 +111,7 @@ describe("credential-bearing outbound fetch policy", () => { const rawFetch = vi.fn(async () => redirectedResponse); const wrapped = createFailClosedFetch(rawFetch); - const response = await wrapped("https://api.github.com/app/installations"); + const response = await wrapped("https://api.github.com/meta"); expect(response.status).toBe(502); expect(response.headers.get("x-noema-egress-policy")).toBe("blocked-redirect"); @@ -246,6 +247,24 @@ describe("credential-bearing outbound fetch policy", () => { await expect(pending).rejects.toBe(reason); }); + it("fails closed before credential egress when caller cancellation authority is malformed", async () => { + const rawFetch = vi.fn(); + const wrapped = createFailClosedFetch(rawFetch); + + const response = await wrapped( + "https://api.github.com/repos/ContextualWisdomLab/noema/installation", + { + method: "GET", + headers: { authorization: "Bearer sensitive" }, + signal: {} as AbortSignal, + }, + ); + + expect(response.status).toBe(502); + expect(response.headers.get("x-noema-egress-policy")).toBe("blocked-request-policy"); + expect(rawFetch).not.toHaveBeenCalled(); + }); + it("rethrows non-timeout network failures unchanged", async () => { const failure = new TypeError("network unavailable"); const rawFetch = vi.fn(async () => { diff --git a/test/outbound-fetch-preaborted-signal.test.ts b/test/outbound-fetch-preaborted-signal.test.ts new file mode 100644 index 000000000..38fc18292 --- /dev/null +++ b/test/outbound-fetch-preaborted-signal.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createFailClosedFetch, + type FetchLike, +} from "../src/outbound-fetch-policy"; + +describe("credential-egress caller cancellation authority", () => { + it("never enters the raw transport when the caller signal is already aborted", async () => { + const rawFetch = vi.fn(async () => new Response("unexpected transport")); + const wrapped = createFailClosedFetch(rawFetch); + const caller = new AbortController(); + const reason = new DOMException("caller cancelled before egress", "AbortError"); + caller.abort(reason); + + await expect(wrapped( + "https://api.github.com/repos/ContextualWisdomLab/noema/installation", + { + method: "GET", + headers: { authorization: "Bearer sensitive" }, + signal: caller.signal, + }, + )).rejects.toBe(reason); + expect(rawFetch).not.toHaveBeenCalled(); + }); + + it("preserves mid-flight caller cancellation even when the transport ignores the abort signal", async () => { + let resolveTransport!: (response: Response) => void; + const transport = new Promise((resolve) => { + resolveTransport = resolve; + }); + const rawFetch = vi.fn(async (_input, init) => { + expect(init?.signal?.aborted).toBe(false); + return transport; + }); + const wrapped = createFailClosedFetch(rawFetch); + const caller = new AbortController(); + const reason = new DOMException("caller cancelled during egress", "AbortError"); + + const pending = wrapped( + "https://api.github.com/repos/ContextualWisdomLab/noema/installation", + { + method: "GET", + headers: { authorization: "Bearer sensitive" }, + signal: caller.signal, + }, + ); + await vi.waitFor(() => expect(rawFetch).toHaveBeenCalledTimes(1)); + caller.abort(reason); + resolveTransport(new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + })); + + await expect(pending).rejects.toBe(reason); + }); + + it("preserves caller cancellation when an abort-ignoring transport rejects later for another reason", async () => { + let rejectTransport!: (error: Error) => void; + const transport = new Promise((_resolve, reject) => { + rejectTransport = reject; + }); + const rawFetch = vi.fn(async () => transport); + const wrapped = createFailClosedFetch(rawFetch); + const caller = new AbortController(); + const reason = new DOMException("caller revoked transport authority", "AbortError"); + const lateFailure = new TypeError("late transport failure"); + + const pending = wrapped( + "https://api.github.com/repos/ContextualWisdomLab/noema/installation", + { + method: "GET", + headers: { authorization: "Bearer sensitive" }, + signal: caller.signal, + }, + ); + await vi.waitFor(() => expect(rawFetch).toHaveBeenCalledTimes(1)); + caller.abort(reason); + rejectTransport(lateFailure); + + await expect(pending).rejects.toBe(reason); + }); + + it("preserves mid-flight caller cancellation when the late response is bodyless", async () => { + let resolveTransport!: (response: Response) => void; + const transport = new Promise((resolve) => { + resolveTransport = resolve; + }); + const rawFetch = vi.fn(async () => transport); + const wrapped = createFailClosedFetch(rawFetch); + const caller = new AbortController(); + const reason = new DOMException("caller cancelled before a bodyless response", "AbortError"); + + const pending = wrapped( + "https://api.github.com/repos/ContextualWisdomLab/noema/installation", + { + method: "GET", + headers: { authorization: "Bearer sensitive" }, + signal: caller.signal, + }, + ); + await vi.waitFor(() => expect(rawFetch).toHaveBeenCalledTimes(1)); + caller.abort(reason); + resolveTransport(new Response(null, { status: 200 })); + + await expect(pending).rejects.toBe(reason); + }); + + it("preserves caller cancellation while an abort-ignoring response body is still streaming", async () => { + let markPullStarted!: () => void; + const pullStarted = new Promise((resolve) => { + markPullStarted = resolve; + }); + let releaseBody!: () => void; + const bodyReleased = new Promise((resolve) => { + releaseBody = resolve; + }); + const body = new ReadableStream({ + async pull(controller) { + markPullStarted(); + await bodyReleased; + controller.enqueue(new TextEncoder().encode("{}")); + controller.close(); + }, + }); + const rawFetch = vi.fn(async () => new Response(body, { + status: 200, + headers: { "content-type": "application/json" }, + })); + const wrapped = createFailClosedFetch(rawFetch); + const caller = new AbortController(); + const reason = new DOMException("caller cancelled during body streaming", "AbortError"); + + const pending = wrapped( + "https://api.github.com/repos/ContextualWisdomLab/noema/installation", + { + method: "GET", + headers: { authorization: "Bearer sensitive" }, + signal: caller.signal, + }, + ); + await pullStarted; + caller.abort(reason); + releaseBody(); + + await expect(pending).rejects.toBe(reason); + }); +}); diff --git a/test/outbound-fetch-stream-timeout.test.ts b/test/outbound-fetch-stream-timeout.test.ts new file mode 100644 index 000000000..a1730fafb --- /dev/null +++ b/test/outbound-fetch-stream-timeout.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createFailClosedFetch, + type FetchLike, +} from "../src/outbound-fetch-policy"; + +describe("credential-egress streamed response deadlines", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("returns the bounded timeout response when the deadline expires while reading the response body", async () => { + vi.useFakeTimers(); + const rawFetch = vi.fn(async (_input, init) => { + const signal = init?.signal; + if (!signal) throw new Error("expected bounded outbound signal"); + const body = new ReadableStream({ + start(controller) { + signal.addEventListener("abort", () => controller.error(signal.reason), { once: true }); + }, + }); + return new Response(body); + }); + const wrapped = createFailClosedFetch(rawFetch); + + const pending = wrapped("https://api.github.com/meta"); + await vi.advanceTimersByTimeAsync(10_000); + const response = await pending; + + expect(response.status).toBe(504); + expect(response.statusText).toBe("Gateway Timeout"); + expect(response.headers.get("x-noema-egress-policy")).toBe("blocked-timeout"); + expect(await response.text()).toBe(""); + }); + + it("enforces the deadline when a transport body ignores abort", async () => { + vi.useFakeTimers(); + let markBodyReadStarted!: () => void; + const bodyReadStarted = new Promise((resolve) => { + markBodyReadStarted = resolve; + }); + const cancel = vi.fn(); + const body = new ReadableStream({ + pull() { + markBodyReadStarted(); + return new Promise(() => undefined); + }, + cancel, + }, { highWaterMark: 0 }); + const rawFetch = vi.fn(async () => new Response(body)); + const wrapped = createFailClosedFetch(rawFetch); + + const pending = wrapped("https://api.github.com/meta"); + await bodyReadStarted; + await vi.advanceTimersByTimeAsync(10_000); + const response = await pending; + + expect(cancel).toHaveBeenCalledOnce(); + expect(response.status).toBe(504); + expect(response.headers.get("x-noema-egress-policy")).toBe("blocked-timeout"); + }); + + it("preserves caller cancellation that races with a newly readable body chunk", async () => { + const caller = new AbortController(); + const cancellationReason = new DOMException("caller revoked request authority", "AbortError"); + const cancel = vi.fn(); + const body = new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array([1])); + caller.abort(cancellationReason); + }, + cancel, + }, { highWaterMark: 0 }); + const rawFetch = vi.fn(async () => new Response(body)); + const wrapped = createFailClosedFetch(rawFetch); + + await expect(wrapped("https://api.github.com/meta", { signal: caller.signal })) + .rejects.toBe(cancellationReason); + expect(cancel).toHaveBeenCalledOnce(); + }); +}); diff --git a/test/outbound-fetch-token-media-type.test.ts b/test/outbound-fetch-token-media-type.test.ts new file mode 100644 index 000000000..3a0d51123 --- /dev/null +++ b/test/outbound-fetch-token-media-type.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createFailClosedFetch, + type FetchLike, +} from "../src/outbound-fetch-policy"; + +const tokenUrl = "https://api.github.com/app/installations/12345/access_tokens"; +const installationUrl = "https://api.github.com/repos/ContextualWisdomLab/noema/installation"; +const tokenBody = JSON.stringify({ + repositories: ["noema"], + permissions: { + contents: "read", + pull_requests: "write", + checks: "read", + }, +}); + +describe("installation-token outbound media type", () => { + it.each([ + ["record", { authorization: "Bearer app-jwt" }], + ["tuple list", [["authorization", "Bearer app-jwt"]] as [string, string][]], + ])("adds exact application/json before %s headers cross the network boundary", async (_label, headers) => { + const rawFetch = vi.fn(async () => Response.json({ token: "issued" })); + const wrapped = createFailClosedFetch(rawFetch); + + const response = await wrapped(tokenUrl, { + method: "POST", + headers, + body: tokenBody, + }); + + expect(response.status).toBe(200); + expect(rawFetch).toHaveBeenCalledOnce(); + const forwarded = new Headers(rawFetch.mock.calls[0][1]?.headers); + expect(forwarded.get("authorization")).toBe("Bearer app-jwt"); + expect(forwarded.get("content-type")).toBe("application/json"); + }); + + it("does not manufacture authorization for a headerless token request while adding its media type", async () => { + const rawFetch = vi.fn(); + const wrapped = createFailClosedFetch(rawFetch); + + const response = await wrapped(tokenUrl, { + method: "POST", + body: tokenBody, + }); + + expect(response.status).toBe(502); + expect(response.headers.get("x-noema-egress-policy")).toBe("blocked-request-policy"); + expect(rawFetch).not.toHaveBeenCalled(); + }); + + it("rejects installation-token media authority on a repository-installation lookup", async () => { + const rawFetch = vi.fn(); + const wrapped = createFailClosedFetch(rawFetch); + + const response = await wrapped(installationUrl, { + method: "GET", + headers: { + authorization: "Bearer app-jwt", + "content-type": "application/json", + }, + }); + + expect(response.status).toBe(502); + expect(response.headers.get("x-noema-egress-policy")).toBe("blocked-request-policy"); + expect(rawFetch).not.toHaveBeenCalled(); + }); + + it("rejects a caller-supplied non-JSON media type rather than overriding it", async () => { + const rawFetch = vi.fn(); + const wrapped = createFailClosedFetch(rawFetch); + + const response = await wrapped(tokenUrl, { + method: "POST", + headers: { + authorization: "Bearer app-jwt", + "content-type": "text/plain;charset=UTF-8", + }, + body: tokenBody, + }); + + expect(response.status).toBe(502); + expect(response.headers.get("x-noema-egress-policy")).toBe("blocked-request-policy"); + expect(rawFetch).not.toHaveBeenCalled(); + }); + + it("does not promote pre-normalized Headers whose credential framing is no longer observable", async () => { + const rawFetch = vi.fn(); + const wrapped = createFailClosedFetch(rawFetch); + + const response = await wrapped(tokenUrl, { + method: "POST", + headers: new Headers({ authorization: "Bearer app-jwt" }), + body: tokenBody, + }); + + expect(response.status).toBe(502); + expect(response.headers.get("x-noema-egress-policy")).toBe("blocked-request-policy"); + expect(rawFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/test/outbound-fetch-transport-abort.test.ts b/test/outbound-fetch-transport-abort.test.ts new file mode 100644 index 000000000..e89ddd4e8 --- /dev/null +++ b/test/outbound-fetch-transport-abort.test.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createFailClosedFetch, + type FetchLike, +} from "../src/outbound-fetch-policy"; + +describe("credential-egress transport abort deadlines", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("enforces the deadline before response headers when a transport ignores abort", async () => { + vi.useFakeTimers(); + let resolveTransport!: (response: Response) => void; + const transport = new Promise((resolve) => { + resolveTransport = resolve; + }); + const cancel = vi.fn(); + const rawFetch = vi.fn(() => transport); + const wrapped = createFailClosedFetch(rawFetch); + + const pending = wrapped("https://api.github.com/meta"); + let observed: Response | undefined; + let rejected: unknown; + void pending.then( + (response) => { + observed = response; + }, + (error: unknown) => { + rejected = error; + }, + ); + + await vi.advanceTimersByTimeAsync(10_000); + await Promise.resolve(); + + try { + expect(rejected).toBeUndefined(); + expect(observed?.status).toBe(504); + expect(observed?.headers.get("x-noema-egress-policy")).toBe("blocked-timeout"); + } finally { + resolveTransport(new Response(new ReadableStream({ cancel }))); + await Promise.resolve(); + await Promise.resolve(); + } + expect(cancel).toHaveBeenCalledOnce(); + }); + + it("preserves caller cancellation before response headers when a transport ignores abort", async () => { + const caller = new AbortController(); + const cancellationReason = new DOMException("caller revoked request authority", "AbortError"); + let resolveTransport!: (response: Response) => void; + const transport = new Promise((resolve) => { + resolveTransport = resolve; + }); + const rawFetch = vi.fn(() => transport); + const wrapped = createFailClosedFetch(rawFetch); + + const pending = wrapped("https://api.github.com/meta", { signal: caller.signal }); + const rejection = pending.then( + () => undefined, + (error: unknown) => error, + ); + + caller.abort(cancellationReason); + + try { + await expect(rejection).resolves.toBe(cancellationReason); + } finally { + resolveTransport(new Response(null)); + await Promise.resolve(); + await Promise.resolve(); + } + }); + + it("rejects a response when caller authority is revoked after the transport wins its race", async () => { + const caller = new AbortController(); + const cancellationReason = new DOMException("caller revoked settling response authority", "AbortError"); + let resolveTransport!: (response: Response) => void; + const transport = new Promise((resolve) => { + resolveTransport = resolve; + }); + const cancel = vi.fn(); + + // Register this reaction before the wrapper registers Promise.race's + // transport reaction. Resolving the native transport queues this reaction + // first; it revokes caller authority before the already-queued transport + // reaction settles the race and before the awaiting wrapper can trust the + // response. This exercises the real native-Promise settlement ordering + // without replacing Promise.then or weakening the production guard. + void transport.then(() => caller.abort(cancellationReason)); + + const rawFetch = vi.fn(() => transport); + const wrapped = createFailClosedFetch(rawFetch); + const pending = wrapped("https://api.github.com/meta", { signal: caller.signal }); + + resolveTransport(new Response(new ReadableStream({ cancel }))); + + await expect(pending).rejects.toBe(cancellationReason); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it("rejects a bodyless response when caller authority is revoked after transport settlement", async () => { + const caller = new AbortController(); + const cancellationReason = new DOMException("caller revoked bodyless response authority", "AbortError"); + let resolveTransport!: (response: Response) => void; + const transport = new Promise((resolve) => { + resolveTransport = resolve; + }); + + void transport.then(() => caller.abort(cancellationReason)); + + const rawFetch = vi.fn(() => transport); + const wrapped = createFailClosedFetch(rawFetch); + const pending = wrapped("https://api.github.com/meta", { signal: caller.signal }); + + resolveTransport(new Response(null, { status: 204 })); + + await expect(pending).rejects.toBe(cancellationReason); + }); + + it("cleans a late response when transport revokes caller authority synchronously", async () => { + const caller = new AbortController(); + const cancellationReason = new DOMException("transport observed caller revocation", "AbortError"); + let resolveTransport!: (response: Response) => void; + const transport = new Promise((resolve) => { + resolveTransport = resolve; + }); + const cancel = vi.fn(); + const rawFetch = vi.fn(() => { + caller.abort(cancellationReason); + return transport; + }); + const wrapped = createFailClosedFetch(rawFetch); + + await expect( + wrapped("https://api.github.com/meta", { signal: caller.signal }), + ).rejects.toBe(cancellationReason); + + resolveTransport(new Response(new ReadableStream({ cancel }))); + await Promise.resolve(); + await Promise.resolve(); + + expect(cancel).toHaveBeenCalledOnce(); + }); +}); diff --git a/test/outbound-fetch-url-canonicality.test.ts b/test/outbound-fetch-url-canonicality.test.ts index 1dd7fbc68..135352ddf 100644 --- a/test/outbound-fetch-url-canonicality.test.ts +++ b/test/outbound-fetch-url-canonicality.test.ts @@ -3,14 +3,14 @@ import { isTrustedCredentialEgress } from "../src/outbound-fetch-policy"; describe("credential-egress URL authority", () => { it("does not normalize raw string aliases into trusted GitHub destinations", () => { - expect(isTrustedCredentialEgress(" https://api.github.com/app/installations")).toBe(false); - expect(isTrustedCredentialEgress("https://api.github.com/app/installations ")).toBe(false); - expect(isTrustedCredentialEgress("https://api.github.com:443/app/installations")).toBe(false); - expect(isTrustedCredentialEgress("https://API.GITHUB.COM/app/installations")).toBe(false); + expect(isTrustedCredentialEgress(" https://api.github.com/meta")).toBe(false); + expect(isTrustedCredentialEgress("https://api.github.com/meta ")).toBe(false); + expect(isTrustedCredentialEgress("https://api.github.com:443/meta")).toBe(false); + expect(isTrustedCredentialEgress("https://API.GITHUB.COM/meta")).toBe(false); }); it("preserves canonical string and already-parsed URL authority", () => { - expect(isTrustedCredentialEgress("https://api.github.com/app/installations")).toBe(true); - expect(isTrustedCredentialEgress(new URL("https://api.github.com:443/app/installations"))).toBe(true); + expect(isTrustedCredentialEgress("https://api.github.com/meta")).toBe(true); + expect(isTrustedCredentialEgress(new URL("https://api.github.com:443/meta"))).toBe(true); }); }); diff --git a/test/outbound-request-compartment.test.ts b/test/outbound-request-compartment.test.ts index 3177a1c6b..48c7213e5 100644 --- a/test/outbound-request-compartment.test.ts +++ b/test/outbound-request-compartment.test.ts @@ -62,7 +62,10 @@ describe("outbound credential request compartmentalization", () => { }))).toBe(false); expect(isTrustedCredentialEgressRequest(installationTokenUrl, { method: "POST", - headers: { authorization: "Bearer app-jwt" }, + headers: { + authorization: "Bearer app-jwt", + "content-type": "application/json", + }, body: installationTokenBody, })).toBe(true); }); @@ -118,6 +121,11 @@ describe("outbound credential request compartmentalization", () => { repositoryInstallationUrl, { headers: { authorization: "Basic app-jwt" } }, ], + [ + "a noncanonical bearer scheme", + repositoryInstallationUrl, + { headers: { authorization: "bearer app-jwt" } }, + ], [ "a cookie", repositoryInstallationUrl, @@ -174,4 +182,25 @@ describe("outbound credential request compartmentalization", () => { expect(await response.text()).toBe(""); } }); -}); + + it.each([ + ["an invalid header name", { "bad header": "value" }], + ["a newline-bearing authorization value", { authorization: "Bearer app-jwt\r\nx-leak: secret" }], + ] satisfies Array<[string, Record]>) ( + "fails closed before the network call when Headers rejects %s", + async (_label, headers) => { + const rawFetch = vi.fn(); + const wrapped = createFailClosedFetch(rawFetch); + + const response = await wrapped(repositoryInstallationUrl, { + method: "GET", + headers, + }); + + expect(rawFetch).not.toHaveBeenCalled(); + expect(response.status).toBe(502); + expect(response.headers.get("x-noema-egress-policy")).toBe("blocked-request-policy"); + expect(await response.text()).toBe(""); + }, + ); +}); \ No newline at end of file diff --git a/test/patch-validator-image-build-cache.test.ts b/test/patch-validator-image-build-cache.test.ts index b2cc079d4..fdd364cf4 100644 --- a/test/patch-validator-image-build-cache.test.ts +++ b/test/patch-validator-image-build-cache.test.ts @@ -22,10 +22,18 @@ describe("patch-validator image build cache", () => { ); }); - it("lets an in-flight exact-head build finish exporting the shared cache", () => { + it("cancels superseded exact-head builds instead of spending the serial image lane on stale evidence", () => { expect(workflow).toContain( "group: noema-patch-validator-image-${{ github.event.pull_request.number || github.ref }}", ); - expect(workflow).toContain("cancel-in-progress: false"); + expect(workflow).toContain("cancel-in-progress: true"); + }); + + it("retries transient scanner release download failures before failing closed", () => { + expect(workflow).toContain("download_scanner_asset() {"); + expect(workflow).toContain("--retry 3"); + expect(workflow).toContain("--retry-all-errors"); + expect(workflow).toContain("--retry-delay 2"); + expect(workflow).toContain("--retry-max-time 90"); }); }); diff --git a/test/patch-validator-image-build-regression.test.ts b/test/patch-validator-image-build-regression.test.ts index d4789c70a..180db2e9c 100644 --- a/test/patch-validator-image-build-regression.test.ts +++ b/test/patch-validator-image-build-regression.test.ts @@ -2,12 +2,13 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; 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 dependencies with the exact Node/npm toolchain declared by devEngines", () => { + it("builds the static runtime with the exact Node/npm toolchain declared by devEngines", () => { expect(dockerfile).toContain("ARG NODE_VERSION=24.19.0"); expect(dockerfile).toContain('test "$(/opt/node/bin/npm --version)" = "11.17.0"'); - expect(dockerfile).toContain("FROM node_builder AS dependencies"); + expect(dockerfile).not.toContain("FROM validator_deps"); expect(dockerfile).not.toContain("FROM node:24.18.0-alpine3.24"); expect(dockerfile).not.toContain("--without-npm"); @@ -15,25 +16,78 @@ describe("patch-validator exact-toolchain image build regression", () => { expect(runtimeStage).toContain( "COPY --from=node_builder --chown=65532:65532 /opt/node/bin/node /nodejs/bin/node", ); + expect(runtimeStage).toContain( + "COPY --from=validator_deps --chown=65532:65532 /node_modules /opt/noema/node_modules", + ); expect(runtimeStage).not.toContain("/opt/node/bin/npm"); }); - it("keeps the freshly installed Node executable on PATH while make install installs npm", () => { + it("materializes lockfile dependencies before Docker and forbids npm registry access in the image build", () => { + expect(imageWorkflow).toContain("Set up exact dependency materialization toolchain"); + expect(imageWorkflow).toContain("Materialize exact patch-validator dependencies"); + 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(dockerfile).toContain( + "COPY --from=validator_deps --chown=65532:65532 /node_modules /opt/noema/node_modules", + ); + expect(dockerfile).not.toContain("FROM validator_deps"); + expect(dockerfile).not.toContain("npm ci"); + expect(dockerfile).not.toContain("npm prune"); + }); + + it("bounds checksum-pinned runtime source downloads instead of granting remote ADD the full image-build deadline", () => { + expect(dockerfile).toContain("curl --fail --location --proto '=https' --proto-redir '=https' --tlsv1.2"); + expect(dockerfile).toContain("--connect-timeout 20"); + expect(dockerfile).toContain("--max-time 180"); + 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("NODE_SOURCE_SHA256"); + expect(dockerfile).toContain("OPENSSL_SOURCE_SHA256"); + expect(dockerfile).toContain("sha256sum"); + expect(dockerfile).not.toContain("ADD --checksum"); + }); + + it("bounds checksum-pinned scanner asset downloads before granting them image verification authority", () => { + const scannerStepStart = imageWorkflow.indexOf("- name: Install checksum-pinned Syft and Grype"); + const scannerStepEnd = imageWorkflow.indexOf( + "- name: Set up exact dependency materialization toolchain", + scannerStepStart, + ); + const scannerStep = imageWorkflow.slice(scannerStepStart, scannerStepEnd); + + expect(scannerStepStart).toBeGreaterThanOrEqual(0); + expect(scannerStepEnd).toBeGreaterThan(scannerStepStart); + expect(scannerStep).toContain("timeout --signal=TERM --kill-after=30s 5m"); + expect(scannerStep).toContain("--proto '=https'"); + expect(scannerStep).toContain("--proto-redir '=https'"); + expect(scannerStep).toContain("--connect-timeout 20"); + expect(scannerStep).toContain("--max-time 180"); + expect(scannerStep).toContain("--retry-max-time 90"); + expect(scannerStep).toContain("sha256sum --check --strict"); + }); + + it("keeps the freshly installed Node executable on PATH while the Node make install installs npm", () => { const nodeBuilderStage = dockerfile.slice( dockerfile.indexOf("FROM alpine:3.24.1"), - dockerfile.indexOf("FROM node_builder AS dependencies"), + dockerfile.indexOf("FROM scratch AS runtime"), ); + const nodeBuild = nodeBuilderStage.slice(nodeBuilderStage.indexOf("WORKDIR /usr/src/node")); expect(nodeBuilderStage).toContain('ENV PATH="/opt/node/bin:${PATH}"'); + expect(nodeBuild).toContain("./configure"); + expect(nodeBuild).toContain("&& make install"); expect(nodeBuilderStage.indexOf('ENV PATH="/opt/node/bin:${PATH}"')).toBeLessThan( - nodeBuilderStage.indexOf("&& make install"), + nodeBuilderStage.indexOf("WORKDIR /usr/src/node"), ); }); it("installs the static GCC runtime archive before requesting a fully static Node binary", () => { const nodeBuilderStage = dockerfile.slice( dockerfile.indexOf("FROM alpine:3.24.1"), - dockerfile.indexOf("FROM node_builder AS dependencies"), + dockerfile.indexOf("FROM scratch AS runtime"), ); expect(nodeBuilderStage).toContain("--fully-static"); @@ -46,7 +100,7 @@ describe("patch-validator exact-toolchain image build regression", () => { it("keeps the Unicode property-escape smoke probe intact across the shell boundary", () => { const nodeBuilderStage = dockerfile.slice( dockerfile.indexOf("FROM alpine:3.24.1"), - dockerfile.indexOf("FROM node_builder AS dependencies"), + dockerfile.indexOf("FROM scratch AS runtime"), ); const unicodePropertyProbe = `--eval='/\\p{ID_Continue}/u.test("a")'`; @@ -60,4 +114,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 62d5ef88e..53be0d0e9 100644 --- a/test/patch-validator-image-contract.test.ts +++ b/test/patch-validator-image-contract.test.ts @@ -5,6 +5,10 @@ import { describe, expect, it } from "vitest"; const repositoryRoot = resolve(import.meta.dirname, ".."); const dockerfilePath = resolve(repositoryRoot, "Dockerfile.patch-validator"); +const imageWorkflowPath = resolve( + repositoryRoot, + ".github/workflows/patch-validator-image.yml", +); const packageJsonPath = resolve(repositoryRoot, "package.json"); const ignorefilePath = resolve( repositoryRoot, @@ -23,6 +27,7 @@ function readRequiredFile(path: string): string { 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 imageWorkflow = readRequiredFile(imageWorkflowPath); const packageJson = JSON.parse(readRequiredFile(packageJsonPath)) as Record; const ignorefile = readRequiredFile(ignorefilePath); const fromLines = dockerfile @@ -32,10 +37,10 @@ describe("patch-validator image contract", () => { expect(fromLines).toEqual([ "FROM alpine:3.24.1@sha256:79ff19e9084a00eece421b2523fb93e22d730e2c0e525905de047e848e56d95f AS node_builder", - "FROM node_builder AS dependencies", "FROM scratch AS runtime", ]); expect(fromLines[0]).toMatch(/@sha256:[0-9a-f]{64}(?:\s|$)/); + expect(dockerfile).not.toContain("FROM validator_deps"); expect(dockerfile).toContain("ARG NODE_VERSION=24.19.0"); expect(dockerfile).toContain( @@ -52,16 +57,22 @@ describe("patch-validator image contract", () => { 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( + expect(dockerfile).not.toContain("npm ci"); + expect(dockerfile).not.toContain("npm prune"); + 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(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"); + expect(imageWorkflow).toContain("node_modules/typescript/bin/tsc"); + expect(imageWorkflow).toContain("node_modules/vitest/vitest.mjs"); + 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}"', + ); - const runtimeStage = dockerfile.slice(dockerfile.indexOf(fromLines[2])); + const runtimeStage = dockerfile.slice(dockerfile.indexOf(fromLines[1])); expect(runtimeStage).not.toMatch(/^RUN\b/m); expect(runtimeStage).not.toMatch(/^ADD\b/m); expect(runtimeStage).not.toContain("COPY . "); @@ -79,7 +90,7 @@ describe("patch-validator image contract", () => { "COPY --from=node_builder --chown=65532:65532 --chmod=0444 /usr/src/node/LICENSE /licenses/node/LICENSE", ); expect(runtimeStage).toContain( - "COPY --from=dependencies --chown=65532:65532 /build/node_modules /opt/noema/node_modules", + "COPY --from=validator_deps --chown=65532:65532 /node_modules /opt/noema/node_modules", ); expect(runtimeStage).toContain( "COPY --chown=65532:65532 patch-validator/entrypoint.mjs /opt/noema/entrypoint.mjs", @@ -137,21 +148,24 @@ describe("patch-validator image contract", () => { it("removes Worker-only tooling and native addons before copying runtime dependencies", () => { const dockerfile = readRequiredFile(dockerfilePath); + const imageWorkflow = readRequiredFile(imageWorkflowPath); - expect(dockerfile).toContain("npm_config_os=wasip1-threads"); - expect(dockerfile).toContain("npm_config_cpu=wasm32"); - expect(dockerfile).toContain( + expect(dockerfile).not.toContain("npm_config_os=wasip1-threads"); + expect(dockerfile).not.toContain("npm_config_cpu=wasm32"); + expect(imageWorkflow).toContain("npm_config_os=wasip1-threads"); + expect(imageWorkflow).toContain("npm_config_cpu=wasm32"); + expect(imageWorkflow).toContain( "npm pkg delete devDependencies.@cloudflare/workers-types devDependencies.wrangler", ); - expect(dockerfile).toContain( + expect(imageWorkflow).toContain( "npm prune --include=optional --ignore-scripts --no-audit --no-fund", ); - expect(dockerfile).toContain( + expect(imageWorkflow).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"); + expect(imageWorkflow).toContain("test ! -e node_modules/@cloudflare/workers-types"); + expect(imageWorkflow).toContain("test ! -e node_modules/wrangler"); + expect(imageWorkflow).toContain("test ! -e node_modules/workerd"); + expect(imageWorkflow).toContain("test ! -e node_modules/miniflare"); }); }); diff --git a/test/patch-validator-openssl-security-floor.test.ts b/test/patch-validator-openssl-security-floor.test.ts new file mode 100644 index 000000000..35604eeb3 --- /dev/null +++ b/test/patch-validator-openssl-security-floor.test.ts @@ -0,0 +1,27 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +const dockerfile = readFileSync("Dockerfile.patch-validator", "utf8"); + +const OPENSSL_3_5_8_SHA256 = + "a8f84a39918ec6415ce765d9b429d313ba97b8143169c172e734b9514464f5b2"; + +describe("patch-validator static OpenSSL security floor", () => { + it("builds the static Node runtime against checksum-pinned OpenSSL 3.5.8 or newer instead of the vulnerable Node-bundled 3.5.7", () => { + expect(dockerfile).toContain("ARG OPENSSL_VERSION=3.5.8"); + expect(dockerfile).toContain( + `ARG OPENSSL_SOURCE_SHA256=${OPENSSL_3_5_8_SHA256}`, + ); + expect(dockerfile).toContain( + "https://github.com/openssl/openssl/releases/download/openssl-${OPENSSL_VERSION}/openssl-${OPENSSL_VERSION}.tar.gz", + ); + expect(dockerfile).toContain("--shared-openssl"); + expect(dockerfile).toContain("--shared-openssl-includes=/opt/openssl/include"); + expect(dockerfile).toContain("--shared-openssl-libpath=/opt/openssl/lib"); + expect(dockerfile).toContain( + "process.versions.openssl !== process.env.OPENSSL_VERSION", + ); + expect(dockerfile).not.toContain("ARG OPENSSL_VERSION=3.5.7"); + }); +}); diff --git a/test/patch-validator-static-runtime.test.ts b/test/patch-validator-static-runtime.test.ts index 8ba50b74a..429031b20 100644 --- a/test/patch-validator-static-runtime.test.ts +++ b/test/patch-validator-static-runtime.test.ts @@ -20,8 +20,14 @@ describe("patch-validator static scratch runtime", () => { 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", + '"https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}.tar.xz"', ); + expect(dockerfile).toContain('"$NODE_SOURCE_SHA256"'); + expect(dockerfile).toContain("sha256sum --check --strict"); + expect(dockerfile).toContain("timeout --signal=TERM --kill-after=30s 5m"); + expect(dockerfile).toContain("--connect-timeout 20"); + expect(dockerfile).toContain("--max-time 180"); + expect(dockerfile).not.toContain("ADD --checksum=sha256:${NODE_SOURCE_SHA256}"); expect(dockerfile).toContain("--fully-static"); expect(dockerfile).not.toContain("--with-intl=none"); expect(dockerfile).toContain("--with-intl=small-icu"); diff --git a/test/workflow-concurrency-policy.test.ts b/test/workflow-concurrency-policy.test.ts index 68bda8b6b..ce42f2c74 100644 --- a/test/workflow-concurrency-policy.test.ts +++ b/test/workflow-concurrency-policy.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; const workflowPaths = [ ["ci", ".github/workflows/ci.yml"], ["reviewer-ci", ".github/workflows/reviewer-ci.yml"], + ["patch-validator-image", ".github/workflows/patch-validator-image.yml"], ] as const; describe("pull-request workflow execution policy", () => {