ci: gate releases on CVEs and unify Dependabot, Trivy and Docker Scout (PER-15358) - #338
EliMoshkovich wants to merge 24 commits into
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:9a55b612b2a629439eb020d066d2fee7c7460c37d95bbd4bea4aafd43c496697 |
| 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
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
340ed77 to
af30d7f
Compare
…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>
a61019b to
91c7a9a
Compare
…-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>
d8c1615 to
1ec0206
Compare
Checked the actual CI runs rather than restating the expectation, and two of the
fixes in this series are now confirmed working from CI output rather than local
reasoning:
- The corrected VEX subcomponent PURLs bind. Scout logs "Loaded 1 VEX
document" and the gate drops from 5 findings to 3; CVE-2026-78662 and
CVE-2026-56855 no longer appear. #339 is an accidental control group - it
branches off main so it carries no waivers, and it still reports all 5. Same
image, same scanner, waivers the only difference.
- The Trivy gate actually runs now. On the previous run it was `skipped`
because the Scout gate failed first; with `if: !cancelled()` it reports
`failure` on its own findings.
And the two scanners independently agree on what is left - Scout
"3 vulnerabilities found in 2 packages", Trivy "Total: 3 (HIGH: 3, CRITICAL: 0)"
- same CVEs, same fix versions. That cross-check is worth recording, because the
whole argument for running two scanners is that they disagree; when they agree,
the remaining set is probably real.
All three remaining findings have an owner and an upstream fix, so the doc now
says explicitly: do not waive them.
Also added the structural gap behind all of this: tests.yml and release.yml
check out permit-opa at `ref: main`, so a PDP build depends on whatever landed
there since the last run and NOTHING in this repo records which permit-opa
commit went into a given image. That is why this gate turned red with no PDP
change, and why a released image cannot be traced to its OPA source. Pinning
that checkout to a SHA would fix both, but it stops releases picking up
permit-opa main automatically - a behaviour change that needs a decision, not a
quiet edit, so it is written down as a gap rather than done.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I had these the wrong way round. Verified against docker/scout-action@v1 action.yaml: summary "Publish the output as GitHub Action summary" default true write-comment "Write the output as a Pull Request comment" default true `summary` writes to the job summary page and is wanted on EVERY run - a release especially, since that is the run whose CVE report nobody can see afterwards. I gated it on pull_request with the comment "Only a pull_request run has a PR to comment on", which described `write-comment` while changing `summary`. Net effect once this job started running on releases: the release job summary was suppressed AND a PR comment was still attempted with no PR to attach it to. Both scout steps now keep `summary: true` and gate `write-comment` instead. The gate step needed it too - it set neither, so it inherited both defaults. Found while reviewing #335, which reached the same conclusion independently; credit there. This is the fourth instance in this series of the same failure mode already documented in the runbook - config that is accepted without error and does not do what it reads like. Co-Authored-By: Claude Opus 5 (1M context) <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>
…and alert Adopts the agent-security pattern for all three scanners: a PR gate with a sticky report, immediate Slack on CRITICAL/HIGH, and a release gate with teeth. PR gate and report - Trivy moves from `exit-code: 1` to scan-to-JSON, format, then a separate fail step, so findings exist as a file and can be reported. The gate blocks on the same predicate as before plus four report-unreadable paths the old exit code could not express. - One sticky comment per PR, marker `<!-- pdp-image-scan -->`, covering Trivy and Scout together. Scout's own write-comment goes off on both steps, so a PR gets at most two bot comments instead of three. - New dependency-review job gates the dependency diff and posts on failure only. Immediate Slack, not a weekly batch - notify-slack.yml, a reusable notifier that is a green no-op with a ::notice:: until SLACK_WEBHOOK_URL exists, and that reports its own delivery failures. - Trivy: daily published-tag scan alerts when any tag is not CLEAN. - Docker Scout: new daily leg on `latest`. Every OpenVEX statement gains pkg:docker/permitio/pdp-v2@latest so the seven waivers keep suppressing. - Dependabot: daily poll of the alerts API. There is no dependabot_alert workflow trigger, so this is a schedule. It filters through .trivyignore.yaml because all three currently-open HIGH alerts are already waived, and an unfiltered feed would ping daily about triaged work. - Gate failures alert on push and release, never on pull_request, where the red check and the comment already reach the author. - The weekly digest stays, demoted to a roll-up. It uniquely carries the waiver-expiry warning: all seven waivers expire 2026-12-31 together. Release gate - verify-published-image scans the tag that actually shipped, by digest, on both platforms. This is the only place linux/arm64 is ever scanned. - update-pdp-api-ecs-service now needs it, so a CVE-bearing image does not reach the fleet. It runs after the push, so it cannot un-publish the tag. Supporting - format_scan_report.py, check_waiver_parity.py, check_dependabot_alerts.py, with 81 tests. classify_image_cves.py now fails closed: a zero-byte report was reported as CLEAN with exit 0. - Waiver parity gap closed, 5 -> 7, and enforced by a pre-commit hook rather than a comment. The rolling GitHub issue is removed, not deprecated. - PyYAML declared; it was reaching a required check only via uvicorn[standard]. actionlint 8 findings, all pre-existing, one cleared. zizmor clean on the new workflows. Slack stays inert until the secret is added. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sage Trivy, Docker Scout and Dependabot ran on separate crons (06:17, same run but its own notify, and 07:00) and each sent its own Slack message. One CVE in a published image therefore produced three notifications on the same morning - three scanners agreeing with each other, which reads as three problems and is how a channel gets muted. They now run as parallel jobs under one trigger in daily-security-scan.yml, and the `digest` job merges their verdicts into one body with a line per scanner, so agreement is visible at a glance. The cost is that the message waits for the slowest scanner; on a daily cadence that is minutes. - image-scan-published.yml -> daily-security-scan.yml, and the `dependabot` job moves in from the deleted dependabot-alert-watch.yml. - `digest` now needs [scan, scout-latest, dependabot] and keys each scanner on its JOB RESULT, not on a finding count: Scout's summary step exports a body on the clean path too, so the body alone cannot tell "the gate passed" from "the gate never ran", and a scanner that did not run must not read as clean. - `status` only escalates ok -> warn -> fail, so a clean Dependabot result cannot talk down a Trivy SOURCE verdict. - notify-scout is gone; there is now exactly one Slack sender in the workflow, plus verify-delivery so a dead webhook reddens the run rather than passing silently on a workflow that is quiet by design. - The dispatch input is `all_alerts` now that it shares a workflow with `tags`. - Cron stays 06:17 - off the hour, which GitHub documents as the slot most likely to be dropped, and the Dependabot leg carries a --new-since window. Digest logic exercised over 12 cases against the extracted run: silent only when all three are clean; notifies on REBUILD, SOURCE, ERROR, a failed or skipped Scout job, a new Dependabot alert, a backlog-only alert, a failed Dependabot job, and on no verdict files at all. shellcheck clean. actionlint 8 findings (unchanged, all pre-existing), zizmor clean, 81 tests pass, all 17 pre-commit hooks pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VvR24Cibs2Z8cVsEqkp3Es
Cron moves from `17 6 * * *` to `17 6 */3 * *`, and the workflow is renamed daily-security-scan.yml -> scheduled-security-scan.yml because a file called "daily" that runs every third day is the same silently-wrong config this branch exists to remove. The Dependabot leg moves with it. It reports a DELTA over a time window, so a window sized to the old cadence would have gone blind for two days in three: --new-since goes 25 -> 73 hours (72 plus one hour of overlap, so an alert opened between two runs cannot fall through the gap). The workflow now passes it explicitly, next to the cron it derives from, and the script's default moves in step for anyone running it by hand. Be precise about what `*/3` in the day-of-month field is: days 1, 4, 7 ... 31, resetting each month, so the 31st-to-1st gap is one day and February's is one or two. Cron has no true "every 72 hours". Both consequences are bounded - a SHORT gap only means the window overlaps and may repeat an alert once, and a LONG gap cannot occur - and the digest already branches on the standing backlog (total_unwaived) rather than only on what is new in the window, which is what makes a missed run recoverable. actionlint 8 findings (unchanged, all pre-existing), zizmor clean, 81 tests pass, all 17 pre-commit hooks pass. Verified against the live alert feed at both the default and the explicit window: 0 new, 0 unwaived, 3 waived. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VvR24Cibs2Z8cVsEqkp3Es
… cannot cache (PER-16045) Zeev's 2026-09-22 review: six threads, all accuracy claims about other repos, other PRs, and what a build log proves. - "until this lands" scoped the permit-opa#52 breakage to main. release.yml checks this repo out with no `ref:`, so a release builds the Dockerfile of the commit it was cut from, while permit-opa still comes from `ref: main`; tests.yml also builds on `v*` pushes. v0.9.15 is d3da8b9, still on the 1.25 builder. State the lasting rule instead: cut every release and `v*` build from a commit containing this FROM line. - The x/crypto waivers still spoke of permit-opa#49 as pending. It MERGED 2026-09-16 and permit-opa main carries x/crypto v0.55.0 + grpc v1.83.2. Both statements are past tense now, and say why the superseded 0.53.0 subcomponent PURL stays in the list. VEX doc re-issued as version 12. - The three-builds sentence stated permit-opa#51/#52 outcomes as current; both are open and being edited. Replaced with wording that stays true whatever they land as: permit-opa's toolchain policy lives in permit-opa's own files, not in this comment. - The PDP#338 reword note covered only the float paragraph. #338 also drops docker-scout's `pull_request` gate and adds image-scan-published.yml (daily), so it falsifies the auditability paragraph too - and closes that gap rather than recording it. Its Dependabot entry carries `cooldown: default-days: 7`. - The floor check's echo is a gate, not a record: it caches on the base image digest and reads CACHED in a typical release log. The compile RUN cannot cache (`COPY custom* /custom` sees a tarball the workflow regenerates every run), so echo the toolchain there instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PDP image vulnerability reportImage: ✅ No CRITICAL or HIGH findings after waivers. TrivyNo CRITICAL or HIGH findings. Docker ScoutNo CRITICAL or HIGH findings. Scanned by Trivy, Docker Scout. Waivers: |
… permit-opa go 1.26) (#342) * build(docker): move the OPA builder to golang:1.26-bookworm (PER-16045) Lands ahead of permit-opa raising its go directive to 1.26, which golang.org/x/crypto >= 0.56.0 forces (its own go.mod declares go 1.26.0). That x/crypto version clears CVE-2026-78662 / CVE-2026-56855, today waived in .docker/scout/pdp-v2.vex.json. Ordering only works one way: tests.yml and release.yml build permit-opa from an unpinned `ref: main` checkout, and the official golang images set GOTOOLCHAIN=local, so a 1.25 builder facing a go 1.26 module hard-fails instead of fetching a toolchain - breaking build-pdp-image everywhere the moment the permit-opa change merges. A 1.26 builder on today's go 1.25.0 module is forward-compatible. Same change as the closed PDP#334 (PER-15358), re-proposed under PER-16045. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * build(docker): enforce the go1.26.6 floor, set GOTOOLCHAIN, fix the x/crypto waiver gates (PER-16045) Review follow-up (Zeev): - opa_build sets ENV GOTOOLCHAIN=local instead of relying on the base image, and fails the build if the builder is below go1.26.6 (GO-2026-6090) - the same shape as the CPython floor check in `main`. It also catches a merge that restores golang:1.25 (checked: a 1.25 builder fails on it). - The comment names what the 1.26 toolchain changes in /app/bin/opa (Green Tea GC) versus what follows permit-opa's go.mod (GODEBUG defaults), and the merge order now includes permit-opa#51. Drops the closed PDP#334. - pdp-v2.vex.json (version 11) and the tests.yml scout comment no longer claim the builder is golang:1.25 or gate removal on the closed PDP#334; the remaining gate is permit-opa#52. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(docker): say why the OPA builder floats and what the floor covers (PER-16045) Review follow-up (Zeev, round 2): - Dockerfile: the patch version floats on purpose (Go security releases arrive without a bump), and the exact toolchain is recorded in the binary's build info, which is what scanners read. The floor check caches on the base digest, and it binds only the branch that compiles permit-opa, not the prebuilt-OPA fallback. - release.yml: opa_build re-executes from `COPY custom*` on; the two layers before it cache on the base image digest. - Dockerfile: drop the second `USER permit` (a no-op after a COPY). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(docker): scope the float's audit claim, derive the ensurepip path (PER-16045) Review round 3 on #342. * The float rationale said the build info "is what image scanners read". Nothing here scans a published tag: the docker-scout job is `pull_request`-only and points at a local tag, which tests.yml already calls a gap (PER-15358). The paragraph now says what actually holds - the toolchain is in the build info, survives `-s -w`, and is readable with `go version` on an extracted copy, because the runtime base ships no Go - and calls it an after-the-fact audit rather than a gate. * The same paragraph now names PDP#338, which digest-pins this line and adds a daily Dependabot digest bump, and says what to reword once it lands; and it names permit-opa's release assets as the build that pins the toolchain exactly, so both trees describe the same three-way policy. * The floor RUN read `go env GOVERSION` and printed it only when it failed. It now echoes the accepted version too, on the one run where the base image digest moved - so the layer log answers which 1.26.x compiled /app/bin/opa. Verified in /bin/sh: go1.26.8 and go1.26.6 exit 0 and print, go1.26.5 and go1.25.14 still exit 1 with the floor message. * The comment block above the FROM now says that permit-opa's `pdp-builder` check parses that line for a literal `golang:<major>.<minor>`, so an ARG, a line split or a stage rename breaks another repo's CI. Verified by running permit-opa#52's check-pdp-builder.sh against this file: still parses. * The ensurepip removal hardcoded python3.13 while the CPython floor check 34 lines above branches for 3.13 and 3.14. Exactly one could be right, and the day the base tag moved to 3.14 the `rm -r` (no -f) would have failed the build. The path now comes from sysconfig's stdlib, so both places agree and neither has to be edited when the base moves. On a stock CPython under /usr/local it resolves to the same path as the literal; verified the exact quoting through /bin/sh. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(docker): fix five time-bound claims; echo the toolchain where it cannot cache (PER-16045) Zeev's 2026-09-22 review: six threads, all accuracy claims about other repos, other PRs, and what a build log proves. - "until this lands" scoped the permit-opa#52 breakage to main. release.yml checks this repo out with no `ref:`, so a release builds the Dockerfile of the commit it was cut from, while permit-opa still comes from `ref: main`; tests.yml also builds on `v*` pushes. v0.9.15 is d3da8b9, still on the 1.25 builder. State the lasting rule instead: cut every release and `v*` build from a commit containing this FROM line. - The x/crypto waivers still spoke of permit-opa#49 as pending. It MERGED 2026-09-16 and permit-opa main carries x/crypto v0.55.0 + grpc v1.83.2. Both statements are past tense now, and say why the superseded 0.53.0 subcomponent PURL stays in the list. VEX doc re-issued as version 12. - The three-builds sentence stated permit-opa#51/#52 outcomes as current; both are open and being edited. Replaced with wording that stays true whatever they land as: permit-opa's toolchain policy lives in permit-opa's own files, not in this comment. - The PDP#338 reword note covered only the float paragraph. #338 also drops docker-scout's `pull_request` gate and adds image-scan-published.yml (daily), so it falsifies the auditability paragraph too - and closes that gap rather than recording it. Its Dependabot entry carries `cooldown: default-days: 7`. - The floor check's echo is a gate, not a record: it caches on the base image digest and reads CACHED in a typical release log. The compile RUN cannot cache (`COPY custom* /custom` sees a tarball the workflow regenerates every run), so echo the toolchain there instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * build(docker): drop the other dead USER pair; say the vanilla fallback is unpinned (PER-16045) `USER permit` before `COPY kong_routes.json` and the `USER root` after it changed nothing: no RUN executes between them, and a COPY without --chown writes root:root whatever USER is set. Same shape as the one this PR already removed. The file stays root-owned, as it was. The floor comment now says the no-tarball fallback downloads OPA `latest` with no --fail and no checksum, so that deferral lives in the file and not only in the PR body. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Linear: PER-15886 · umbrella PER-15358
#337 cleared the 12 CVEs a customer found in
permitio/pdp-v2:0.9.14. This PR closes the holes that let them reach a customer before they reached us, and then gives all three scanners — Dependabot, Trivy, Docker Scout — a consistent gate, report and alert.⚙️ Required setup — read this first
Four things must be configured or parts of this PR are inert or actively broken. Items 1 and 2 are not optional; 2 fixes a live defect.
1.
SLACK_WEBHOOK_URL— repository Actions secretEvery Slack path in this PR is a green no-op until this exists. It merges and runs green either way, printing
::notice::SLACK_WEBHOOK_URL is not set; skipping Slack notification.— so a green notify job proves nothing on its own. Open it and look for that annotation.Create an incoming webhook in Slack, then:
Or: Settings → Secrets and variables → Actions → New repository secret. It starts posting the day the secret appears, with no workflow edit. The name matches the org convention (36 uses across permitio repos).
2. Dependabot secret store — fixes a live defect
Dependabot-triggered runs read only the Dependabot secret store, which is currently empty (
gh api repos/permitio/PDP/dependabot/secrets→total_count: 0). So on every Dependabot PR today,build-pdp-imageclones the privatepermitio/permit-opawith an empty token, dies, and takespdp-testeranddocker-scoutwith it vianeeds:. The image CVE gate does not run on the PRs it exists for.These are the same values already in the Actions store — they must be duplicated, not moved:
Or: Settings → Secrets and variables → Dependabot.
3. Repository label
dependencies.github/dependabot.ymlsetslabels: ["dependencies"]on all five ecosystems. The repo has only the nine GitHub defaults, and specifying labels suppresses the defaults — so Dependabot PRs currently arrive with no labels at all.gh label create dependencies --repo permitio/PDP --color 0366d6 --description "Dependency updates"4. Dependabot security updates — repo setting, not a file
Currently
disabled(gh api repos/permitio/PDP --jq .security_and_analysis). This PR configures version updates only; security updates are the leg that opens PRs for known advisories.Not required, but do it once after merge
workflow_dispatchthe scan to confirm Docker Scout resolvesregistry://permitio/pdp-v2:latestto the PURLpkg:docker/permitio/pdp-v2@latest. Docker's docs say it does; if it doesn't, all 7 OpenVEX waivers stop suppressing and you get alerted about already-triaged CVEs every run.Nothing else is needed
No GitHub App, no PAT, no bot credentials.
GITHUB_TOKENcovers everything, including the Dependabot alerts API viavulnerability-alerts: read. There are deliberately no auto-PRs, so no push-capable token is involved.Part 1 — why we heard this from a customer
We already ran a Docker Scout CVE gate. It didn't help, for two independent reasons.
1. A build-time gate can't protect a shipped image. The gate passed on 2026-08-04 and was the last word on that tag forever. Alpine published
openssl 3.5.8-r0afterwards. Every CVE disclosed after the build was invisible by construction. The image wasn't broken — it had rotted on the shelf.2. Releases skipped the gate entirely.
release.ymlreachestests.ymlviaworkflow_call, wheregithub.event_nameis the caller's event —release. Verified empirically rather than from the YAML: the actual 0.9.14 release run (30900950607) showspdp-tests/docker-scout SKIPPEDalongsidebuild-and-push-pdp success. Same on 0.9.13. The gate landed 2026-03-24 in #304 and every release since went out ungated.Removing that condition fixes it.
build-and-push-pdpalready declaresneeds: pdp-tests, so a failing gate now blocks the push.Part 2 — what each scanner does now
latest, same digestdependency-reviewjobPR gate and report
The Trivy step moves from
exit-code: 1to scan-to-JSON → format → a separate fail step, so findings exist as a file and can be reported rather than living only in a step log. The gate does not weaken:severity,trivyignores,ignore-unfixedandvuln-typecarry over byte-identical, socritical + high > 0is the old predicate evaluated two steps later — plus three report-unreadable failure modes the old exit code could not express.One sticky comment per PR, marker
<!-- pdp-image-scan -->, covering Trivy and Scout together. Scout's ownwrite-commentgoes tofalseon both steps, so a PR gets at most two bot comments instead of three. Guarded for fork and Dependabot PRs (read-only token there), with$GITHUB_STEP_SUMMARYand one::error::per finding as fallbacks.dependency-reviewis new: it gates the dependency diff atfail-on-severity: highand posts its own commenton-failure, so a clean PR shows nothing. The repo is public, so the dependency graph is free and this needs no GHAS.One schedule, one message
scheduled-security-scan.ymlruns all three scanners as parallel jobs on17 6 */3 * *(every third day) and adigestjob merges them into one Slack message:They ran on separate crons at first, each sending its own message, so one CVE produced three notifications on the same morning — three scanners agreeing, which reads as three problems. The digest keys each scanner on its job result, not its finding count: Scout exports a body on the clean path too, so the body alone cannot tell "the gate passed" from "the gate never ran", and a scanner that did not run must never read as clean.
statusonly escalatesok → warn → fail, so a clean Dependabot result cannot talk down a TrivySOURCEverdict.*/3in the day-of-month field means days 1, 4, 7 … 31 and resets each month, so the 31st→1st gap is one day. Cron has no true "every 72 hours". Both directions are bounded: a short gap only overlaps the window and may repeat an alert once; a long gap cannot occur.The Dependabot filter is load-bearing
All three currently-open HIGH alerts —
CVE-2026-50271(ddtrace),CVE-2026-54283andCVE-2026-48818(starlette) — are CVEs already waived in.trivyignore.yamland the VEX doc. An unfiltered feed would repeat the same three every run until the channel was muted, and then the alert that matters arrives in a muted channel.Verified against the live feed: 6 open → 3 at critical/high → 0 reported, 3 recognised as waived. A control run with the waiver set emptied reports exactly those three, so the zero is the filter working, not a dead code path. Consequence worth knowing: adding a waiver also silences its Dependabot alert.
There is no
dependabot_alertworkflow trigger, so this leg is a poll. It reports a delta over a 73-hour window (72 + 1h overlap) and the standing backlog, so a scheduled run GitHub drops does not lose that day permanently.Release gate
New
verify-published-imagejob scans the tag that actually shipped, by digest, per platform — the only placelinux/arm64is ever scanned, sincetests.ymlbuilds amd64 only andno-cache-filters: mainmeans the pushed build re-resolves packages and can genuinely differ from the one the gate passed.update-pdp-api-ecs-servicenow lists it inneeds:, so an unwaived CRITICAL/HIGH in a freshly published image stops the rollout. It runs after the push, so it cannot un-publish the tag; what it prevents is rolling that image to the fleet.Part 3 — supporting changes
python:3.13-alpine3.23silently gained a new digest with patched OpenSSL between 0.9.14's build and the customer's scan, and because the tag string never changed there was nothing for a human or a bot to notice. Pinning inverts that into a Dependabot digest-bump PR. Costs no package freshness —apk upgradestill floats the Alpine set at build time.pip,cargo,github-actionsweekly;docker /daily (the 0.9.14 signal);docker /test_offline_modeweekly. Documentedignoreentries with removal gates forstarlette/ddtrace(capped byopal-common, so a bump PR is unmergeable) andwebsocketsmajors..trivyignore.yaml— Trivy counterpart to the OpenVEX doc. Every entry carriesexpired_at, so a waiver cannot silently become permanent. Parity gap closed 5 → 7 (CVE-2026-48710,CVE-2026-48817were VEX-only) and now enforced by awaiver-paritypre-commit hook rather than a comment.classify_image_cves.pyfails closed. A zero-byte report was reported asCLEANwith exit 0; it now exits 2 withverdict=ERROR. It splits a case that "a fix exists" hides:/app/bin/opais compiled from permit-opa, so agrpcorx/cryptoCVE needs ago.modbump in that repo and no release here can clear it.format_scan_report.py,check_waiver_parity.py,check_dependabot_alerts.py— 81 tests inhorizon/tests/, which is the path the requiredpytestsjob actually runs.notify-slack.yml— reusable notifier, a green no-op until the secret exists, and it reports its own delivery failures (errors: trueplus an exported delivery outcome, becausecontinue-on-erroralone makes a dead webhook invisible).[image-cve]GitHub issue is removed, not deprecated. Slack replaces it.uvicorn[standard].docs/image-vulnerability-management.md— the posture, a tested triage runbook, a known-good image reference, and the gaps deliberately left open.Verification
actionlint: 8 findings, all pre-existing — zero introduced, one cleared vs the baseline.zizmor: clean on every new workflow; 17 pre-existing findings cleared across the branch.pre-commit run --all-files: all 17 hooks pass.docker/scout-action # v1.24.0on a v1.20.4 SHA; corrected.)run:bodies and driving them over 13 fixtures; the merged digest over 12 more. Both scanners' gates confirmed running and reporting on the same CI run.Known gaps, documented rather than fixed
dependency-reviewis a third waiver surface that does not read.trivyignore.yaml, so a PR touching thestarletteorddtracepins can be failed on an already-waived advisory.check_waiver_parity.pycovers 2 of 3 surfaces.security-digest.ymlwarns 30 days ahead — that weekly roll-up is the only thing looking past that date.GitPythoncould drift in. Highest-value structural fix outstanding.security/snyk (permit)returns a quota error on every recent PR — broken integration, not a finding.Related: permitio/permit-opa#49 (merged), permitio/permit-opa#50, permitio/opal#957.
🤖 Generated with Claude Code
https://claude.ai/code/session_01VvR24Cibs2Z8cVsEqkp3Es