fix: clear all 12 CVEs from customer scan of published pdp-v2:0.9.14 (PER-15358) - #337
Conversation
…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>
🔍 Vulnerabilities of
|
| digest | sha256:5e94d74be0599ac75dc5350c06a11fcf269613fac6dab4e4d7b4c857f0714f2f |
| vulnerabilities | |
| platform | linux/amd64 |
| size | 139 MB |
| packages | 247 |
📦 Base Image python:3.13-alpine3.23
| also known as |
|
| digest | sha256:c694844958d415b76979bb3e0927273b013294ab36e715ca1fc4962713a8ee69 |
| vulnerabilities |
Description
Description
Description
Description
Description
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Description
Description
Description
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Description
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Description
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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>
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>
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>
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
zeevmoney
left a comment
There was a problem hiding this comment.
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-filteris not a valid input; the action declaresno-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 imagepdp-testervalidated, becausetests.yml:72builds without the filter. Both edits need to ship together. - HIGH
requirements.txt:94—GitPython>=3.1.59,<4changes no resolution on any build path;<4duplicates 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, andCargo.lockis 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-scoutred on this PR is not caused by it, andno-cache-filters: opa_buildcannot clear it — those Go module versions come from permit-opa'sgo.sum, and that stage already rebuilds every run because the regeneratedcustom_opa.tar.gzbusts itsCOPY. Droppingopa_buildfrom the filter value would make the config honest about that. tests.yml:186gates the scan onpull_request, so releases and pushes tomainnever 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.
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>
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>
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>
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>
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>
|
Checked the The corrected VEX PURLs bind5 → 3, with
Both scanners independently agree on the remainderScout: So the fix is a merge, not a changeAll three are owned by permitio/permit-opa#49 ( Every other job passes, One structural thing this exposed, now written down
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 ( |
zeevmoney
left a comment
There was a problem hiding this comment.
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 pinsgolang: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 ispull_request-only so it never runs on a release, and Scout does not report the CPython interpreter at all — as the comment kept attests.yml:252-254says, and as the scan output confirms. The removed waiver'spkg:generic/pythonsubcomponent was likely binding nothing.
Non-blocking:
- MEDIUM
.github/workflows/tests.yml:263— the@nextscope argument rests on a Trivy re-scan, a.trivyignore.yamland aschedule: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.mddoes 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.
| # 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. |
There was a problem hiding this comment.
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.0while 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.
There was a problem hiding this comment.
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.
| # 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 |
There was a problem hiding this comment.
"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"There was a problem hiding this comment.
Confirmed on all three sub-points — I checked each rather than accepting them, and all three hold:
tests.yml:198is stillif: github.event_name == 'pull_request'.- On run 34620527942 the unfiltered report is 15 findings across 6 packages —
busybox,containerd,x/crypto,grpc,starlette,ddtrace. Grepping that job log forcpython/pkg:generic/python@3returns nothing. git show origin/main:.docker/scout/pdp-v2.vex.jsonshows the removed waiver's subcomponent waspkg:generic/python— a PURL that appears in no Scout output. It was suppressing nothing, which is the same trap as the missingvprefix 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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 trivyover the whole tree returns exactly two hits, both comment lines added by this PR — this one andtests.yml:82("that docker-scout/Trivy gate"). There is no Trivy action, step, or config.- No
.trivyignore*file anywhere. - No
schedule:orcron: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.ymlhunk 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.
There was a problem hiding this comment.
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.
| # 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).
| # | ||
| # 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| # 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. |
There was a problem hiding this comment.
"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:
- 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.
tests.yml:77buildsplatforms: linux/amd64only;release.yml:62and:95buildlinux/amd64,linux/arm64. The arm64 package set is never built or scanned here, and Alpine's arch repos version independently.- The scan does not run on the release path at all —
docker-scoutisif: github.event_name == 'pull_request'(tests.yml:198), skipped on aworkflow_callfrom 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. - Before this change the two were, ironically, closer: the inert filter meant both builds served
mainfrom the same GHA entry, so the amd64apklayer 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).
There was a problem hiding this comment.
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>
…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>
Round 3: an automated pass over my own branch, and eight fixes in
|
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
left a comment
There was a problem hiding this comment.
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/pythonis not a PURL scout emits" is false;docker scout sbomlistspython | 3.13.15 | genericfor this exact base image, and the deleted waiver was bound to a component Scout indexes. The conclusion still holds via thepull_request-only gate. - MEDIUM
Dockerfile:195— three of the eight imports cannot detect what the comment says they guard;decimalandhashlibfall back silently and cannot raise. Removing_decimal.soand_hashlib.sofrom the real base image leaves the one-liner exiting 0. Coverage is 6 of the 12 non-libcso:deps the surgery could strip. - MEDIUM
.github/workflows/release.yml:86— the caching measurement comes from an amd64-onlypull_requestrun and is quoted on a two-platform release step; the "longest step" ranking does not survive the transplant, andrust_chefcached 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-mhgris 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 showsopenssl-devandOPENSSL_DIR=/usrare 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-48710statement still asserts the app "registers no add_middleware/BaseHTTPMiddleware". That is false —horizon/pdp.py:281adopts OPAL's app andopal_client/client.py:272reachesopal_common/middleware.py:77, which registers CORSMiddleware. The conclusion survives (CORSMiddleware dispatches on Origin and method, never onrequest.url.path), but the premise is wrong, and PR #335 already carries the corrected wording. docker-scoutremains 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.
| # 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 sbomreportspkg:generic/python@3.13.15for 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 ispull_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.
| $(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)" |
There was a problem hiding this comment.
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_newBoth 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.
There was a problem hiding this comment.
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.
| # 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| # 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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| # 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 |
There was a problem hiding this comment.
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 makeinstalls anopenssl-devnothing links againstDockerfile:31—ENV OPENSSL_DIR=/usris never consultedDockerfile: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.
There was a problem hiding this comment.
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-devandENV OPENSSL_DIR=/usrdead 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.
| # 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. |
There was a problem hiding this comment.
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:
- 3.1.58 (6): CVE-2026-76218, -76219, -76220, -76221 (=
GHSA-jm78-9fvv-mhgr), -76222, and CVE-2026-76217 (MODERATE) - 3.1.59 (5): CVE-2026-78675, -78676, -78677, plus CVE-2026-78678 and CVE-2026-78679 (both MODERATE)
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.
There was a problem hiding this comment.
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.
| "timestamp": "2026-07-29T00:00:00Z", | ||
| "version": 4, | ||
| "timestamp": "2026-09-15T00:00:00Z", | ||
| "version": 9, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
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>
Linear: PER-15886 · umbrella PER-15358
Customer report
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.14as publishedlibcrypto3/libssl33.5.7-r03.5.8-r0libuuid2.41.4-r02.41.6-r13.13.143.13.15Alpine 3.23 published
openssl 3.5.8-r0andutil-linux 2.41.6-r1after 0.9.14 was built. The Dockerfile already doesapk update && apk upgradeand 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:
EVP_Cipher(). Needs directEVP_Cipher()calls on ChaCha20-Poly1305/AES-OCB. Python'sssl/hashlibdo not do this.Also worth noting: the six
libuuidfindings are flagged against the util-linux source package, but every one is inmount/umount/nsenter— binaries that are not installed in this image.libuuidis 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:
GitPythonis transitive viaopal-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.59also closesCVE-2026-78675/-78677, and3.1.58closesCVE-2026-76218/-76219/-76220/-76222+GHSA-jm78-9fvv-mhgr— seven advisories cleared by one floor.Changes
requirements.txt— floorGitPython>=3.1.59,<4. Clears 1 CRITICAL + 6 HIGH.<4matches opal-common's own cap..github/workflows/release.yml+tests.yml—no-cache-filters: mainon 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-04apk upgrade/pip installlayers and re-ship the exact packages the customer just flagged. (Originally written here asno-cache-filter: main,opa_build— round 1 corrected the spelling, which was making it inert, and droppedopa_build, which already re-executes. Round 3 corrected the claim that the Rust stages "cache normally": on run 34858998837 onlyrust_chefcached andcargo chef cookran 404.6s.).docker/scout/pdp-v2.vex.jsonv4 → v5 — drop theCVE-2026-15308waiver. CPython backported thehtml.parserfix into 3.13.15, which the floating base tag now resolves. The waiver's own text said to remove it at exactly this point.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+piplayers with--no-cache: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.1already exists as a tag). Until a rebuild happens,0.9.14on 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.
no-cache-filteris not a valid inputaction.ymlat v5.4.0 and v6.9.0 — onlyno-cache-filtersexists; 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.tests.yml's build now passes the sameno-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.opa_buildfrom the filter:COPY custom* /custom+custom/absent from.dockerignore+ tarball regenerated every run means that stage already re-executes.Cargo.lockdeliberately not touched — real gap, wider blast radius.@nextNo gap, now documented.Wrong — overturned in round 2. The Trivy/.trivyignore.yamlpublished-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.Removing the waiver is the enforcement.My pushback was wrong — Zeev was right. Scout never reports the interpreter, the gate ispull_request-only, and the removed waiver boundpkg:generic/python, a PURL Scout does not emit. Fixed properly in round 3: the build now checks the interpreter itself.His two other out-of-diff points are handled too: the PR-only scan gate in #338, and the
CLONE_REPO_TOKENexposure in #339 (verified reproducible — with only.git/, a nested.git/configreally 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 (theGitPythonfloor over an exact pin, and droppingopa_buildfrom the filter). He also verified the cache filter empirically rather than by reading it —CACHED→DONE 5.5son 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.
golang:1.26-bookwormtests.ymland both VEX impact statements (v7 → v8). Removal gates now name a local, closable condition. The same claim in this PR body is corrected abovetests.yml:198is stillpull_request-only; run 34620527942's unfiltered report is 15 findings across 6 packages with no CPython entry; the removed waiver boundpkg:generic/python, a PURL Scout never emits — so it was suppressing nothingapklayer now assertssys.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@nextscope argument rests on machinery that does not existgrep -rni trivyreturns exactly the two comment lines this PR added; no.trivyignore*; noschedule:/cron:in any workflowtests.yml:82fixed the same waydocs/image-vulnerability-management.mddoes not existgit ls-files '*.md'→ three files, nodocs/directoryDockerfile:158-161already got rightddtraceis 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-cachebuildtests.yml:77amd64-only vsrelease.yml:62/:95amd64+arm64; scan never runs on the release pathMerge order with #335: #337 goes first; #335 then rebases to keep this branch's seven statements and add
CVE-2026-54282on top as v9 (not v8 — correcting bothx/cryptoimpact statements bumped this branch to v8).Still open and unchanged from round 1, both out of diff:
timeout-minuteson the two image-building jobs, andactionlintin.pre-commit-config.yaml— which would have caught the originalno-cache-filterinput-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:sysis a builtin, soimport sysloads no extension modules — a version-only check passes on an interpreter whose entirelib-dynloadis unresolvable, which is what the.python-rundepssurgery produces if theso:list comes back short. It now importsssl/hashlib/zlib/lzma/bz2/ctypes/pyexpat/decimalfirst, and usessys.exitrather thanassert(which-Ostrips). I had claimed the opposite in a review thread and have corrected it there.vulnerable_code_not_in_execute_pathasserts the component ships and is merely uncalled; the impact statements argue the stronger, provable claim — excluded at link time. Nowvulnerable_code_not_present. The four starlette waivers keep the old label, which is correct for them. v8 → v9.release.ymlnamed the wrong Rust stage, in both directionsrust_chef;cargo chef cookwas the longest step at 404.6s. The stage that does cache runsapk add musl-dev openssl-dev zig …— a package-resolving layer outside the filter, previously unmentioned.<4does not cap GitPython drift to a patchpackaging. Conclusion unchanged, premise now true.Dockerfile:57cited from three places across two filesgit merge-treemerges clean, so all three assertions would go false silently. They name the stage now.timestamp; LOW "seven advisories" is eight; LOWtests.ymlsaid an undeclared input is "silently dropped"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:
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.v0.9.14) showsskipped pdp-tests / docker-scoutalongsidesuccess 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.permitio/permit-opaatref: mainwith no pin, and Scout suppresses on PURL, never on whethersshis linked. If permit-opa ever links it, the waiver keeps suppressing a now-reachable DoS with no signal in either repo.Cargo.lock, no--locked, nocargo 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.decimalandhashlibcannot detect a missing.so_decimal/_hashlibbehind ameta_pathfinder: the verbatim old line exited 0. Now imports the underscore C extensions directly. Deviation: I dropped_gdbm/readline/_cursesfrom the suggested list —_gdbmis 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.pkg:generic/pythonis a PURL Scout emitstests.ymlalready said correctly; the plain true reason — 3.13.15 carries the fix — now stated.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_chefcached three of four —#18 rustup target addre-ran at 23.3s.(3,14,0) >= (3,13,15)isTrue, 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.GHSA-jm78-9fvv-mhgrisCVE-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.openssl-dev/OPENSSL_DIRare dead configCarried forward and now fixed: the
CVE-2026-48710waiver claimed the app "registers noadd_middleware/BaseHTTPMiddleware". That is false —horizon/pdp.pyadopts OPAL's app andopal_client/client.pyreachesopal_common/middleware.py:77, which registersCORSMiddleware. The conclusion survives:starlette/middleware/cors.pyreads nopath, nourland never constructs aRequest, dispatching only onOriginandscope['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.)#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 unversionedpkg:docker/permitio/pdp-v2product@idon a superset assumption without testing it against a real scan —824c783exists because Scout matches on the full PURL string.Also in this PR: the two x/crypto CVEs Go 1.25 cannot clear
The
docker-scoutcheck on this PR fails on 5 HIGH that do not come from this repo./app/bin/opais compiled frompermit-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:
grpc1.82.1 → 1.83.2 andx/crypto0.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.)x/crypto >= 0.56.0, whose go.mod declaresgo 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, andgolang:1.26-bookwormexists. The blocker is local, not upstream:Dockerfile:57is stillgolang:1.25-bookwormand permit-opa's go.mod isgo 1.25.0, and the official golang images setGOTOOLCHAIN=localso 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 ingolang.org/x/crypto/ssh, andgo list -deps ./cmd/opain permit-opa shows that package is not linked into the binary at all — onlycryptobyte,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 togo 1.26.0+x/crypto 0.56.0, delete the statements. Nothing waits on Go.Worth flagging since it's a trap anyone can hit. Scout matches VEX
subcomponentson the full PURL string, and it emits Go PURLs without the conventionalvprefix: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-scoutprints the PURL above each finding, andpkg:pypi/starlette@0.50.0/pkg:pypi/ddtrace@3.19.8were 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-scoutis red, and it is supposed to beChecked the actual run rather than assuming. Nothing here needs fixing — the gate is working and reporting real findings with an owner.
The corrected waivers are confirmed binding — down from 5 findings to 3, with
CVE-2026-78662/CVE-2026-56855gone. #339 is an accidental control group: it branches offmain, so it carries no waivers and still reports all 5. Same image, same scanner, waivers the only difference.main)All three remaining are fixed by permitio/permit-opa#49 (
grpc→ 1.83.2,x/crypto→ 0.55.0).tests.ymlclonespermit-opa@main, so that PR must merge first. Every other job passes, including the fullpdp-testere2e.The other red check,
security/snyk (permit), returnsERRORon every PR in every permitio repo — a broken integration, not a finding.Merge order
tests.ymlclonespermit-opa@mainat build time, so: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-v2image built from this branch with permit-opa#49'scustom_opa.tar.gzscans 0 CRITICAL/HIGH across OS + Python + Go.(The other red check,
security/snyk (permit), isERRORon 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-scoutgate intests.ymlisif: 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 asworkflow_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