diff --git a/.docker/scout/pdp-v2.vex.json b/.docker/scout/pdp-v2.vex.json index dd6367f2..2d8e4fda 100644 --- a/.docker/scout/pdp-v2.vex.json +++ b/.docker/scout/pdp-v2.vex.json @@ -2,28 +2,46 @@ "@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-15T00:00:00Z", + "version": 10, "statements": [ { "vulnerability": { - "name": "CVE-2026-48818" + "name": "CVE-2026-78662" }, "products": [ { "@id": "pkg:docker/permitio/pdp-v2@next", "subcomponents": [ - { "@id": "pkg:pypi/starlette@0.50.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-48818 is an SSRF / NTLM credential-theft issue in starlette.staticfiles that only triggers on Windows, via UNC-path resolution when serving a StaticFiles mount. The PDP runs exclusively on Linux (Alpine) and mounts no StaticFiles anywhere in the app (only API routers are mounted), so the vulnerable code path is never present or executed. 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." + "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": { - "name": "CVE-2026-54283" + "name": "CVE-2026-56855" + }, + "products": [ + { + "@id": "pkg:docker/permitio/pdp-v2@next", + "subcomponents": [ + { "@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_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": { + "name": "CVE-2026-48818" }, "products": [ { @@ -35,11 +53,11 @@ ], "status": "not_affected", "justification": "vulnerable_code_not_in_execute_path", - "impact_statement": "CVE-2026-54283 causes request.form() size limits to be silently ignored for application/x-www-form-urlencoded bodies, enabling a memory-exhaustion DoS. The PDP is a JSON-only API: it never calls request.form(), does not depend on python-multipart, and parses no form-encoded or multipart request bodies, so the vulnerable form-parsing path is never executed. The fix lands only in starlette >= 1.3.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-48818 is an SSRF / NTLM credential-theft issue in starlette.staticfiles that only triggers on Windows, via UNC-path resolution when serving a StaticFiles mount. The PDP runs exclusively on Linux (Alpine) and mounts no StaticFiles anywhere in the app (only API routers are mounted), so the vulnerable code path is never present or executed. 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-48710" + "name": "CVE-2026-54283" }, "products": [ { @@ -51,11 +69,11 @@ ], "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-54283 causes request.form() size limits to be silently ignored for application/x-www-form-urlencoded bodies, enabling a memory-exhaustion DoS. The PDP is a JSON-only API: it never calls request.form(), does not depend on python-multipart, and parses no form-encoded or multipart request bodies, so the vulnerable form-parsing path is never executed. The fix lands only in starlette >= 1.3.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": { - "name": "CVE-2026-48817" + "name": "CVE-2026-48710" }, "products": [ { @@ -67,23 +85,23 @@ ], "status": "not_affected", "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." + "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": { - "name": "CVE-2026-15308" + "name": "CVE-2026-48817" }, "products": [ { "@id": "pkg:docker/permitio/pdp-v2@next", "subcomponents": [ - { "@id": "pkg:generic/python" } + { "@id": "pkg:pypi/starlette@0.50.0" } ] } ], "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." + "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": { diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 96639ffe..7ec35693 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,6 +63,47 @@ jobs: tags: permitio/pdp-v2:${{ github.event.release.tag_name }} cache-from: type=gha cache-to: type=gha,mode=max + # 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, 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 - 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. + # + # 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) if: "!github.event.release.prerelease" @@ -74,6 +115,10 @@ 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. + # 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 aa2cc265..01cf3a59 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -78,6 +78,19 @@ 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 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 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 - name: Save Docker image as artifact run: docker save permitio/pdp-v2:next -o pdp-image.tar @@ -231,9 +244,16 @@ 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; 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, 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 (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. # # 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 @@ -245,6 +265,16 @@ 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 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 8f4a7b2f..37833f15 100644 --- a/Dockerfile +++ b/Dockerfile @@ -104,14 +104,47 @@ 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. +# 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. +# +# 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): 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 +162,36 @@ 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-filters: main` to force that stage to re-resolve on every release, and +# 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. +# 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 # the official python:alpine image pins via the .python-rundeps virtual package. @@ -143,7 +206,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, _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 5f57b852..380631a4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -76,5 +76,65 @@ 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-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 +# 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. 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. 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 - 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. 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 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. +# +# The real structural fix is a lockfile, which would make this whole transitive tree +# 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