fix: clear CPython CVEs from customer scan of 0.9.14-rc1 (PER-15358) - #329
Conversation
A customer CPE scan of permitio/pdp-v2:0.9.14-rc1 raised 9 CVEs. Three were the starlette findings already waived in that very commit (0.9.14-rc1 IS 8b9b17f) - their scanner does not read OpenVEX. Two are quic-go in the OPA binary and belong in permit-opa. The remaining four are CPython itself. Fixed by moving the base image python:3.10-alpine3.22 -> python:3.13-alpine3.23: * CVE-2026-6019 - http.cookies Morsel.js_output escaping (fixed in 3.13.14) * CVE-2026-7210 - expat hash-flooding entropy (needs 3.13.14 AND libexpat >= 2.8.0; the new base ships expat 2.8.1) * CVE-2023-36632 - email.utils.parseaddr recursion. DISPUTED by PSF and never fixed, but its CPE range is < 3.11.4, so 3.13 is out of it. These were NOT scanner false positives. PSF fixed them only on the 3.13/3.14/ 3.15 branches - each fix commit is a [3.13]/[3.14]/[3.15] backport and there is no 3.10/3.11/3.12 equivalent - so the vulnerable code really was present in 3.10.20. Verified in the shipped image: js_output did the bare quote-replace with no encoding, and pyexpat linked expat 2.7.4. An upgrade was the only fix. Python 3.10 also reaches EOL in October 2026, so this move was due regardless. Waived: * CVE-2026-15308 (html.parser quadratic DoS) - cannot be fixed by anyone today. PSF patched it only in 3.15.0b4 and NVD's range is < 3.15.0, so no released Python satisfies it. Unreachable here: nothing imports html.parser, not the PDP's own code and not any package in the image (verified by scanning every .py under site-packages). The base image patch version floats deliberately, so 3.13.15 will clear this automatically and the waiver can then be dropped. Not addressed here (different repo): * CVE-2025-64702 / CVE-2026-40898 - quic-go v0.54.1 in /app/bin/opa, fixed in 0.57.0 and 0.59.1. `go version -m` confirms the dep, and 368 http3 references survive in the stripped binary (http3.Transport, http3.Conn, http3.RoundTripOpt), so the vulnerable QPACK decode path IS linked - though only the client is present, there is no http3.Server. Fix is `go get quic-go@v0.59.1` in permit-opa and MUST merge to permit-opa@main before a PDP rebuild picks it up. ruff's target-version is deliberately left at py310 while the runtime moves to 3.13. It is a lint floor, not a runtime constraint; raising it surfaces 5 findings unrelated to this work (3x ASYNC109 wanting `timeout` parameters rewritten as asyncio.timeout() context managers in horizon/facts/update_subscriber.py and horizon/proxy/api.py, plus UP041/UP043) that would fail the pre-commit job. mypy's python_version does move to 3.13 to track the real interpreter. Also documents a gap in the Scout gate: it reads packages, so it never sees the CPython interpreter the way a CPE scanner does. That blind spot is why four Python CVEs reached a customer without failing CI. Verified by building the main stage on the new base: Python 3.13.14, expat 2.8.1, all deps import (incl. opal-client/opal-common 0.9.6, pydantic 1.10.26), and the .python-rundeps sqlite surgery still works - sqlite3, pip and ensurepip all removed. horizon.main exits SystemExit(1) on a missing API key on both 3.10 and 3.13, so that is baseline behaviour and not a 3.13 regression. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🔍 Vulnerabilities of
|
| digest | sha256:5da584aba45bfe2873d2c4407ecc37af100219c6a83671233cc65a5f63d7fa09 |
| vulnerabilities | |
| platform | linux/amd64 |
| size | 133 MB |
| packages | 250 |
📦 Base Image python:3.13-alpine3.23
| also known as |
|
| digest | sha256:72c39ab9dbf2227aa91ec2246e6492260ee2530c36bdf37b208394b42d757b60 |
| vulnerabilities |
Description
Description
Description
Description
Description
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Description
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Description
Description
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Description
Description
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Description
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Description
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # 3.13.14 - that is the floor for the fixes above. | ||
| # | ||
| # 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 |
There was a problem hiding this comment.
You updated from 3.10 to 3.13 ? this probably needs some verifications that every package we use is compatible with it
There was a problem hiding this comment.
You were right that this needed checking — and it turned out CI wasn't checking it at all. The pytests job pinned Python 3.11.8 while the image ran 3.10, so the unit suite had never executed on the shipped interpreter. Pushed 240c74e to run it on 3.13 so the two stay in lockstep.
Verified before and after that change:
In CI (run 30533253549)
pytestson CPython 3.13.14 — 118 passedbuild-pdp-image— all deps install on musl/alpine 3.23;pydantic1.10.26 Cython-compiles from sourcepdp-tester— 24/24 e2e against the built 3.13 image (enforcement rbac/rebac/derivation, user_permissions, sync_facts, health, logs), no retries
Against the real artifact — pulled the pdp-image artifact so this is the shipped image, not a re-resolve
- 118 unit tests pass inside the amd64 image — musl, its own dependency set,
setuptoolsabsent - 118 pass on a native arm64 musl build of the Python layers; CI only builds
linux/amd64, so that surface had no coverage - Image confirms
Python 3.13.14/expat_2.8.1— the CVE fixes are in the artifact, not just in the advisory ranges - All 86 installed top-level packages import
Static
- Nothing in
horizon/uses an API changed or removed in 3.12/3.13 — nodatetime.utcnow, bareget_event_loop(),locale.getdefaultlocale,ssl.wrap_socket, or removedast/pkgutilspellings. The onlyDeprecationWarningwe raise is FastAPI'son_eventathorizon/pdp.py:446-447, which fires on 3.10 too. - One non-obvious risk specific to this image: 3.12 removed
distutilsfrom the stdlib, and the Dockerfile uninstallssetuptools, so there's no_distutils_hackshim either — a stray runtimeimport distutilsthat worked on 3.10 is a hard failure here. Verified the runtime import graph is clean withdistutils/setuptools/pkg_resourcesblocked; the remaining references in deps (cffi shims, ddtrace's vendored pybind11/psutil setup helpers) are build-time only.
Two things this turned up that aren't about Python, both better as separate tickets than as scope here:
websocketsisn't pinned, and it drifted mid-review. The build that first validated this PR shipped16.1.1;17.0published a few hours later, so any rebuild picked up a new major on the OPAL pubsub path with nothing capping it (fastapi-websocket-pubsuballows>=14.0). The current run does build and e2e-test17.0, so we're covered now — but 18.0 can do the same thing. Same class as the starlette/ddtrace drift therequirements.txtcomments describe.test_warn_if_opal_verifier_disabled_firesis environment-dependent. It passes in CI but fails when run inside the image: the image bakesOPAL_AUTH_PUBLIC_KEY(Dockerfile:229), so the verifier is enabled and the warning correctly doesn't fire — the test's premise at line 112 only holds outside the image.
There was a problem hiding this comment.
Nothing in horizon/ uses an API changed or removed in 3.12/3.13 — no datetime.utcnow, bare get_event_loop(), locale.getdefaultlocale, ssl.wrap_socket, or removed ast/pkgutil spellings. The only DeprecationWarning we raise is FastAPI's on_event at horizon/pdp.py:446-447, which fires on 3.10 too.
Verify this statement is true also for the used opal-client and opal-common packages ( including fastapi websocket pubsub and rpc packages ) - if possible just check the entire dependency tree somehow
websockets isn't pinned, and it drifted mid-review. The build that first validated this PR shipped 16.1.1; 17.0 published a few hours later, so any rebuild picked up a new major on the OPAL pubsub path with nothing capping it (fastapi-websocket-pubsub allows >=14.0). The current run does build and e2e-test 17.0, so we're covered now — but 18.0 can do the same thing. Same class as the starlette/ddtrace drift the requirements.txt comments describe.
Lets pin it - we don't know what major side effects this can have
The pytests job pinned Python 3.11.8 while the image ran 3.10, so the unit suite has never executed on the shipped interpreter and a regression that only reproduces there could not fail CI. Moving the image to 3.13 widened that gap, which is what Omer flagged in review on #329. Verified before changing it, against the real artifact (the pdp-image from run 30447703110, the same run whose pdp-tester passed): - 118/118 unit tests inside the real amd64 image - musl, its own dependency resolution, no setuptools shim - 118/118 on a native arm64 build of the Python layers, which CI never builds (linux/amd64 only); pydantic 1.10.26 Cython-compiles from source on musl/arm64 against 3.13 - image confirms Python 3.13.14 and expat 2.8.1 - the CVE fixes are in the artifact, not just the advisory ranges - no code in horizon/ uses any API changed or removed in 3.12/3.13; the only DeprecationWarning we raise is FastAPI's on_event, which fires on 3.10 too Patch version floats, matching the Dockerfile base, so the two move together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing else bounds websockets: opal-common/opal-client and fastapi-websocket-rpc allow >=10.3, fastapi-websocket-pubsub >=14.0, uvicorn[standard] >=13.0. With no lockfile, a rebuild silently absorbs new majors on the OPAL pub/sub path. That happened during review of #329. The CI build that first validated the Python 3.13 move shipped websockets 16.1.1; 17.0 published a few hours later, so the next rebuild would have carried an unvalidated major to a release. Pinning to 17.0 rather than 16.1.1 because 17.0 is the version run 30533253549 actually validated: 24/24 pdp-tester e2e against the built image, plus 118 unit tests on CPython 3.13.14. Verified the pin resolves against every lower bound above and that the suite still passes on the pinned tree (118/118, arm64 musl, 3.13.14). Requested by @omer9564 in review. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What prompted this
A customer ran a CPE-based scan against
permitio/pdp-v2:0.9.14-rc1and returned 9 CVEs, all markedStop=true. It's four separate problems wearing nine hats.Worth noting up front:
0.9.14-rc1is commit 8b9b17f — the "clear remaining CVEs" commit. Three of the nine findings are the exact starlette CVEs waived in that commit. Their scanner doesn't read OpenVEX, so our reasoning was invisible to them. That's a communication gap, not a code gap.Disposition
< 3.11.4rangepermit-opa— see belowThese were not scanner false positives
The tempting read is "CPE noise." That's wrong, and I checked the shipped image rather than trusting advisories:
[3.13]/[3.14]/[3.15]backport; there is no 3.10/3.11/3.12 equivalent.Morsel.js_output()did the bare.replace('"', r'\"')with no encoding, andpyexpatlinked expat 2.7.4, under the 2.8.0 the fix requires.So an interpreter upgrade was the only fix. Python 3.10 also hits EOL in October 2026, so this was due regardless.
Note 3.12 would have cleared nothing — NVD's ranges are unbounded-below (
< 3.13.14), so 3.12.13 still matches. ≥ 3.13.14 is required, which forces Alpine 3.22 → 3.23.Why CVE-2026-15308 is waived rather than fixed
Patched only in
3.15.0b4, and NVD's range is< 3.15.0— no released Python satisfies it; every Python image in the world matches today. It's also genuinely unreachable: nothing importshtml.parser, not our code and not any package in the image (verified by scanning every.pyundersite-packages).The base image patch version floats deliberately, so when 3.13.15 ships this clears automatically and the waiver can be dropped.
quic-go — needs a separate PR in
permit-opago version -m /app/bin/opaconfirmspermit-opa (devel)→quic-go v0.54.1. The vulnerable HTTP/3 code is linked: 368http3references survive in the stripped binary (http3.Transport,http3.Conn,http3.RoundTripOpt).Mitigating: only
http3.Transportis present — nohttp3.Server, so it's client-side only. Still has a real fix, unlike the starlette/ddtrace waivers.Action:
go get github.com/quic-go/quic-go@v0.59.1 && go mod tidyinpermit-opa. It's a transitive dep, so rungo mod why -mfirst. This must merge topermit-opa@mainbefore a PDP rebuild picks it up — same constraint as the oras-go bump in PER-15532.Deliberate scope calls
target-versionstayspy310while the runtime moves to 3.13. It's a lint floor, not a runtime constraint. Raising it produces 5 findings unrelated to this work — 3×ASYNC109wantingtimeoutparams rewritten asasyncio.timeout()context managers inupdate_subscriber.pyandproxy/api.py— which would fail the pre-commit job. Worth a follow-up.mypy'spython_versiondoes move to 3.13.python:3.13-alpine3.23), matching the previous base and the rebuild-picks-it-up posture.3.13.14is documented as the hard floor.Gap this exposes
The Scout gate reads packages, so it never sees the CPython interpreter the way a CPE scanner does. That blind spot is why four Python CVEs reached a customer while the gate was tight enough to catch a ddtrace baggage DoS. Documented inline in
tests.yml; worth a follow-up to add CPE scanning or a base-image freshness check.Verification
Built the
mainstage on the new base — the risky part being the.python-rundepssqlite surgery:opal-client/opal-common0.9.6 andpydantic1.10.26.Morsel.js_outputnow encodes its value — CVE-2026-6019 verifiably gone, not just version-bumped past.horizon.mainexitsSystemExit(1)on a missing API key on both 3.10 and 3.13 — baseline, not a regression.Full PDP tester hasn't run against this yet; that's what this PR is for.
🤖 Generated with Claude Code