Skip to content

fix: clear all 12 CVEs from customer scan of published pdp-v2:0.9.14 (PER-15358) - #337

Merged
zeevmoney merged 10 commits into
mainfrom
fix/pdp-image-cves-sept-2026
Sep 16, 2026
Merged

zeevmoney merged 10 commits into
mainfrom
fix/pdp-image-cves-sept-2026

Conversation

@EliMoshkovich

@EliMoshkovich EliMoshkovich commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

Linear: PER-15886 · umbrella PER-15358

Customer report

We are planning to go into production with the newest pdp-v2 version. Our internal scanning found that the latest docker version 0.9.14 you offer on docker hub contains 2 critical vulnerabilities. Could you provide us an image where these OS vulnerabilities are fixed?

Their scan of permitio/pdp-v2:0.9.14: 21 findings / 12 unique CVEs, 100% OS packages, 2 CRITICAL + 10 HIGH.

Is it real? Yes — but it is base-image drift, not a source defect

I scanned the actual published manifest (both arches, digest sha256:f3031d83…, built 2026-08-04):

0.9.14 as published after a rebuild with no source change
libcrypto3 / libssl3 3.5.7-r0 3.5.8-r0
libuuid 2.41.4-r0 2.41.6-r1
CPython 3.13.14 3.13.15
trivy OS CRITICAL/HIGH 8 0

Alpine 3.23 published openssl 3.5.8-r0 and util-linux 2.41.6-r1 after 0.9.14 was built. The Dockerfile already does apk update && apk upgrade and already floats its base tag — it was correct. The image was simply never rebuilt. Nothing in the customer's OS list required a Dockerfile fix.

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.

The two "CRITICAL"s are not critical

Per OpenSSL's own advisory page, of the nine: six are Low, three are Moderate, none is Critical. Both CVEs the customer's scanner escalated to CRITICAL are OpenSSL-rated Low:

  • CVE-2026-63073 — untrusted CMP sender DN used as a format string. Needs the PDP to act as a CMP client. It does not. (Red Hat also rates Low)
  • CVE-2026-75803 — AEAD tag not verified on empty ciphertext when finalized via raw EVP_Cipher(). Needs direct EVP_Cipher() calls on ChaCha20-Poly1305/AES-OCB. Python's ssl/hashlib do not do this.

Also worth noting: the six libuuid findings are flagged against the util-linux source package, but every one is in mount/umount/nsenter — binaries that are not installed in this image. libuuid is UUID generation only. Cleared anyway by the version bump.

What I did find that the customer did NOT report

Their scan was OS-only. A library scan surfaced a materially worse issue:

GitPython 3.1.57 → CVE-2026-78676, a genuine 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 via git hooks on any unrelated GitConfigParser write.

GitPython is transitive via opal-common (gitpython<4,>=3.1.32) and nothing else bounded it, so the lockfile-less build took whatever was current. A rebuild would have fixed this only by luck. Now floored explicitly. 3.1.59 also closes CVE-2026-78675/-78677, and 3.1.58 closes CVE-2026-76218/-76219/-76220/-76222 + GHSA-jm78-9fvv-mhgr — seven advisories cleared by one floor.

Changes

  1. requirements.txt — floor GitPython>=3.1.59,<4. Clears 1 CRITICAL + 6 HIGH. <4 matches opal-common's own cap.
  2. .github/workflows/release.yml + tests.yml — no-cache-filters: main on all three build steps. This is the load-bearing fix. The GHA cache key is instruction text + parent layer, so a release cut weeks later would happily replay the 2026-08-04 apk upgrade / pip install layers and re-ship the exact packages the customer just flagged. (Originally written here as no-cache-filter: main,opa_build — round 1 corrected the spelling, which was making it inert, and dropped opa_build, which already re-executes. Round 3 corrected the claim that the Rust stages "cache normally": on run 34858998837 only rust_chef cached and cargo chef cook ran 404.6s.)
  3. .docker/scout/pdp-v2.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's own text said to remove it at exactly this point.
  4. Dockerfile — record why a correct Dockerfile still shipped stale packages, so the next person does not go looking for a source bug.

Verification

Built the exact base + apk + pip layers with --no-cache:

libcrypto3 3.5.8-r0   libssl3 3.5.8-r0   libuuid 2.41.6-r1
Python 3.13.15        GitPython 3.1.62   cryptography 50.0.1

trivy --severity CRITICAL,HIGH --pkg-types os,library
  → 12 customer CVEs: 0 remaining
  → GitPython CRITICAL: cleared

3 HIGHs remain, all pre-existing and already VEX-waived as unreachable behind OPAL's dependency caps: starlette CVE-2026-48818/-54283, ddtrace CVE-2026-50271. No change in this PR affects them.

To deliver to the customer

Merging is not enough — a release must be cut so a new image is actually published. Recommend 0.9.15 (v0.9.15-rc.1 already exists as a tag). Until a rebuild happens, 0.9.14 on Docker Hub keeps reporting these findings.

Review round 1 — @zeevmoney (all 7 threads resolved, 0d28883)

A strong review that caught a change of mine doing nothing at all. Every finding was verified independently before I changed anything.

Finding Outcome
HIGH no-cache-filter is not a valid input Fixed. Checked action.yml at v5.4.0 and v6.9.0 — only no-cache-filters exists; the singular is the buildx CLI flag. The input was being dropped, so this PR's "load-bearing" fix was inert and the stale-layer replay was still live.
HIGH spelling fix alone decouples tested from published Fixed together. tests.yml's build now passes the same no-cache-filters: main. Cuts both ways: otherwise the gate could pass on cached packages the release won't ship, or fail on packages it would never contain.
HIGH GitPython floor changes no resolution Comment rewritten. Confirmed inert — my verification build resolved 3.1.62 with the floor. The rebuild is what cleared the CVE. Floor kept as a tripwire for a future downward push, with the reasoning (and why a floor, not an exact pin) written down.
MED Rust-caching claim inaccurate Fixed, and dropped opa_build from the filter: COPY custom* /custom + custom/ absent from .dockerignore + tarball regenerated every run means that stage already re-executes. Cargo.lock deliberately not touched — real gap, wider blast radius.
MED VEX products bound to @next No gap, now documented. Wrong — overturned in round 2. The Trivy/.trivyignore.yaml published-tag scan I cited does not exist in this repo. Since that phantom was the argument, the conclusion inverts: the published tags are matched by no waiver mechanism at all. Now written as a gap.
MED 3.13.15 floor unenforced Removing the waiver is the enforcement. My pushback was wrong — Zeev was right. Scout never reports the interpreter, the gate is pull_request-only, and the removed waiver bound pkg:generic/python, a PURL Scout does not emit. Fixed properly in round 3: the build now checks the interpreter itself.
MED present tense for a nonexistent workflow Fixed. Reworded as a requirement; also fixed the same plural bug copied into that comment.

His two other out-of-diff points are handled too: the PR-only scan gate in #338, and the CLONE_REPO_TOKEN exposure in #339 (verified reproducible — with only .git/, a nested .git/config really does reach the image).

Review round 2 — @zeevmoney (b3e85f7)

On the delta 7fef179..ee023e6. The three round-1 fixes were accepted, including two pushbacks Zeev withdrew (the GitPython floor over an exact pin, and dropping opa_build from the filter). He also verified the cache filter empirically rather than by reading it — CACHED → DONE 5.5s on the identical base digest, a measured cost of ~6s.

This round was almost entirely about the new prose: ~50 of 87 changed lines were comments asserting security properties. Two of them asserted properties that do not hold. Every finding was verified independently before anything changed, and all six held.

Finding Verified how Outcome
HIGH "Go 1.26 is still unreleased" is false — and it was the stated removal gate for two waivers go.dev release history: 1.26.0 2026-02-10, 1.26.8 2026-09-01, 1.27.0 2026-08-19. Contradicted in-repo too: #334 pins golang:1.26-bookworm Fixed in tests.yml and both VEX impact statements (v7 → v8). Removal gates now name a local, closable condition. The same claim in this PR body is corrected above
HIGH "removing the waiver IS the enforcement" does not hold Three independent checks, all confirming: tests.yml:198 is still pull_request-only; run 34620527942's unfiltered report is 15 findings across 6 packages with no CPython entry; the removed waiver bound pkg:generic/python, a PURL Scout never emits — so it was suppressing nothing Fixed, and made real. Rather than only correcting the prose, the apk layer now asserts sys.version_info >= (3,13,15) — folded into the existing RUN so it costs no layer, placed after the sqlite surgery so it also proves the interpreter survived, and it runs on every build path including releases
MED the @next scope argument rests on machinery that does not exist grep -rni trivy returns exactly the two comment lines this PR added; no .trivyignore*; no schedule:/cron: in any workflow Fixed, and the conclusion inverted. The phantom was the argument, so removing it flips "correct because" into an open gap: published tags are matched by no waiver mechanism at all. tests.yml:82 fixed the same way
MED docs/image-vulnerability-management.md does not exist git ls-files '*.md' → three files, no docs/ directory Fixed. Cites the tracker, and names the absence out loud — the treatment Dockerfile:158-161 already got right
MED the tripwire paragraph's lockfile scenario runs backwards Correct: a lock is what gets installed, so the floor is never consulted Fixed, plus loudness made conditional on the capping package being exact-pinned. On transitivity I agreed with the point but not the wording — ddtrace is imported first-party (horizon/pdp.py:321), so "all four are transitive" would swap one unverifiable claim for another. Cost of drift is the separator. Also stopped implying CI evidence where the verification was a local --no-cache build
MED "the scanned image matches the published one" overstates it tests.yml:77 amd64-only vs release.yml:62/:95 amd64+arm64; scan never runs on the release path Fixed. The guarantee is that neither image is built on a stale package set — explicitly not that they match

Merge order with #335: #337 goes first; #335 then rebases to keep this branch's seven statements and add CVE-2026-54282 on top as v9 (not v8 — correcting both x/crypto impact statements bumped this branch to v8).

Still open and unchanged from round 1, both out of diff: timeout-minutes on the two image-building jobs, and actionlint in .pre-commit-config.yaml — which would have caught the original no-cache-filter input-name bug in under a second. Both belong in #338 with the other CI-hygiene work.

Review round 3 — automated multi-agent pass (85e5c59)

Five specialist agents (code review, security audit, architecture, backend/runtime, vulnerability scanning) plus an independent pass over the branch at b3e85f7. Of 25 specific factual claims tested, 24 held and one did not. Every finding below was re-verified by hand; three agent claims were dropped as overstated.

Fixed in 85e5c59:

Finding Outcome
HIGH The 3.13.15 check could not detect a broken interpreter Fixed. sys is a builtin, so import sys loads no extension modules — a version-only check passes on an interpreter whose entire lib-dynload is unresolvable, which is what the .python-rundeps surgery produces if the so: list comes back short. It now imports ssl/hashlib/zlib/lzma/bz2/ctypes/pyexpat/decimal first, and uses sys.exit rather than assert (which -O strips). I had claimed the opposite in a review thread and have corrected it there.
MED Wrong OpenVEX justification on both x/crypto waivers Fixed. vulnerable_code_not_in_execute_path asserts the component ships and is merely uncalled; the impact statements argue the stronger, provable claim — excluded at link time. Now vulnerable_code_not_present. The four starlette waivers keep the old label, which is correct for them. v8 → v9.
MED release.yml named the wrong Rust stage, in both directions Fixed. Run 34858998837: exactly three vertices cached, all rust_chef; cargo chef cook was the longest step at 404.6s. The stage that does cache runs apk add musl-dev openssl-dev zig … — a package-resolving layer outside the filter, previously unmentioned.
MED <4 does not cap GitPython drift to a patch Fixed. It admits 3.2.0; the cap is a major. Verified with packaging. Conclusion unchanged, premise now true.
MED Dockerfile:57 cited from three places across two files Fixed. #334 moves that stage and git merge-tree merges clean, so all three assertions would go false silently. They name the stage now.
MED Stale VEX timestamp; LOW "seven advisories" is eight; LOW tests.yml said an undeclared input is "silently dropped" All fixed. Actions emits an Unexpected input(s) annotation — the wrong wording was the one claiming no signal exists.

Not fixed here — these change security posture or other PRs, and want a decision:

  • HIGH — the waiver evidence covers a third CVE it does not waive. Per vuln.go.dev, all three live in the same package: GO-2026-6303 (CVE-2026-56854), GO-2026-6354 (CVE-2026-78662), GO-2026-6355 (CVE-2026-56855) → golang.org/x/crypto/ssh. The waivers say that package is not linked; the same file says permit-opa#49 must fix CVE-2026-56854. Both cannot be true. Waiving it on the same evidence takes the gate from 3 HIGH to 2 — not to green, since the two grpc CVEs are in genuinely linked code.
  • HIGH — no release has ever been scanned. Verified on the run that published this very image: release run 30900950607 (v0.9.14) shows skipped pdp-tests / docker-scout alongside success build-and-push-pdp. That is the root cause of the customer report. The ~4-line fix lives in ci: gate releases on CVEs and unify Dependabot, Trivy and Docker Scout (PER-15358) #338, which is based on this branch.
  • HIGH — the waivers fail open. Both workflows clone permitio/permit-opa at ref: main with no pin, and Scout suppresses on PURL, never on whether ssh is linked. If permit-opa ever links it, the waiver keeps suppressing a now-reachable DoS with no signal in either repo.
  • MED — the Rust binary (PID 1) has no tracked Cargo.lock, no --locked, no cargo audit, and Scout reports zero cargo PURLs; MED — the SARIF uploaded to code scanning has no VEX applied, so all 15 findings including the 7 waived ones land in the Security tab; MED — arm64 is built only by the job that publishes it and is scanned by nothing; MED — CVE-2026-48710 is a CISA KEV entry (added 2026-09-02, due 2026-09-16) and that fact is recorded nowhere.

Review round 4 — @zeevmoney, approved (9676cfc)

Approved with no CRITICAL or HIGH, on the delta ee023e6..85e5c59. Both round-2 HIGHs confirmed resolved. Seven non-blocking findings remain, two of them established by executing rather than reading, and both of those land on the control I added in round 3. All seven are fixed.

Finding Outcome
MED decimal and hashlib cannot detect a missing .so Fixed — the sharpest catch of four rounds. Both fall back silently to pure Python, so two of my eight imports detected nothing. Reproduced by blocking _decimal/_hashlib behind a meta_path finder: the verbatim old line exited 0. Now imports the underscore C extensions directly. Deviation: I dropped _gdbm/readline/_curses from the suggested list — _gdbm is absent from this machine's CPython 3.13, and all three guard libs the PDP never uses, so they'd add no detection and a standing false-failure risk.
MED pkg:generic/python is a PURL Scout emits Fixed, and my reasoning was invalid. I inferred it from the scan findings list, which only shows packages that have findings and says nothing about what the SBOM indexes. Reason (c) dropped; reason (a) narrowed to what tests.yml already said correctly; the plain true reason — 3.13.15 carries the fix — now stated.
MED The cache measurement doesn't transplant Fixed. Run 34858998837 is PDP CI Tests / pull_request / --platform linux/amd64, quoted on a two-platform release step. The 404.6s figure carries over; the "longest step" ranking cannot. Also: rust_chef cached three of four — #18 rustup target add re-ran at 23.3s.
LOW The floor passed 3.14.0–3.14.6 Fixed. (3,14,0) >= (3,13,15) is True, and those are exactly the versions lacking the backport. Now branch-aware: {13: (3,13,15), 14: (3,14,7)}.get(v[1], (3,15,0)), tested across eight versions.
LOW Both GitPython counts wrong Fixed from source. GHSA-jm78-9fvv-mhgr is CVE-2026-76221 (OSV aliases), so by GHSA id the 76218–76222 run read as having a gap. The move clears eleven distinct CVEs, eight HIGH-or-above. PyPI says nine releases in that window, not eight.
LOW openssl-dev / OPENSSL_DIR are dead config Named as dead and tracked, not removed — editing the Rust builder stage on a branch this deep risks a broken build for a cosmetic win. Happy to do it as its own PR.
LOW VEX v9 vs #335's v5 Merge order recorded below; version is now 10, which is what a union resolution needs anyway.

Carried forward and now fixed: the CVE-2026-48710 waiver claimed the app "registers no add_middleware/BaseHTTPMiddleware". That is 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 constructs a Request, dispatching only on Origin and scope['method'], so it cannot observe the divergence this CVE induces. Premise now stated accurately. (I repeated this same false claim in my own round-3 review — it was wrong there too.)

⚠️ Merge order with #335

#337 merges first; #335 rebases onto it. The two diverge structurally — this PR has 7 statements at v10 including both x/crypto waivers; #335 has 6 at v5 including CVE-2026-54282, which exists nowhere else. A side-pick resolution loses real waivers in either direction. Rebasing #335 onto a merged #337 makes it a one-statement addition instead of a seven-vs-six conflict. #335 should not inherit the unversioned pkg:docker/permitio/pdp-v2 product @id on a superset assumption without testing it against a real scan — 824c783 exists because Scout matches on the full PURL string.

Also in this PR: the two x/crypto CVEs Go 1.25 cannot clear

The docker-scout check on this PR fails on 5 HIGH that do not come from this repo. /app/bin/opa is compiled from permit-opa, so its Go module CVEs land in our scans. That gate has been red since 2026-09-06 — #333 and #336 both went red on it and were merged anyway — so this is pre-existing drift, not a regression here.

Split by what is actually fixable:

  • permitio/permit-opa#49 bumps grpc 1.82.1 → 1.83.2 and x/crypto 0.53.0 → 0.55.0, clearing 3 of the 5. (Note: 1.83.1 is not enough for CVE-2026-84445 — its range covers >=1.83.0 <1.83.2. Only caught by scanning the rebuilt binary.)
  • CVE-2026-78662 and CVE-2026-56855 are fixed only in x/crypto >= 0.56.0, whose go.mod declares go 1.26.0. Go 1.26 is not released — corrected in round 2: Go 1.26.0 shipped 2026-02-10, 1.26.8 is current, and golang:1.26-bookworm exists. The blocker is local, not upstream: 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. Gated on build(docker): move the OPA builder to golang:1.26-bookworm (PER-15358) #334 plus a matching permit-opa change.

Those two are waived in vex.json (now v8) on evidence, not assertion: both live in golang.org/x/crypto/ssh, and go list -deps ./cmd/opa in permit-opa shows that package is not linked into the binary at all — only cryptobyte, chacha20(+poly1305), pbkdf2, curve25519, blake2b, salsa20, nacl/*, hkdf, sha3. The SSH transport is never linked, and the PDP speaks no SSH of its own. Each statement names its removal gate, and after round 2 that gate is a condition someone can actually close: land #334, move permit-opa to go 1.26.0 + x/crypto 0.56.0, delete the statements. Nothing waits on Go.

⚠️ The first version of those waivers bound to nothing

Worth flagging since it's a trap anyone can hit. Scout matches VEX subcomponents on the full PURL string, and it emits Go PURLs without the conventional v prefix:

pkg:golang/golang.org/x/crypto@0.53.0      <- what Scout emits
pkg:golang/golang.org/x/crypto@v0.55.0     <- what I first wrote

Two independent mismatches — the v, and the version (the build resolves 0.53.0 until permit-opa#49 merges, 0.55.0 after). The waivers suppressed nothing, and would have kept suppressing nothing after #49 landed, looking like a Scout bug weeks later.

Diagnosed by reading the gate log rather than guessing: docker-scout prints the PURL above each finding, and pkg:pypi/starlette@0.50.0 / pkg:pypi/ddtrace@3.19.8 were being suppressed correctly in the same run — so the mechanism was fine and my PURL was wrong. (Scout CLI needs a Docker login this environment doesn't have, so this was verified from its output, not a local re-run.)

Fixed by listing both versions as subcomponents. Deliberately not fixed by dropping subcomponents, which broadens the statement to the whole image — scope is the point of a waiver. Both traps are now recorded in the runbook in #338.

CI: docker-scout is red, and it is supposed to be

Checked the actual run rather than assuming. Nothing here needs fixing — the gate is working and reporting real findings with an owner.

✓ Loaded 1 VEX document
pkg:golang/google.golang.org/grpc@1.82.1
  ✗ HIGH CVE-2026-84304
  ✗ HIGH CVE-2026-84445
pkg:golang/golang.org/x/crypto@0.53.0
  ✗ HIGH CVE-2026-56854
3 vulnerabilities found in 2 packages

The corrected waivers are confirmed binding — down from 5 findings to 3, with CVE-2026-78662 / CVE-2026-56855 gone. #339 is an accidental control group: it branches off main, so it carries no waivers and still reports all 5. Same image, same scanner, waivers the only difference.

Branch Waivers? Gate reports
#339 (off main) no 5
#337 / #338 yes 3
after permit-opa#49 yes 0 (expected)

All three remaining are fixed by permitio/permit-opa#49 (grpc → 1.83.2, x/crypto → 0.55.0). tests.yml clones permit-opa@main, so that PR must merge first. Every other job passes, including the full pdp-tester e2e.

The other red check, security/snyk (permit), returns ERROR on every PR in every permitio repo — a broken integration, not a finding.

Merge order

tests.yml clones permit-opa@main at build time, so:

⚠️ This PR cannot go green until permitio/permit-opa#49 merges. Once it's in, the two grpc CVEs disappear from the build and the 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. And a full pdp-v2 image built from this branch with permit-opa#49's custom_opa.tar.gz scans 0 CRITICAL/HIGH across OS + Python + Go.

(The other red check, security/snyk (permit), is ERROR on every recent PR in this repo — broken integration, not a finding.)

Follow-up

The root cause is that nothing re-scans a published tag. The docker-scout gate in tests.yml is if: github.event_name == 'pull_request', so it scanned this image in July and could not have seen CVEs disclosed on 2026-09-09 — and because releases arrive as workflow_call, it never gated a release at all. #338 fixes both, adds scheduled re-scanning of published tags, Dependabot, and digest-pinned base images.

🤖 Generated with Claude Code

…358)

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>
Copilot AI lite review requested due to automatic review settings September 11, 2026 14:42
@linear-code

linear-code Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

PER-15358

PER-15886

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

github-actions Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

🔍 Vulnerabilities of permitio/pdp-v2:next

📦 Image Reference permitio/pdp-v2:next
digestsha256:5e94d74be0599ac75dc5350c06a11fcf269613fac6dab4e4d7b4c857f0714f2f
vulnerabilitiescritical: 0 high: 5 medium: 4 low: 1 unspecified: 1
platformlinux/amd64
size139 MB
packages247
📦 Base Image python:3.13-alpine3.23
also known as
  • 3.13.15-alpine3.23
  • b3fdbd3f0fb569f6c053a23e74b6e99a856bf9efe5823bca837a80a875ee7a9f
digestsha256:c694844958d415b76979bb3e0927273b013294ab36e715ca1fc4962713a8ee69
vulnerabilitiescritical: 0 high: 6 medium: 2 low: 0 unspecified: 3
critical: 0 high: 2 medium: 2 low: 1 starlette 0.50.0 (pypi)

pkg:pypi/starlette@0.50.0

high 7.5: CVE--2026--54283 Allocation of Resources Without Limits or Throttling

Affected range>=0.4.1
<1.3.1
Fixed version1.3.1
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
EPSS Score0.397%
EPSS Percentile33rd percentile
Description

Summary

request.form() accepts max_fields and max_part_size to bound resource consumption while parsing form data. These limits are enforced for multipart/form-data, but silently ignored for application/x-www-form-urlencoded. An unauthenticated attacker can therefore send a urlencoded body with an arbitrarily large number of fields or an arbitrarily large field, even when the application configured limits it believed would apply.

Details

request.form() dispatches to a different parser depending on the Content-Type. For multipart/form-data the max_files, max_fields, and max_part_size limits are forwarded to the parser, but for application/x-www-form-urlencoded the parser is constructed without them. It has no max_fields or max_part_size parameter to receive them, and it appends every field with no count check and accumulates each field's name and value with no size check. The configured limits are therefore both unreachable and unenforced for url-encoded bodies.

Because the url-encoded parser does its work synchronously between stream reads, the two attack shapes have different effects:

  • Field count drives CPU and event-loop blocking. A body of ~1,000,000 fields (a sub-10MB payload such as f0=v&f1=v&...) blocks the worker's event loop for several seconds while parsing, during which the worker serves no other request.
  • Field size drives memory. A single large field value (e.g. a 50MB value) is buffered in full to build the FormData, forcing memory allocation proportional to the request body.

The equivalent multipart/form-data request is correctly rejected with 400 Too many fields / 400 Field exceeded maximum size.

Impact

This Denial of service (DoS) vulnerability affects all applications built with Starlette (or FastAPI) that call request.form() on application/x-www-form-urlencoded requests. A single request with a very large number of fields blocks the event loop for several seconds, and a single request with a very large field forces unbounded memory allocation; in either case, parallel requests can render the service unusable. A reverse proxy that enforces a request body size limit reduces but does not eliminate the exposure, since a sub-10MB body is already enough to block the event loop.

Mitigation

Upgrade to a patched version, which forwards max_fields and max_part_size to the url-encoded parser and enforces them while parsing, raising before the oversized field or excess fields are accumulated. The defaults match multipart/form-data (max_fields=1000, max_part_size=1MB) and can be customized via request.form(max_fields=..., max_part_size=...).

high 7.5: CVE--2026--48818 Server-Side Request Forgery (SSRF)

Affected range<1.1.0
Fixed version1.1.0
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
EPSS Score0.368%
EPSS Percentile30th percentile
Description

Summary

When serving static files on Windows, StaticFiles resolves the requested path with os.path.realpath. If a UNC path (such as \\attacker.com\share) reaches the resolver, realpath causes the process to open a connection to the remote host over SMB (port 445). This is a server-side request forgery (SSRF) that leaks the service account's NTLMv2 credentials to the attacker-controlled host, which can then be cracked offline or relayed to other hosts.

Details

StaticFiles.lookup_path() joins the requested path onto the served directory and calls os.path.realpath on the result before checking containment with os.path.commonpath. On Windows, a UNC path is absolute, so os.path.join discards the served directory and realpath resolves the bare UNC path, triggering the outbound SMB connection and NTLM authentication before the containment check rejects the path. The HTTP response is a benign 404, but the credential disclosure has already happened. POSIX systems are not affected.

This only affects the default configuration (follow_symlink=False), which uses os.path.realpath. The follow_symlink=True branch uses os.path.abspath, which performs no I/O.

Impact

Applications running on Windows that serve files with StaticFiles (directly, or via a framework built on Starlette such as FastAPI) in the default configuration are affected. StaticFiles is typically unauthenticated, so any client can trigger the SMB connection and leak the service account's NTLMv2 hash. A secondary impact is discovering internal hosts reachable over SMB by timing responses for valid versus invalid addresses.

Mitigation

Applications not running on Windows are not affected. On Windows, serving static files through a dedicated web server (such as nginx or IIS) instead of StaticFiles avoids the issue. Blocking outbound SMB (port 445) from the application host prevents the credential disclosure even if a UNC path is resolved.

medium 6.5: CVE--2026--48710 Improper Validation of Unsafe Equivalence in Input

Affected range<=1.0.0
Fixed version1.0.1
CVSS Score6.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N
EPSS Score36.257%
EPSS Percentile98th percentile
Description

Summary

In affected versions, the HTTP Host request header was not validated before being used to reconstruct request.url. Because the routing algorithm relies on the raw HTTP path while request.url is rebuilt from the Host header, a malformed header could make request.url.path differ from the path that was actually requested. Middleware and endpoints that apply security restrictions based on request.url (rather than the raw scope path) could therefore be bypassed.

Details

When a client requests http://example.com/foo, it sends:

GET /foo HTTP/1.1
Host: example.com

Affected versions reconstructed the URL by concatenating http://{host}{path} and re-parsing the result. The Host value is only valid as a uri-host [ ":" port ] per RFC 9112 §3.2, where uri-host follows the restricted host grammar of RFC 3986 §3.2.2. When it contains characters outside that grammar - notably /, ?, or # - those characters move the path/query/fragment boundaries during re-parsing, so the parsed request.url.path no longer matches the path the server actually received. For example:

GET /foo HTTP/1.1
Host: example.com/abc?bar=

reconstructs to http://example.com/abc?bar=/foo, whose parsed path is /abc - even though routing used the real path /foo. The router still dispatches to /foo and the endpoint executes, but any middleware or code that reads request.url.path sees /abc, so path-based authorization checks can be bypassed.

Impact

Any application running an affected version that relies on request.url (or request.url.path) for security-sensitive decisions is affected. The most common case is middleware that gates access to certain path prefixes based on request.url.path. Deployments fronted by a proxy or load balancer are mitigated only if that proxy rejects or normalizes the malformed Host header before forwarding and the application does not trust attacker-controlled host headers (e.g. X-Forwarded-Host) elsewhere.

Mitigation

Upgrade to a patched version, which validates the Host header against the grammar of RFC 9112 §3.2 / RFC 3986 §3.2.2 when constructing request.url and falls back to scope["server"] for malformed values.

medium 5.3: CVE--2026--48817 Use of Externally-Controlled Input to Select Classes or Code ('Unsafe Reflection')

Affected range<1.1.0
Fixed version1.1.0
CVSS Score5.3
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N
EPSS Score0.213%
EPSS Percentile12th percentile
Description

Summary

When dispatching a request, HTTPEndpoint selects the handler by lowercasing the HTTP method and looking it up as an attribute with getattr, without restricting the lookup to a known set of HTTP verbs.

When an HTTPEndpoint subclass is registered through Route(...) without an explicit methods= argument, the route does not constrain the method and every method reaches the endpoint. If a non-standard HTTP method whose lowercased name matches an attribute on the endpoint subclass reaches the endpoint, that attribute is invoked as if it were a request handler. An attacker can use this to reach methods that were never meant to be HTTP handlers, such as internal helpers, without the authorization checks applied by the intended public handler.

Details

HTTPEndpoint uses the client-supplied method name to resolve an instance attribute, without validating it against the set of HTTP verbs the endpoint supports. A method such as _DO_DELETE therefore resolves an attribute like _do_delete and invokes it. Non-standard methods are valid RFC 9110 token methods, so an endpoint must not treat the method name as a trusted attribute selector.

Impact

An application is affected when all of the following hold:

  • It defines an HTTPEndpoint subclass and registers it via Route(...) without an explicit methods= argument.
  • The subclass defines additional methods whose names match a non-standard HTTP-method token shape and that accept a single request argument and return a response.

This also affects frameworks built on Starlette, like FastAPI.

Mitigation

Register HTTPEndpoint subclasses with an explicit methods= argument on the Route, listing only the HTTP verbs the endpoint supports. The route then rejects any other method with 405 Method Not Allowed before it reaches the endpoint, so non-standard methods cannot resolve an attribute.

low 3.7: CVE--2026--54282 Improper Input Validation

Affected range<1.3.0
Fixed version1.3.0
CVSS Score3.7
CVSS VectorCVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:L/A:N
EPSS Score0.187%
EPSS Percentile8th percentile
Description

Summary

In affected versions, the HTTP request path is not validated before being used to reconstruct request.url. Because request.url is rebuilt by concatenating {scheme}://{host}{path} and re-parsing the result, a path that does not begin with / (for example @<!-- -->google.com) moves the authority boundary during re-parsing, so request.url.hostname and request.url.netloc become attacker-controlled. Code that reads request.url.hostname (rather than the Host header or scope) can therefore be misled into trusting an attacker-supplied host.

Details

When a client requests a path that does not start with /:

GET @<!-- -->google.com HTTP/1.1
Host: localhost

affected versions reconstruct the URL as http://localhost@<!-- -->google.com. Per RFC 3986 §3.2.1, the substring before @ in the authority is userinfo, so re-parsing yields username = "localhost" and hostname = "google.com", with an empty path:

request.url          == "http://localhost@<!-- -->google.com"
request.url.hostname == "google.com"
request.url.path     == ""

The root cause is that the path is concatenated directly after the host without a separating /, and without validating that it begins with one. Only the Host header was validated when constructing request.url; the path was not.

This requires an ASGI server that forwards a request-target lacking a leading / into scope["path"].

Impact

Any application running an affected version that uses request.url, request.url.netloc, or request.url.hostname for a security-sensitive decision (host-based authorization, redirect/callback base, SSRF target, cache key, audit log) may be affected, when no fronting proxy or load balancer rejects the malformed request-target first.

Note that this is less exploitable than GHSA-86qp-5c8j-p5mr: there, the poison is carried in the Host header, so the real path still routes to a valid endpoint while request.url.path lies. Here, the poison must be carried in the path itself, and that path (@<!-- -->google.com) does not match any registered route, so routing returns 404 and no endpoint handler runs. The exposure is limited to code that reads request.url before routing - notably middleware - or in 404/exception handlers.

Mitigation

Upgrade to a patched version, which prevents the request path from crossing into the URL authority. The request above instead yields http://localhost/@<!-- -->google.com with request.url.hostname == "localhost".

critical: 0 high: 2 medium: 0 low: 0 unspecified: 1golang.org/x/crypto 0.55.0 (golang)

pkg:golang/golang.org/x/crypto@0.55.0

high : CVE--2026--78662

Affected range<0.56.0
Fixed version0.56.0
EPSS Score0.315%
EPSS Percentile24th percentile
Description

Previously, a channel registered in the mux's chanList is not usable until it is established. A malicious peer was able flood the channel's incomingRequests, deadlocking the entire connection.

Now, we add an atomic established state, set when a channel becomes usable. Until such a time, handlePacket drops every packet other than the open confirmation/failure, without blocking and without tearing down the connection.

high : CVE--2026--56855

Affected range<0.56.0
Fixed version0.56.0
EPSS Score0.378%
EPSS Percentile31st percentile
Description

Previously, after a channel has been established, a malicious peer could send crafted messages that would deadlock the entire connection.

Now, we handle all RFC 4254 channel messages; global requests are handled explicitly. Then, treat all other messages as a protocol error and tear the connection down instead of buffering and blocking.

unspecified : GO--2026--5932

Affected range>=0
Fixed versionNot Fixed
Description

The golang.org/x/crypto/openpgp package is unsafe by design, has numerous known security issues, is not maintained, and should not be used.

If you are required to interoperate with OpenPGP systems and need a maintained package, consider github.com/ProtonMail/go-crypto/openpgp which is a maintained fork that aims to be a drop-in replacement for this package.

critical: 0 high: 1 medium: 0 low: 0 ddtrace 3.19.8 (pypi)

pkg:pypi/ddtrace@3.19.8

high 7.5: CVE--2026--50271 Uncontrolled Resource Consumption

Affected range<4.8.2
Fixed version4.8.2
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
EPSS Score0.793%
EPSS Percentile54th percentile
Description

Impact

Datadog tracing libraries that implement W3C baggage propagation parse incoming baggage HTTP headers without enforcing item-count or byte-size limits on the extract path. The DD_TRACE_BAGGAGE_MAX_ITEMS (default 64) and DD_TRACE_BAGGAGE_MAX_BYTES (default 8192) limits were applied only to baggage injection, not extraction. A remote, unauthenticated attacker can send a request whose baggage header contains an arbitrarily large number of comma-separated key-value pairs (or a single very large value). The tracer allocates a hash-map entry for each pair on every request, causing unbounded CPU and memory consumption and enabling a remote Denial of Service against any HTTP service that has the baggage propagation style enabled.
The baggage propagation style is enabled by default in most affected tracers, so any internet-facing service that has been instrumented with an affected tracer version is exposed unless the propagation style has been explicitly narrowed.

Patches

This is resolved in version 4.8.2 and later of the dd-trace-py library

Workarounds

If users cannot upgrade immediately:

  1. Disable baggage extraction by removing baggage from DD_TRACE_PROPAGATION_STYLE (or DD_TRACE_PROPAGATION_STYLE_EXTRACT if set independently).
  2. Cap the maximum HTTP request header size at an upstream proxy or web server (for example, Apache LimitRequestFieldSize, Nginx large_client_header_buffers, Envoy max_request_headers_kb).

Resources

Related upstream advisories:
opentelemetry-go GHSA-mh2q-q3fh-2475
opentelemetry-dotnet GHSA-g94r-2vxg-569j

critical: 0 high: 0 medium: 1 low: 0 github.com/containerd/containerd/v2 2.2.5 (golang)

pkg:golang/github.com/containerd/containerd/v2@2.2.5

medium 6.8: CVE--2026--53495 Uncontrolled Resource Consumption

Affected range>=2.2.0
<2.2.8
Fixed version2.2.8
CVSS Score6.8
CVSS VectorCVSS:4.0/AV:L/AC:L/AT:N/PR:L/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N
Description

Impact

A bug in containerd's CRI ExecSync implementation allows exec probes and lifecycle hooks with background child processes to keep containerd's stdio-drain goroutines indefinitely blocked. Because the I/O drain phase lacks a default timeout or context cancellation handling, repeated ExecSync invocations (like probes) that include long-lived background processes against a container can cause containerd to leak goroutines and host memory. Over time, this resource exhaustion can cause the containerd daemon to be terminated by the OOM killer, rendering containerd unavailable until it is restarted. This issue affects containerd on Linux systems running with the CRI plugin enabled. Users not using containerd's CRI implementation or not running containers on Linux are not affected.

Patches

This bug has been fixed in containerd 2.3.5, 2.2.8, 2.0.12, and 1.7.35. Users should update to these versions to resolve the issue.

Workarounds

Ensure exec probes and lifecycle hooks do not launch long-lived background child processes.

Credits

The containerd project would like to thank XlabAI Team of Tencent Xuanwu Lab (xlabai@tencent.com), including Guannan Wang, Zhanpeng Liu, Jiashuo Liang, and Guancheng Li, and @IamwhatIamSY who independently discovered and responsibly disclosed this issue in accordance with the containerd security policy.

For more information

If there are any questions or comments about this advisory:

  • Open an issue in containerd
  • Send an email to [security@containerd.io](mailto:security@containerd.io)

To report a security issue in containerd:

critical: 0 high: 0 medium: 1 low: 0 busybox 1.37.0-r30 (apk)

pkg:apk/alpine/busybox@1.37.0-r30?os_name=alpine&os_version=3.23

medium : CVE--2025--60876

Affected range<=1.37.0-r30
Fixed versionNot Fixed
EPSS Score0.291%
EPSS Percentile22nd percentile
Description

@github-actions

github-actions Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

🔍 Vulnerabilities of permitio/pdp-v2:next

📦 Image Reference permitio/pdp-v2:next
digestsha256:5e94d74be0599ac75dc5350c06a11fcf269613fac6dab4e4d7b4c857f0714f2f
vulnerabilitiescritical: 0 high: 0 medium: 0 low: 0
platformlinux/amd64
size139 MB
packages247
📦 Base Image python:3.13-alpine3.23
also known as
  • 3.13.15-alpine3.23
  • b3fdbd3f0fb569f6c053a23e74b6e99a856bf9efe5823bca837a80a875ee7a9f
digestsha256:c694844958d415b76979bb3e0927273b013294ab36e715ca1fc4962713a8ee69
vulnerabilitiescritical: 0 high: 6 medium: 2 low: 0 unspecified: 3

EliMoshkovich added a commit that referenced this pull request Sep 11, 2026
A customer found 12 CVEs in permitio/pdp-v2:0.9.14 before we did. The companion PR
clears them; this one closes the two holes that let them reach a customer at all.

Hole 1: a build-time gate cannot protect a shipped image.
The docker-scout gate passed on 2026-08-04 and was the last word on that tag
forever. Alpine published openssl 3.5.8-r0 and util-linux 2.41.6-r1 afterwards,
and CVEs disclosed after the build were invisible to us by construction. Nothing
in the repo ever looked at a published tag again.

Hole 2: releases skipped the gate entirely.
docker-scout was `if: github.event_name == 'pull_request'`. release.yml reaches
tests.yml through `workflow_call`, where github.event_name is the caller's event
('release') - so the CVE gate was skipped on exactly the runs that publish to
Docker Hub. Every released image went out ungated. Removing that condition makes
it gate releases too, and since build-and-push-pdp already `needs: pdp-tests`, a
failing gate now blocks the push.

Added:

- image-scan-published.yml - daily re-scan of the PUBLISHED tags. Scans `latest`
  plus the newest non-prerelease release tag: `latest` is what an unpinned pull
  gets, the release tag is what a pinned customer runs, and 0.9.14 was reported by
  a customer pinned to a tag that was no longer `latest`. Uploads SARIF, files one
  rolling issue per tag, fails the run.

- classify_image_cves.py - turns a Trivy report into the verdict that actually
  matters: CLEAN, REBUILD (every finding already patched upstream - cut a release,
  no code change), or SOURCE (something needs a human). Answering this by hand for
  0.9.14 took the whole investigation; the signal was sitting in Trivy's
  FixedVersion the entire time.

  It also splits a case that "has a fix" hides: Go modules in /app/bin/opa are
  compiled from permit-opa, so a grpc or x/crypto CVE is fixed by a go.mod bump in
  THAT repo and no release here can clear it. Go stdlib is the exception - it comes
  from this repo's floating golang stage. Verified against the real 0.9.14 report:
  28 rebuildable, 3 permit-opa, 0 unfixable.

- Trivy as a second gate in tests.yml, alongside Scout. The existing NOTE on the
  Scout gate already concedes it reads packages and cannot see the CPython
  interpreter the way a CPE scanner does - that blind spot is how four Python CVEs
  reached a customer scan of 0.9.14-rc1 (PER-15532). The scanners disagree in
  useful ways: on the published 0.9.14, Scout was clean while Trivy found 31
  CRITICAL/HIGH, including a CRITICAL GitPython RCE (CVE-2026-78676) that the
  customer's own OS-only scan also missed. Customers scan us with whatever they
  own; running one scanner ships whatever it happens not to look at.

- .trivyignore.yaml - Trivy counterpart to the OpenVEX doc Scout reads, for the
  same 3 waivers. Every entry carries `expired_at`, so a waiver cannot silently
  become permanent: when it lapses the CVE reappears, which is the prompt to
  re-check whether OPAL has relaxed the cap that forced it. Both waiver files
  change together or neither does.

- docs/image-vulnerability-management.md - the posture, the triage runbook, and
  the gaps deliberately left open (unattended Dependabot queue with 6 open alerts,
  permit-opa's Go deps ungated in their own repo, no signing/SBOM, no auto-rebuild
  cadence - detection is automated, cutting the release stays a human call).

- pyproject.toml: scope T201 off .github/scripts/*.py, where stdout is the tool's
  interface rather than stray debugging.

Gate verified both directions with trivy 0.68.2:
  published 0.9.14      -> exit 1, 31 findings, verdict SOURCE
  rebuilt + PR #337 fix -> exit 0, 0 findings, verdict CLEAN
So the gate catches the real regression and is not permanently red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…5358)

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>
Copilot AI review requested due to automatic review settings September 11, 2026 15:22

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

EliMoshkovich added a commit that referenced this pull request Sep 11, 2026
A customer found 12 CVEs in permitio/pdp-v2:0.9.14 before we did. The companion PR
clears them; this one closes the two holes that let them reach a customer at all.

Hole 1: a build-time gate cannot protect a shipped image.
The docker-scout gate passed on 2026-08-04 and was the last word on that tag
forever. Alpine published openssl 3.5.8-r0 and util-linux 2.41.6-r1 afterwards,
and CVEs disclosed after the build were invisible to us by construction. Nothing
in the repo ever looked at a published tag again.

Hole 2: releases skipped the gate entirely.
docker-scout was `if: github.event_name == 'pull_request'`. release.yml reaches
tests.yml through `workflow_call`, where github.event_name is the caller's event
('release') - so the CVE gate was skipped on exactly the runs that publish to
Docker Hub. Every released image went out ungated. Removing that condition makes
it gate releases too, and since build-and-push-pdp already `needs: pdp-tests`, a
failing gate now blocks the push.

Added:

- image-scan-published.yml - daily re-scan of the PUBLISHED tags. Scans `latest`
  plus the newest non-prerelease release tag: `latest` is what an unpinned pull
  gets, the release tag is what a pinned customer runs, and 0.9.14 was reported by
  a customer pinned to a tag that was no longer `latest`. Uploads SARIF, files one
  rolling issue per tag, fails the run.

- classify_image_cves.py - turns a Trivy report into the verdict that actually
  matters: CLEAN, REBUILD (every finding already patched upstream - cut a release,
  no code change), or SOURCE (something needs a human). Answering this by hand for
  0.9.14 took the whole investigation; the signal was sitting in Trivy's
  FixedVersion the entire time.

  It also splits a case that "has a fix" hides: Go modules in /app/bin/opa are
  compiled from permit-opa, so a grpc or x/crypto CVE is fixed by a go.mod bump in
  THAT repo and no release here can clear it. Go stdlib is the exception - it comes
  from this repo's floating golang stage. Verified against the real 0.9.14 report:
  28 rebuildable, 3 permit-opa, 0 unfixable.

- Trivy as a second gate in tests.yml, alongside Scout. The existing NOTE on the
  Scout gate already concedes it reads packages and cannot see the CPython
  interpreter the way a CPE scanner does - that blind spot is how four Python CVEs
  reached a customer scan of 0.9.14-rc1 (PER-15532). The scanners disagree in
  useful ways: on the published 0.9.14, Scout was clean while Trivy found 31
  CRITICAL/HIGH, including a CRITICAL GitPython RCE (CVE-2026-78676) that the
  customer's own OS-only scan also missed. Customers scan us with whatever they
  own; running one scanner ships whatever it happens not to look at.

- .trivyignore.yaml - Trivy counterpart to the OpenVEX doc Scout reads, for the
  same 3 waivers. Every entry carries `expired_at`, so a waiver cannot silently
  become permanent: when it lapses the CVE reappears, which is the prompt to
  re-check whether OPAL has relaxed the cap that forced it. Both waiver files
  change together or neither does.

- docs/image-vulnerability-management.md - the posture, the triage runbook, and
  the gaps deliberately left open (unattended Dependabot queue with 6 open alerts,
  permit-opa's Go deps ungated in their own repo, no signing/SBOM, no auto-rebuild
  cadence - detection is automated, cutting the release stays a human call).

- pyproject.toml: scope T201 off .github/scripts/*.py, where stdout is the tool's
  interface rather than stray debugging.

Gate verified both directions with trivy 0.68.2:
  published 0.9.14      -> exit 1, 31 findings, verdict SOURCE
  rebuilt + PR #337 fix -> exit 0, 0 findings, verdict CLEAN
So the gate catches the real regression and is not permanently red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-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>
Copilot AI review requested due to automatic review settings September 11, 2026 15:43
EliMoshkovich added a commit that referenced this pull request Sep 11, 2026
A customer found 12 CVEs in permitio/pdp-v2:0.9.14 before we did. The companion PR
clears them; this one closes the two holes that let them reach a customer at all.

Hole 1: a build-time gate cannot protect a shipped image.
The docker-scout gate passed on 2026-08-04 and was the last word on that tag
forever. Alpine published openssl 3.5.8-r0 and util-linux 2.41.6-r1 afterwards,
and CVEs disclosed after the build were invisible to us by construction. Nothing
in the repo ever looked at a published tag again.

Hole 2: releases skipped the gate entirely.
docker-scout was `if: github.event_name == 'pull_request'`. release.yml reaches
tests.yml through `workflow_call`, where github.event_name is the caller's event
('release') - so the CVE gate was skipped on exactly the runs that publish to
Docker Hub. Every released image went out ungated. Removing that condition makes
it gate releases too, and since build-and-push-pdp already `needs: pdp-tests`, a
failing gate now blocks the push.

Added:

- image-scan-published.yml - daily re-scan of the PUBLISHED tags. Scans `latest`
  plus the newest non-prerelease release tag: `latest` is what an unpinned pull
  gets, the release tag is what a pinned customer runs, and 0.9.14 was reported by
  a customer pinned to a tag that was no longer `latest`. Uploads SARIF, files one
  rolling issue per tag, fails the run.

- classify_image_cves.py - turns a Trivy report into the verdict that actually
  matters: CLEAN, REBUILD (every finding already patched upstream - cut a release,
  no code change), or SOURCE (something needs a human). Answering this by hand for
  0.9.14 took the whole investigation; the signal was sitting in Trivy's
  FixedVersion the entire time.

  It also splits a case that "has a fix" hides: Go modules in /app/bin/opa are
  compiled from permit-opa, so a grpc or x/crypto CVE is fixed by a go.mod bump in
  THAT repo and no release here can clear it. Go stdlib is the exception - it comes
  from this repo's floating golang stage. Verified against the real 0.9.14 report:
  28 rebuildable, 3 permit-opa, 0 unfixable.

- Trivy as a second gate in tests.yml, alongside Scout. The existing NOTE on the
  Scout gate already concedes it reads packages and cannot see the CPython
  interpreter the way a CPE scanner does - that blind spot is how four Python CVEs
  reached a customer scan of 0.9.14-rc1 (PER-15532). The scanners disagree in
  useful ways: on the published 0.9.14, Scout was clean while Trivy found 31
  CRITICAL/HIGH, including a CRITICAL GitPython RCE (CVE-2026-78676) that the
  customer's own OS-only scan also missed. Customers scan us with whatever they
  own; running one scanner ships whatever it happens not to look at.

- .trivyignore.yaml - Trivy counterpart to the OpenVEX doc Scout reads, for the
  same 3 waivers. Every entry carries `expired_at`, so a waiver cannot silently
  become permanent: when it lapses the CVE reappears, which is the prompt to
  re-check whether OPAL has relaxed the cap that forced it. Both waiver files
  change together or neither does.

- docs/image-vulnerability-management.md - the posture, the triage runbook, and
  the gaps deliberately left open (unattended Dependabot queue with 6 open alerts,
  permit-opa's Go deps ungated in their own repo, no signing/SBOM, no auto-rebuild
  cadence - detection is automated, cutting the release stays a human call).

- pyproject.toml: scope T201 off .github/scripts/*.py, where stdout is the tool's
  interface rather than stray debugging.

Gate verified both directions with trivy 0.68.2:
  published 0.9.14      -> exit 1, 31 findings, verdict SOURCE
  rebuilt + PR #337 fix -> exit 0, 0 findings, verdict CLEAN
So the gate catches the real regression and is not permanently red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EliMoshkovich added a commit that referenced this pull request Sep 11, 2026
Both cost a real debugging cycle on #337, and both fail SILENTLY - a
near-miss PURL binds to nothing and the gate just keeps reporting the CVE, which
reads as a broken scanner rather than a broken waiver.

- Go PURLs carry no `v` prefix: scout emits
  pkg:golang/golang.org/x/crypto@0.53.0, not @v0.53.0, despite Go module
  versions conventionally having one.
- The version must be one the image actually resolves; with a bump in flight,
  list every candidate version as separate subcomponents entries.

Added the diagnostic that would have caught it immediately: read the PURL out of
the docker-scout log, which prints pkg:...@Version above each finding, and
cross-check against a waiver known to work - if pkg:pypi/starlette@0.50.0 is
suppressed in the same run then the mechanism is fine and your PURL is wrong.

Also warns against the tempting wrong fix: dropping subcomponents makes the
statement apply to the whole image, suppressing the CVE wherever it appears
instead of only where it was analysed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings September 11, 2026 15:45

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@zeevmoney zeevmoney left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested — 3 HIGH, 4 MEDIUM.

Every CVE claim in this PR was verified against NVD/OSV, the OpenSSL advisory of 2026-08-25, the util-linux 2.41.6 announcement, and Alpine's package index. All nine OpenSSL CVEs, the three util-linux ones, and all seven GitPython advisories check out, >=3.1.59 genuinely covers all seven, and CVE-2026-15308 really is fixed in CPython 3.13.15 — Scout on this run confirms the base resolves to 3.13.15-alpine3.23. The diagnosis is right and the waiver removal is justified. The findings below are about the remedy, not the analysis.

Blocking:

  • HIGH .github/workflows/release.yml:73 — no-cache-filter is not a valid input; the action declares no-cache-filters (plural). Undeclared inputs are warned about and dropped, so the load-bearing fix is silently inert and the stale-layer replay remains possible.
  • HIGH .github/workflows/release.yml:87 — correcting that spelling alone makes the published image stop being the image pdp-tester validated, because tests.yml:72 builds without the filter. Both edits need to ship together.
  • HIGH requirements.txt:94 — GitPython>=3.1.59,<4 changes no resolution on any build path; <4 duplicates opal-common's own cap and the floor never binds. The build resolves 3.1.62.

Non-blocking:

  • MEDIUM .github/workflows/release.yml:72 — the Rust-stages claim is inaccurate, and Cargo.lock is untracked, .dockerignored, and built without --locked.
  • MEDIUM .docker/scout/pdp-v2.vex.json:6 — all seven waivers bind the product to @next, so a published-tag re-scan matches none of them; same PURL trap 824c783 just fixed for subcomponents.
  • MEDIUM Dockerfile:111 — the 3.13.15 floor is advisory only, and the waiver that used to absorb a regression is now gone.
  • MEDIUM Dockerfile:153 — present tense describes a scheduled re-scan workflow that does not exist in this repo.

Details are in the inline comments on each line.

Two things worth flagging that are outside the diff and not blocking:

  • The docker-scout red on this PR is not caused by it, and no-cache-filters: opa_build cannot clear it — those Go module versions come from permit-opa's go.sum, and that stage already rebuilds every run because the regenerated custom_opa.tar.gz busts its COPY. Dropping opa_build from the filter value would make the config honest about that.
  • tests.yml:186 gates the scan on pull_request, so releases and pushes to main never scan. 0.9.14 shipped unscanned, and "main is green" says nothing about this gate. Extending that condition to the release event is a two-word change that closes the specific hole this PR is about.

Separately, release.yml:40-45 checks out permit-opa with CLONE_REPO_TOKEN and no persist-credentials: false. .dockerignore excludes only the root .git/, so permit-opa/.git/config — carrying the auth header — is inside the build context that Dockerfile:25 and :42 copy, and those stages are exported to the GHA cache via mode=max. Pre-existing, worth its own PR.

I pushed one commit to this branch (7fef179): tests.yml still documented the CVE-2026-15308 waiver that this PR removes.

Comment thread .github/workflows/release.yml Outdated
Comment thread .github/workflows/release.yml Outdated
Comment thread .github/workflows/release.yml Outdated
Comment thread requirements.txt
Comment thread Dockerfile Outdated
Comment thread Dockerfile Outdated
Comment thread .docker/scout/pdp-v2.vex.json Outdated
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>
Copilot AI review requested due to automatic review settings September 11, 2026 15:50
EliMoshkovich added a commit that referenced this pull request Sep 11, 2026
A customer found 12 CVEs in permitio/pdp-v2:0.9.14 before we did. The companion PR
clears them; this one closes the two holes that let them reach a customer at all.

Hole 1: a build-time gate cannot protect a shipped image.
The docker-scout gate passed on 2026-08-04 and was the last word on that tag
forever. Alpine published openssl 3.5.8-r0 and util-linux 2.41.6-r1 afterwards,
and CVEs disclosed after the build were invisible to us by construction. Nothing
in the repo ever looked at a published tag again.

Hole 2: releases skipped the gate entirely.
docker-scout was `if: github.event_name == 'pull_request'`. release.yml reaches
tests.yml through `workflow_call`, where github.event_name is the caller's event
('release') - so the CVE gate was skipped on exactly the runs that publish to
Docker Hub. Every released image went out ungated. Removing that condition makes
it gate releases too, and since build-and-push-pdp already `needs: pdp-tests`, a
failing gate now blocks the push.

Added:

- image-scan-published.yml - daily re-scan of the PUBLISHED tags. Scans `latest`
  plus the newest non-prerelease release tag: `latest` is what an unpinned pull
  gets, the release tag is what a pinned customer runs, and 0.9.14 was reported by
  a customer pinned to a tag that was no longer `latest`. Uploads SARIF, files one
  rolling issue per tag, fails the run.

- classify_image_cves.py - turns a Trivy report into the verdict that actually
  matters: CLEAN, REBUILD (every finding already patched upstream - cut a release,
  no code change), or SOURCE (something needs a human). Answering this by hand for
  0.9.14 took the whole investigation; the signal was sitting in Trivy's
  FixedVersion the entire time.

  It also splits a case that "has a fix" hides: Go modules in /app/bin/opa are
  compiled from permit-opa, so a grpc or x/crypto CVE is fixed by a go.mod bump in
  THAT repo and no release here can clear it. Go stdlib is the exception - it comes
  from this repo's floating golang stage. Verified against the real 0.9.14 report:
  28 rebuildable, 3 permit-opa, 0 unfixable.

- Trivy as a second gate in tests.yml, alongside Scout. The existing NOTE on the
  Scout gate already concedes it reads packages and cannot see the CPython
  interpreter the way a CPE scanner does - that blind spot is how four Python CVEs
  reached a customer scan of 0.9.14-rc1 (PER-15532). The scanners disagree in
  useful ways: on the published 0.9.14, Scout was clean while Trivy found 31
  CRITICAL/HIGH, including a CRITICAL GitPython RCE (CVE-2026-78676) that the
  customer's own OS-only scan also missed. Customers scan us with whatever they
  own; running one scanner ships whatever it happens not to look at.

- .trivyignore.yaml - Trivy counterpart to the OpenVEX doc Scout reads, for the
  same 3 waivers. Every entry carries `expired_at`, so a waiver cannot silently
  become permanent: when it lapses the CVE reappears, which is the prompt to
  re-check whether OPAL has relaxed the cap that forced it. Both waiver files
  change together or neither does.

- docs/image-vulnerability-management.md - the posture, the triage runbook, and
  the gaps deliberately left open (unattended Dependabot queue with 6 open alerts,
  permit-opa's Go deps ungated in their own repo, no signing/SBOM, no auto-rebuild
  cadence - detection is automated, cutting the release stays a human call).

- pyproject.toml: scope T201 off .github/scripts/*.py, where stdout is the tool's
  interface rather than stray debugging.

Gate verified both directions with trivy 0.68.2:
  published 0.9.14      -> exit 1, 31 findings, verdict SOURCE
  rebuilt + PR #337 fix -> exit 0, 0 findings, verdict CLEAN
So the gate catches the real regression and is not permanently red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EliMoshkovich added a commit that referenced this pull request Sep 11, 2026
Both cost a real debugging cycle on #337, and both fail SILENTLY - a
near-miss PURL binds to nothing and the gate just keeps reporting the CVE, which
reads as a broken scanner rather than a broken waiver.

- Go PURLs carry no `v` prefix: scout emits
  pkg:golang/golang.org/x/crypto@0.53.0, not @v0.53.0, despite Go module
  versions conventionally having one.
- The version must be one the image actually resolves; with a bump in flight,
  list every candidate version as separate subcomponents entries.

Added the diagnostic that would have caught it immediately: read the PURL out of
the docker-scout log, which prints pkg:...@Version above each finding, and
cross-check against a waiver known to work - if pkg:pypi/starlette@0.50.0 is
suppressed in the same run then the mechanism is fine and your PURL is wrong.

Also warns against the tempting wrong fix: dropping subcomponents makes the
statement apply to the whole image, suppressing the CVE wherever it appears
instead of only where it was analysed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Copilot AI review requested due to automatic review settings September 11, 2026 16:11
EliMoshkovich added a commit that referenced this pull request Sep 11, 2026
A customer found 12 CVEs in permitio/pdp-v2:0.9.14 before we did. The companion PR
clears them; this one closes the two holes that let them reach a customer at all.

Hole 1: a build-time gate cannot protect a shipped image.
The docker-scout gate passed on 2026-08-04 and was the last word on that tag
forever. Alpine published openssl 3.5.8-r0 and util-linux 2.41.6-r1 afterwards,
and CVEs disclosed after the build were invisible to us by construction. Nothing
in the repo ever looked at a published tag again.

Hole 2: releases skipped the gate entirely.
docker-scout was `if: github.event_name == 'pull_request'`. release.yml reaches
tests.yml through `workflow_call`, where github.event_name is the caller's event
('release') - so the CVE gate was skipped on exactly the runs that publish to
Docker Hub. Every released image went out ungated. Removing that condition makes
it gate releases too, and since build-and-push-pdp already `needs: pdp-tests`, a
failing gate now blocks the push.

Added:

- image-scan-published.yml - daily re-scan of the PUBLISHED tags. Scans `latest`
  plus the newest non-prerelease release tag: `latest` is what an unpinned pull
  gets, the release tag is what a pinned customer runs, and 0.9.14 was reported by
  a customer pinned to a tag that was no longer `latest`. Uploads SARIF, files one
  rolling issue per tag, fails the run.

- classify_image_cves.py - turns a Trivy report into the verdict that actually
  matters: CLEAN, REBUILD (every finding already patched upstream - cut a release,
  no code change), or SOURCE (something needs a human). Answering this by hand for
  0.9.14 took the whole investigation; the signal was sitting in Trivy's
  FixedVersion the entire time.

  It also splits a case that "has a fix" hides: Go modules in /app/bin/opa are
  compiled from permit-opa, so a grpc or x/crypto CVE is fixed by a go.mod bump in
  THAT repo and no release here can clear it. Go stdlib is the exception - it comes
  from this repo's floating golang stage. Verified against the real 0.9.14 report:
  28 rebuildable, 3 permit-opa, 0 unfixable.

- Trivy as a second gate in tests.yml, alongside Scout. The existing NOTE on the
  Scout gate already concedes it reads packages and cannot see the CPython
  interpreter the way a CPE scanner does - that blind spot is how four Python CVEs
  reached a customer scan of 0.9.14-rc1 (PER-15532). The scanners disagree in
  useful ways: on the published 0.9.14, Scout was clean while Trivy found 31
  CRITICAL/HIGH, including a CRITICAL GitPython RCE (CVE-2026-78676) that the
  customer's own OS-only scan also missed. Customers scan us with whatever they
  own; running one scanner ships whatever it happens not to look at.

- .trivyignore.yaml - Trivy counterpart to the OpenVEX doc Scout reads, for the
  same 3 waivers. Every entry carries `expired_at`, so a waiver cannot silently
  become permanent: when it lapses the CVE reappears, which is the prompt to
  re-check whether OPAL has relaxed the cap that forced it. Both waiver files
  change together or neither does.

- docs/image-vulnerability-management.md - the posture, the triage runbook, and
  the gaps deliberately left open (unattended Dependabot queue with 6 open alerts,
  permit-opa's Go deps ungated in their own repo, no signing/SBOM, no auto-rebuild
  cadence - detection is automated, cutting the release stays a human call).

- pyproject.toml: scope T201 off .github/scripts/*.py, where stdout is the tool's
  interface rather than stray debugging.

Gate verified both directions with trivy 0.68.2:
  published 0.9.14      -> exit 1, 31 findings, verdict SOURCE
  rebuilt + PR #337 fix -> exit 0, 0 findings, verdict CLEAN
So the gate catches the real regression and is not permanently red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EliMoshkovich added a commit that referenced this pull request Sep 11, 2026
Both cost a real debugging cycle on #337, and both fail SILENTLY - a
near-miss PURL binds to nothing and the gate just keeps reporting the CVE, which
reads as a broken scanner rather than a broken waiver.

- Go PURLs carry no `v` prefix: scout emits
  pkg:golang/golang.org/x/crypto@0.53.0, not @v0.53.0, despite Go module
  versions conventionally having one.
- The version must be one the image actually resolves; with a bump in flight,
  list every candidate version as separate subcomponents entries.

Added the diagnostic that would have caught it immediately: read the PURL out of
the docker-scout log, which prints pkg:...@Version above each finding, and
cross-check against a waiver known to work - if pkg:pypi/starlette@0.50.0 is
suppressed in the same run then the mechanism is fine and your PURL is wrong.

Also warns against the tempting wrong fix: dropping subcomponents makes the
statement apply to the whole image, suppressing the CVE wherever it appears
instead of only where it was analysed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@EliMoshkovich

Copy link
Copy Markdown
Collaborator Author

Checked the docker-scout failure properly. Nothing in this repo needs fixing — and the run confirms two fixes from the review round actually took effect, which I could only reason about before.

The corrected VEX PURLs bind

✓ Loaded 1 VEX document
pkg:golang/google.golang.org/grpc@1.82.1
  ✗ HIGH CVE-2026-84304
  ✗ HIGH CVE-2026-84445
pkg:golang/golang.org/x/crypto@0.53.0
  ✗ HIGH CVE-2026-56854
3 vulnerabilities found in 2 packages

5 → 3, with CVE-2026-78662 / CVE-2026-56855 gone. #339 turns out to be an accidental control group — it branches off main, carries no waivers, and still reports all 5 against the same image with the same scanner. Waivers are the only variable.

Branch Waivers? Reports
#339 (off main) no 5
#337 / #338 yes 3
after permit-opa#49 yes 0 expected

Both scanners independently agree on the remainder

Scout: 3 vulnerabilities found in 2 packages. Trivy (on #338, which adds it): Total: 3 (HIGH: 3, CRITICAL: 0) — same three CVEs, same fix versions. Given the whole case for two scanners is that they disagree, agreement here is good evidence the residual set is real rather than a feed artifact.

So the fix is a merge, not a change

All three are owned by permitio/permit-opa#49 (grpc 1.82.1 → 1.83.2, x/crypto 0.53.0 → 0.55.0, both verified to build and to scan clean). tests.yml clones permit-opa@main, so that PR has to land first. I deliberately did not waive them — they have upstream fixes and an owner, and waiving them is exactly the reflex this whole PR argues against.

Every other job passes, pdp-tester e2e included.

One structural thing this exposed, now written down

tests.yml and release.yml both check out permit-opa at ref: main, so a PDP build's result depends on whatever landed there since the last run, and nothing in this repo records which permit-opa commit went into a given image. That is precisely why this gate went red with no PDP change, and it also means a released image can't be traced back to its OPA source.

Pinning that checkout to a SHA would fix both and turn upstream drift into a visible PR here. It's a real behaviour change though — releases would stop following permit-opa main automatically — so I've recorded it as a gap in docs/image-vulnerability-management.md rather than deciding it unilaterally. Happy to do it if you want it.

(security/snyk (permit) is ERROR on every PR in every permitio repo — broken integration, not a finding.)

@zeevmoney zeevmoney left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested — 2 HIGH, 4 MEDIUM. Second round, on the delta 7fef179..ee023e6.

The three fixes land. no-cache-filters is now spelled correctly on all three call sites, opa_build is out, and tests.yml carries the same filter so the scanned image is no longer built from a stale cache. I verified that empirically rather than by reading it: on run 34618486309 the buildkit log shows [main 5/14] RUN ... apk update && apk upgrade ... → CACHED, and on run 34620527942 the same vertex is DONE 5.5s, both against the identical base digest sha256:75f27d68…. Measured cost of the filter is about 6 seconds, not the minutes I projected last round.

Two pushbacks I accept: the GitPython>=3.1.59,<4 floor is the right constraint here and I withdraw the exact-pin suggestion, and dropping opa_build was correct for the reasons now recorded inline.

The findings below are almost entirely about the new prose, which is where this round's risk sits: roughly 50 of the 87 changed lines are comments that assert security properties.

Blocking:

  • HIGH .github/workflows/tests.yml:250 — "Go 1.26 is still unreleased" is false (1.26.0 shipped 2026-02-10; 1.26.8 current; #334 in this repo pins golang:1.26-bookworm). The same claim, plus "no golang:1.26 base image exists", is in both new VEX impact statements, where it is the stated removal gate for two waivers — so it reads as blocked on upstream Go when the blocker is local.
  • HIGH Dockerfile:112 — "removing the waiver IS the enforcement" does not hold. The gate is pull_request-only so it never runs on a release, and Scout does not report the CPython interpreter at all — as the comment kept at tests.yml:252-254 says, and as the scan output confirms. The removed waiver's pkg:generic/python subcomponent was likely binding nothing.

Non-blocking:

  • MEDIUM .github/workflows/tests.yml:263 — the @next scope argument rests on a Trivy re-scan, a .trivyignore.yaml and a schedule: trigger that do not exist in this repo; remove the premise and the conclusion inverts. Also flags a VEX content divergence with #335 (v4→v5 vs this branch's v7).
  • MEDIUM requirements.txt:111 — docs/image-vulnerability-management.md does not exist on this branch.
  • MEDIUM requirements.txt:98 — the lockfile case is the one scenario the floor cannot catch, not one it catches; "fails loudly" holds only against an exact-pinned cap; and transitivity does not distinguish GitPython from the three pins cited against it.
  • MEDIUM Dockerfile:154 — "the scanned image matches the published one" overstates it: two independent fresh resolutions, amd64-only on the test side, and no scan on the release path at all.

Details are in the inline comments on each line.

Worth noting the pattern rather than only the instances: Dockerfile:158-161 handles exactly this problem correctly in this same commit — "Deliberately phrased as a requirement, not a description: no workflow in this repo has a schedule: trigger, so nothing here does it yet." Four other new references describe unmerged work in the present tense. Applying that one sentence's discipline uniformly would clear three of the four MEDIUMs.

Outside the diff, unchanged from last round: the three Scout HIGHs are clearable only by permitio/permit-opa#49, which is open and unreviewed; timeout-minutes is absent on both image-building jobs; and actionlint is still not in .pre-commit-config.yaml, though it catches the original input-name bug in under a second.

One correction to my own earlier review: I said #335 points Scout at released tags. It does not — it makes the job unconditional, but the scanned image is still local://permitio/pdp-v2:next, so the @next waivers keep matching. The #335 risk is the content conflict, not the binding.

Comment thread .github/workflows/tests.yml Outdated
# 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Go 1.26 has been released since February — this states the waiver's removal gate as something that can never be closed

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.

The first half is right: golang.org/x/crypto@v0.56.0's go.mod does declare go 1.26.0 (v0.55.0 declares go 1.25.0). The second half is false, and has been for about seven months.

Per Go's release history: Go 1.26.0 shipped 2026-02-10, point releases run through go1.26.8 (2026-09-01), and Go 1.27.0 has been out since 2026-08-19.

The same sentence appears, more strongly, in both new x/crypto impact statements in .docker/scout/pdp-v2.vex.json, which add "no golang:1.26 base image exists". That is contradicted inside this repo: PR #334 pins FROM golang:1.26-bookworm and its own comment reads "the tag currently serves 1.26.8".

This is worth more than a prose correction because the sentence is the removal gate — both statements end "Remove this waiver and move x/crypto to >= 0.56.0 once a Go 1.26 toolchain is available." As written that reads as "blocked on upstream Go, indefinitely." The real blocker is narrow, actionable, and entirely inside Permit's control: Dockerfile:57 is still golang:1.25-bookworm, and permit-opa's go.mod is go 1.25.0 with golang.org/x/crypto v0.53.0 // indirect. The official golang images set GOTOOLCHAIN=local, so the builder will not silently fetch a newer toolchain — which is exactly why #334 exists and says so.

So the path to deleting both waivers is: land #334, move permit-opa to go 1.26.0 + x/crypto 0.56.0, delete the statements. Nothing waits on Go.

Suggestion: Replace the "still unreleased" clause with the actual constraint, and restate each waiver's removal gate as a condition someone can close:

          # 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; the blocker is local - 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 set GOTOOLCHAIN=local so the
          # bump cannot happen implicitly. Gated on PDP#334 plus a matching permit-opa
          # change, not on Go. See PER-15358.

Both VEX impact statements need the same correction. Note the sentence is also copied verbatim into #338's .trivyignore.yaml, so it will propagate to a second file if not fixed here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and fixed in all three places.

Verified against go.dev's release history rather than taking it: 1.26.0 on 2026-02-10, 1.26.8 on 2026-09-01, 1.27.0 on 2026-08-19. And the "no golang:1.26 base image exists" half was contradicted inside this repo — #334 pins FROM golang:1.26-bookworm and its own comment reads "the tag currently serves 1.26.8".

You're right that this mattered more than a prose slip, because the sentence was the removal gate. Both x/crypto impact statements now state the blocker as local and name a condition someone can close:

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.

tests.yml carries the same correction, including GOTOOLCHAIN=local as the reason the bump can't happen implicitly. vex.json v7 → v8.

Comment thread Dockerfile Outdated
# 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Removing the waiver IS the enforcement" does not hold — the gate never sees this regression

This answers my earlier point about the 3.13.15 floor being unenforced, but the answer isn't correct. Two independent reasons, both already documented elsewhere in this same diff.

1. The gate does not run on the path that matters. tests.yml:198 is still if: github.event_name == 'pull_request'. Releases reach tests.yml through workflow_call, where github.event_name is the caller's event, so the job is skipped on every release and every push to main. Your own comment 40 lines below, at Dockerfile:156, says exactly this: "the docker-scout gate in tests.yml runs only on pull_request". A base-image regression landing between merge and release cut is not gated at all.

2. Scout does not report the CPython interpreter, so there is nothing to fail on. The comment you kept at tests.yml:252-254 states it directly: "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 CVEs reached a customer scan of 0.9.14-rc1 without failing CI."

The scan output agrees. On run 34620527942 the unfiltered report lists 15 findings across 6 packages — golang.org/x/crypto, google.golang.org/grpc, starlette, ddtrace, containerd, busybox — and no CPython entry of any kind.

There is a third detail that points the same way: the waiver being removed bound its subcomponent to pkg:generic/python, which is not a PURL Scout emits. It was very likely suppressing nothing in the first place — the identical trap you documented so carefully for the Go PURLs' missing v prefix, one file over.

Put together: if Scout never flags a sub-3.13.15 interpreter, removing the waiver neither tightened nor loosened anything. The claim as written tells the next reader a security control exists where none does, which is the more consequential form of the phantom-reference problem you fixed well at Dockerfile:158-161.

Suggestion: Say what is actually true, and name the thing that would enforce it:

# Do not drop below 3.13.15 - that is the floor for every fix named above. Nothing
# enforces it today: Scout reads packages and never reports the interpreter (see the
# NOTE in tests.yml), and the gate is pull_request-only, so a release never scans at
# all. A CPE-based scanner is what catches this class - that is why the companion
# change adds a second scanner. Until it lands, this line is a convention, not a
# control.

If you want it to be a control now, the one-liner from the last round still works and runs on every build path including releases:

RUN python3 -c "import sys; assert sys.version_info[:3] >= (3,13,15), sys.version"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed on all three sub-points — I checked each rather than accepting them, and all three hold:

  1. tests.yml:198 is still if: github.event_name == 'pull_request'.
  2. On run 34620527942 the unfiltered report is 15 findings across 6 packages — busybox, containerd, x/crypto, grpc, starlette, ddtrace. Grepping that job log for cpython / pkg:generic / python@3 returns nothing.
  3. git show origin/main:.docker/scout/pdp-v2.vex.json shows the removed waiver's subcomponent was pkg:generic/python — a PURL that appears in no Scout output. It was suppressing nothing, which is the same trap as the missing v prefix one file over.

So the claim was wrong in the more consequential direction, as you say: it told the next reader a control existed where none did.

Rather than only correcting the prose, I took your one-liner. The apk RUN now ends with:

    python3 -c "import sys; assert sys.version_info[:3] >= (3, 13, 15), sys.version"

Folded into the existing layer so it adds none, and placed after the .python-rundeps / sqlite surgery so it doubles as proof the interpreter survived that. It runs on every build path including releases, and a cache hit implies an unchanged parent — i.e. the same base digest — so a base below 3.13.15 cannot ship either way. The comment now says plainly that Scout never enforced this and names a CPE-based scanner as the thing that would.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correcting myself on this thread. In my reply above I wrote that the check was "placed after the sqlite surgery so it doubles as proof the interpreter survived that." That was wrong, and it is the same kind of claim this thread was about.

sys is a builtin, so import sys loads no extension modules at all:

'sys' in sys.builtin_module_names        -> True
extension modules loaded by `import sys` -> []

So the check passed on an interpreter whose entire lib-dynload could be unresolvable — which is exactly what the .python-rundeps surgery two lines above produces if apk info -qR .python-rundeps | grep '^so:' ever comes back short. The virtual would be created empty, apk del .python-rundeps sqlite-libs would strip the interpreter's shared objects, my check would pass, and the image would build green and die at startup on import ssl. That matters most on arm64, which tests.yml never builds and nothing in CI ever starts — a release build is the first time that code path runs.

Fixed in 85e5c59:

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)"

Two changes beyond the imports: it now covers the surgery as well as the version, and it uses sys.exit instead of assert, which -O / PYTHONOPTIMIZE would strip — neither is set today, but "the control" shouldn't be one build-env variable from being a no-op. Verified in both directions locally: below-floor exits 1 with a message, at-floor exits 0.

Your underlying point in this thread stands unchanged and I'm not relitigating it — removing the waiver was never the enforcement. I just replaced a comment that was wrong with a check that was also wrong, and the second error was mine alone.

One related finding worth flagging here, since it came out of the same pass: all three x/crypto CVEs share an import path — GO-2026-6303 (CVE-2026-56854), GO-2026-6354 (CVE-2026-78662), GO-2026-6355 (CVE-2026-56855) all resolve to golang.org/x/crypto/ssh per vuln.go.dev. So the evidence waiving two of them covers the one still reddening the gate. That's a posture decision rather than a typo, so I've left it for you — details in the round-3 section of the PR description.

# ("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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The safety argument here rests on machinery that does not exist in this repo, and #335 changes the same block from a different base

Two separate problems in this block.

1. The premise is a phantom. Lines 266-268 assert, in the present indicative, that "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." None of that is here:

  • rg -ni --hidden trivy over the whole tree returns exactly two hits, both comment lines added by this PR — this one and tests.yml:82 ("that docker-scout/Trivy gate"). There is no Trivy action, step, or config.
  • No .trivyignore* file anywhere.
  • No schedule: or cron: trigger in any of the six workflows.

That matters more than a stale cross-reference, because the phantom is the argument. The block concludes the @next-only binding is "correct precisely BECAUSE this gate is the only consumer of the OpenVEX doc" — safe on the grounds that a second mechanism covers published tags. Remove that second mechanism and the conclusion inverts: every statement binding to pkg:docker/permitio/pdp-v2@next means the published tags are covered by nothing at all. A reader auditing waiver coverage is told a gap is fine when it is open.

Dockerfile:158-161, in this same commit, handles the identical situation correctly — "Deliberately phrased as a requirement, not a description: no workflow in this repo has a schedule: trigger, so nothing here does it yet." Same treatment is what this block needs.

2. #335 rewrites this block and the VEX doc from a divergent base. PR #335 (david/per-15358-scout-gate-releases-vex, head 19c80cd, open and mergeable) touches both artifacts:

  • It changes every "@id": "pkg:docker/permitio/pdp-v2@next" to "pkg:docker/permitio/pdp-v2" — so the moment it merges, lines 263-264 are false.
  • It deletes if: github.event_name == 'pull_request' from the docker-scout job.
  • Its tests.yml hunk spans the same region as this block.

The sharper issue is content, not wording: #335 diffs from v4 and produces v5, replacing CVE-2026-15308 with a new CVE-2026-54282 statement. This branch is at v7 — CVE-2026-15308 dropped, the two x/crypto waivers added, and no CVE-2026-54282 at all. Whichever merges second conflicts on the same JSON objects, and a careless resolution silently drops either the two x/crypto waivers (gate goes red on known non-issues) or the CVE-2026-54282 one.

To be precise about what #335 does not do, since I got this wrong myself first time round: its scout job still scans image: local://permitio/pdp-v2:next, and so does #338's. Neither points Scout at a published tag, so @next is not broken by either PR today. #335 just removes the binding anyway.

Suggestion: Describe the gap instead of a fictional cover:

          # 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). When that lands, 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.

And fix tests.yml:82's "docker-scout/Trivy gate" the same way. Separately, worth agreeing a merge order with #335 and recording it in both PRs — if #337 goes first, #335 needs a rebase that keeps v7's seven statements and adds CVE-2026-54282 on top as v8.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — and the phantom really was load-bearing, so I inverted the conclusion rather than just deleting the sentence.

Checked all three: grep -rni trivy over the tree returns exactly the two comment lines this PR added (this one and tests.yml:82), there is no .trivyignore* anywhere, and no workflow has a schedule: or cron: trigger. Remove the second mechanism and the @next binding stops being "correct because" and becomes an open gap — the published tags are matched by no waiver mechanism at all. That is what the block says now:

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.

tests.yml:82's "docker-scout/Trivy gate" is fixed the same way.

On merge order with #335: agreed, and worth pinning down. #337 is ready first, so the order is #337 → #335, with #335 rebasing to keep this branch's statements and adding CVE-2026-54282 on top. One correction to your arithmetic: correcting both x/crypto impact statements bumped this branch v7 → v8, so #335 lands as v9, not v8. I'll record the same note on #335.

Comment thread requirements.txt Outdated
# 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

docs/image-vulnerability-management.md does not exist on this branch

There is no docs/ directory here at all. git ls-files '*.md' returns three tracked files in total: README.md, test_offline_mode/README.md, watchdog/README.md. The path resolves to nothing.

It is the load-bearing sentence of the paragraph, which is what makes it worth fixing rather than noting: the lockfile is your own stated "real structural fix" and the reason you decline the exact pin, so a reader who wants to check that the plan is real goes to this path and finds nothing.

Same class as the phantom-workflow reference at Dockerfile:153-161 — which this same commit fixed, and fixed well, by naming the absence out loud ("no workflow in this repo has a schedule: trigger, so nothing here does it yet"). This pointer got the opposite treatment and reads as a live cross-reference.

Suggestion: Cite the tracker, which exists, rather than the file, which doesn't — and add the path in whichever PR merges second:

# 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. Drop this floor once opal-common itself floors
# gitpython >= 3.1.59.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — git ls-files '*.md' returns exactly README.md, test_offline_mode/README.md, watchdog/README.md, and there is no docs/ directory at all. The path resolved to nothing.

Fixed as suggested, and given the same treatment as Dockerfile:158-161 — it names the absence out loud instead of reading as a live cross-reference:

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).

Comment thread requirements.txt Outdated
#
# 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tripwire paragraph is right about the conclusion, but one of its three scenarios runs backwards

Taking the pushback first: it holds. >=3.1.59,<4 is the right constraint here and I withdraw the ==3.1.62 suggestion — no waiver PURL to keep bound, nothing first-party imports GitPython, and with <4 the only drift available is a patch inside 3.1.x, which is a normal posture. An exact pin would have bought no security and cost a recurring manual bump against a package that shipped eight releases in the seven weeks to 3.1.62. The rewritten comment is a large improvement on what it replaced.

Three corrections to the reasoning, in descending order of importance.

1. The lockfile scenario is inverted. Line 98 lists "the lockfile this build still lacks pinning an older version" as a case the floor catches. It is the one case where the floor is guaranteed not to fire. Once a lockfile exists, Dockerfile:210 installs from the lock (or --require-hashes) and requirements.txt is not read at install time at all. A lock pinning 3.1.54 installs 3.1.54; the floor is never consulted.

The real property runs the other way and is arguably stronger: pip-compile / uv pip compile read requirements.txt as input, so a lock generated from this file can never be produced with GitPython below 3.1.59 in the first place. The floor prevents the bad lock rather than catching it. As written, the comment claims a safety net at precisely the moment the net is bypassed.

2. "the resolve fails loudly" holds for part of the stated surface. It is true when the capping package is itself top-level exact-pinned — bump opal-common to a version whose gitpython cap drops below 3.1.59 and pip has nothing to backtrack on, raises ResolutionImpossible, and the image build fails at pip install, in front of the person doing the bump. That is the likeliest scenario and it is genuinely loud.

It is not true when the cap arrives via a ranged or transitive dependency: pip backtracks and quietly picks an older version of that package, and resolution succeeds. GitPython still cannot land below the floor — the security guarantee survives both — but the failure is silent, and the floor has converted a silent GitPython downgrade into a silent downgrade of something else.

3. "it is a TRANSITIVE dependency" is contradicted by all three cited counterexamples. websockets, ddtrace and starlette are transitive too — via opal-common/opal-client, fastapi-websocket-{rpc,pubsub}, uvicorn, fastapi. All three exact pins already make this repo the owner of an upstream's cadence. Transitivity is the property all four share, so it cannot be the one that distinguishes GitPython. The distinction that does work is cost of drift: websockets drift takes an unvalidated major onto a live pub/sub path (documented, #329); ddtrace/starlette drift unbinds a VEX PURL and reds the gate; GitPython drift moves a patch of a package with no waiver that nothing imports.

Suggestion:

# 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: because
# opal-common is exact-pinned, pip has nothing to backtrack on and raises
# ResolutionImpossible, so the image build fails at `pip install`, loudly, in front of
# whoever is doing the bump. A cap arriving via a RANGED dependency instead resolves
# quietly by backtracking that package - 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.
#
# What the floor does NOT cover is a lockfile: once one exists the lock is installed and
# this file is not read at install time. Its value moves upstream instead -
# pip-compile/uv read this file as INPUT, so a lock generated from it can never be
# produced below 3.1.59.
#
# Deliberately a floor and NOT an exact pin, unlike `websockets`/`ddtrace`/`starlette`
# above - though not because those are direct and this is transitive; all four are
# transitive. They are pinned because drift is expensive: an unvalidated MAJOR on the
# OPAL pub/sub path, or an unbound VEX waiver PURL. GitPython has neither - no waiver,
# nothing first-party imports it, and `<4` caps drift to a PATCH inside 3.1.x.

One smaller thing: "A verification build of the full image confirms exactly that" cites no run, while the websockets note twenty lines up sets the opposite precedent — "17.0 is the version run 30533253549 actually validated". This is the more contestable of the two claims, since it is the entire basis for "this line changed nothing". Worth naming the run.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for withdrawing the exact-pin suggestion. Taking the three corrections in order — two adopted as written, one adopted in substance but not in wording.

1. The lockfile inversion is right, and it is now stated the other way round. Once a lock exists it is what gets installed, this file is not read at install time, and the floor is never consulted; the value moves upstream, where pip-compile/uv read this file as input so a lock below 3.1.59 cannot be generated in the first place. The comment ends on your framing — "prevents the bad lock rather than catching it."

2. Loudness is now conditional, on the capping package being exact-pinned — which opal-common==0.9.6 is, so that path genuinely raises ResolutionImpossible at pip install. The ranged/transitive case is called out as resolving quietly by backtracking that package, with the part that survives both stated explicitly: GitPython still cannot land below the floor.

3. Agreed the transitivity argument does not work — but I did not use your wording, because "all four are transitive" is not quite right either. ddtrace is imported first-party at horizon/pdp.py:321 (from ddtrace import config, patch), and starlette in horizon/tests/. What is true of all four is that they are top-level entries here that would also arrive transitively anyway. So the comment says transitivity cannot be the separator and uses cost of drift — your point — without swapping one unverifiable claim for another. The "nothing first-party imports it" clause for GitPython I did verify: no import git / from git anywhere outside site-packages.

On the run id: there is no run to cite — it was a local --no-cache build of the base + apk + pip layers, not CI. Rather than leave it looking like the websockets precedent, the comment now says so and says why (no CI job resolves this file in isolation). Fair catch that it was the more contestable of the two claims.

Comment thread Dockerfile Outdated
# 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"so the scanned image matches the published one" overstates what the filter can deliver

Adding the filter to tests.yml was the right call and it closes the divergence I raised last round — I verified it took effect, not just that it was written: on run 34618486309 (pre-fix) the buildkit log shows [main 5/14] RUN ... apk update && apk upgrade ... → CACHED, and on run 34620527942 (post-fix) the same vertex is DONE 5.5s, with both runs resolving the identical base digest sha256:75f27d68…. That is precisely the "base hasn't moved, package index has" case, and it is now genuinely re-resolved.

But "matches" claims more than that. The two images are built from two independent fresh resolutions, not one shared one, and four things keep them apart:

  1. Separate workflow runs on separate builders, each hitting the live Alpine and PyPI indexes at its own moment. Nothing pins one resolution to the other.
  2. tests.yml:77 builds platforms: linux/amd64 only; release.yml:62 and :95 build linux/amd64,linux/arm64. The arm64 package set is never built or scanned here, and Alpine's arch repos version independently.
  3. The scan does not run on the release path at all — docker-scout is if: github.event_name == 'pull_request' (tests.yml:198), skipped on a workflow_call from a release. So the scanned image is always a PR-time build and the published image a release-time build: days to weeks apart, not minutes.
  4. Before this change the two were, ironically, closer: the inert filter meant both builds served main from the same GHA entry, so the amd64 apk layer was byte-identical between tested and published. Forcing both fresh is the right trade, but it does mean they now differ by whatever moved in between.

So the guarantee the filter actually provides is "neither image is built on a stale package set" — which is the important one, and worth stating as such. It is not "same set".

Suggestion:

#      `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. Note this does not make the two images identical: they are
#      separate fresh resolutions, tests.yml builds amd64 only, and the scout gate is
#      pull_request-only so a release is never scanned. Closing that last gap needs the
#      gate to run on release events (PER-15358).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and adopted — "matches" was claiming more than the filter can deliver.

Verified the divergence you list: tests.yml:77 is platforms: linux/amd64 against release.yml:62 and :95 at linux/amd64,linux/arm64, and tests.yml:198 keeps the scan off the release path entirely. Your point 4 is the sharpest one — the inert filter did make the two amd64 apk layers byte-identical, so this change trades "identical but possibly both stale" for "both fresh but not identical." That is the right trade, and the comment now says which of the two it bought:

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).

Thanks for verifying the filter empirically rather than by reading — CACHED → DONE 5.5s on the same base digest is the measurement that mattered, and 6 seconds is a much easier cost to defend than the minutes projected.

…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>
Copilot AI review requested due to automatic review settings September 14, 2026 14:57

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…laims

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>
Copilot AI review requested due to automatic review settings September 15, 2026 08:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@dshoen619
dshoen619 requested a review from zeevmoney September 15, 2026 08:41
@dshoen619

Copy link
Copy Markdown
Contributor

Round 3: an automated pass over my own branch, and eight fixes in 85e5c59

I ran a five-agent review (code review, security audit, architecture, backend/runtime, vulnerability scanning) plus an independent pass over b3e85f7. Of 25 specific factual claims tested, 24 held and one did not. Every finding was re-verified by hand before I acted on it, and three agent claims were dropped as overstated — including two that said waiving one more CVE would turn the gate green. It would not; the two grpc findings are in genuinely linked code.

Six of the eight defects were in the commit I pushed that morning.

Behaviour changes

The 3.13.15 check could not detect what I said it detected. In this thread I told @zeevmoney that placing it after the sqlite surgery made it "proof the interpreter survived that." That was wrong:

'sys' in sys.builtin_module_names        -> True
extension modules loaded by `import sys` -> []

sys is a builtin, so the old check loaded no extension modules and would have passed on an interpreter whose entire lib-dynload was unresolvable — exactly what apk del .python-rundeps produces if the so:-derived list ever comes back short. Green build, image dies at startup on import ssl. It now reads:

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)"

sys.exit rather than assert, which -O/PYTHONOPTIMIZE would strip. Confirmed executing on the real build — vertex #21 [main 5/14] DONE 6.1s, not CACHED — so all eight modules genuinely resolve after the surgery. It is now a smoke test of that surgery, not just a version string.

Both x/crypto waivers had the wrong OpenVEX justification. vulnerable_code_not_in_execute_path asserts the component ships and is merely uncalled. The impact statements argue the stronger, provable claim — the ssh package is excluded at link time — which is vulnerable_code_not_present. The statement contradicted its own justification. The four starlette waivers keep the old label, which is right for them, since starlette really is installed and imported. v8 → v9, and the timestamp now says when the document was issued rather than three days earlier.

Verified the change suppresses identically: gate still reports 3 findings in 2 packages, same CVE set as the v7 and v8 baselines.

Statements that were simply wrong

release.yml said "what does still cache is cargo chef cook" False. On run 34858998837 exactly three vertices cached — all 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, previously unmentioned. Both halves corrected.
<4 "caps its drift to a PATCH inside 3.1.x" It admits 3.2.0 — the cap is a major. Verified with packaging. Conclusion unchanged, premise now true. (Inherited from a round-2 suggestion, so it was wrong in both places.)
Dockerfile:57 cited from 3 places in 2 files #334 moves that stage, and git merge-tree shows it merges clean — three security assertions would go false with no conflict and no check. They name the stage now.
"seven advisories cleared by this floor" The paragraph enumerates eight.
tests.yml: undeclared input is "silently dropped" Actions emits an Unexpected input(s) annotation. release.yml already said this correctly; the wrong one was the one claiming no signal exists.

I also corrected three stale claims in the PR description itself, including two round-1 table rows still asserting things round 2 disproved.

Left deliberately — these need a decision, not a commit

1. The waiver evidence covers a third CVE it doesn't waive. Per vuln.go.dev, all three share an import path:

GO-2026-6303  CVE-2026-56854  golang.org/x/crypto/ssh  fixed 0.55.0
GO-2026-6354  CVE-2026-78662  golang.org/x/crypto/ssh  fixed 0.56.0
GO-2026-6355  CVE-2026-56855  golang.org/x/crypto/ssh  fixed 0.56.0

The waivers say that package isn't linked; the same file says permit-opa#49 must fix CVE-2026-56854. Both can't be true. Waiving it on the same evidence takes the gate 3 → 2 HIGH.

2. No release has ever been scanned. Verified on the run that published this very image — release run 30900950607 (v0.9.14) shows skipped pdp-tests / docker-scout next to success build-and-push-pdp. That is the root cause of the customer report, and the ~4-line fix is sitting in #338, which is based on this branch. Worth hoisting.

3. The waivers fail open. Both workflows clone permitio/permit-opa at ref: main unpinned, and Scout suppresses on PURL — never on whether ssh is linked. If permit-opa ever links it, the waiver keeps suppressing a now-reachable DoS with no signal in either repo.

Also open, lower priority: the Rust binary (PID 1) has no tracked Cargo.lock, no --locked, no cargo audit, and Scout reports zero cargo PURLs; the SARIF uploaded to code scanning has no VEX applied, so all 15 findings including the 7 waived ones land in the Security tab; and arm64 is built only by the job that publishes it.

⏰ One with a clock on it: CVE-2026-48710 is a CISA KEV entry (added 2026-09-02, due 2026-09-16). The reachability argument holds — nothing under horizon/ registers middleware — but nothing in the repo records that it's KEV-listed, and at MEDIUM severity only-severities: critical,high couldn't fail on it even with the waiver deleted.

CI on 85e5c59: everything green except docker-scout (unchanged 3 HIGH, still blocked on permitio/permit-opa#49) and snyk/Copilot, both on quota.

@zeevmoney zeevmoney left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — no CRITICAL or HIGH issues found. Round 3, on the delta ee023e6..85e5c59.

Both round-2 HIGH findings are resolved. The Go-1.26 claim is corrected everywhere it appeared, and the 3.13.15 floor is now a real control rather than a comment — the check imports extension modules before testing the version, which catches a .python-rundeps surgery that leaves lib-dynload unresolvable, and it uses sys.exit rather than assert, which -O strips. Both round-2 MEDIUM phantom references are gone, and the "matches the published one" overclaim is replaced with an accurate statement of what the filter does and does not guarantee.

Two of the findings below were established by executing something rather than reading it, which is why they are worth acting on despite being non-blocking.

Non-blocking:

  • MEDIUM Dockerfile:117 — "pkg:generic/python is not a PURL scout emits" is false; docker scout sbom lists python | 3.13.15 | generic for this exact base image, and the deleted waiver was bound to a component Scout indexes. The conclusion still holds via the pull_request-only gate.
  • MEDIUM Dockerfile:195 — three of the eight imports cannot detect what the comment says they guard; decimal and hashlib fall back silently and cannot raise. Removing _decimal.so and _hashlib.so from the real base image leaves the one-liner exiting 0. Coverage is 6 of the 12 non-libc so: deps the surgery could strip.
  • MEDIUM .github/workflows/release.yml:86 — the caching measurement comes from an amd64-only pull_request run and is quoted on a two-platform release step; the "longest step" ranking does not survive the transplant, and rust_chef cached three of four layers rather than all.
  • LOW Dockerfile:111 — the check is a single global tuple compare, so 3.14.0-3.14.6 pass while line 109 says they lack the fix.
  • LOW requirements.txt:87 — "eight advisories" is eleven distinct CVEs by OSV (the three omitted are MODERATE); GHSA-jm78-9fvv-mhgr is CVE-2026-76221 and reads as a gap in the 76218-76222 run. Line 125's "eight releases" is nine.
  • LOW .github/workflows/release.yml:94 — the new "pdp-server vendors OpenSSL" sentence is correct, and therefore shows openssl-dev and OPENSSL_DIR=/usr are dead config to delete rather than explain.
  • LOW .docker/scout/pdp-v2.vex.json:6 — v9 here against v5 in open PR #335, from a divergent base; a side-pick resolution drops either both x/crypto waivers or the CVE-2026-54282 one.

Details are in the inline comments on each line.

Two items outside this delta, carried forward:

  • The CVE-2026-48710 statement still asserts the app "registers no add_middleware/BaseHTTPMiddleware". That is false — horizon/pdp.py:281 adopts OPAL's app and opal_client/client.py:272 reaches opal_common/middleware.py:77, which registers CORSMiddleware. The conclusion survives (CORSMiddleware dispatches on Origin and method, never on request.url.path), but the premise is wrong, and PR #335 already carries the corrected wording.
  • docker-scout remains red on 3 HIGH — CVE-2026-84304 and CVE-2026-84445 in grpc, CVE-2026-56854 in x/crypto — all from the OPA binary and clearable only by permitio/permit-opa#49, which is open and has its own blocker in permit-opa#48. This approval does not clear that gate.

Comment thread Dockerfile Outdated
# 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scout does emit pkg:generic/python — reason (c) is false, and reason (a) is stronger than the evidence behind it

The rewritten block replaces round-2's refuted argument with three independent reasons. Two of them don't survive a direct test.

Reason (c) says the removed waiver bound to pkg:generic/python, "which is not a PURL scout emits, so it was almost certainly suppressing nothing to begin with." Scout emits exactly that PURL:

$ docker scout sbom --format list python:3.13-alpine3.23
  python   │ 3.13.15   │ generic

and the CycloneDX output carries "purl": "pkg:generic/python@3.13.15", alongside pkg:apk/alpine/.python-rundeps@20260831.234605. git show 743a172:.docker/scout/pdp-v2.vex.json confirms the removed statement's subcomponent was { "@id": "pkg:generic/python" } — bound to a component Scout genuinely indexes.

Reason (a) — "scout reads packages and never reports the CPython interpreter at all" — is a stronger claim than the one tests.yml:257-259 already makes, and that file gets it right: "this gate reads packages, so it does not flag the CPython interpreter itself the way a CPE-based scanner does." Whether advisories are matched against the interpreter is a different question from whether it is indexed, and the SBOM settles the second. The cited parenthetical (run 34620527942, 15 findings across 6 packages, no CPython row) is accurate — I checked it — but that run scanned an already-patched 3.13.15, so zero CPython rows is what you'd see either way.

The conclusion is unaffected. Reason (b) — the gate is pull_request-only (tests.yml:4, tests.yml:199) — is true and on its own justifies adding the runtime check. Nothing here breaks the build.

What makes this worth correcting rather than letting go: it's a checkable factual claim about tooling, used as stated justification for deleting a security waiver, in a repo that already spent commit 824c783 ("bind the x/crypto waivers to the PURLs scout actually emits") on precisely this class of error. The next person weighing whether a pkg:generic/... waiver is worth writing would be misled.

Suggestion: Drop (c), narrow (a) to match what tests.yml says:

# scout indexes the interpreter as `pkg:generic/python@<version>` but does not match
# CPython advisories against it the way a CPE-based scanner does, and the gate is
# `pull_request`-only so a release never scans at all.

If the waiver removal is meant to stand on its own, the simple true reason is that 3.13.15 carries the fix. docker scout sbom --format list needs no Hub login, so this is one command to confirm.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and the way I got it wrong is worth recording because it's a reasoning error, not a typo.

My evidence was the scan findings list — six packages, no CPython row. That says nothing about what Scout indexes; a component with no matching advisories simply never appears there. The SBOM is a different artifact, and I never looked at it. So the inference was invalid regardless of which way the answer went, and I stated it as fact in a comment justifying a waiver deletion — in a branch that already spent 824c783 on exactly this class of error.

Fixed in 9676cfc. Reason (c) is gone, reason (a) is narrowed to what tests.yml already said correctly:

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.

And I took your last suggestion: the plain true reason the waiver could go is simply that 3.13.15 carries the fix. That's now stated, so the paragraph no longer leans on the tooling argument at all.

Comment thread Dockerfile Outdated
$(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, 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 link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three of these eight imports cannot detect what the comment says they guard against — decimal and hashlib cannot fail at all

Adding a real control here was the right call, and the reasoning for sys.exit over assert is correct (-O strips asserts). But the import list does not deliver what lines 121-125 claim for it, and I tested that by breaking the image rather than reading it.

decimal and hashlib are structurally incapable of raising. From CPython's own stdlib:

# decimal.py
try:    from _decimal import *
except ImportError: import _pydecimal; sys.modules[__name__] = _pydecimal

# hashlib.py
try:    import _hashlib; new = __hash_new
except ImportError: _hashlib = None; new = __py_new

Both fall back silently. ssl.py is the contrast — import _ssl # if we can't import it, let the error propagate.

Executed inside python:3.13-alpine3.23, after moving _decimal.cpython-313-*.so and _hashlib.cpython-313-*.so out of lib-dynload, the verbatim one-liner from this line returned exit 0, with _pydecimal used: True, _hashlib loaded: False, and hashlib.sha256(b'x') still working.

_decimal and pyexpat have nothing for the surgery to remove. scanelf -qn in the same image:

_decimal  -> libc.musl-aarch64.so.1
pyexpat   -> libc.musl-aarch64.so.1
_hashlib  -> libcrypto.so.3, libc...
_ssl      -> libssl.so.3, libcrypto.so.3, libc...

libmpdec and libexpat are statically linked, so neither has an apk-provided so: dep the .python-rundeps rework could strip — and _hashlib's only library is already covered by import ssl.

Coverage. apk info -qR .python-rundeps returns 14 so: deps; 13 non-sqlite, 12 excluding libc. Covered: libssl, libcrypto, libz, liblzma, libbz2, libffi — 6 of 12. Uncovered: libuuid.so.1, libreadline.so.8, libncursesw.so.6, libpanelw.so.6, libgdbm.so.6, libgdbm_compat.so.4.

The control still works for the starkest case the comment names — a wholly unresolvable lib-dynload — so this is a narrowing, not a breakage. But "is the point, not decoration" overstates it in a way a maintainer would act on.

Suggestion: Import the extension modules directly, so a missing .so cannot be papered over by a pure-Python fallback, and cover the libs actually at risk:

python3 -c "import sys, _ssl, _hashlib, _decimal, zlib, _lzma, _bz2, _ctypes, pyexpat, _uuid, readline, _curses, _gdbm; sys.version_info[:3] >= (3, 13, 15) or sys.exit('CPython %s is below the 3.13.15 floor' % sys.version)"

All twelve import cleanly in the base image (verified). Do not extend this to a blanket lib-dynload sweep — import _tkinter fails in the pristine base with ImportError: Error loading shared library libtk8.6.so, so a sweep false-positives.

One related placement note: this runs inside the apk layer, but the second package operation — the .build-deps install/remove around pip install ~30 lines below — happens after it and is unguarded. If the intent is "the image never ships a broken interpreter", the check wants to be after the last package mutation, not the first.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and this is the sharpest catch of the four rounds — it lands on the control I added specifically to stop making unverified claims.

I reproduced it before changing anything. Blocking _decimal/_hashlib behind a meta_path finder and running the verbatim old line:

Dockerfile import line: PASSED (all 9 imports succeeded)
exit code = 0
decimal impl file : .../python3.13/_pydecimal.py
hashlib._hashlib  : None
hashlib.sha256()  : still works, pure-python

decimal.py:101-109 and hashlib.py:169-178 both swallow the ImportError. The other six do propagate — ssl.py:100 is the contrast, unguarded and commented as such. So your "detects nothing" is exactly right about those two names; the line as a whole still caught a wholesale lib-dynload wipe through ssl, which makes this a narrowing rather than a breakage. I've said that in the comment rather than overclaiming in the other direction.

One change from your suggested list: I dropped _gdbm, readline and _curses.

_gdbm is not importable on this machine's CPython 3.13 at all:

$ python3.13 -c "import _gdbm"
ModuleNotFoundError: No module named '_gdbm'

I take your point that all twelve import cleanly in python:3.13-alpine3.23 — you tested that and I didn't. But _gdbm being absent from a mainstream 3.13 build shows the risk isn't theoretical, and those three only guard libreadline/libncursesw/libgdbm, none of which the PDP uses. A .python-rundeps accident that dropped them would drop libssl too and get caught anyway. So they'd add no detection and a standing false-failure risk on any future base change. Landed as:

python3 -c "import sys, _ssl, _hashlib, _decimal, zlib, _lzma, _bz2, _ctypes, pyexpat, _uuid; ..."

Nine extension modules, covering libssl, libcrypto, libz, liblzma, libbz2, libffi, libuuid and lib-dynload itself. Kept _uuid on your evidence — libuuid is in .python-rundeps and was in the customer's original CVE list, so it earns its place.

Your placement note is fair and I've recorded it rather than acted on it: the check sits in the apk layer, so it proves the interpreter survived the sqlite surgery, not every later package mutation. Moving it after .build-deps is the better position but it's a different layer and a different change; the comment now says which of the two it is.

Comment thread Dockerfile Outdated
# 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check is a single global tuple compare, so it passes on 3.14.0-3.14.6 — the exact versions line 109 says lack the fix

This line says the apk-layer check enforces the floor "for every fix named above". The check is sys.version_info[:3] >= (3, 13, 15), a single point in the global version ordering rather than a per-branch floor. (3, 14, 0) >= (3, 13, 15) is True, so every 3.14.x passes — while line 109 states the CVE-2026-15308 backport "landed in 3.13.15 (also 3.14.7)".

So a base moved to the 3.14 line and pinned anywhere in 3.14.0-3.14.6 sails through the control this block says enforces the floor, while missing the fix the control exists for.

Verified: 3.14.7 exits 0 on the verbatim check, 3.13.14 exits 1, 3.12.12 exits 1.

The window is narrow in practice — python:3.14-alpine resolves to 3.14.7 today, so the floating tag is safe. Reaching the gap needs an explicit pin to a superseded 3.14 patch, which cuts against this file's own floating-patch posture. Worth closing anyway, since the whole point of this round was making the floor a real control rather than a comment.

Suggestion: Make it branch-aware:

v = sys.version_info[:3]
ok = v >= (3, 14, 7) if v[:2] >= (3, 14) else v >= (3, 13, 15)

Or, if a minor bump should always be a deliberate act, assert the branch and say so in the message:

sys.version_info[:2] == (3, 13) and sys.version_info[:3] >= (3, 13, 15)

Either way, scope this line's "the floor for every fix named above" to the 3.13 series, since that is all the check currently covers.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed. (3, 14, 0) >= (3, 13, 15) is True, so every 3.14.x passed a check that exists for a CVE the line above says landed in 3.13.15 and 3.14.7 — the gap was 3.14.0 through 3.14.6.

Took the branch-aware form, in the dict shape so a third branch is one entry rather than another if:

v = sys.version_info[:3]
v >= {13: (3, 13, 15), 14: (3, 14, 7)}.get(v[1], (3, 15, 0)) or sys.exit(...)

The (3, 15, 0) default does double duty, as you'd expect: it passes anything ≥ 3.15 and rejects anything below 3.13, since 3.12.x loses on the minor. 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 — only 3.13.15, 3.14.7, 3.15.0 and 4.0.0 pass. Metacharacter audit over the payload returns zero $, backtick or backslash, and it round-trips through sh -c correctly.

Also scoped the prose, which was the underlying problem: the line used to say "the floor for every fix named above" while the check only covered one branch. It now names both floors explicitly and says why a flat compare was wrong.

Comment thread .github/workflows/release.yml Outdated
# 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This measurement comes from an amd64-only pull_request run, and it is being quoted on a two-platform release step

The operative advice — "do not budget this filter against a cached dependency compile" — is right. The evidence offered for it doesn't transfer, in three ways.

Wrong workflow and wrong arch. Run 34858998837 is workflowName: "PDP CI Tests", event: "pull_request". Its buildx invocation is:

docker buildx build --cache-from type=gha --cache-to type=gha,mode=max \
  --no-cache-filter main --platform linux/amd64 --tag permitio/pdp-v2:next --load .

Single-arch. This comment sits on release.yml's pre-release step, which is platforms: linux/amd64,linux/arm64.

The superlative can't survive that transplant. "the single longest step at 404.6s" is a ranking. In a release build opa_build and main are each built twice, the second leg emulated under QEMU. On the cited amd64 run, #23 [opa_build 3/3] go build took 67.0s and #36 [main 12/14] pip install took 63.1s — their arm64 counterparts don't exist in that run at all, and under QEMU they are exactly the steps that balloon. The 404.6s figure itself does carry over, since the rust stages are --platform=$BUILDPLATFORM; the "longest step" claim does not.

Cache scope differs too. A PR run restores GHA caches from its own branch plus the base; a release runs on a tag ref and restores only from the default branch. What cached on this branch's PR run doesn't predict what caches on a release.

Separately, rust_chef did not cache. The log has exactly three CACHED steps — #15 [rust_chef 4/5] cargo install, #16 [rust_chef 2/5] WORKDIR, #17 [rust_chef 3/5] apk add — while #18 [rust_chef 5/5] rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl re-ran at DONE 23.3s. Three of four layers. The parenthetical (#15-#17) is literally the CACHED set, which makes the sentence read as though that were the whole stage.

Suggestion: Name the source workflow and drop the generalisation:

        # keyed on recipe.json - 404.6s on tests.yml run 34858998837 (amd64-only). The rust
        # stages are `--platform=$BUILDPLATFORM`, so that cost is the same here, but a
        # release also builds opa_build and main a second time under QEMU, and a tag ref
        # restores different GHA caches than a PR ref - do not read that run's cache profile
        # as this step's. On that run rust_chef cached three of its four layers (#15-#17);
        # #18 `rustup target add` re-ran.

Verifiable with gh run view 34858998837 --repo permitio/PDP --json workflowName,event plus the #NN CACHED lines in the build-pdp-image job log.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three corrections confirmed, and the rust_chef one I had literally in front of me and misread.

gh run view 34858998837 --json workflowName,event
  workflow=PDP CI Tests  event=pull_request
buildx: --platform linux/amd64

So: wrong workflow, wrong trigger, single-arch — quoted on a step that builds amd64+arm64. And the cache statuses:

#15 [rust_chef 4/5] cargo install        CACHED
#16 [rust_chef 2/5] WORKDIR              CACHED
#17 [rust_chef 3/5] apk add ...          CACHED
#18 [rust_chef 5/5] rustup target add    DONE 23.3s   <- re-ran

Three of four. I wrote (#15-#17) because that was the CACHED set and then described it as the stage, which reads exactly as you say.

Fixed in 9676cfc: the 404.6s figure is kept and attributed to its source run, with an explicit "read that measurement narrowly" paragraph noting the rust stages are --platform=$BUILDPLATFORM so their cost carries over, while opa_build and main are each built twice here with one leg under QEMU, and a tag ref restores different GHA caches than a PR ref. The "longest step" ranking is gone — you're right that a ranking can't survive that transplant even though the absolute number can.

Comment thread .github/workflows/release.yml Outdated
# 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sentence is correct — and it therefore shows openssl-dev and OPENSSL_DIR are dead config that should be deleted, not explained

The claim checks out. pdp-server/Cargo.toml has openssl = { version = "0.10", features = ["vendored"] } # Required for docker build on Alpine, and openssl-sys's build script settles precedence: find_openssl returns find_vendored::get_openssl(target) whenever the vendored feature is on and OPENSSL_NO_VENDOR is unset — before find_normal, which is the only path that reads OPENSSL_DIR, is ever reached. Neither is set here, so the vendored build always wins.

That makes three things inert:

  • Dockerfile:15 — apk add --no-cache musl-dev openssl-dev zig pkgconf perl make installs an openssl-dev nothing links against
  • Dockerfile:31 — ENV OPENSSL_DIR=/usr is never consulted
  • Dockerfile:14 — ENV PKGCONFIG_SYSROOTDIR=/ exists for the same system-OpenSSL discovery path

So this comment spends three lines explaining why a cached openssl-dev going stale is tolerable, when the accurate conclusion is that it has no reason to be installed. Under "replace, don't deprecate" and "proactively flag dead code", removing it also removes the stale-package surface the paragraph is apologising for.

Suggestion: Either drop openssl-dev from Dockerfile:15 and ENV OPENSSL_DIR=/usr from Dockerfile:31 — keeping perl/make, which openssl-src needs to build the vendored copy, and musl-dev/zig for linking — then shorten this comment to note the cached rust_chef apk layer installs only build tools that never ship. Or, if the removal is deliberately out of scope here, say that instead of arguing the staleness is harmless:

        # `openssl-dev` and `OPENSSL_DIR=/usr` are dead since the vendored feature landed;
        # removing them is tracked separately.

Those two Dockerfile lines are outside this delta — this comment line is what surfaces them.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The claim checks out and so does your conclusion — pdp-server/Cargo.toml:31 is openssl = { version = "0.10", features = ["vendored"] }, and openssl-sys's build script reaches find_vendored::get_openssl before find_normal whenever that feature is on and OPENSSL_NO_VENDOR is unset. Neither is set here, so openssl-dev is installed for nothing and OPENSSL_DIR=/usr is never read.

I took your second option rather than the first. The comment now says they're dead and that removal is tracked, instead of arguing the staleness is harmless:

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.

Reasoning for not deleting them in this PR: it means editing the Rust builder stage, and the failure mode if I'm wrong about zig/cargo-zigbuild's link path is a broken build on a branch that is already four rounds deep and carrying a release. perl/make have to stay for openssl-src regardless, so it isn't a clean one-line removal either. Happy to do it as its own PR where a red build costs nothing — say the word and I'll open it alongside the others.

Comment thread requirements.txt Outdated
# 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both counts in this block are off, and one of them was just changed in this commit

"eight advisories cleared by this floor" — eight is the number of ids the sentence enumerates, not the number of advisories the 3.1.57 → 3.1.59 move clears. OSV gives eleven distinct CVEs across the two releases:

The three omitted are all MODERATE, so the filter is consistent and the (all HIGH) parenthetical shows it's deliberate — but the summary clause says "eight advisories" unqualified, which reads as a total.

Also in the same sentence: GHSA-jm78-9fvv-mhgr is listed by GHSA id alongside four CVE ids, which reads as "this one has no CVE". It does — CVE-2026-76221 / PYSEC-2026-3783. Since the other four are 76218/76219/76220/76222, a reader looking for the gap lands on 76221 and has no way to see the GHSA already is it.

"eight releases in the seven weeks to 3.1.62" (line 125) — there is no reading that makes this eight. 3.1.62 was uploaded 2026-09-07; seven weeks earlier is 2026-07-20. In that window: 3.1.53, .54, .55, .56, .57, .58, .59, .60, .61, .62 — ten, or nine counting "to 3.1.62" as exclusive.

That one errs in the safe direction (the real cadence is faster, which strengthens the "don't own an upstream's cadence" argument), so nothing downstream changes. Flagging it because it's the second count in this block that doesn't survive a check, and this commit already moved the first from "seven" to "eight".

Suggestion: Fix both in one edit so the block isn't churned a third time:

# CVE-2026-76222 (all HIGH) - eight HIGH-or-above advisories cleared by this floor; three
# further MODERATE ones (CVE-2026-76217, CVE-2026-78678, CVE-2026-78679) go with them.

swapping GHSA-jm78-9fvv-mhgr for CVE-2026-76221 so the run reads 76218/76219/76220/76221/76222 with no gap — and "nine releases" at line 125. Both are regenerable from https://pypi.org/pypi/GitPython/json (upload_time_iso_8601 per release) rather than from memory.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both counts confirmed wrong, and I regenerated them from source rather than fixing them by hand a third time.

GHSA-jm78-9fvv-mhgr is CVE-2026-76221 — OSV gives aliases: ['CVE-2026-76221', 'PYSEC-2026-3783'], severity HIGH. So listing it by GHSA id alongside four CVE ids made the 76218–76222 run read as though 76221 were missing. Now listed by CVE id, with a note that the two are the same advisory so the next reader doesn't re-open the question.

The advisory total, via OSV query for GitPython @ 3.1.57 filtered to fixes in 3.1.58/3.1.59 — eleven distinct CVEs:

3.1.58  CVE-2026-76217 MODERATE   3.1.59  CVE-2026-78675 HIGH
        CVE-2026-76218 HIGH               CVE-2026-78676 CRITICAL
        CVE-2026-76219 HIGH               CVE-2026-78677 HIGH
        CVE-2026-76220 HIGH               CVE-2026-78678 MODERATE
        CVE-2026-76221 HIGH               CVE-2026-78679 MODERATE
        CVE-2026-76222 HIGH

Eight HIGH-or-above, three MODERATE. The comment now says both numbers, so "eight" is qualified rather than reading as a total.

Release count, from pypi.org/pypi/GitPython/json upload_time_iso_8601: 3.1.62 uploaded 2026-09-07, seven weeks earlier is 2026-07-20, and the window holds 3.1.53 through 3.1.62 — nine before 3.1.62, ten inclusive. Now "nine". You're right it errs safe, but it was the second count in one block that didn't survive a check, which is the part worth fixing.

Comment thread .docker/scout/pdp-v2.vex.json Outdated
"timestamp": "2026-07-29T00:00:00Z",
"version": 4,
"timestamp": "2026-09-15T00:00:00Z",
"version": 9,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

v9 here vs v5 in open PR #335, from a divergent base — a side-pick resolution loses real waivers either way

This delta moves the doc 7 → 9. The open sibling PR #335 (david/per-15358-scout-gate-releases-vex, OPEN, non-draft, base main) carries its own whole-file rewrite at "version": 5. Both diverge from the common base (main @ 9a1f75e is v4 with 6 statements), and they diverge structurally:

this PR (v9) #335 (v5)
statements 7 6
product @id pkg:docker/permitio/pdp-v2@next (all) pkg:docker/permitio/pdp-v2 (unversioned)
x/crypto waivers CVE-2026-78662, CVE-2026-56855 absent
CVE-2026-54282 absent present

So whichever merges second conflicts across the statements array, and taking either side wholesale loses something real: take #335's and both x/crypto waivers disappear — the two this delta just rewrote; take this one's and the CVE-2026-54282 waiver disappears. A straight take-theirs also regresses the version 9 → 5.

The @next binding itself is not at risk from either outcome — #335 keeps image: local://permitio/pdp-v2:next on both scout steps, so @next matches fine there. The hazard is confined to JSON content and how the conflict gets resolved.

Suggestion: Decide the merge order and record it in the PR body. The safe resolution is a manual union, not a side pick: keep this PR's seven statements, add #335's CVE-2026-54282 statement, and set version to 10 so it exceeds both.

Do not adopt #335's unversioned pkg:docker/permitio/pdp-v2 product @id on a superset assumption — confirm first that scout treats an unversioned product @id as matching local://permitio/pdp-v2:next. Given commit 824c783 in this same branch, which existed entirely because scout matches VEX subcomponents on the full PURL string, that assumption is worth testing rather than inheriting.

If #335 merges first, rebase onto it and redo the two x/crypto statements on top rather than restoring this file wholesale.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and I've set the version so the union you describe is the only resolution that makes arithmetic sense.

Current divergence, read off both branches:

this PR #335
version 10 (was 9) 5
statements 7 6
product @id pkg:docker/permitio/pdp-v2@next pkg:docker/permitio/pdp-v2
x/crypto ×2 present absent
CVE-2026-54282 absent present

This round moved it 9 → 10 anyway, because the CVE-2026-48710 premise correction changed content. That happens to land on the number you recommended, so a union resolution needs no renumber: keep these seven, add #335's CVE-2026-54282, done.

Recorded in the PR body: #337 merges first, #335 rebases onto it. Rebasing that direction means #335 adds one statement to a file that already has the x/crypto pair, rather than anyone resolving a seven-vs-six conflict by picking a side.

Agreed on not inheriting the unversioned pkg:docker/permitio/pdp-v2 @id on a superset assumption — given 824c783 existed precisely because Scout matches subcomponents on the full PURL string, "unversioned matches everything" is exactly the shape of assumption that already cost this branch a round. That wants testing against a real scan before it lands, and it's #335's to test.

…ounts

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>
Copilot AI review requested due to automatic review settings September 16, 2026 07:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@zeevmoney
zeevmoney merged commit d3da8b9 into main Sep 16, 2026
33 of 39 checks passed
@zeevmoney
zeevmoney deleted the fix/pdp-image-cves-sept-2026 branch September 16, 2026 11:53
zeevmoney added a commit that referenced this pull request Sep 16, 2026
Resolves the 12 conflict hunks created when #337 squash-merged into main.
Eleven were comment prose; only pdp-v2.vex.json conflicted on content.

Took main's side:
- pdp-v2.vex.json (version 10, both x/crypto justifications
  vulnerable_code_not_present) - strictly newer than the branch's version 7
- release.yml, requirements.txt - the branch changes neither functionally
- tests.yml Unexpected-input note and the x/crypto/Go 1.26 note, which the
  branch predates

Took the branch's side:
- tests.yml gate name (docker-scout/Trivy) and the VEX-scope note, both
  correct now that the published-tag Trivy scan lands here

Blended, where the merge falsified a comment on one side or the other:
- Dockerfile: kept main's per-branch CPython floor rationale and the
  executable check, replaced its floating-tag note with the branch's
  digest-pin note, and corrected three claims this change invalidates -
  the gate is no longer pull_request-only, and the scheduled re-scan now
  exists. Noted that a release is gated on the amd64 proxy build rather
  than the published multi-arch manifest.
- .trivyignore.yaml, docs/image-vulnerability-management.md: realigned the
  x/crypto waivers with the VEX doc's justification and dropped the stale
  "Go 1.26 is not released" claim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants