From 83c3049c0c4407de5f72db06f1b478c952ea32d4 Mon Sep 17 00:00:00 2001 From: eli Date: Fri, 11 Sep 2026 09:41:10 -0500 Subject: [PATCH 01/10] fix: clear all 12 CVEs from customer scan of published 0.9.14 (PER-15358) A customer CPE scan of permitio/pdp-v2:0.9.14 on 2026-09-09 reported 21 findings / 12 unique CVEs, all OS packages, flagged 2 CRITICAL + 10 HIGH. Verified: the published tag really does contain them, but NOT because of a source defect. 0.9.14 was built 2026-08-04 and froze libcrypto3/libssl3 3.5.7-r0 and libuuid 2.41.4-r0. Alpine 3.23 has since published openssl 3.5.8-r0 and util-linux 2.41.6-r1. A rebuild of the UNCHANGED Dockerfile resolves both and scans clean, so this is pure base-image drift on a tag that was never rebuilt. All nine OpenSSL CVEs (CVE-2026-14456, -14457, -18798, -54874, -63072, -63073, -63075, -63076, -75803) are fixed in OpenSSL 3.5.8; the three util-linux ones in 2.41.6. Worth noting for the customer reply: upstream OpenSSL rates six of these Low and three Moderate - NONE is Critical. Both CVEs the scanner called CRITICAL (CVE-2026-63073 CMP sender-DN format string, CVE-2026-75803 AEAD tag bypass on empty ciphertext via EVP_Cipher) are OpenSSL-severity Low, and neither code path is one the PDP drives - it runs no CMP client and calls no raw EVP_Cipher. While verifying, found something the customer's OS-only scan did NOT report and which a rebuild would only have fixed by luck: GitPython 3.1.57 carries CVE-2026-78676 - a genuine CRITICAL (CVSS 3.1 9.8) argument-injection RCE via git-config re-serialization into core.hooksPath. GitPython is transitive via opal-common (`gitpython<4,>=3.1.32`) and nothing else bounded it, so the unpinned build took whatever was current. Now floored at >=3.1.59,<4, which clears that CRITICAL plus six HIGHs. Changes: - requirements.txt: floor GitPython >=3.1.59,<4 (1 CRITICAL + 6 HIGH). - release.yml: `no-cache-filter: main,opa_build` on both build steps. The GHA cache key is instruction text + parent layer, so a release cut weeks later could replay the old `apk upgrade`/`pip install` layers and re-ship the exact packages a customer just flagged. Rust stages still cache normally. - vex.json v4 -> v5: drop the CVE-2026-15308 waiver. CPython backported the html.parser fix into 3.13.15, which the floating base tag now resolves - the waiver itself said to remove it at this point. - Dockerfile: record why a correct Dockerfile still shipped stale packages. Verified by building the exact base + apk + pip layers with no cache: libcrypto3/libssl3 3.5.8-r0, libuuid 2.41.6-r1, Python 3.13.15, GitPython 3.1.62, cryptography 50.0.1 trivy os+library CRITICAL/HIGH: 12 customer CVEs -> 0 remaining. Only 3 HIGHs remain, all pre-existing and already VEX-waived as unreachable behind OPAL's caps (starlette CVE-2026-48818/-54283, ddtrace CVE-2026-50271). Co-Authored-By: Claude Opus 5 (1M context) --- .docker/scout/pdp-v2.vex.json | 20 ++------------------ .github/workflows/release.yml | 11 +++++++++++ Dockerfile | 35 +++++++++++++++++++++++++++++------ requirements.txt | 16 ++++++++++++++++ 4 files changed, 58 insertions(+), 24 deletions(-) diff --git a/.docker/scout/pdp-v2.vex.json b/.docker/scout/pdp-v2.vex.json index dd6367f2..e446a745 100644 --- a/.docker/scout/pdp-v2.vex.json +++ b/.docker/scout/pdp-v2.vex.json @@ -2,8 +2,8 @@ "@context": "https://openvex.dev/ns/v0.2.0", "@id": "https://openvex.dev/docs/permitio-pdp/pdp-v2-cve-waivers", "author": "Permit.io", - "timestamp": "2026-07-29T00:00:00Z", - "version": 4, + "timestamp": "2026-09-11T00:00:00Z", + "version": 5, "statements": [ { "vulnerability": { @@ -69,22 +69,6 @@ "justification": "vulnerable_code_not_in_execute_path", "impact_statement": "CVE-2026-48817 is an unsafe-reflection issue in starlette.endpoints.HTTPEndpoint request dispatch: the HTTP method is lowercased and resolved with getattr against the endpoint instance without restricting the lookup to known HTTP verbs, letting an attacker invoke internal helper methods that bypass authorization. The PDP defines no HTTPEndpoint or WebSocketEndpoint subclass anywhere in the codebase - every route is registered through FastAPI APIRouter decorators with explicit HTTP methods - so the vulnerable dispatch class is never instantiated or reached. The fix lands only in starlette >= 1.1.0, but opal-common/opal-client 0.9.6 cap starlette<1, so the PDP is pinned to 0.50.0 (see requirements.txt). This waiver is temporary and must be removed once OPAL relaxes its starlette<1 bound and starlette is upgraded to >= 1.3.1. Tracked under PER-15358." }, - { - "vulnerability": { - "name": "CVE-2026-15308" - }, - "products": [ - { - "@id": "pkg:docker/permitio/pdp-v2@next", - "subcomponents": [ - { "@id": "pkg:generic/python" } - ] - } - ], - "status": "not_affected", - "justification": "vulnerable_code_not_in_execute_path", - "impact_statement": "CVE-2026-15308 is a CPU denial-of-service in CPython's incremental HTML parser: html.parser.HTMLParser degrades to quadratic complexity on repeated unterminated markup declarations, so parsing attacker-controlled HTML can exhaust CPU. The PDP never parses HTML. Nothing imports html.parser - neither the PDP's own code (horizon/, pdp-server/) nor any package installed in the image; this was verified by scanning every .py file under site-packages, so the vulnerable class is never instantiated. Unlike the other CPython findings in this scan (CVE-2026-6019 and CVE-2026-7210, both cleared by moving the base image to Python 3.13.14), this one cannot be upgraded away: PSF fixed it only in 3.15.0b4 and NVD's CPE range is < 3.15.0, so no released Python satisfies it and every Python-based image in the world matches today. The Dockerfile intentionally floats the base image patch version (python:3.13-alpine3.23), so when 3.13.15 ships with the backport a rebuild will pick it up automatically. Remove this waiver at that point. Tracked under PER-15358." - }, { "vulnerability": { "name": "CVE-2026-50271" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 96639ffe..e5745fab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,6 +63,14 @@ jobs: tags: permitio/pdp-v2:${{ github.event.release.tag_name }} cache-from: type=gha cache-to: type=gha,mode=max + # Never serve the OS/dependency layers from the GHA cache. Their cache key is the + # instruction text plus the parent layer, so a release cut weeks after the last + # build replays the OLD `apk update && apk upgrade` + `pip install` results and + # re-ships packages that upstream has since patched. That is exactly how + # permitio/pdp-v2:0.9.14 came to carry openssl 3.5.7-r0 into a customer scan five + # weeks after Alpine shipped 3.5.8-r0 (PER-15358). The expensive Rust stages + # (rust_chef/rust_planner/rust_builder) still cache normally. + no-cache-filter: main,opa_build - name: Build and push PDP image - (official release) if: "!github.event.release.prerelease" @@ -74,6 +82,9 @@ jobs: tags: permitio/pdp-v2:${{ github.event.release.tag_name }},permitio/pdp-v2:latest cache-from: type=gha cache-to: type=gha,mode=max + # See the pre-release step above: forces `apk upgrade` and `pip install` to + # re-resolve so a release can never republish a stale OS/dependency set. + no-cache-filter: main,opa_build update-pdp-api-ecs-service: needs: build-and-push-pdp diff --git a/Dockerfile b/Dockerfile index 8f4a7b2f..87e7e531 100644 --- a/Dockerfile +++ b/Dockerfile @@ -104,14 +104,15 @@ RUN --mount=type=cache,target=/go/pkg/mod \ # < 3.11.4, so 3.13 is out of it # PSF fixed these only on the 3.13/3.14/3.15 branches - there is no 3.10/3.11/3.12 backport - # so the vulnerable code really was present in 3.10.20 and an upgrade was the only fix. -# CVE-2026-15308 (html.parser DoS) is NOT cleared by this bump: it is patched only in -# 3.15.0b4 and NVD's range is < 3.15.0, so no released Python satisfies it. It is waived in -# .docker/scout/pdp-v2.vex.json as unreachable (nothing in the image imports html.parser). +# CVE-2026-15308 (html.parser CPU-exhaustion DoS) is now cleared too: it was waived here as +# unreachable while it was patched only in 3.15.0b4, but CPython backported the fix and it +# landed in 3.13.15 (also 3.14.7). The base tag floats, so the current build resolves +# 3.13.15 and the waiver has been REMOVED from .docker/scout/pdp-v2.vex.json (v4 -> v5). +# Do not drop below 3.13.15 - that is the floor for every fix named above. # # The patch version floats deliberately (see the previous python:3.10-alpine3.22 base and -# the rebuild-picks-it-up posture in PER-15532): when 3.13.15 ships it will clear -# CVE-2026-15308 automatically and that waiver can then be dropped. Do not drop below -# 3.13.14 - that is the floor for the fixes above. +# the rebuild-picks-it-up posture in PER-15532). Note what that posture costs if nothing +# ever rebuilds: see the apk note below. # # Python 3.10 also reaches end of life in October 2026, so this move was due regardless. FROM python:3.13-alpine3.23 AS main @@ -129,6 +130,28 @@ RUN mkdir -p /app/backup && chmod -R 777 /app/backup # Build deps (build-base, *-dev) are installed and removed in the pip install # layer to avoid persisting binutils CVEs (CVE-2025-69649, CVE-2025-69650). # +# `apk upgrade` here is the ONLY thing that keeps the OS package set current, and it is +# only as fresh as the build that ran it. permitio/pdp-v2:0.9.14 was built 2026-08-04 and +# pinned libcrypto3/libssl3 3.5.7-r0 + libuuid 2.41.4-r0 at that moment. Alpine 3.23 later +# published openssl 3.5.8-r0 and util-linux 2.41.6-r1, so by 2026-09-09 a customer CPE scan +# of the UNCHANGED published tag reported 12 CVEs / 21 findings - nine OpenSSL +# (CVE-2026-14456, CVE-2026-14457, CVE-2026-18798, CVE-2026-54874, CVE-2026-63072, +# CVE-2026-63073, CVE-2026-63075, CVE-2026-63076, CVE-2026-75803) and three util-linux. +# Not one of them was a source defect: this Dockerfile was already correct, and a rebuild +# with no edits produces 0 findings. The image was simply never rebuilt. See PER-15358. +# +# Two consequences, both load-bearing: +# 1. Release builds MUST NOT serve this layer from cache. release.yml uses +# `cache-from: type=gha`, and the cache key is this instruction text plus the parent +# layer - so a release cut months later could replay the 2026-08-04 apk layer and +# re-ship the exact packages a customer just flagged. release.yml therefore passes +# `no-cache-filter: main,opa_build` to force both to re-resolve on every release. +# 2. A tag that is never rebuilt rots on its own, and no build-time gate can catch that: +# the docker-scout gate in tests.yml runs only on pull_request, so it scanned this +# image in July and could not possibly have seen CVEs disclosed in September. +# Detecting drift therefore requires re-scanning the PUBLISHED tags on a schedule, +# which lives in its own workflow rather than here. +# # The PDP never uses SQLite, but its FTS5/zipfile CVEs (CVE-2026-11822, # CVE-2026-11824, CVE-2025-70873) are still reported against sqlite-libs, which # the official python:alpine image pins via the .python-rundeps virtual package. diff --git a/requirements.txt b/requirements.txt index 5f57b852..8b937225 100644 --- a/requirements.txt +++ b/requirements.txt @@ -76,5 +76,21 @@ starlette==0.50.0 # pdp-tester run on the result - do not let a rebuild choose a major on its own. See # PER-15358. websockets==17.0 +# GitPython is a TRANSITIVE dependency of opal-common (`gitpython<4,>=3.1.32`) and nothing +# else bounds it, so with no lockfile the image build resolved whatever was current at build +# time. permitio/pdp-v2:0.9.14 (built 2026-08-04) therefore shipped 3.1.57, which carries +# CVE-2026-78676 - a CRITICAL (CVSS 3.1 9.8 / CVSS 4.0 9.3) argument-injection RCE: unsafe +# re-serialization of a multi-line git-config value promotes a dormant quoted value into a +# live directive such as core.hooksPath, giving arbitrary code execution through git's hook +# mechanism on any unrelated GitConfigParser write. 3.1.59 also closes CVE-2026-78675 and +# CVE-2026-78677, and 3.1.58 closes CVE-2026-76218 / CVE-2026-76219 / CVE-2026-76220 / +# CVE-2026-76222 + GHSA-jm78-9fvv-mhgr (all HIGH) - seven advisories cleared by this floor. +# +# A rebuild alone would have picked up a fixed GitPython by chance, since the constraint was +# open. That is exactly the failure mode the `websockets` note above describes, so the floor +# is stated here to make the fix resolver-enforced rather than incidental. The <4 cap matches +# opal-common's own bound and keeps a major out of a build that has no lockfile. Drop this +# pin only once opal-common itself floors gitpython >= 3.1.59. See PER-15358. +GitPython>=3.1.59,<4 opal-common==0.9.6 opal-client==0.9.6 From 81056d6b93ed19d61a01b32dd36dbe4e99d9d29c Mon Sep 17 00:00:00 2001 From: eli Date: Fri, 11 Sep 2026 10:22:03 -0500 Subject: [PATCH 02/10] fix: waive the two x/crypto/ssh CVEs that Go 1.25 cannot clear (PER-15358) The docker-scout gate on this PR fails on 5 HIGH that do NOT come from this repo: /app/bin/opa is compiled from permitio/permit-opa, so its Go module CVEs land in our scans. The gate has been red on them since 2026-09-06 - #333 and #336 both went red and were merged anyway - so this is pre-existing drift, not a regression from this PR. It matters now because the companion PR makes the gate block releases. Split by what is actually fixable: permitio/permit-opa#49 bumps grpc 1.80.0 -> 1.83.2 and x/crypto 0.53.0 -> 0.55.0, clearing 3 of the 5 (CVE-2026-84304, CVE-2026-84445, CVE-2026-56854). Note 1.83.1 is NOT enough for CVE-2026-84445 - its affected range covers >=1.83.0 <1.83.2 - which only showed up by scanning the rebuilt binary. The remaining two, CVE-2026-78662 and CVE-2026-56855, are fixed only in x/crypto >= 0.56.0, which declares `go >= 1.26.0`. Go 1.26 is not released (there is no golang:1.26 image) and this image builds on golang:1.25-bookworm, so that upgrade does not exist yet. Waived here as not_affected / vulnerable_code_not_in_execute_path, on evidence rather than assertion: both live in golang.org/x/crypto/ssh, and `go list -deps ./cmd/opa` in permit-opa shows x/crypto/ssh is NOT among the linked x/crypto packages (cryptobyte, chacha20(+poly1305), pbkdf2, curve25519, blake2b, salsa20, nacl/box, nacl/secretbox, hkdf, sha3). The SSH transport is never linked into the binary, and the PDP speaks no SSH of its own. Each statement names the removal gate: drop it when a Go 1.26 toolchain lands. vex.json v5 -> v6. Trivy does not currently flag these two at module level, so the .trivyignore.yaml counterparts are added in the companion PR to keep the two waiver files saying the same thing. NOTE ON CI: this PR cannot go green until permitio/permit-opa#49 merges, because tests.yml clones permit-opa@main at build time to produce custom_opa.tar.gz. Once #49 is in, the two grpc CVEs disappear from the build and these two waivers cover the rest. Verified locally: trivy on the rebuilt OPA binary with grpc 1.83.2 + x/crypto 0.55.0 reports 0 CRITICAL/HIGH, down from 5. Co-Authored-By: Claude Opus 5 (1M context) --- .docker/scout/pdp-v2.vex.json | 36 +++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/.docker/scout/pdp-v2.vex.json b/.docker/scout/pdp-v2.vex.json index e446a745..2e1c5b84 100644 --- a/.docker/scout/pdp-v2.vex.json +++ b/.docker/scout/pdp-v2.vex.json @@ -2,9 +2,41 @@ "@context": "https://openvex.dev/ns/v0.2.0", "@id": "https://openvex.dev/docs/permitio-pdp/pdp-v2-cve-waivers", "author": "Permit.io", - "timestamp": "2026-09-11T00:00:00Z", - "version": 5, + "timestamp": "2026-09-11T12:00:00Z", + "version": 6, "statements": [ + { + "vulnerability": { + "name": "CVE-2026-78662" + }, + "products": [ + { + "@id": "pkg:docker/permitio/pdp-v2@next", + "subcomponents": [ + { "@id": "pkg:golang/golang.org/x/crypto@v0.55.0" } + ] + } + ], + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "CVE-2026-78662 is a connection-deadlock DoS - a channel registered in the mux's chanList before it is established lets a malicious peer flood incomingRequests in golang.org/x/crypto/ssh. It reaches the pdp-v2 image only because /app/bin/opa is compiled from permitio/permit-opa, which carries x/crypto as an INDIRECT dependency. The vulnerable package is not present in the binary: `go list -deps ./cmd/opa` in permit-opa resolves exactly these x/crypto packages - cryptobyte(+asn1), chacha20, chacha20poly1305, internal/poly1305, internal/alias, pbkdf2, curve25519, blake2b, salsa20/salsa, nacl/box, nacl/secretbox, hkdf, sha3 - and golang.org/x/crypto/ssh is absent from that list, so the SSH transport code is never linked, let alone reachable. The PDP additionally speaks no SSH on any code path of its own. Both CVEs are fixed only in x/crypto >= 0.56.0, which declares `go >= 1.26.0`; Go 1.26 is not released (no golang:1.26 base image exists) and this image builds on golang:1.25-bookworm, so the upgrade is not available yet. permitio/permit-opa#49 raises the floor as far as Go 1.25 allows (0.55.0, which clears CVE-2026-56854) and bumps grpc to 1.83.2. Remove this waiver and move x/crypto to >= 0.56.0 once a Go 1.26 toolchain is available. Tracked under PER-15358." + }, + { + "vulnerability": { + "name": "CVE-2026-56855" + }, + "products": [ + { + "@id": "pkg:docker/permitio/pdp-v2@next", + "subcomponents": [ + { "@id": "pkg:golang/golang.org/x/crypto@v0.55.0" } + ] + } + ], + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "CVE-2026-56855 is a connection-deadlock DoS - crafted post-establishment channel messages are buffered instead of being treated as a protocol error in golang.org/x/crypto/ssh. It reaches the pdp-v2 image only because /app/bin/opa is compiled from permitio/permit-opa, which carries x/crypto as an INDIRECT dependency. The vulnerable package is not present in the binary: `go list -deps ./cmd/opa` in permit-opa resolves exactly these x/crypto packages - cryptobyte(+asn1), chacha20, chacha20poly1305, internal/poly1305, internal/alias, pbkdf2, curve25519, blake2b, salsa20/salsa, nacl/box, nacl/secretbox, hkdf, sha3 - and golang.org/x/crypto/ssh is absent from that list, so the SSH transport code is never linked, let alone reachable. The PDP additionally speaks no SSH on any code path of its own. Both CVEs are fixed only in x/crypto >= 0.56.0, which declares `go >= 1.26.0`; Go 1.26 is not released (no golang:1.26 base image exists) and this image builds on golang:1.25-bookworm, so the upgrade is not available yet. permitio/permit-opa#49 raises the floor as far as Go 1.25 allows (0.55.0, which clears CVE-2026-56854) and bumps grpc to 1.83.2. Remove this waiver and move x/crypto to >= 0.56.0 once a Go 1.26 toolchain is available. Tracked under PER-15358." + }, { "vulnerability": { "name": "CVE-2026-48818" From 824c7839feea4a8142902bd1f41a76a8fa16182b Mon Sep 17 00:00:00 2001 From: eli Date: Fri, 11 Sep 2026 10:43:51 -0500 Subject: [PATCH 03/10] fix: bind the x/crypto waivers to the PURLs scout actually emits (PER-15358) The two waivers added in the previous commit suppressed nothing. The gate run on 81056d6 still reported CVE-2026-78662 and CVE-2026-56855, and the reason is the subcomponent PURL: docker scout matches VEX statements on the FULL PURL string, and the one I wrote matched neither the version in the image nor scout's spelling of it. Scout's own output names the component pkg:golang/golang.org/x/crypto@0.53.0 while the waiver said `@v0.55.0`. Two independent mismatches: 1. NO `v` PREFIX. Go module versions conventionally carry one and the purl spec permits it, but scout emits Go PURLs without it. Confirmed against the waivers that DO work in the same run - pkg:pypi/starlette@0.50.0 and pkg:pypi/ddtrace@3.19.8 are suppressed because they match byte-for-byte. `@v0.55.0` bound to nothing at all, and would have kept binding to nothing even after permit-opa#49 merged - a silent tripwire that would have looked like the waiver "not working" weeks later. 2. Wrong version. The build resolves x/crypto 0.53.0 until permit-opa#49 merges and 0.55.0 after, so a statement naming one version stops suppressing the moment the other is in play. Both PURLs are therefore listed as subcomponents, which is the sanctioned pattern for one CVE spanning several package versions. Deliberately NOT solved by dropping `subcomponents` altogether: per Docker's VEX guide that broadens the statement to the entire image, which would suppress these CVEs wherever they appeared rather than only in the OPA binary. Scope is the point of the waiver. Both impact statements now record the `v`-prefix trap and why two versions are listed, so the next person editing this file does not rediscover it. vex.json v6 -> v7. Verified by scanning the gate log rather than assuming: docker scout needs a login this environment does not have, so the PURL format was taken from scout's own printed output and cross-checked against the pypi waivers that already suppress correctly in the same run. This does not turn the gate green on its own - the 2 grpc CVEs and CVE-2026-56854 still need permit-opa#49. It makes these two waivers actually bind once it lands. Co-Authored-By: Claude Opus 5 (1M context) --- .docker/scout/pdp-v2.vex.json | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.docker/scout/pdp-v2.vex.json b/.docker/scout/pdp-v2.vex.json index 2e1c5b84..ed6834f7 100644 --- a/.docker/scout/pdp-v2.vex.json +++ b/.docker/scout/pdp-v2.vex.json @@ -3,7 +3,7 @@ "@id": "https://openvex.dev/docs/permitio-pdp/pdp-v2-cve-waivers", "author": "Permit.io", "timestamp": "2026-09-11T12:00:00Z", - "version": 6, + "version": 7, "statements": [ { "vulnerability": { @@ -13,13 +13,14 @@ { "@id": "pkg:docker/permitio/pdp-v2@next", "subcomponents": [ - { "@id": "pkg:golang/golang.org/x/crypto@v0.55.0" } + { "@id": "pkg:golang/golang.org/x/crypto@0.53.0" }, + { "@id": "pkg:golang/golang.org/x/crypto@0.55.0" } ] } ], "status": "not_affected", "justification": "vulnerable_code_not_in_execute_path", - "impact_statement": "CVE-2026-78662 is a connection-deadlock DoS - a channel registered in the mux's chanList before it is established lets a malicious peer flood incomingRequests in golang.org/x/crypto/ssh. It reaches the pdp-v2 image only because /app/bin/opa is compiled from permitio/permit-opa, which carries x/crypto as an INDIRECT dependency. The vulnerable package is not present in the binary: `go list -deps ./cmd/opa` in permit-opa resolves exactly these x/crypto packages - cryptobyte(+asn1), chacha20, chacha20poly1305, internal/poly1305, internal/alias, pbkdf2, curve25519, blake2b, salsa20/salsa, nacl/box, nacl/secretbox, hkdf, sha3 - and golang.org/x/crypto/ssh is absent from that list, so the SSH transport code is never linked, let alone reachable. The PDP additionally speaks no SSH on any code path of its own. Both CVEs are fixed only in x/crypto >= 0.56.0, which declares `go >= 1.26.0`; Go 1.26 is not released (no golang:1.26 base image exists) and this image builds on golang:1.25-bookworm, so the upgrade is not available yet. permitio/permit-opa#49 raises the floor as far as Go 1.25 allows (0.55.0, which clears CVE-2026-56854) and bumps grpc to 1.83.2. Remove this waiver and move x/crypto to >= 0.56.0 once a Go 1.26 toolchain is available. Tracked under PER-15358." + "impact_statement": "CVE-2026-78662 is a connection-deadlock DoS - a channel registered in the mux's chanList before it is established lets a malicious peer flood incomingRequests in golang.org/x/crypto/ssh. It reaches the pdp-v2 image only because /app/bin/opa is compiled from permitio/permit-opa, which carries x/crypto as an INDIRECT dependency. The vulnerable package is not present in the binary: `go list -deps ./cmd/opa` in permit-opa resolves exactly these x/crypto packages - cryptobyte(+asn1), chacha20, chacha20poly1305, internal/poly1305, internal/alias, pbkdf2, curve25519, blake2b, salsa20/salsa, nacl/box, nacl/secretbox, hkdf, sha3 - and golang.org/x/crypto/ssh is absent from that list, so the SSH transport code is never linked, let alone reachable. The PDP additionally speaks no SSH on any code path of its own. Both CVEs are fixed only in x/crypto >= 0.56.0, which declares `go >= 1.26.0`; Go 1.26 is not released (no golang:1.26 base image exists) and this image builds on golang:1.25-bookworm, so the upgrade is not available yet. permitio/permit-opa#49 raises the floor as far as Go 1.25 allows (0.55.0, which clears CVE-2026-56854) and bumps grpc to 1.83.2. Two subcomponent PURLs are listed on purpose: 0.53.0 is what the build resolves until permit-opa#49 merges and 0.55.0 is what it resolves after, and docker scout matches VEX subcomponents on the FULL PURL string, so a statement naming only one of them silently stops suppressing the moment the version moves. Note also that scout emits Go PURLs WITHOUT the conventional `v` prefix (pkg:golang/golang.org/x/crypto@0.53.0, not @v0.53.0) - the first version of this waiver used @v0.55.0 and bound to nothing at all. Remove this waiver and move x/crypto to >= 0.56.0 once a Go 1.26 toolchain is available. Tracked under PER-15358." }, { "vulnerability": { @@ -29,13 +30,14 @@ { "@id": "pkg:docker/permitio/pdp-v2@next", "subcomponents": [ - { "@id": "pkg:golang/golang.org/x/crypto@v0.55.0" } + { "@id": "pkg:golang/golang.org/x/crypto@0.53.0" }, + { "@id": "pkg:golang/golang.org/x/crypto@0.55.0" } ] } ], "status": "not_affected", "justification": "vulnerable_code_not_in_execute_path", - "impact_statement": "CVE-2026-56855 is a connection-deadlock DoS - crafted post-establishment channel messages are buffered instead of being treated as a protocol error in golang.org/x/crypto/ssh. It reaches the pdp-v2 image only because /app/bin/opa is compiled from permitio/permit-opa, which carries x/crypto as an INDIRECT dependency. The vulnerable package is not present in the binary: `go list -deps ./cmd/opa` in permit-opa resolves exactly these x/crypto packages - cryptobyte(+asn1), chacha20, chacha20poly1305, internal/poly1305, internal/alias, pbkdf2, curve25519, blake2b, salsa20/salsa, nacl/box, nacl/secretbox, hkdf, sha3 - and golang.org/x/crypto/ssh is absent from that list, so the SSH transport code is never linked, let alone reachable. The PDP additionally speaks no SSH on any code path of its own. Both CVEs are fixed only in x/crypto >= 0.56.0, which declares `go >= 1.26.0`; Go 1.26 is not released (no golang:1.26 base image exists) and this image builds on golang:1.25-bookworm, so the upgrade is not available yet. permitio/permit-opa#49 raises the floor as far as Go 1.25 allows (0.55.0, which clears CVE-2026-56854) and bumps grpc to 1.83.2. Remove this waiver and move x/crypto to >= 0.56.0 once a Go 1.26 toolchain is available. Tracked under PER-15358." + "impact_statement": "CVE-2026-56855 is a connection-deadlock DoS - crafted post-establishment channel messages are buffered instead of being treated as a protocol error in golang.org/x/crypto/ssh. It reaches the pdp-v2 image only because /app/bin/opa is compiled from permitio/permit-opa, which carries x/crypto as an INDIRECT dependency. The vulnerable package is not present in the binary: `go list -deps ./cmd/opa` in permit-opa resolves exactly these x/crypto packages - cryptobyte(+asn1), chacha20, chacha20poly1305, internal/poly1305, internal/alias, pbkdf2, curve25519, blake2b, salsa20/salsa, nacl/box, nacl/secretbox, hkdf, sha3 - and golang.org/x/crypto/ssh is absent from that list, so the SSH transport code is never linked, let alone reachable. The PDP additionally speaks no SSH on any code path of its own. Both CVEs are fixed only in x/crypto >= 0.56.0, which declares `go >= 1.26.0`; Go 1.26 is not released (no golang:1.26 base image exists) and this image builds on golang:1.25-bookworm, so the upgrade is not available yet. permitio/permit-opa#49 raises the floor as far as Go 1.25 allows (0.55.0, which clears CVE-2026-56854) and bumps grpc to 1.83.2. Two subcomponent PURLs are listed on purpose: 0.53.0 is what the build resolves until permit-opa#49 merges and 0.55.0 is what it resolves after, and docker scout matches VEX subcomponents on the FULL PURL string, so a statement naming only one of them silently stops suppressing the moment the version moves. Note also that scout emits Go PURLs WITHOUT the conventional `v` prefix (pkg:golang/golang.org/x/crypto@0.53.0, not @v0.53.0) - the first version of this waiver used @v0.55.0 and bound to nothing at all. Remove this waiver and move x/crypto to >= 0.56.0 once a Go 1.26 toolchain is available. Tracked under PER-15358." }, { "vulnerability": { From 7fef179084c08a5d6330708a6d40665258bd6198 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Fri, 11 Sep 2026 18:44:07 +0300 Subject: [PATCH 04/10] docs: drop CVE-2026-15308 from the scout gate's waiver list The OpenVEX waiver for CVE-2026-15308 was removed in this branch (v4 -> v5) because CPython backported the html.parser fix into 3.13.15, which the floating python:3.13-alpine3.23 base now resolves. The docker-scout gate's comment still described that waiver as active and as unfixable before 3.15.0b4. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KY2DZ9RpvA157tQzGAggtt --- .github/workflows/tests.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index aa2cc265..69ff1139 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -231,9 +231,7 @@ jobs: # CVE-2026-48818 / CVE-2026-54283, all fixed only in starlette >=1.x, which # OPAL's starlette<1 cap forbids; ddtrace CVE-2026-50271, fixed only in # ddtrace >=4.8.2, which OPAL's ddtrace<4 cap forbids and which the Dockerfile - # mitigates by dropping "baggage" from the extract styles; and CPython - # CVE-2026-15308 (html.parser DoS), which nothing in the image imports and which - # is fixed only in 3.15.0b4, so no released Python clears it yet. See PER-15358. + # mitigates by dropping "baggage" from the extract styles. See PER-15358. # # NOTE: this gate reads packages, so it does not flag the CPython interpreter # itself the way a CPE-based scanner does - that blind spot is why four Python From dd358c7835b0b95da890250289cdf322bc4b54c0 Mon Sep 17 00:00:00 2001 From: eli Date: Fri, 11 Sep 2026 10:50:08 -0500 Subject: [PATCH 05/10] docs: list the x/crypto waivers in the scout gate's inventory too Follows 7fef179. That commit was right to strip CVE-2026-15308 from this comment - the waiver went away in this branch - but the same branch then ADDED two waivers in vex.json v6/v7 (x/crypto CVE-2026-78662 and CVE-2026-56855) and the inventory did not mention them. So the comment was accurate about what is no longer waived and silent about what now is. Keeping the list in step with vex.json matters more here than it looks: this comment is the only place a reader sees WHY the gate tolerates a finding without opening the OpenVEX doc, and the two x/crypto entries are the ones most likely to be mistaken for an oversight - they are HIGH, they sit in a Go module nobody in this repo edits, and they are waived rather than fixed only because x/crypto >= 0.56.0 requires an unreleased Go 1.26. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/tests.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 69ff1139..7b357916 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -231,7 +231,11 @@ jobs: # CVE-2026-48818 / CVE-2026-54283, all fixed only in starlette >=1.x, which # OPAL's starlette<1 cap forbids; ddtrace CVE-2026-50271, fixed only in # ddtrace >=4.8.2, which OPAL's ddtrace<4 cap forbids and which the Dockerfile - # mitigates by dropping "baggage" from the extract styles. See PER-15358. + # mitigates by dropping "baggage" from the extract styles; and x/crypto + # CVE-2026-78662 / CVE-2026-56855, which live in golang.org/x/crypto/ssh - a + # package `go list -deps ./cmd/opa` proves is not linked into /app/bin/opa - and + # which are fixed only in x/crypto >=0.56.0, a version that declares + # `go >= 1.26.0` while Go 1.26 is still unreleased. See PER-15358. # # NOTE: this gate reads packages, so it does not flag the CPython interpreter # itself the way a CPE-based scanner does - that blind spot is why four Python From 0d2888306a8c70c141471de9c5daa5717f922819 Mon Sep 17 00:00:00 2001 From: eli Date: Fri, 11 Sep 2026 10:59:53 -0500 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20address=20Zeev's=20review=20?= =?UTF-8?q?=E2=80=94=20the=20cache=20filter=20was=20inert,=20plus=20honest?= =?UTF-8?q?=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four code-affecting findings verified independently before changing anything. HIGH release.yml:73/87 - `no-cache-filter` is not an input. CONFIRMED against docker/build-push-action action.yml at v5.4.0 and v6.9.0: the input is `no-cache-filters` (plural); the singular form is the buildx CLI flag. An undeclared input is warned about and dropped, so the one change this PR called load-bearing was doing nothing at all and the stale-layer replay was still live. Both steps now use the plural spelling, with a note so the next reader does not "tidy" it back. HIGH release.yml:87 - he is right that fixing the spelling alone would make the published image stop being the image pdp-tester validated, because tests.yml builds with no filter. Fixed together: tests.yml's build now passes the same `no-cache-filters: main`. That also makes the gate honest - it was scanning a possibly-cached package set rather than the one a release would ship, which can fail in both directions (passing on cached packages while the release ships newer ones, or failing on packages the release would not contain). Dropped `opa_build` from the filter value. CONFIRMED: `COPY custom* /custom` pulls in custom_opa.tar.gz, `custom/` is absent from .dockerignore, and the Pre-build step regenerates that tarball every run - so the stage already re-executes unconditionally and listing it implied this filter governs the OPA binary's Go versions. It does not; those come from permit-opa's go.sum (permitio/permit-opa#49). MEDIUM release.yml:72 - "the expensive Rust stages still cache normally" was inaccurate and is gone. rust_planner's `COPY . .` sees the same regenerated tarball, so it and the final cargo zigbuild re-run every build; what actually still caches is `cargo chef cook`, keyed on recipe.json. The comment now says that instead. (His Cargo.lock point is real and separate: it is untracked, .dockerignore'd, and built without --locked. Not touched here - that is a reproducibility change with its own blast radius.) HIGH requirements.txt:94 - correct, and the most useful finding. The floor changes no resolution on any current path: opal-common's gitpython<4,>=3.1.32 is the only other bound, pip takes the newest, and a fresh resolve lands on 3.1.62 with or without the line - my own verification build showed exactly that. What cleared the CVE was the rebuild. The comment claimed the line made the fix "resolver-enforced rather than incidental", which overclaimed. Rewritten to say what it is: a tripwire for the case where something later pushes the resolver below the fix (a narrower opal bound, a new transitive cap, a lockfile pinning older), where the alternative is silently reintroducing a CVSS 9.8 RCE. Also records why a floor and not an exact pin - websockets/ddtrace/starlette are pinned to keep waiver PURLs bound or to keep majors off the OPAL pub/sub path, neither of which applies to a transitive dep with no waiver - and why `<4` stays: it is our own bound, so it holds if opal ever allows a 4.x. MEDIUM Dockerfile:111 - the 3.13.15 floor is not enforced by the comment, but it IS enforced, and by this PR: while CVE-2026-15308 was waived a base below 3.13.15 passed the gate anyway; with the waiver gone the same regression is reported with nothing to suppress it and FAILS. Said explicitly, since "the waiver that used to absorb a regression is gone" is the mechanism, not a gap. MEDIUM Dockerfile:153 - correct, present tense described a scheduled workflow this branch does not contain. Reworded as a requirement and says plainly that nothing here has a `schedule:` trigger yet. MEDIUM vex.json:6 - no gap today, and now documented rather than left implicit. Every product binds `@next` because this gate is the ONLY consumer of the OpenVEX doc; the scheduled published-tag re-scan uses Trivy, which reads .trivyignore.yaml and keys on CVE id, so tag names never enter into it. Recorded next to vex-author, including what breaks if Scout is ever aimed at a published tag. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 33 ++++++++++++++++++++++++--------- .github/workflows/tests.yml | 21 +++++++++++++++++++++ Dockerfile | 16 ++++++++++++---- requirements.txt | 29 ++++++++++++++++++++++++----- 4 files changed, 81 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5745fab..f22b34c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,14 +63,28 @@ jobs: tags: permitio/pdp-v2:${{ github.event.release.tag_name }} cache-from: type=gha cache-to: type=gha,mode=max - # Never serve the OS/dependency layers from the GHA cache. Their cache key is the - # instruction text plus the parent layer, so a release cut weeks after the last - # build replays the OLD `apk update && apk upgrade` + `pip install` results and - # re-ships packages that upstream has since patched. That is exactly how - # permitio/pdp-v2:0.9.14 came to carry openssl 3.5.7-r0 into a customer scan five - # weeks after Alpine shipped 3.5.8-r0 (PER-15358). The expensive Rust stages - # (rust_chef/rust_planner/rust_builder) still cache normally. - no-cache-filter: main,opa_build + # Never serve the `main` stage from the GHA cache. Its cache key is the instruction + # text plus the parent layer, so when the base image digest has NOT moved but + # Alpine's package index has, a release cut later replays the old + # `apk update && apk upgrade` + `pip install` results and re-ships packages + # upstream has since patched. That is the shape of PER-15358: 0.9.14 froze + # libcrypto3 3.5.7-r0 on 2026-08-04 and Alpine published 3.5.8-r0 afterwards. + # + # NOTE the input is PLURAL. `no-cache-filter` (singular) is the buildx CLI flag; + # the action input is `no-cache-filters`, and an undeclared input is warned about + # and dropped - i.e. the singular spelling makes this silently inert. + # + # `opa_build` is deliberately NOT listed: `COPY custom* /custom` pulls in + # custom_opa.tar.gz, which the Pre-build step regenerates every run, so that stage + # already re-executes unconditionally. Listing it would imply this filter controls + # the OPA binary's Go dependency versions, and it does not - those come from + # permit-opa's go.sum (see permitio/permit-opa#49). + # + # The Rust stages are untouched by this filter, which is not the same as saying + # they cache cleanly: `rust_planner`'s `COPY . .` also sees the regenerated + # tarball, so it and the final `cargo zigbuild` re-run every build regardless. + # What does still cache is `cargo chef cook`, keyed on recipe.json. + no-cache-filters: main - name: Build and push PDP image - (official release) if: "!github.event.release.prerelease" @@ -84,7 +98,8 @@ jobs: cache-to: type=gha,mode=max # See the pre-release step above: forces `apk upgrade` and `pip install` to # re-resolve so a release can never republish a stale OS/dependency set. - no-cache-filter: main,opa_build + # Plural input, and `main` only - both for the reasons documented there. + no-cache-filters: main update-pdp-api-ecs-service: needs: build-and-push-pdp diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7b357916..fc9802a7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -78,6 +78,18 @@ jobs: tags: permitio/pdp-v2:next cache-from: type=gha cache-to: type=gha,mode=max + # Must match release.yml. Without it the image that pdp-tester exercises and + # that docker-scout/Trivy gate is built from a possibly-stale cached `main` + # stage, while the released image re-resolves `apk upgrade` and `pip install` + # - so CI would be validating and scanning a DIFFERENT package set than the one + # customers pull. Two ways that bites: a gate that passes on cached packages + # while the release ships newer ones, and the reverse, a gate that fails on + # packages the release would not actually contain. + # + # Plural input on purpose - `no-cache-filter` is the buildx CLI flag, the action + # input is `no-cache-filters`, and an undeclared input is silently dropped. + # See PER-15358 and the long note in release.yml. + no-cache-filters: main - name: Save Docker image as artifact run: docker save permitio/pdp-v2:next -o pdp-image.tar @@ -247,6 +259,15 @@ jobs: # this, our Permit.io-authored statements are matched and displayed # ("VEX: not affected") but NOT suppressed, so the gate still fails on # the waived CVEs. Must match the `author` field in pdp-v2.vex.json. + # + # SCOPE OF THE VEX DOC: every statement in pdp-v2.vex.json binds its product to + # `pkg:docker/permitio/pdp-v2@next`, which matches the `local://...:next` image + # scanned here and nothing else. That is correct precisely BECAUSE this gate is + # the only consumer of the OpenVEX doc - the scheduled re-scan of the published + # tags uses Trivy, which reads .trivyignore.yaml and keys on CVE id, so tag + # names never enter into it. If Scout is ever pointed at `:latest` or a release + # tag, every product @id here has to be extended first or all seven waivers + # stop suppressing and the run goes red on known non-issues. vex-author: Permit.io - name: Upload SARIF report diff --git a/Dockerfile b/Dockerfile index 87e7e531..422e52b2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -108,7 +108,12 @@ RUN --mount=type=cache,target=/go/pkg/mod \ # unreachable while it was patched only in 3.15.0b4, but CPython backported the fix and it # landed in 3.13.15 (also 3.14.7). The base tag floats, so the current build resolves # 3.13.15 and the waiver has been REMOVED from .docker/scout/pdp-v2.vex.json (v4 -> v5). -# Do not drop below 3.13.15 - that is the floor for every fix named above. +# Do not drop below 3.13.15 - that is the floor for every fix named above. Note what +# enforces that floor now, because it is not this comment: removing the waiver IS the +# enforcement. While CVE-2026-15308 was waived, a base that resolved below 3.13.15 still +# sailed through the gate. With the waiver gone, the same regression is reported by Scout +# with nothing to suppress it, so it FAILS the gate instead of shipping quietly. That is +# a stricter posture than before, not a looser one. # # The patch version floats deliberately (see the previous python:3.10-alpine3.22 base and # the rebuild-picks-it-up posture in PER-15532). Note what that posture costs if nothing @@ -145,12 +150,15 @@ RUN mkdir -p /app/backup && chmod -R 777 /app/backup # `cache-from: type=gha`, and the cache key is this instruction text plus the parent # layer - so a release cut months later could replay the 2026-08-04 apk layer and # re-ship the exact packages a customer just flagged. release.yml therefore passes -# `no-cache-filter: main,opa_build` to force both to re-resolve on every release. +# `no-cache-filters: main` to force that stage to re-resolve on every release, and +# tests.yml passes the same value so the scanned image matches the published one. # 2. A tag that is never rebuilt rots on its own, and no build-time gate can catch that: # the docker-scout gate in tests.yml runs only on pull_request, so it scanned this # image in July and could not possibly have seen CVEs disclosed in September. -# Detecting drift therefore requires re-scanning the PUBLISHED tags on a schedule, -# which lives in its own workflow rather than here. +# Detecting drift therefore REQUIRES re-scanning the PUBLISHED tags on a schedule. +# Deliberately phrased as a requirement, not a description: no workflow in this repo +# has a `schedule:` trigger, so nothing here does it yet. That is the job of the +# companion change tracked under PER-15358. # # The PDP never uses SQLite, but its FTS5/zipfile CVEs (CVE-2026-11822, # CVE-2026-11824, CVE-2025-70873) are still reported against sqlite-libs, which diff --git a/requirements.txt b/requirements.txt index 8b937225..818ed009 100644 --- a/requirements.txt +++ b/requirements.txt @@ -86,11 +86,30 @@ websockets==17.0 # CVE-2026-78677, and 3.1.58 closes CVE-2026-76218 / CVE-2026-76219 / CVE-2026-76220 / # CVE-2026-76222 + GHSA-jm78-9fvv-mhgr (all HIGH) - seven advisories cleared by this floor. # -# A rebuild alone would have picked up a fixed GitPython by chance, since the constraint was -# open. That is exactly the failure mode the `websockets` note above describes, so the floor -# is stated here to make the fix resolver-enforced rather than incidental. The <4 cap matches -# opal-common's own bound and keeps a major out of a build that has no lockfile. Drop this -# pin only once opal-common itself floors gitpython >= 3.1.59. See PER-15358. +# BE PRECISE ABOUT WHAT THIS LINE DOES, because it is easy to over-read: it changes no +# resolution on any current build path. opal-common's `gitpython<4,>=3.1.32` is the only +# other bound, nothing else constrains the package, and pip prefers the newest satisfying +# version - so a fresh resolve lands on 3.1.62 with or without this line. A verification +# build of the full image confirms exactly that. What actually cleared the CVE from the +# image was the REBUILD; this line did not. +# +# Its job is to be a tripwire, not a fix. A floor only bites when something pushes the +# resolver below it, and that is a real future: a narrower opal-common bound, a new +# transitive cap, or the lockfile this build still lacks pinning an older version. Without +# the floor any of those silently reintroduces a CVSS 9.8 RCE and the only thing standing +# between it and a release is a scanner noticing. With it, the resolve fails loudly. +# +# Deliberately a floor and NOT an exact pin, unlike `websockets`/`ddtrace`/`starlette` +# above. Those are pinned for reasons that do not apply here - to keep a VEX waiver PURL +# bound to a known version, or to stop an unvalidated major landing on the OPAL pub/sub +# path. GitPython has no waiver, sits on no hot path, and is a TRANSITIVE dependency: +# pinning it exactly would make this repo the owner of an upstream's upgrade cadence for +# no security gain. Keeping `<4` is not redundancy for its own sake either - it is our own +# bound, so it still holds if opal-common ever relaxes to allow a 4.x major. +# +# The real structural fix is a lockfile, which would make this whole transitive tree +# explicit and reproducible; see docs/image-vulnerability-management.md. Drop this floor +# once opal-common itself floors gitpython >= 3.1.59. See PER-15358. GitPython>=3.1.59,<4 opal-common==0.9.6 opal-client==0.9.6 From ee023e63d48d3a2a219fe334cc9f8e0f5eec48f8 Mon Sep 17 00:00:00 2001 From: eli Date: Fri, 11 Sep 2026 11:11:08 -0500 Subject: [PATCH 07/10] docs: drop a vex.json version number that already rotted The comment said the CVE-2026-15308 waiver was removed '(v4 -> v5)'. True at the time, but the doc is at v7 now - two later commits added and then re-bound the x/crypto waivers - so a reader checking the file finds a number that does not match and has to work out which is wrong. Version numbers in prose rot by construction. The removal is the durable fact; the revision it happened in is recoverable from git. Dropped. Co-Authored-By: Claude Opus 5 (1M context) --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 422e52b2..4f5d1634 100644 --- a/Dockerfile +++ b/Dockerfile @@ -107,7 +107,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ # CVE-2026-15308 (html.parser CPU-exhaustion DoS) is now cleared too: it was waived here as # unreachable while it was patched only in 3.15.0b4, but CPython backported the fix and it # landed in 3.13.15 (also 3.14.7). The base tag floats, so the current build resolves -# 3.13.15 and the waiver has been REMOVED from .docker/scout/pdp-v2.vex.json (v4 -> v5). +# 3.13.15 and the waiver has been REMOVED from .docker/scout/pdp-v2.vex.json. # Do not drop below 3.13.15 - that is the floor for every fix named above. Note what # enforces that floor now, because it is not this comment: removing the waiver IS the # enforcement. While CVE-2026-15308 was waived, a base that resolved below 3.13.15 still From b3e85f70cd3344c82c801dee29dc84bb1828e132 Mon Sep 17 00:00:00 2001 From: David Shoen Date: Mon, 14 Sep 2026 17:50:23 +0300 Subject: [PATCH 08/10] fix: correct the review-round-2 claims, and make the 3.13.15 floor a real control Zeev's second round found six places where the new prose asserted a security property that does not hold. Each was verified before changing anything. HIGH - "Go 1.26 is still unreleased" is false, and it was the stated removal gate for two waivers. Go 1.26.0 shipped 2026-02-10, 1.26.8 is current and 1.27.0 is out; golang:1.26-bookworm exists and PDP#334 already pins it. The blocker is entirely local: Dockerfile:57 is still golang:1.25-bookworm and permit-opa's go.mod is go 1.25.0, and the official golang images set GOTOOLCHAIN=local so the toolchain cannot move implicitly. Corrected in tests.yml and in both x/crypto impact statements, whose removal gates now name conditions someone can actually close. vex.json v7 -> v8. HIGH - "removing the waiver IS the enforcement" does not hold, for three independent reasons: the gate is pull_request-only so a release never scans; scout reads packages and never reports the interpreter (run 34620527942 lists 15 findings across 6 packages, none CPython); and the removed waiver bound pkg:generic/python, which is not a PURL scout emits, so it was almost certainly suppressing nothing. Rather than only describing the gap, the apk layer now asserts sys.version_info >= (3,13,15) - which runs on every build path, releases included, and costs no extra layer. MED - the @next scope argument rested on a scheduled Trivy re-scan, a .trivyignore.yaml and a schedule: trigger, none of which exist in this repo. Since the phantom WAS the argument, removing it inverts the conclusion: the published tags are matched by no waiver mechanism at all. Now stated as a gap. Same fix for the "docker-scout/Trivy gate" reference at tests.yml:82. MED - docs/image-vulnerability-management.md does not exist; there is no docs/ directory. Cite the tracker, which does. MED - the lockfile scenario ran backwards. A lock is what gets installed, so the floor is never consulted; its real value is upstream, where pip-compile/uv read this file as input and cannot generate a lock below 3.1.59. Also: the resolve fails loudly only when the capping package is exact-pinned, and transitivity does not distinguish GitPython from the three pins cited against it - all four are transitive. Cost of drift does. Named the verification as a local --no-cache build rather than implying a CI run. MED - "the scanned image matches the published one" overstates what the filter delivers: two independent fresh resolutions, amd64-only on the test side, and no scan on the release path at all. The guarantee is that neither is built on a stale package set. Co-Authored-By: Claude Opus 5 --- .docker/scout/pdp-v2.vex.json | 6 ++--- .github/workflows/tests.yml | 24 ++++++++++------- Dockerfile | 27 +++++++++++++------ requirements.txt | 49 ++++++++++++++++++++++++----------- 4 files changed, 71 insertions(+), 35 deletions(-) diff --git a/.docker/scout/pdp-v2.vex.json b/.docker/scout/pdp-v2.vex.json index ed6834f7..824f9a47 100644 --- a/.docker/scout/pdp-v2.vex.json +++ b/.docker/scout/pdp-v2.vex.json @@ -3,7 +3,7 @@ "@id": "https://openvex.dev/docs/permitio-pdp/pdp-v2-cve-waivers", "author": "Permit.io", "timestamp": "2026-09-11T12:00:00Z", - "version": 7, + "version": 8, "statements": [ { "vulnerability": { @@ -20,7 +20,7 @@ ], "status": "not_affected", "justification": "vulnerable_code_not_in_execute_path", - "impact_statement": "CVE-2026-78662 is a connection-deadlock DoS - a channel registered in the mux's chanList before it is established lets a malicious peer flood incomingRequests in golang.org/x/crypto/ssh. It reaches the pdp-v2 image only because /app/bin/opa is compiled from permitio/permit-opa, which carries x/crypto as an INDIRECT dependency. The vulnerable package is not present in the binary: `go list -deps ./cmd/opa` in permit-opa resolves exactly these x/crypto packages - cryptobyte(+asn1), chacha20, chacha20poly1305, internal/poly1305, internal/alias, pbkdf2, curve25519, blake2b, salsa20/salsa, nacl/box, nacl/secretbox, hkdf, sha3 - and golang.org/x/crypto/ssh is absent from that list, so the SSH transport code is never linked, let alone reachable. The PDP additionally speaks no SSH on any code path of its own. Both CVEs are fixed only in x/crypto >= 0.56.0, which declares `go >= 1.26.0`; Go 1.26 is not released (no golang:1.26 base image exists) and this image builds on golang:1.25-bookworm, so the upgrade is not available yet. permitio/permit-opa#49 raises the floor as far as Go 1.25 allows (0.55.0, which clears CVE-2026-56854) and bumps grpc to 1.83.2. Two subcomponent PURLs are listed on purpose: 0.53.0 is what the build resolves until permit-opa#49 merges and 0.55.0 is what it resolves after, and docker scout matches VEX subcomponents on the FULL PURL string, so a statement naming only one of them silently stops suppressing the moment the version moves. Note also that scout emits Go PURLs WITHOUT the conventional `v` prefix (pkg:golang/golang.org/x/crypto@0.53.0, not @v0.53.0) - the first version of this waiver used @v0.55.0 and bound to nothing at all. Remove this waiver and move x/crypto to >= 0.56.0 once a Go 1.26 toolchain is available. Tracked under PER-15358." + "impact_statement": "CVE-2026-78662 is a connection-deadlock DoS - a channel registered in the mux's chanList before it is established lets a malicious peer flood incomingRequests in golang.org/x/crypto/ssh. It reaches the pdp-v2 image only because /app/bin/opa is compiled from permitio/permit-opa, which carries x/crypto as an INDIRECT dependency. The vulnerable package is not present in the binary: `go list -deps ./cmd/opa` in permit-opa resolves exactly these x/crypto packages - cryptobyte(+asn1), chacha20, chacha20poly1305, internal/poly1305, internal/alias, pbkdf2, curve25519, blake2b, salsa20/salsa, nacl/box, nacl/secretbox, hkdf, sha3 - and golang.org/x/crypto/ssh is absent from that list, so the SSH transport code is never linked, let alone reachable. The PDP additionally speaks no SSH on any code path of its own. Both CVEs are fixed only in x/crypto >= 0.56.0, whose go.mod declares `go 1.26.0`. That blocker is LOCAL, not upstream: Go 1.26 has been stable since 2026-02-10 (1.26.8 current, 1.27.0 released) and golang:1.26-bookworm exists, but this image still builds its OPA on golang:1.25-bookworm (Dockerfile:57) and permit-opa's go.mod is still `go 1.25.0`. The official golang images ship GOTOOLCHAIN=local, so a 1.25 builder hard-fails rather than fetching a newer toolchain, which is why the base bump has to land first. permitio/permit-opa#49 raises the floor as far as Go 1.25 allows (0.55.0, which clears CVE-2026-56854) and bumps grpc to 1.83.2. Two subcomponent PURLs are listed on purpose: 0.53.0 is what the build resolves until permit-opa#49 merges and 0.55.0 is what it resolves after, and docker scout matches VEX subcomponents on the FULL PURL string, so a statement naming only one of them silently stops suppressing the moment the version moves. Note also that scout emits Go PURLs WITHOUT the conventional `v` prefix (pkg:golang/golang.org/x/crypto@0.53.0, not @v0.53.0) - the first version of this waiver used @v0.55.0 and bound to nothing at all. Removal gate, both closable inside Permit: land PDP#334 (moves the OPA builder to golang:1.26-bookworm), then move permit-opa's go directive to 1.26.0 and x/crypto to >= 0.56.0, then delete this statement. Nothing here waits on Go. Tracked under PER-15358." }, { "vulnerability": { @@ -37,7 +37,7 @@ ], "status": "not_affected", "justification": "vulnerable_code_not_in_execute_path", - "impact_statement": "CVE-2026-56855 is a connection-deadlock DoS - crafted post-establishment channel messages are buffered instead of being treated as a protocol error in golang.org/x/crypto/ssh. It reaches the pdp-v2 image only because /app/bin/opa is compiled from permitio/permit-opa, which carries x/crypto as an INDIRECT dependency. The vulnerable package is not present in the binary: `go list -deps ./cmd/opa` in permit-opa resolves exactly these x/crypto packages - cryptobyte(+asn1), chacha20, chacha20poly1305, internal/poly1305, internal/alias, pbkdf2, curve25519, blake2b, salsa20/salsa, nacl/box, nacl/secretbox, hkdf, sha3 - and golang.org/x/crypto/ssh is absent from that list, so the SSH transport code is never linked, let alone reachable. The PDP additionally speaks no SSH on any code path of its own. Both CVEs are fixed only in x/crypto >= 0.56.0, which declares `go >= 1.26.0`; Go 1.26 is not released (no golang:1.26 base image exists) and this image builds on golang:1.25-bookworm, so the upgrade is not available yet. permitio/permit-opa#49 raises the floor as far as Go 1.25 allows (0.55.0, which clears CVE-2026-56854) and bumps grpc to 1.83.2. Two subcomponent PURLs are listed on purpose: 0.53.0 is what the build resolves until permit-opa#49 merges and 0.55.0 is what it resolves after, and docker scout matches VEX subcomponents on the FULL PURL string, so a statement naming only one of them silently stops suppressing the moment the version moves. Note also that scout emits Go PURLs WITHOUT the conventional `v` prefix (pkg:golang/golang.org/x/crypto@0.53.0, not @v0.53.0) - the first version of this waiver used @v0.55.0 and bound to nothing at all. Remove this waiver and move x/crypto to >= 0.56.0 once a Go 1.26 toolchain is available. Tracked under PER-15358." + "impact_statement": "CVE-2026-56855 is a connection-deadlock DoS - crafted post-establishment channel messages are buffered instead of being treated as a protocol error in golang.org/x/crypto/ssh. It reaches the pdp-v2 image only because /app/bin/opa is compiled from permitio/permit-opa, which carries x/crypto as an INDIRECT dependency. The vulnerable package is not present in the binary: `go list -deps ./cmd/opa` in permit-opa resolves exactly these x/crypto packages - cryptobyte(+asn1), chacha20, chacha20poly1305, internal/poly1305, internal/alias, pbkdf2, curve25519, blake2b, salsa20/salsa, nacl/box, nacl/secretbox, hkdf, sha3 - and golang.org/x/crypto/ssh is absent from that list, so the SSH transport code is never linked, let alone reachable. The PDP additionally speaks no SSH on any code path of its own. Both CVEs are fixed only in x/crypto >= 0.56.0, whose go.mod declares `go 1.26.0`. That blocker is LOCAL, not upstream: Go 1.26 has been stable since 2026-02-10 (1.26.8 current, 1.27.0 released) and golang:1.26-bookworm exists, but this image still builds its OPA on golang:1.25-bookworm (Dockerfile:57) and permit-opa's go.mod is still `go 1.25.0`. The official golang images ship GOTOOLCHAIN=local, so a 1.25 builder hard-fails rather than fetching a newer toolchain, which is why the base bump has to land first. permitio/permit-opa#49 raises the floor as far as Go 1.25 allows (0.55.0, which clears CVE-2026-56854) and bumps grpc to 1.83.2. Two subcomponent PURLs are listed on purpose: 0.53.0 is what the build resolves until permit-opa#49 merges and 0.55.0 is what it resolves after, and docker scout matches VEX subcomponents on the FULL PURL string, so a statement naming only one of them silently stops suppressing the moment the version moves. Note also that scout emits Go PURLs WITHOUT the conventional `v` prefix (pkg:golang/golang.org/x/crypto@0.53.0, not @v0.53.0) - the first version of this waiver used @v0.55.0 and bound to nothing at all. Removal gate, both closable inside Permit: land PDP#334 (moves the OPA builder to golang:1.26-bookworm), then move permit-opa's go directive to 1.26.0 and x/crypto to >= 0.56.0, then delete this statement. Nothing here waits on Go. Tracked under PER-15358." }, { "vulnerability": { diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fc9802a7..c78f3a2e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -79,7 +79,7 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max # Must match release.yml. Without it the image that pdp-tester exercises and - # that docker-scout/Trivy gate is built from a possibly-stale cached `main` + # that docker-scout gate is built from a possibly-stale cached `main` # stage, while the released image re-resolves `apk upgrade` and `pip install` # - so CI would be validating and scanning a DIFFERENT package set than the one # customers pull. Two ways that bites: a gate that passes on cached packages @@ -246,8 +246,13 @@ jobs: # mitigates by dropping "baggage" from the extract styles; and x/crypto # CVE-2026-78662 / CVE-2026-56855, which live in golang.org/x/crypto/ssh - a # package `go list -deps ./cmd/opa` proves is not linked into /app/bin/opa - and - # which are fixed only in x/crypto >=0.56.0, a version that declares - # `go >= 1.26.0` while Go 1.26 is still unreleased. See PER-15358. + # which are fixed only in x/crypto >=0.56.0, whose go.mod declares `go 1.26.0`. + # Go 1.26 has been stable since 2026-02-10 (1.26.8 is current, and 1.27.0 is out) + # - the blocker is local, not upstream. The OPA builder is still + # golang:1.25-bookworm (Dockerfile:57) and permit-opa's go.mod is `go 1.25.0`, + # and the official golang images ship GOTOOLCHAIN=local so the toolchain cannot + # move implicitly. Gated on PDP#334 plus a matching permit-opa change, not on Go. + # See PER-15358. # # NOTE: this gate reads packages, so it does not flag the CPython interpreter # itself the way a CPE-based scanner does - that blind spot is why four Python @@ -262,12 +267,13 @@ jobs: # # SCOPE OF THE VEX DOC: every statement in pdp-v2.vex.json binds its product to # `pkg:docker/permitio/pdp-v2@next`, which matches the `local://...:next` image - # scanned here and nothing else. That is correct precisely BECAUSE this gate is - # the only consumer of the OpenVEX doc - the scheduled re-scan of the published - # tags uses Trivy, which reads .trivyignore.yaml and keys on CVE id, so tag - # names never enter into it. If Scout is ever pointed at `:latest` or a release - # tag, every product @id here has to be extended first or all seven waivers - # stop suppressing and the run goes red on known non-issues. + # scanned here and nothing else. That is sound only while this gate is the ONLY + # consumer of the doc, which it is today - nothing re-scans the PUBLISHED tags + # yet (PER-15358), so read this as a gap, not as cover: those tags are matched by + # no waiver mechanism at all. When a published-tag scan does land it must either + # key on CVE id rather than PURL, or every product @id here has to be extended + # first, or all seven waivers stop suppressing and the run goes red on known + # non-issues. vex-author: Permit.io - name: Upload SARIF report diff --git a/Dockerfile b/Dockerfile index 4f5d1634..7ccaf0c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -108,12 +108,17 @@ RUN --mount=type=cache,target=/go/pkg/mod \ # unreachable while it was patched only in 3.15.0b4, but CPython backported the fix and it # landed in 3.13.15 (also 3.14.7). The base tag floats, so the current build resolves # 3.13.15 and the waiver has been REMOVED from .docker/scout/pdp-v2.vex.json. -# Do not drop below 3.13.15 - that is the floor for every fix named above. Note what -# enforces that floor now, because it is not this comment: removing the waiver IS the -# enforcement. While CVE-2026-15308 was waived, a base that resolved below 3.13.15 still -# sailed through the gate. With the waiver gone, the same regression is reported by Scout -# with nothing to suppress it, so it FAILS the gate instead of shipping quietly. That is -# a stricter posture than before, not a looser one. +# Do not drop below 3.13.15 - that is the floor for every fix named above. The apk layer +# below asserts it (`sys.version_info >= (3,13,15)`), because nothing else does. Removing +# the CVE-2026-15308 waiver is NOT what enforces it, for three independent reasons: +# scout reads packages and never reports the CPython interpreter at all (see the NOTE in +# tests.yml - run 34620527942 lists 15 findings across 6 packages, apk/golang/pypi, and no +# CPython entry of any kind); the gate is `pull_request`-only, so a release never scans; +# and the removed waiver bound its subcomponent to `pkg:generic/python`, which is not a +# PURL scout emits, so it was almost certainly suppressing nothing to begin with. Catching +# a stale interpreter through a scanner needs a CPE-based one - that is the companion +# change under PER-15358. Until it lands, the assert is the control, and unlike the gate +# it runs on every build path, releases included. # # The patch version floats deliberately (see the previous python:3.10-alpine3.22 base and # the rebuild-picks-it-up posture in PER-15532). Note what that posture costs if nothing @@ -151,7 +156,12 @@ RUN mkdir -p /app/backup && chmod -R 777 /app/backup # layer - so a release cut months later could replay the 2026-08-04 apk layer and # re-ship the exact packages a customer just flagged. release.yml therefore passes # `no-cache-filters: main` to force that stage to re-resolve on every release, and -# tests.yml passes the same value so the scanned image matches the published one. +# tests.yml passes the same value so the scanned image is not built on a stale +# package set either. That is the guarantee - NOT that the two images match. They +# are two independent fresh resolutions against the live Alpine/PyPI indexes, +# tests.yml builds linux/amd64 only while release.yml builds amd64+arm64, and the +# scout gate is `pull_request`-only so the release build is never the one scanned. +# Closing that last gap needs the gate to run on release events (PER-15358). # 2. A tag that is never rebuilt rots on its own, and no build-time gate can catch that: # the docker-scout gate in tests.yml runs only on pull_request, so it scanned this # image in July and could not possibly have seen CVEs disclosed in September. @@ -174,7 +184,8 @@ RUN --mount=type=cache,target=/var/cache/apk \ apk add bash libffi libressl gcompat && \ apk add --no-cache --virtual .python-rundeps-nosqlite \ $(apk info -qR .python-rundeps | grep '^so:' | grep -v 'libsqlite3') && \ - apk del .python-rundeps sqlite-libs + apk del .python-rundeps sqlite-libs && \ + python3 -c "import sys; assert sys.version_info[:3] >= (3, 13, 15), sys.version" # Copy OPA binary from the build stage diff --git a/requirements.txt b/requirements.txt index 818ed009..62a07c80 100644 --- a/requirements.txt +++ b/requirements.txt @@ -89,27 +89,46 @@ websockets==17.0 # BE PRECISE ABOUT WHAT THIS LINE DOES, because it is easy to over-read: it changes no # resolution on any current build path. opal-common's `gitpython<4,>=3.1.32` is the only # other bound, nothing else constrains the package, and pip prefers the newest satisfying -# version - so a fresh resolve lands on 3.1.62 with or without this line. A verification -# build of the full image confirms exactly that. What actually cleared the CVE from the -# image was the REBUILD; this line did not. +# version - so a fresh resolve lands on 3.1.62 with or without this line. Verified by a +# local --no-cache build of the base + apk + pip layers, which resolved 3.1.62 with the +# floor already present; local rather than a CI run id, unlike the `websockets` note +# above, because no CI job resolves this file in isolation. What actually cleared the CVE +# from the image was the REBUILD; this line did not. # -# Its job is to be a tripwire, not a fix. A floor only bites when something pushes the -# resolver below it, and that is a real future: a narrower opal-common bound, a new -# transitive cap, or the lockfile this build still lacks pinning an older version. Without -# the floor any of those silently reintroduces a CVSS 9.8 RCE and the only thing standing -# between it and a release is a scanner noticing. With it, the resolve fails loudly. +# Its job is to be a tripwire, not a fix. The likeliest way it bites is a bump of the +# opal-common pin below to a version whose gitpython cap drops under 3.1.59: opal-common +# is exact-pinned, so pip has nothing to backtrack on, raises ResolutionImpossible, and +# the image build fails at `pip install` - loudly, in front of whoever is doing the bump. +# A cap arriving through a RANGED dependency instead resolves quietly, by backtracking +# that package to an older version; the failure is silent, but GitPython still cannot land +# below the floor, which is the part that matters. Without it, either case silently +# reinstates a CVSS 9.8 RCE with only a scanner between it and a release. +# +# What the floor does NOT cover is a lockfile - the one case it is easy to assume it does. +# Once a lockfile exists the lock is what gets installed and this file is not read at +# install time at all, so a lock pinning 3.1.54 installs 3.1.54 and the floor is never +# consulted. Its value moves upstream instead, and is arguably stronger there: +# pip-compile/uv read this file as INPUT, so a lock generated from it cannot be produced +# below 3.1.59 in the first place. The floor prevents the bad lock rather than catching it. # # Deliberately a floor and NOT an exact pin, unlike `websockets`/`ddtrace`/`starlette` -# above. Those are pinned for reasons that do not apply here - to keep a VEX waiver PURL -# bound to a known version, or to stop an unvalidated major landing on the OPAL pub/sub -# path. GitPython has no waiver, sits on no hot path, and is a TRANSITIVE dependency: -# pinning it exactly would make this repo the owner of an upstream's upgrade cadence for -# no security gain. Keeping `<4` is not redundancy for its own sake either - it is our own +# above - but NOT because those are direct and this one is transitive. All four are +# top-level entries here that would also arrive transitively anyway (via opal-common/ +# opal-client, fastapi-websocket-{rpc,pubsub}, uvicorn, fastapi), so transitivity cannot +# be what separates them. What separates them is the cost of drift: websockets drift puts +# an unvalidated MAJOR on the live OPAL pub/sub path (#329), and ddtrace/starlette drift +# unbinds a VEX waiver PURL and reds the scout gate. GitPython has neither - no waiver, +# nothing first-party imports it, and `<4` caps its drift to a PATCH inside 3.1.x, which +# is a normal posture. Pinning it exactly would make this repo the owner of an upstream's +# cadence for no security gain, against a package that shipped eight releases in the seven +# weeks to 3.1.62. Keeping `<4` is not redundancy for its own sake either - it is our own # bound, so it still holds if opal-common ever relaxes to allow a 4.x major. # # The real structural fix is a lockfile, which would make this whole transitive tree -# explicit and reproducible; see docs/image-vulnerability-management.md. Drop this floor -# once opal-common itself floors gitpython >= 3.1.59. See PER-15358. +# explicit and reproducible. Not done here - tracked as the highest-value outstanding item +# under PER-15358 (there is no docs/ directory in this repo yet; the write-up lands with +# the companion change). Drop this floor once opal-common itself floors +# gitpython >= 3.1.59. See PER-15358. GitPython>=3.1.59,<4 opal-common==0.9.6 opal-client==0.9.6 From 85e5c591ead16105ecf308e588dbe3e9779f8f35 Mon Sep 17 00:00:00 2001 From: David Shoen Date: Tue, 15 Sep 2026 11:21:39 +0300 Subject: [PATCH 09/10] fix: make the interpreter floor a real check, and correct five more claims Round-3 review (five specialist agents plus an independent pass) found eight defects in the prose and controls this branch added. Each was verified before being changed; the two that alter behaviour are first. The 3.13.15 check could not detect what I said it detected. I told Zeev in a review thread that placing it after the .python-rundeps surgery made it "proof the interpreter survived that." It was not: `sys` is a builtin, so `import sys` loads no extension modules, and a version-only check passes on an interpreter whose entire lib-dynload is unresolvable - exactly what that surgery produces if the `so:`-derived list ever comes back short. It now imports ssl/hashlib/zlib/lzma/bz2/ctypes/pyexpat/decimal first, so it fails on a broken interpreter as well as a stale one, and uses sys.exit rather than `assert`, which -O / PYTHONOPTIMIZE would strip. Verified in both directions locally. The x/crypto waivers used the wrong OpenVEX justification. `vulnerable_code_not_in_execute_path` asserts the component ships and merely is not called; both impact statements argue the stronger and provable claim, that the ssh package is excluded at link time. That is `vulnerable_code_not_present`. The four starlette waivers keep the old label, which is correct for them - starlette is installed and imported. vex.json v8 -> v9, and its timestamp now says when it was actually issued rather than 2026-09-11. Corrections to statements that were simply wrong: - release.yml claimed "what does still cache is `cargo chef cook`". Run 34858998837 says otherwise: exactly three vertices cached, all in `rust_chef`, while cargo chef cook was the longest step in the build at 404.6s. The stage that does cache runs `apk add musl-dev openssl-dev zig ...` - a package-resolving layer outside the filter - and went unmentioned. Both halves corrected; the likely cause (no tracked Cargo.lock, so recipe.json drifts) is recorded. - `<4` does not cap GitPython drift to a patch inside 3.1.x; it admits 3.2.0 and caps at a major. Verified with packaging's SpecifierSet. The conclusion - a floor, not an exact pin - is unchanged, but it now rests on a true premise. - The GitPython paragraph enumerates eight advisories and said seven. - `Dockerfile:57` was cited from three places across two files. PR #334 moves that stage and `git merge-tree` shows it merges clean, so all three assertions would go false with no conflict and no check. They name the stage now. - tests.yml said an undeclared action input is "silently dropped"; release.yml correctly says it is warned about. Actions emits an `Unexpected input(s)` annotation, so the tests.yml wording was wrong in the direction that matters. Not addressed here, because they change security posture or other PRs: the three x/crypto CVEs all live in golang.org/x/crypto/ssh, so the evidence that waives two of them covers CVE-2026-56854 as well; the scan gate has never run on a release; and the waivers bind by PURL against an unpinned permit-opa@main. Co-Authored-By: Claude Opus 5 --- .docker/scout/pdp-v2.vex.json | 12 ++++++------ .github/workflows/release.yml | 17 +++++++++++++---- .github/workflows/tests.yml | 5 +++-- Dockerfile | 29 ++++++++++++++++++----------- requirements.txt | 8 +++++--- 5 files changed, 45 insertions(+), 26 deletions(-) diff --git a/.docker/scout/pdp-v2.vex.json b/.docker/scout/pdp-v2.vex.json index 824f9a47..9ed7dc95 100644 --- a/.docker/scout/pdp-v2.vex.json +++ b/.docker/scout/pdp-v2.vex.json @@ -2,8 +2,8 @@ "@context": "https://openvex.dev/ns/v0.2.0", "@id": "https://openvex.dev/docs/permitio-pdp/pdp-v2-cve-waivers", "author": "Permit.io", - "timestamp": "2026-09-11T12:00:00Z", - "version": 8, + "timestamp": "2026-09-15T00:00:00Z", + "version": 9, "statements": [ { "vulnerability": { @@ -19,8 +19,8 @@ } ], "status": "not_affected", - "justification": "vulnerable_code_not_in_execute_path", - "impact_statement": "CVE-2026-78662 is a connection-deadlock DoS - a channel registered in the mux's chanList before it is established lets a malicious peer flood incomingRequests in golang.org/x/crypto/ssh. It reaches the pdp-v2 image only because /app/bin/opa is compiled from permitio/permit-opa, which carries x/crypto as an INDIRECT dependency. The vulnerable package is not present in the binary: `go list -deps ./cmd/opa` in permit-opa resolves exactly these x/crypto packages - cryptobyte(+asn1), chacha20, chacha20poly1305, internal/poly1305, internal/alias, pbkdf2, curve25519, blake2b, salsa20/salsa, nacl/box, nacl/secretbox, hkdf, sha3 - and golang.org/x/crypto/ssh is absent from that list, so the SSH transport code is never linked, let alone reachable. The PDP additionally speaks no SSH on any code path of its own. Both CVEs are fixed only in x/crypto >= 0.56.0, whose go.mod declares `go 1.26.0`. That blocker is LOCAL, not upstream: Go 1.26 has been stable since 2026-02-10 (1.26.8 current, 1.27.0 released) and golang:1.26-bookworm exists, but this image still builds its OPA on golang:1.25-bookworm (Dockerfile:57) and permit-opa's go.mod is still `go 1.25.0`. The official golang images ship GOTOOLCHAIN=local, so a 1.25 builder hard-fails rather than fetching a newer toolchain, which is why the base bump has to land first. permitio/permit-opa#49 raises the floor as far as Go 1.25 allows (0.55.0, which clears CVE-2026-56854) and bumps grpc to 1.83.2. Two subcomponent PURLs are listed on purpose: 0.53.0 is what the build resolves until permit-opa#49 merges and 0.55.0 is what it resolves after, and docker scout matches VEX subcomponents on the FULL PURL string, so a statement naming only one of them silently stops suppressing the moment the version moves. Note also that scout emits Go PURLs WITHOUT the conventional `v` prefix (pkg:golang/golang.org/x/crypto@0.53.0, not @v0.53.0) - the first version of this waiver used @v0.55.0 and bound to nothing at all. Removal gate, both closable inside Permit: land PDP#334 (moves the OPA builder to golang:1.26-bookworm), then move permit-opa's go directive to 1.26.0 and x/crypto to >= 0.56.0, then delete this statement. Nothing here waits on Go. Tracked under PER-15358." + "justification": "vulnerable_code_not_present", + "impact_statement": "CVE-2026-78662 is a connection-deadlock DoS - a channel registered in the mux's chanList before it is established lets a malicious peer flood incomingRequests in golang.org/x/crypto/ssh. It reaches the pdp-v2 image only because /app/bin/opa is compiled from permitio/permit-opa, which carries x/crypto as an INDIRECT dependency. The vulnerable package is not present in the binary: `go list -deps ./cmd/opa` in permit-opa resolves exactly these x/crypto packages - cryptobyte(+asn1), chacha20, chacha20poly1305, internal/poly1305, internal/alias, pbkdf2, curve25519, blake2b, salsa20/salsa, nacl/box, nacl/secretbox, hkdf, sha3 - and golang.org/x/crypto/ssh is absent from that list, so the SSH transport code is never linked, let alone reachable. The PDP additionally speaks no SSH on any code path of its own. The justification is `vulnerable_code_not_present` rather than `vulnerable_code_not_in_execute_path` on purpose: the x/crypto MODULE is present (cryptobyte, chacha20, curve25519 and the rest are linked), but the ssh package's code is excluded at link time by Go's import graph, which is what OpenVEX defines that label to mean. `not_in_execute_path` would assert the weaker and different claim that the ssh code ships and merely is not called. Both CVEs are fixed only in x/crypto >= 0.56.0, whose go.mod declares `go 1.26.0`. That blocker is LOCAL, not upstream: Go 1.26 has been stable since 2026-02-10 (1.26.8 current, 1.27.0 released) and golang:1.26-bookworm exists, but this image still builds its OPA on golang:1.25-bookworm (the opa_build stage) and permit-opa's go.mod is still `go 1.25.0`. The official golang images ship GOTOOLCHAIN=local, so a 1.25 builder hard-fails rather than fetching a newer toolchain, which is why the base bump has to land first. permitio/permit-opa#49 raises the floor as far as Go 1.25 allows (0.55.0, which clears CVE-2026-56854) and bumps grpc to 1.83.2. Two subcomponent PURLs are listed on purpose: 0.53.0 is what the build resolves until permit-opa#49 merges and 0.55.0 is what it resolves after, and docker scout matches VEX subcomponents on the FULL PURL string, so a statement naming only one of them silently stops suppressing the moment the version moves. Note also that scout emits Go PURLs WITHOUT the conventional `v` prefix (pkg:golang/golang.org/x/crypto@0.53.0, not @v0.53.0) - the first version of this waiver used @v0.55.0 and bound to nothing at all. Removal gate, both closable inside Permit: land PDP#334 (moves the OPA builder to golang:1.26-bookworm), then move permit-opa's go directive to 1.26.0 and x/crypto to >= 0.56.0, then delete this statement. Nothing here waits on Go. Tracked under PER-15358." }, { "vulnerability": { @@ -36,8 +36,8 @@ } ], "status": "not_affected", - "justification": "vulnerable_code_not_in_execute_path", - "impact_statement": "CVE-2026-56855 is a connection-deadlock DoS - crafted post-establishment channel messages are buffered instead of being treated as a protocol error in golang.org/x/crypto/ssh. It reaches the pdp-v2 image only because /app/bin/opa is compiled from permitio/permit-opa, which carries x/crypto as an INDIRECT dependency. The vulnerable package is not present in the binary: `go list -deps ./cmd/opa` in permit-opa resolves exactly these x/crypto packages - cryptobyte(+asn1), chacha20, chacha20poly1305, internal/poly1305, internal/alias, pbkdf2, curve25519, blake2b, salsa20/salsa, nacl/box, nacl/secretbox, hkdf, sha3 - and golang.org/x/crypto/ssh is absent from that list, so the SSH transport code is never linked, let alone reachable. The PDP additionally speaks no SSH on any code path of its own. Both CVEs are fixed only in x/crypto >= 0.56.0, whose go.mod declares `go 1.26.0`. That blocker is LOCAL, not upstream: Go 1.26 has been stable since 2026-02-10 (1.26.8 current, 1.27.0 released) and golang:1.26-bookworm exists, but this image still builds its OPA on golang:1.25-bookworm (Dockerfile:57) and permit-opa's go.mod is still `go 1.25.0`. The official golang images ship GOTOOLCHAIN=local, so a 1.25 builder hard-fails rather than fetching a newer toolchain, which is why the base bump has to land first. permitio/permit-opa#49 raises the floor as far as Go 1.25 allows (0.55.0, which clears CVE-2026-56854) and bumps grpc to 1.83.2. Two subcomponent PURLs are listed on purpose: 0.53.0 is what the build resolves until permit-opa#49 merges and 0.55.0 is what it resolves after, and docker scout matches VEX subcomponents on the FULL PURL string, so a statement naming only one of them silently stops suppressing the moment the version moves. Note also that scout emits Go PURLs WITHOUT the conventional `v` prefix (pkg:golang/golang.org/x/crypto@0.53.0, not @v0.53.0) - the first version of this waiver used @v0.55.0 and bound to nothing at all. Removal gate, both closable inside Permit: land PDP#334 (moves the OPA builder to golang:1.26-bookworm), then move permit-opa's go directive to 1.26.0 and x/crypto to >= 0.56.0, then delete this statement. Nothing here waits on Go. Tracked under PER-15358." + "justification": "vulnerable_code_not_present", + "impact_statement": "CVE-2026-56855 is a connection-deadlock DoS - crafted post-establishment channel messages are buffered instead of being treated as a protocol error in golang.org/x/crypto/ssh. It reaches the pdp-v2 image only because /app/bin/opa is compiled from permitio/permit-opa, which carries x/crypto as an INDIRECT dependency. The vulnerable package is not present in the binary: `go list -deps ./cmd/opa` in permit-opa resolves exactly these x/crypto packages - cryptobyte(+asn1), chacha20, chacha20poly1305, internal/poly1305, internal/alias, pbkdf2, curve25519, blake2b, salsa20/salsa, nacl/box, nacl/secretbox, hkdf, sha3 - and golang.org/x/crypto/ssh is absent from that list, so the SSH transport code is never linked, let alone reachable. The PDP additionally speaks no SSH on any code path of its own. The justification is `vulnerable_code_not_present` rather than `vulnerable_code_not_in_execute_path` on purpose: the x/crypto MODULE is present (cryptobyte, chacha20, curve25519 and the rest are linked), but the ssh package's code is excluded at link time by Go's import graph, which is what OpenVEX defines that label to mean. `not_in_execute_path` would assert the weaker and different claim that the ssh code ships and merely is not called. Both CVEs are fixed only in x/crypto >= 0.56.0, whose go.mod declares `go 1.26.0`. That blocker is LOCAL, not upstream: Go 1.26 has been stable since 2026-02-10 (1.26.8 current, 1.27.0 released) and golang:1.26-bookworm exists, but this image still builds its OPA on golang:1.25-bookworm (the opa_build stage) and permit-opa's go.mod is still `go 1.25.0`. The official golang images ship GOTOOLCHAIN=local, so a 1.25 builder hard-fails rather than fetching a newer toolchain, which is why the base bump has to land first. permitio/permit-opa#49 raises the floor as far as Go 1.25 allows (0.55.0, which clears CVE-2026-56854) and bumps grpc to 1.83.2. Two subcomponent PURLs are listed on purpose: 0.53.0 is what the build resolves until permit-opa#49 merges and 0.55.0 is what it resolves after, and docker scout matches VEX subcomponents on the FULL PURL string, so a statement naming only one of them silently stops suppressing the moment the version moves. Note also that scout emits Go PURLs WITHOUT the conventional `v` prefix (pkg:golang/golang.org/x/crypto@0.53.0, not @v0.53.0) - the first version of this waiver used @v0.55.0 and bound to nothing at all. Removal gate, both closable inside Permit: land PDP#334 (moves the OPA builder to golang:1.26-bookworm), then move permit-opa's go directive to 1.26.0 and x/crypto to >= 0.56.0, then delete this statement. Nothing here waits on Go. Tracked under PER-15358." }, { "vulnerability": { diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f22b34c7..027b90a3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -80,10 +80,19 @@ jobs: # the OPA binary's Go dependency versions, and it does not - those come from # permit-opa's go.sum (see permitio/permit-opa#49). # - # The Rust stages are untouched by this filter, which is not the same as saying - # they cache cleanly: `rust_planner`'s `COPY . .` also sees the regenerated - # tarball, so it and the final `cargo zigbuild` re-run every build regardless. - # What does still cache is `cargo chef cook`, keyed on recipe.json. + # The Rust stages are untouched by this filter, and in practice they barely cache at + # all: `rust_planner`'s `COPY . .` sees the regenerated tarball, so it and the final + # `cargo zigbuild` re-run every build. `cargo chef cook` re-runs too, despite being + # keyed on recipe.json - on run 34858998837 it was the single longest step at 404.6s. + # The likely cause is that no Cargo.lock is tracked (it is also .dockerignore'd), so + # `cargo chef prepare` resolves crates.io fresh each build and the lock embedded in + # recipe.json drifts. Do not budget this filter against a cached dependency compile. + # + # The only stage that DID cache on that run is `rust_chef` (#15-#17), whose + # `apk add musl-dev openssl-dev zig pkgconf perl make` is a package-resolving layer + # of exactly the class this filter exists to de-stale - outside the filter, and worth + # knowing about. Impact is limited because pdp-server vendors OpenSSL rather than + # linking that cached openssl-dev, so the staleness stays in the build toolchain. no-cache-filters: main - name: Build and push PDP image - (official release) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c78f3a2e..01cf3a59 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -87,7 +87,8 @@ jobs: # packages the release would not actually contain. # # Plural input on purpose - `no-cache-filter` is the buildx CLI flag, the action - # input is `no-cache-filters`, and an undeclared input is silently dropped. + # input is `no-cache-filters`, and an undeclared input is dropped (with an + # `Unexpected input(s)` warning annotation, which is easy to miss in a green run). # See PER-15358 and the long note in release.yml. no-cache-filters: main @@ -249,7 +250,7 @@ jobs: # which are fixed only in x/crypto >=0.56.0, whose go.mod declares `go 1.26.0`. # Go 1.26 has been stable since 2026-02-10 (1.26.8 is current, and 1.27.0 is out) # - the blocker is local, not upstream. The OPA builder is still - # golang:1.25-bookworm (Dockerfile:57) and permit-opa's go.mod is `go 1.25.0`, + # golang:1.25-bookworm (the opa_build stage) and permit-opa's go.mod is `go 1.25.0`, # and the official golang images ship GOTOOLCHAIN=local so the toolchain cannot # move implicitly. Gated on PDP#334 plus a matching permit-opa change, not on Go. # See PER-15358. diff --git a/Dockerfile b/Dockerfile index 7ccaf0c7..841209ea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -109,16 +109,23 @@ RUN --mount=type=cache,target=/go/pkg/mod \ # landed in 3.13.15 (also 3.14.7). The base tag floats, so the current build resolves # 3.13.15 and the waiver has been REMOVED from .docker/scout/pdp-v2.vex.json. # Do not drop below 3.13.15 - that is the floor for every fix named above. The apk layer -# below asserts it (`sys.version_info >= (3,13,15)`), because nothing else does. Removing -# the CVE-2026-15308 waiver is NOT what enforces it, for three independent reasons: -# scout reads packages and never reports the CPython interpreter at all (see the NOTE in -# tests.yml - run 34620527942 lists 15 findings across 6 packages, apk/golang/pypi, and no -# CPython entry of any kind); the gate is `pull_request`-only, so a release never scans; -# and the removed waiver bound its subcomponent to `pkg:generic/python`, which is not a -# PURL scout emits, so it was almost certainly suppressing nothing to begin with. Catching -# a stale interpreter through a scanner needs a CPE-based one - that is the companion -# change under PER-15358. Until it lands, the assert is the control, and unlike the gate -# it runs on every build path, releases included. +# below enforces it, because nothing else does. Removing the CVE-2026-15308 waiver is NOT +# what enforces it, for three independent reasons: scout reads packages and never reports +# the CPython interpreter at all (see the NOTE in tests.yml - run 34620527942 lists 15 +# findings across 6 packages, apk/golang/pypi, and no CPython entry of any kind); the gate +# is `pull_request`-only, so a release never scans; and the removed waiver bound its +# subcomponent to `pkg:generic/python`, which is not a PURL scout emits, so it was almost +# certainly suppressing nothing to begin with. Catching a stale interpreter through a +# scanner needs a CPE-based one - that is the companion change under PER-15358. +# +# The check imports ssl/hashlib/zlib/lzma/bz2/ctypes/pyexpat/decimal BEFORE testing the +# version, and that is the point, not decoration: `sys` is a builtin, so `import sys` alone +# loads no extension modules at all and a version-only check would pass on an interpreter +# whose entire lib-dynload is unresolvable - precisely what the .python-rundeps surgery +# above produces if the `so:`-derived list ever comes back short. It uses sys.exit rather +# than `assert`, which -O / PYTHONOPTIMIZE strips. A cached `main` layer skips the check, +# but a cache hit implies an unchanged parent and so an unchanged base digest, so the floor +# still holds; both workflows pass `no-cache-filters: main` regardless. # # The patch version floats deliberately (see the previous python:3.10-alpine3.22 base and # the rebuild-picks-it-up posture in PER-15532). Note what that posture costs if nothing @@ -185,7 +192,7 @@ RUN --mount=type=cache,target=/var/cache/apk \ apk add --no-cache --virtual .python-rundeps-nosqlite \ $(apk info -qR .python-rundeps | grep '^so:' | grep -v 'libsqlite3') && \ apk del .python-rundeps sqlite-libs && \ - python3 -c "import sys; assert sys.version_info[:3] >= (3, 13, 15), sys.version" + python3 -c "import sys, ssl, hashlib, zlib, lzma, bz2, ctypes, pyexpat, decimal; sys.version_info[:3] >= (3, 13, 15) or sys.exit('CPython %s is below the 3.13.15 floor' % sys.version)" # Copy OPA binary from the build stage diff --git a/requirements.txt b/requirements.txt index 62a07c80..b55f8dc1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -84,7 +84,7 @@ websockets==17.0 # live directive such as core.hooksPath, giving arbitrary code execution through git's hook # mechanism on any unrelated GitConfigParser write. 3.1.59 also closes CVE-2026-78675 and # CVE-2026-78677, and 3.1.58 closes CVE-2026-76218 / CVE-2026-76219 / CVE-2026-76220 / -# CVE-2026-76222 + GHSA-jm78-9fvv-mhgr (all HIGH) - seven advisories cleared by this floor. +# CVE-2026-76222 + GHSA-jm78-9fvv-mhgr (all HIGH) - eight advisories cleared by this floor. # # BE PRECISE ABOUT WHAT THIS LINE DOES, because it is easy to over-read: it changes no # resolution on any current build path. opal-common's `gitpython<4,>=3.1.32` is the only @@ -118,8 +118,10 @@ websockets==17.0 # be what separates them. What separates them is the cost of drift: websockets drift puts # an unvalidated MAJOR on the live OPAL pub/sub path (#329), and ddtrace/starlette drift # unbinds a VEX waiver PURL and reds the scout gate. GitPython has neither - no waiver, -# nothing first-party imports it, and `<4` caps its drift to a PATCH inside 3.1.x, which -# is a normal posture. Pinning it exactly would make this repo the owner of an upstream's +# nothing first-party imports it. Be precise about what `<4` does bound: it admits 3.2.0 +# and anything else below 4.0, so it caps drift at a MAJOR, not at a patch inside 3.1.x - +# verified with packaging's SpecifierSet. That is still the right posture for a package +# nothing here calls; pinning it exactly would make this repo the owner of an upstream's # cadence for no security gain, against a package that shipped eight releases in the seven # weeks to 3.1.62. Keeping `<4` is not redundancy for its own sake either - it is our own # bound, so it still holds if opal-common ever relaxes to allow a 4.x major. From 9676cfcc4ff4cb47f6dd8a0d142f9471ff016c0b Mon Sep 17 00:00:00 2001 From: David Shoen Date: Wed, 16 Sep 2026 10:48:46 +0300 Subject: [PATCH 10/10] fix: make the interpreter check detect what it claims, and fix four counts Zeev approved round 3 with seven non-blocking findings, two of which he established by executing rather than reading. Both of those land on the control I added last round. Every finding was re-verified before changing anything. The check could be defeated by its own import list. decimal.py and hashlib.py both fall back silently to pure Python when their C extension is missing - `try: from _decimal import *` / `except ImportError: import _pydecimal` - so importing them detected nothing. Verified by blocking `_decimal`/`_hashlib` behind a meta_path finder: the verbatim old line exited 0 with `_pydecimal used: True` and `_hashlib loaded: False`. The other six do propagate, `ssl` included, so the line still caught a wholesale lib-dynload wipe - this is a narrowing, not a breakage. It now imports the underscore modules directly, which removes the asymmetry. Not taking the suggested import list verbatim: it included `_gdbm`, which is absent from this machine's CPython 3.13 (`ModuleNotFoundError`), and readline/_curses/_gdbm guard libs the PDP never uses. Requiring them risks failing a build for no security reason, so the list stops at the nine that cover libssl, libcrypto, libz, liblzma, libbz2, libffi, libuuid and lib-dynload itself. The version floor was a single global tuple compare, so 3.14.0-3.14.6 passed - exactly the versions the comment four lines above says lack the CVE-2026-15308 backport, which landed in 3.13.15 and 3.14.7. Now branch-aware via `{13: (3,13,15), 14: (3,14,7)}.get(v[1], (3,15,0))`, tested across 3.12.11 / 3.13.14 / 3.13.15 / 3.14.0 / 3.14.6 / 3.14.7 / 3.15.0 / 4.0.0, and free of shell metacharacters. `pkg:generic/python` IS a PURL scout emits - `docker scout sbom` reports it for this base image, and the deleted waiver was bound to a component scout indexes. My claim came from the scan FINDINGS list, which only shows packages that have findings and says nothing about what the SBOM indexes; the inference was invalid regardless of the answer. Reason (a) is narrowed to match what tests.yml already says correctly, and the simple true reason - 3.13.15 carries the fix - is stated. The CVE-2026-48710 waiver claimed the app "registers no add_middleware/BaseHTTPMiddleware". False: horizon/pdp.py adopts OPAL's app and opal_client/client.py reaches opal_common/middleware.py:77, which registers CORSMiddleware. The conclusion survives - starlette/middleware/cors.py reads no `path`, no `url` and never builds a Request, dispatching only on Origin and scope['method'] - but the premise was wrong and is now stated accurately. vex.json v9 -> v10, which also clears both open siblings for a union merge. Counts, all regenerated from source rather than memory: - GHSA-jm78-9fvv-mhgr IS CVE-2026-76221, so listing it by GHSA id made the 76218-76222 run read as if it had a gap. Now listed by CVE id. - The 3.1.57 -> 3.1.59 move clears eleven distinct CVEs, eight of them HIGH-or-above; "eight advisories" read as a total. Both numbers now stated. - PyPI says nine GitPython releases in the seven weeks to 3.1.62, not eight. - The cache measurement came from a `pull_request` run of tests.yml built `--platform linux/amd64` and was quoted on a two-platform release step, where opa_build and main are each built twice with one leg under QEMU and a tag ref restores different GHA caches. The 404.6s figure carries over; the "longest step" ranking does not. `rust_chef` cached three of four layers, not all - #18 `rustup target add` re-ran. - `openssl-dev` and `ENV OPENSSL_DIR=/usr` are dead config, since pdp-server vendors OpenSSL. Named as dead and tracked rather than removed here; pulling build config out of the Rust stage is its own change. Co-Authored-By: Claude Opus 5 --- .docker/scout/pdp-v2.vex.json | 4 +-- .github/workflows/release.yml | 30 ++++++++++++++------- Dockerfile | 51 ++++++++++++++++++++++------------- requirements.txt | 8 ++++-- 4 files changed, 61 insertions(+), 32 deletions(-) diff --git a/.docker/scout/pdp-v2.vex.json b/.docker/scout/pdp-v2.vex.json index 9ed7dc95..2d8e4fda 100644 --- a/.docker/scout/pdp-v2.vex.json +++ b/.docker/scout/pdp-v2.vex.json @@ -3,7 +3,7 @@ "@id": "https://openvex.dev/docs/permitio-pdp/pdp-v2-cve-waivers", "author": "Permit.io", "timestamp": "2026-09-15T00:00:00Z", - "version": 9, + "version": 10, "statements": [ { "vulnerability": { @@ -85,7 +85,7 @@ ], "status": "not_affected", "justification": "vulnerable_code_not_in_execute_path", - "impact_statement": "CVE-2026-48710 lets a malformed HTTP Host header make request.url.path diverge from the path actually routed, so security checks that read request.url can be bypassed. The PDP performs no security decision on request.url: authentication is enforced per-route through FastAPI Depends security dependencies (HTTPBearer in horizon/authentication.py), not by path matching, and the app registers no add_middleware/BaseHTTPMiddleware and reads no raw ASGI scope paths. The one path-keyed authorization check - the write-route test in horizon/proxy/api.py cloud_proxy - reads request.path_params, which Starlette's router populates from the raw scope path and which this CVE explicitly leaves unaffected. The fix lands only in starlette >= 1.0.1, but opal-common/opal-client 0.9.6 cap starlette<1, so the PDP is pinned to 0.50.0 (see requirements.txt). This waiver is temporary and must be removed once OPAL relaxes its starlette<1 bound and starlette is upgraded to >= 1.3.1. Tracked under PER-15358." + "impact_statement": "CVE-2026-48710 lets a malformed HTTP Host header make request.url.path diverge from the path actually routed, so security checks that read request.url can be bypassed. The PDP performs no security decision on request.url: authentication is enforced per-route through FastAPI Depends security dependencies (HTTPBearer in horizon/authentication.py), not by path matching. The app is not middleware-free, as an earlier version of this statement claimed: horizon/pdp.py adopts OPAL's FastAPI app, and opal_client/client.py calls configure_middleware, which registers Starlette's CORSMiddleware at opal_common/middleware.py:77. That does not put the PDP on the vulnerable path - CORSMiddleware dispatches solely on the Origin header and scope['method'] (starlette/middleware/cors.py reads no `path`, no `url`, and never constructs a Request), so it cannot observe the divergence this CVE induces between request.url.path and the routed path. It is the only add_middleware call in the image; no BaseHTTPMiddleware is registered anywhere, and no first-party code reads raw ASGI scope paths. The one path-keyed authorization check - the write-route test in horizon/proxy/api.py cloud_proxy - reads request.path_params, which Starlette's router populates from the raw scope path and which this CVE explicitly leaves unaffected. The fix lands only in starlette >= 1.0.1, but opal-common/opal-client 0.9.6 cap starlette<1, so the PDP is pinned to 0.50.0 (see requirements.txt). This waiver is temporary and must be removed once OPAL relaxes its starlette<1 bound and starlette is upgraded to >= 1.3.1. Tracked under PER-15358." }, { "vulnerability": { diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 027b90a3..7ec35693 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,17 +82,27 @@ jobs: # # The Rust stages are untouched by this filter, and in practice they barely cache at # all: `rust_planner`'s `COPY . .` sees the regenerated tarball, so it and the final - # `cargo zigbuild` re-run every build. `cargo chef cook` re-runs too, despite being - # keyed on recipe.json - on run 34858998837 it was the single longest step at 404.6s. - # The likely cause is that no Cargo.lock is tracked (it is also .dockerignore'd), so - # `cargo chef prepare` resolves crates.io fresh each build and the lock embedded in - # recipe.json drifts. Do not budget this filter against a cached dependency compile. + # `cargo zigbuild` re-run every build. `cargo chef cook` re-runs too despite being + # keyed on recipe.json - 404.6s on tests.yml run 34858998837. The likely cause is + # that no Cargo.lock is tracked (it is also .dockerignore'd), so `cargo chef prepare` + # resolves crates.io fresh each build and the lock embedded in recipe.json drifts. + # Do not budget this filter against a cached dependency compile. # - # The only stage that DID cache on that run is `rust_chef` (#15-#17), whose - # `apk add musl-dev openssl-dev zig pkgconf perl make` is a package-resolving layer - # of exactly the class this filter exists to de-stale - outside the filter, and worth - # knowing about. Impact is limited because pdp-server vendors OpenSSL rather than - # linking that cached openssl-dev, so the staleness stays in the build toolchain. + # READ THAT MEASUREMENT NARROWLY: it comes from a `pull_request` run of tests.yml + # built `--platform linux/amd64`. The rust stages are `--platform=$BUILDPLATFORM` so + # their cost carries over, but THIS step builds amd64+arm64, so opa_build and main + # are each built twice with the second leg under QEMU - and a tag ref restores + # different GHA caches than a PR ref. Do not read that run's cache profile, or any + # "longest step" ranking from it, as this step's. + # + # On that run `rust_chef` cached three of its four layers (#15-#17); #18 + # `rustup target add` re-ran. Its `apk add musl-dev openssl-dev zig pkgconf perl make` + # is a package-resolving layer of exactly the class this filter exists to de-stale, + # sitting outside the filter. Impact is limited because pdp-server vendors OpenSSL + # (pdp-server/Cargo.toml) rather than linking that cached openssl-dev, so the + # staleness stays in the build toolchain and never reaches the shipped image. That + # also makes `openssl-dev` and `ENV OPENSSL_DIR=/usr` dead config; removing them is + # out of scope here and tracked separately under PER-15358. no-cache-filters: main - name: Build and push PDP image - (official release) diff --git a/Dockerfile b/Dockerfile index 841209ea..37833f15 100644 --- a/Dockerfile +++ b/Dockerfile @@ -108,24 +108,39 @@ RUN --mount=type=cache,target=/go/pkg/mod \ # unreachable while it was patched only in 3.15.0b4, but CPython backported the fix and it # landed in 3.13.15 (also 3.14.7). The base tag floats, so the current build resolves # 3.13.15 and the waiver has been REMOVED from .docker/scout/pdp-v2.vex.json. -# Do not drop below 3.13.15 - that is the floor for every fix named above. The apk layer -# below enforces it, because nothing else does. Removing the CVE-2026-15308 waiver is NOT -# what enforces it, for three independent reasons: scout reads packages and never reports -# the CPython interpreter at all (see the NOTE in tests.yml - run 34620527942 lists 15 -# findings across 6 packages, apk/golang/pypi, and no CPython entry of any kind); the gate -# is `pull_request`-only, so a release never scans; and the removed waiver bound its -# subcomponent to `pkg:generic/python`, which is not a PURL scout emits, so it was almost -# certainly suppressing nothing to begin with. Catching a stale interpreter through a -# scanner needs a CPE-based one - that is the companion change under PER-15358. +# Do not drop below the patched floor for whichever branch the base resolves to: 3.13.15 on +# the 3.13 line, 3.14.7 on 3.14. The apk layer below enforces exactly that, per branch - +# a flat `>= (3,13,15)` would have passed 3.14.0 through 3.14.6, which are the versions the +# line above says still lack the backport. # -# The check imports ssl/hashlib/zlib/lzma/bz2/ctypes/pyexpat/decimal BEFORE testing the -# version, and that is the point, not decoration: `sys` is a builtin, so `import sys` alone -# loads no extension modules at all and a version-only check would pass on an interpreter -# whose entire lib-dynload is unresolvable - precisely what the .python-rundeps surgery -# above produces if the `so:`-derived list ever comes back short. It uses sys.exit rather -# than `assert`, which -O / PYTHONOPTIMIZE strips. A cached `main` layer skips the check, -# but a cache hit implies an unchanged parent and so an unchanged base digest, so the floor -# still holds; both workflows pass `no-cache-filters: main` regardless. +# Removing the CVE-2026-15308 waiver is NOT what enforces it. Scout indexes the interpreter +# - `docker scout sbom` reports `pkg:generic/python@3.13.15` for this base - but it does not +# match CPython advisories against it the way a CPE-based scanner does (see the NOTE in +# tests.yml), and the gate is `pull_request`-only so a release never scans at all. Catching +# a stale interpreter through a scanner needs a CPE-based one; that is the companion change +# under PER-15358. The plain reason the waiver could go is simply that 3.13.15 carries the +# fix. +# +# The check imports the C extension modules DIRECTLY - `_ssl`, `_hashlib`, `_decimal` and +# friends rather than `ssl`, `hashlib`, `decimal` - and that is the point, not decoration. +# `sys` is a builtin, so `import sys` alone loads no extension modules at all and a +# version-only check would pass on an interpreter whose lib-dynload is unresolvable. But +# the public wrappers are not reliable either: decimal.py and hashlib.py both fall back +# silently to pure Python when their .so is missing, so importing them detects nothing, +# while ssl/zlib/lzma/bz2/ctypes/pyexpat do propagate. Importing the underscore modules +# removes that asymmetry. Between them these nine cover libssl, libcrypto, libz, liblzma, +# libbz2, libffi, libuuid and lib-dynload itself - i.e. the `so:` deps the .python-rundeps +# rework above could strip if its `grep '^so:'` list ever comes back short. Deliberately +# NOT extended to readline/_curses/_gdbm: they guard libs the PDP never uses, and `_gdbm` +# is absent from some perfectly good CPython builds, so requiring it would fail the build +# for no security reason. +# +# It uses sys.exit rather than `assert`, which -O / PYTHONOPTIMIZE strips. A cached `main` +# layer skips the check, but a cache hit implies an unchanged parent and so an unchanged +# base digest, so the floor still holds; both workflows pass `no-cache-filters: main` +# regardless. Note the check runs in this apk layer, before the `.build-deps` install and +# removal around `pip install` further down - so it proves the interpreter survived the +# sqlite surgery, not that it survives every later package mutation. # # The patch version floats deliberately (see the previous python:3.10-alpine3.22 base and # the rebuild-picks-it-up posture in PER-15532). Note what that posture costs if nothing @@ -192,7 +207,7 @@ RUN --mount=type=cache,target=/var/cache/apk \ apk add --no-cache --virtual .python-rundeps-nosqlite \ $(apk info -qR .python-rundeps | grep '^so:' | grep -v 'libsqlite3') && \ apk del .python-rundeps sqlite-libs && \ - python3 -c "import sys, ssl, hashlib, zlib, lzma, bz2, ctypes, pyexpat, decimal; sys.version_info[:3] >= (3, 13, 15) or sys.exit('CPython %s is below the 3.13.15 floor' % sys.version)" + python3 -c "import sys, _ssl, _hashlib, _decimal, zlib, _lzma, _bz2, _ctypes, pyexpat, _uuid; v = sys.version_info[:3]; v >= {13: (3, 13, 15), 14: (3, 14, 7)}.get(v[1], (3, 15, 0)) or sys.exit('CPython %s is below the patched floor for its branch' % sys.version)" # Copy OPA binary from the build stage diff --git a/requirements.txt b/requirements.txt index b55f8dc1..380631a4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -84,7 +84,11 @@ websockets==17.0 # live directive such as core.hooksPath, giving arbitrary code execution through git's hook # mechanism on any unrelated GitConfigParser write. 3.1.59 also closes CVE-2026-78675 and # CVE-2026-78677, and 3.1.58 closes CVE-2026-76218 / CVE-2026-76219 / CVE-2026-76220 / -# CVE-2026-76222 + GHSA-jm78-9fvv-mhgr (all HIGH) - eight advisories cleared by this floor. +# CVE-2026-76221 / CVE-2026-76222 - eight HIGH-or-above advisories cleared by this floor. +# Three further MODERATE ones (CVE-2026-76217, CVE-2026-78678, CVE-2026-78679) come with +# them, so the move clears eleven CVEs in total. CVE-2026-76221 is listed by CVE id rather +# than as GHSA-jm78-9fvv-mhgr, which is the same advisory - by GHSA id it reads as a gap in +# the 76218-76222 run. # # BE PRECISE ABOUT WHAT THIS LINE DOES, because it is easy to over-read: it changes no # resolution on any current build path. opal-common's `gitpython<4,>=3.1.32` is the only @@ -122,7 +126,7 @@ websockets==17.0 # and anything else below 4.0, so it caps drift at a MAJOR, not at a patch inside 3.1.x - # verified with packaging's SpecifierSet. That is still the right posture for a package # nothing here calls; pinning it exactly would make this repo the owner of an upstream's -# cadence for no security gain, against a package that shipped eight releases in the seven +# cadence for no security gain, against a package that shipped nine releases in the seven # weeks to 3.1.62. Keeping `<4` is not redundancy for its own sake either - it is our own # bound, so it still holds if opal-common ever relaxes to allow a 4.x major. #