Skip to content

ci: gate releases on CVEs and unify Dependabot, Trivy and Docker Scout (PER-15358) - #338

Open
EliMoshkovich wants to merge 24 commits into
mainfrom
ci/continuous-image-vuln-scanning
Open

EliMoshkovich wants to merge 24 commits into
mainfrom
ci/continuous-image-vuln-scanning

Conversation

@EliMoshkovich

@EliMoshkovich EliMoshkovich commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

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 secret

Every 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:

gh secret set SLACK_WEBHOOK_URL --repo permitio/PDP --body 'https://hooks.slack.com/services/T…/B…/…'

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-image clones the private permitio/permit-opa with an empty token, dies, and takes pdp-tester and docker-scout with it via needs:. 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:

gh secret set CLONE_REPO_TOKEN   --app dependabot --repo permitio/PDP
gh secret set DOCKERHUB_USERNAME --app dependabot --repo permitio/PDP
gh secret set DOCKERHUB_TOKEN    --app dependabot --repo permitio/PDP

Or: Settings → Secrets and variables → Dependabot.

3. Repository label dependencies

.github/dependabot.yml sets labels: ["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.

gh api -X PUT repos/permitio/PDP/automated-security-fixes

Not required, but do it once after merge

workflow_dispatch the scan to confirm Docker Scout resolves registry://permitio/pdp-v2:latest to the PURL pkg: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.

gh workflow run scheduled-security-scan.yml --repo permitio/PDP

Nothing else is needed

No GitHub App, no PAT, no bot credentials. GITHUB_TOKEN covers everything, including the Dependabot alerts API via vulnerability-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-r0 afterwards. 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.

docker-scout:
  if: github.event_name == 'pull_request'   # releases are not pull_request

release.yml reaches tests.yml via workflow_call, where github.event_name is the caller's event — release. Verified empirically rather than from the YAML: the actual 0.9.14 release run (30900950607) shows pdp-tests/docker-scout SKIPPED alongside build-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-pdp already declares needs: pdp-tests, so a failing gate now blocks the push.

Part 2 — what each scanner does now

PR gate Scheduled alert Release gate
Trivy blocks, in the sticky comment published tags, in the shared digest scans the shipped tag by digest, both platforms
Docker Scout blocks, same comment latest, same digest same job
Dependabot new dependency-review job alerts API poll, same digest —

PR gate and report

The Trivy step moves from exit-code: 1 to 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-unfixed and vuln-type carry over byte-identical, so critical + high > 0 is 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 own write-comment goes to false on 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_SUMMARY and one ::error:: per finding as fallbacks.

dependency-review is new: it gates the dependency diff at fail-on-severity: high and posts its own comment on-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.yml runs all three scanners as parallel jobs on 17 6 */3 * * (every third day) and a digest job merges them into one Slack message:

*Trivy* - 2 published tag(s): 1 clean, 1 not clean.
  • permitio/pdp-v2:latest -> SOURCE (3 findings: 1 CRITICAL, 2 HIGH)
  • permitio/pdp-v2:v0.9.15 -> CLEAN
*Docker Scout* - found 3 unwaived CRITICAL/HIGH finding(s) in permitio/pdp-v2:latest...
*Dependabot* - 1 new unwaived CRITICAL alert: CVE-2026-99999 in requests.

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. status only escalates ok → warn → fail, so a clean Dependabot result cannot talk down a Trivy SOURCE verdict.

*/3 in 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-54283 and CVE-2026-48818 (starlette) — are CVEs already waived in .trivyignore.yaml and 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_alert workflow 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-image job scans the tag that actually shipped, by digest, per platform — the only place linux/arm64 is ever scanned, since tests.yml builds amd64 only and no-cache-filters: main means the pushed build re-resolves packages and can genuinely differ from the one the gate passed. update-pdp-api-ecs-service now lists it in needs:, 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

  • Base images pinned by digest. Upstream rebuilds tags in place; python:3.13-alpine3.23 silently 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 upgrade still floats the Alpine set at build time.
  • Dependabot config where there was none: pip, cargo, github-actions weekly; docker / daily (the 0.9.14 signal); docker /test_offline_mode weekly. Documented ignore entries with removal gates for starlette/ddtrace (capped by opal-common, so a bump PR is unmergeable) and websockets majors.
  • .trivyignore.yaml — Trivy counterpart to the OpenVEX doc. Every entry carries expired_at, so a waiver cannot silently become permanent. Parity gap closed 5 → 7 (CVE-2026-48710, CVE-2026-48817 were VEX-only) and now enforced by a waiver-parity pre-commit hook rather than a comment.
  • classify_image_cves.py fails closed. A zero-byte report was reported as CLEAN with exit 0; it now exits 2 with verdict=ERROR. It splits a case that "a fix exists" hides: /app/bin/opa is compiled from permit-opa, so a grpc or x/crypto CVE needs a go.mod bump in that repo and no release here can clear it.
  • format_scan_report.py, check_waiver_parity.py, check_dependabot_alerts.py — 81 tests in horizon/tests/, which is the path the required pytests job actually runs.
  • notify-slack.yml — reusable notifier, a green no-op until the secret exists, and it reports its own delivery failures (errors: true plus an exported delivery outcome, because continue-on-error alone makes a dead webhook invisible).
  • The rolling [image-cve] GitHub issue is removed, not deprecated. Slack replaces it.
  • PyYAML declared — it was reaching a required check only via 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.
  • 81 tests pass; every mutation applied during review was caught.
  • pre-commit run --all-files: all 17 hooks pass.
  • All 10 action pins re-resolved live against the API — zero version comments that disagree with their SHA. (An earlier revision shipped docker/scout-action # v1.24.0 on a v1.20.4 SHA; corrected.)
  • Gate strength re-derived independently by extracting the real 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-review is a third waiver surface that does not read .trivyignore.yaml, so a PR touching the starlette or ddtrace pins can be failed on an already-waived advisory. check_waiver_parity.py covers 2 of 3 surfaces.
  • All 7 waivers expire 2026-12-31 together. Trivy prunes an expired entry, so every PR would go red the same morning. security-digest.yml warns 30 days ahead — that weekly roll-up is the only thing looking past that date.
  • No Python lockfile. The root reason GitPython could drift in. Highest-value structural fix outstanding.
  • No auto-rebuild cadence, no image signing, no SBOM publication. Deliberate; the reasoning is in the doc.
  • 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

…358)

A customer CPE scan of permitio/pdp-v2:0.9.14 on 2026-09-09 reported 21 findings
/ 12 unique CVEs, all OS packages, flagged 2 CRITICAL + 10 HIGH.

Verified: the published tag really does contain them, but NOT because of a source
defect. 0.9.14 was built 2026-08-04 and froze libcrypto3/libssl3 3.5.7-r0 and
libuuid 2.41.4-r0. Alpine 3.23 has since published openssl 3.5.8-r0 and util-linux
2.41.6-r1. A rebuild of the UNCHANGED Dockerfile resolves both and scans clean, so
this is pure base-image drift on a tag that was never rebuilt.

All nine OpenSSL CVEs (CVE-2026-14456, -14457, -18798, -54874, -63072, -63073,
-63075, -63076, -75803) are fixed in OpenSSL 3.5.8; the three util-linux ones in
2.41.6. Worth noting for the customer reply: upstream OpenSSL rates six of these
Low and three Moderate - NONE is Critical. Both CVEs the scanner called CRITICAL
(CVE-2026-63073 CMP sender-DN format string, CVE-2026-75803 AEAD tag bypass on
empty ciphertext via EVP_Cipher) are OpenSSL-severity Low, and neither code path
is one the PDP drives - it runs no CMP client and calls no raw EVP_Cipher.

While verifying, found something the customer's OS-only scan did NOT report and
which a rebuild would only have fixed by luck:

  GitPython 3.1.57 carries CVE-2026-78676 - a genuine CRITICAL (CVSS 3.1 9.8)
  argument-injection RCE via git-config re-serialization into core.hooksPath.

GitPython is transitive via opal-common (`gitpython<4,>=3.1.32`) and nothing else
bounded it, so the unpinned build took whatever was current. Now floored at
>=3.1.59,<4, which clears that CRITICAL plus six HIGHs.

Changes:
- requirements.txt: floor GitPython >=3.1.59,<4 (1 CRITICAL + 6 HIGH).
- release.yml: `no-cache-filter: main,opa_build` on both build steps. The GHA
  cache key is instruction text + parent layer, so a release cut weeks later
  could replay the old `apk upgrade`/`pip install` layers and re-ship the exact
  packages a customer just flagged. Rust stages still cache normally.
- vex.json v4 -> v5: drop the CVE-2026-15308 waiver. CPython backported the
  html.parser fix into 3.13.15, which the floating base tag now resolves - the
  waiver itself said to remove it at this point.
- Dockerfile: record why a correct Dockerfile still shipped stale packages.

Verified by building the exact base + apk + pip layers with no cache:
  libcrypto3/libssl3 3.5.8-r0, libuuid 2.41.6-r1, Python 3.13.15,
  GitPython 3.1.62, cryptography 50.0.1
  trivy os+library CRITICAL/HIGH: 12 customer CVEs -> 0 remaining.
Only 3 HIGHs remain, all pre-existing and already VEX-waived as unreachable
behind OPAL's caps (starlette CVE-2026-48818/-54283, ddtrace CVE-2026-50271).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 11, 2026 14:50
@linear-code

linear-code Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

PER-15358

PER-15886

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Copilot AI review requested due to automatic review settings September 11, 2026 14:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

@github-actions

github-actions Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

🔍 Vulnerabilities of permitio/pdp-v2:next

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

pkg:pypi/starlette@0.50.0

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

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

Summary

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

Details

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

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

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

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

Impact

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

Mitigation

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

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

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

Summary

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

Details

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

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

Impact

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

Mitigation

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

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

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

Summary

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

Details

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

GET /foo HTTP/1.1
Host: example.com

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

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

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

Impact

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

Mitigation

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

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

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

Summary

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

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

Details

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

Impact

An application is affected when all of the following hold:

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

This also affects frameworks built on Starlette, like FastAPI.

Mitigation

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

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

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

Summary

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

Details

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

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

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

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

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

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

Impact

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

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

Mitigation

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

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

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

high : CVE--2026--78662

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

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

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

high : CVE--2026--56855

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

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

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

unspecified : GO--2026--5932

Affected range>=0
Fixed versionNot Fixed
Description

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

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

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

pkg:pypi/ddtrace@3.19.8

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

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

Impact

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

Patches

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

Workarounds

If users cannot upgrade immediately:

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

Resources

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

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

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

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

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

Impact

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

Patches

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

Workarounds

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

Credits

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

For more information

If there are any questions or comments about this advisory:

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

To report a security issue in containerd:

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

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

medium : CVE--2025--60876

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

@github-actions

github-actions Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

🔍 Vulnerabilities of permitio/pdp-v2:next

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

@EliMoshkovich
EliMoshkovich force-pushed the ci/continuous-image-vuln-scanning branch from 340ed77 to af30d7f Compare September 11, 2026 15:08
Copilot AI review requested due to automatic review settings September 11, 2026 15:08
@EliMoshkovich
EliMoshkovich changed the base branch from main to fix/pdp-image-cves-sept-2026 September 11, 2026 15:08

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

…5358)

The docker-scout gate on this PR fails on 5 HIGH that do NOT come from this
repo: /app/bin/opa is compiled from permitio/permit-opa, so its Go module CVEs
land in our scans. The gate has been red on them since 2026-09-06 - #333 and
#336 both went red and were merged anyway - so this is pre-existing drift, not
a regression from this PR. It matters now because the companion PR makes the
gate block releases.

Split by what is actually fixable:

  permitio/permit-opa#49 bumps grpc 1.80.0 -> 1.83.2 and x/crypto 0.53.0 ->
  0.55.0, clearing 3 of the 5 (CVE-2026-84304, CVE-2026-84445, CVE-2026-56854).
  Note 1.83.1 is NOT enough for CVE-2026-84445 - its affected range covers
  >=1.83.0 <1.83.2 - which only showed up by scanning the rebuilt binary.

  The remaining two, CVE-2026-78662 and CVE-2026-56855, are fixed only in
  x/crypto >= 0.56.0, which declares `go >= 1.26.0`. Go 1.26 is not released
  (there is no golang:1.26 image) and this image builds on golang:1.25-bookworm,
  so that upgrade does not exist yet. Waived here as not_affected /
  vulnerable_code_not_in_execute_path, on evidence rather than assertion: both
  live in golang.org/x/crypto/ssh, and `go list -deps ./cmd/opa` in permit-opa
  shows x/crypto/ssh is NOT among the linked x/crypto packages (cryptobyte,
  chacha20(+poly1305), pbkdf2, curve25519, blake2b, salsa20, nacl/box,
  nacl/secretbox, hkdf, sha3). The SSH transport is never linked into the
  binary, and the PDP speaks no SSH of its own. Each statement names the
  removal gate: drop it when a Go 1.26 toolchain lands.

vex.json v5 -> v6. Trivy does not currently flag these two at module level, so
the .trivyignore.yaml counterparts are added in the companion PR to keep the
two waiver files saying the same thing.

NOTE ON CI: this PR cannot go green until permitio/permit-opa#49 merges, because
tests.yml clones permit-opa@main at build time to produce custom_opa.tar.gz.
Once #49 is in, the two grpc CVEs disappear from the build and these two waivers
cover the rest. Verified locally: trivy on the rebuilt OPA binary with grpc
1.83.2 + x/crypto 0.55.0 reports 0 CRITICAL/HIGH, down from 5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 11, 2026 15:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

@EliMoshkovich
EliMoshkovich force-pushed the ci/continuous-image-vuln-scanning branch from a61019b to 91c7a9a Compare September 11, 2026 15:30
Copilot AI review requested due to automatic review settings September 11, 2026 15:30

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

…-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>
@EliMoshkovich
EliMoshkovich force-pushed the ci/continuous-image-vuln-scanning branch from d8c1615 to 1ec0206 Compare September 11, 2026 16:11
Copilot AI review requested due to automatic review settings September 11, 2026 16:11

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Base automatically changed from fix/pdp-image-cves-sept-2026 to main September 16, 2026 11:53
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>
Copilot AI review requested due to automatic review settings September 16, 2026 17:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

zeevmoney and others added 3 commits September 16, 2026 23:51
…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
EliMoshkovich added a commit that referenced this pull request Sep 22, 2026
… 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>
Copilot AI review requested due to automatic review settings September 23, 2026 13:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

@zeevmoney zeevmoney changed the title ci: re-scan published images and gate releases on CVEs (PER-15358) ci: gate releases on CVEs and unify Dependabot, Trivy and Docker Scout (PER-15358) Sep 23, 2026
@github-actions

Copy link
Copy Markdown

PDP image vulnerability report

Image: permitio/pdp-v2:next

✅ No CRITICAL or HIGH findings after waivers.

Trivy

No CRITICAL or HIGH findings.

Docker Scout

No CRITICAL or HIGH findings.


Scanned by Trivy, Docker Scout. Waivers: .trivyignore.yaml + .docker/scout/pdp-v2.vex.json. View run

EliMoshkovich added a commit that referenced this pull request Sep 23, 2026
… 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>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants