…(PER-15358) (#337)
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KY2DZ9RpvA157tQzGAggtt
* 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) <noreply@anthropic.com>
* fix: address Zeev's review — the cache filter was inert, plus honest comments
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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Zeev Manilovich <zeevmanilovich@gmail.com>
Co-authored-by: David Shoen <dbshoen@gmail.com>
One line: excludes the new pdp-tester
policy.*cases from this repo'spdp-testerjob. Companion to permitio/pdp-tester#152 (PER-15775).Why this repo needs a change at all
.github/workflows/tests.ymlchecks outpermitio/pdp-testerwith no pinnedref:, so it tracks that repo'smain, and then runs:with no include or exclude list — which means it runs every test case it discovers. So a new case added in pdp-tester lands here automatically, on the next run, without anything in this repo changing.
What is landing there
pdp-tester#152 adds five
policy.*cases. Unlike every existing case, they create policy schema during the test — roles, resource types, and relations on the stockFile/Foldertypes — and only then the facts that depend on it. That is the point: facts ride the WAL while schema and rego ridePOLICY_DATA/POLICY_FILES, so a PDP with a stale policy plane answers the existing suite correctly and stays green.For this job it would mean schema writes into the shared staging Permit environment on every run, each triggering a rego rebuild pushed to every PDP in that environment — on top of roughly +50s of runtime from the propagation waits those cases need. Nothing here needs that coverage: the cases are meant for the pdp-tester and Cloud PDP releases, which name them explicitly.
Merge order
No constraint. This is safe to merge before or after pdp-tester#152 —
EXCLUDE_TESTSis a pattern, so it costs nothing while the cases do not exist, and is already in place if they land first.🤖 Generated with Claude Code