Skip to content

ci: CVE-gate PRs, releases and published images; harden workflows and credentials (PER-15358) - #338

Merged
zeevmoney merged 44 commits into
mainfrom
ci/continuous-image-vuln-scanning
Oct 4, 2026
Merged

zeevmoney merged 44 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. Rollout steps and repository configuration are tracked there.

Why

Three gaps in CI:

  1. Releases skip the CVE gate. tests.yml's docker-scout job has if: github.event_name == 'pull_request', and release.yml reaches it through workflow_call, where event_name is release.
  2. Nothing re-scans a published image. A tag is scanned once, at build time. CVEs disclosed after that are never seen.
  3. CI depends on long-lived credentials and unpinned actions. A Docker Hub token, a personal access token for cloning private repos, and actions referenced by floating tags.

What this changes

1. Pull requests (tests.yml)

  • Docker Scout and Trivy gate the image on CRITICAL/HIGH and report into one sticky PR comment.
  • dependency-review gates the dependency diff at high. It comments only on failure.
  • The gate no longer has the PR-only condition, so it also runs on pushes to main and on releases. A failure on main or on a release posts to Slack. A PR failure does not, because it is already on the PR.

2. Releases (release.yml)

The release is scanned before anything is published:

pdp-tests → build-pdp-release → scan-pdp-release (amd64, arm64) → publish-pdp-release → update-pdp-api-ecs-service
            no credentials      no credentials                     env + OIDC push
  • build-pdp-release builds the multi-platform image once, into an OCI archive. It holds no Docker credential.

  • scan-pdp-release runs Trivy on each platform from that archive. A finding fails the job and nothing is published.

  • publish-pdp-release runs in a protected environment: release tags only, required reviewers, self-review blocked. It does the following:

    • logs in to Docker Hub through short-lived OIDC;
    • pushes with skopeo copy --all --preserve-digests;
    • checks that the registry serves the scanned index digest.

    Pre-releases get only their own tag. Official releases also move latest.

  • ECS rollout depends on the publish, so a release that fails its scan never reaches the fleet.

  • notify-release-failure posts to Slack when the build, scan or push fails, and names which one.

3. Scheduled scanning (scheduled-security-scan.yml, security-digest.yml)

  • Every third day, three scans run in parallel: Trivy on latest and the newest release tag, Docker Scout on latest, and the Dependabot alerts API. One Slack message covers all three. It is sent only when something is not clean.
  • The Dependabot scan filters through .trivyignore.yaml, so advisories that are already waived don't repeat on every run.
  • Weekly digest (Monday): open Dependabot PRs, waivers expiring within 30 days, and the result of the last scheduled scan.

4. Credentials

Use Before Now
Docker Hub pull (Scout logins) long-lived token OIDC, pull-only
Docker Hub push (release) long-lived token OIDC, scoped to the protected release environment
Clone permit-opa / pdp-tester personal access token GitHub App token: one per job, 1-hour lifetime, read-only, scoped to the one repo being cloned

No workflow uses secrets: inherit.

5. Workflow hardening (aligned with permit-python)

  • New workflow-hardening.yml runs actionlint and zizmor (persona: regular) on every PR and every push to main, and fails on any finding. It is a separate workflow, so a lint result can never block a release.
  • Every action is pinned to a commit SHA, with its release tag alongside. Every actions/checkout sets persist-credentials: false.
  • Each action uses one version across all workflows. azure/setup-helm goes from v3 to v5.0.1, because v3 declared node16.
  • Least privilege:
    • workflow-level contents: read;
    • id-token: write only on the jobs that mint tokens;
    • helm_release.yml reduced to contents: read.
  • No ${{ }} expansion of secrets, inputs or event data inside any run: body. Everything goes through env:.
  • deploy_sidecar.yml is deleted. It built ./helm/, which was removed in 570e21b, so it could not run.
  • The five local reusable-workflow calls carry a # zizmor: ignore[self-repository] with a justification. actionlint 1.7.12 rejects the $/ form that zizmor suggests.

6. Supply chain

  • Base images (rust, golang, python) are pinned by digest.
  • .github/dependabot.yml is added: docker / daily; pip, cargo, github-actions and docker /test_offline_mode weekly; 7-day cooldown.
  • The waivers in .trivyignore.yaml and the OpenVEX doc must agree. A waiver-parity pre-commit hook enforces this, and every waiver has an expiry date.

7. Scripts and tests

  • .github/scripts/ holds format_scan_report.py, classify_image_cves.py, check_waiver_parity.py and check_dependabot_alerts.py. All of them fail closed when a report can't be read.
  • 86 tests in horizon/tests/.

Rationale and runbook: docs/image-vulnerability-management.md.

Verification

  • CI is green on this head, including:
    • Workflow Hardening (zero findings);
    • Scout via OIDC;
    • both App-token clones;
    • docker-scout, pdp-tester and CodeQL.
  • Locally:
    • actionlint is clean;
    • zizmor with online audits reports no findings;
    • every pin resolves to a commit, not a tag object;
    • pre-commit is green.
  • Not exercisable from a PR: the release build → scan → publish path. The first pre-release after merge exercises it.

Note for whoever cuts releases: every release, rc included, now waits for an approval before it is pushed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PHJFjyY84UkG7zAfXUHPpP

…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 and others added 8 commits October 1, 2026 09:25
…om VEX

After merging main: main removed CVE-2026-78662 / CVE-2026-56855 from
pdp-v2.vex.json (the pinned permit-opa 0.0.23 ships x/crypto v0.57.0), but this
branch's .trivyignore.yaml still waived them, so check_waiver_parity.py and
three test_ci_scripts tests failed. Drop them and the stale header text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…5358)

Bugs:
- notify-gate-failure could never fire: a job `if:` without a status function
  gets an implicit success(), false exactly when docker-scout failed. Add
  always(); drop the comment that contradicted it.
- scheduled-security-scan.yml's Scout summary step never wrote its output, so
  Slack always said "no summary". Restore the GITHUB_OUTPUT line.
- classify_image_cves.py called digest-pinned Go stdlib and ==-pinned Python
  findings REBUILD, though a rebuild cannot move them. New owners
  "golang base digest" and "requirements pin" force SOURCE; table cells are
  escaped like format_scan_report.py's.
- The parity tests ran against the real waiver files and today's date, so the
  required pytests job would go red on 2027-01-01. They now use a temp tree
  and a fixed --today; the real files stay checked by pre-commit.

Hardening:
- An expired waiver no longer silences its Dependabot alert.
- Trivy waivers carry purls bound to the pinned starlette/ddtrace versions,
  like the VEX statements.
- dependency-review allows the five waived GHSAs.
- Trivy SARIF honours the severity filter (limit-severities-for-sarif).
- CodeQL upload-sarif v3 -> v4.38.2 (v3 deprecated Dec 2026).
- docker-scout job: SHA-pin checkout/download-artifact/login, and don't
  persist GITHUB_TOKEN; declare DOCKERHUB_* in workflow_call.
- A Dependabot PR without a Dependabot CLONE_REPO_TOKEN now fails with an
  explanation instead of an opaque clone error.

Docs/comments: five waivers (not seven), golang 1.26 (not 1.25), the removed
x/crypto rows, SLACK_WEBHOOK_URL exists, cadence wording, and an unverified
claim about GitHub's default cooldown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With 'labels:' set to a label the repo doesn't have, Dependabot applies no
label at all; with it unset, Dependabot creates and applies its defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Split render()'s next-step lines into _next_steps(); pre-commit --all-files
and the 86 CI-script tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The three Docker Hub logins that only pull - the Scout gate in tests.yml,
the Scout leg of the scheduled scan, and release.yml's
verify-published-image - now exchange the run's GitHub ID token for a
short-lived Docker token instead of reading DOCKERHUB_TOKEN. Nothing is
stored that could leak or need rotating.

- docker/login-action v3.7.0 -> v4.6.0 (OIDC needs >= v4.5.0), SHA-pinned.
- `username` is the Docker org from vars.DOCKERHUB_ORGANIZATION; `password`
  is omitted, which is what selects the OIDC flow. The connection id comes
  from vars.DOCKERHUB_OIDC_CONNECTIONID.
- `id-token: write` on each of the three jobs, and on release.yml's
  `pdp-tests` caller job: a called workflow cannot exceed its caller's job
  permissions, so without it the gate would pass on every PR and fail only
  on a real release.
- DOCKERHUB_OIDC_EXPIREIN 1800 instead of the 300s default: the token is
  minted before several Scout steps and a remote pull, and an expiry
  mid-job would read as a Scout failure.
- tests.yml no longer declares DOCKERHUB_* in workflow_call; nothing reads
  them there.

The Docker-side connection grants Image Pull only on permitio/pdp-v2 to
`repo:permitio/PDP:*`. The release PUSH in build-and-push-pdp deliberately
stays on DOCKERHUB_TOKEN: push must never be granted on `:*`, and moving it
needs a second, tags-only ruleset first.

Unverified until CI runs: that Docker Scout accepts the OIDC-issued token,
and whether Dependabot-triggered runs can mint an ID token. This PR's own
pull_request run exercises the first.

actionlint 6 findings, all pre-existing (the two "dockerhub_* not defined"
findings are gone). zizmor: 0 introduced vs 0bb89cd. pre-commit green.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VvR24Cibs2Z8cVsEqkp3Es
The image builds clone permitio/permit-opa (compiled into /app/bin/opa) and
the pdp-tester job clones permitio/pdp-tester; GITHUB_TOKEN can only read
permitio/PDP. Those clones used CLONE_REPO_TOKEN, a personal access token of
unknown owner and scope.

They now use the org-owned GitHub App `permitio-pdp-ci` (Contents read and
Metadata read only, no webhook, installed on permit-opa and pdp-tester).
Each job mints its own token with actions/create-github-app-token v3.2.0,
narrowed to the ONE repository it clones and to contents:read. The token
lasts an hour, is revoked when the job ends, and belongs to no person.

- tests.yml build-pdp-image and release.yml build-and-push-pdp: token for
  permit-opa only. tests.yml pdp-tester: token for pdp-tester only.
- tests.yml workflow_call now requires CLONE_APP_CLIENT_ID and
  CLONE_APP_PRIVATE_KEY instead of CLONE_REPO_TOKEN; release.yml passes them
  through its existing `secrets: inherit`.
- The client id is a secret although it is not sensitive: whether `vars`
  reaches Dependabot-triggered runs is undocumented, and the clone has to
  work there. Both secrets live in the Actions and Dependabot stores.
- The Dependabot pre-check now names the App secrets, and no longer tells
  people to copy DOCKERHUB_* there - the Scout login is OIDC.

actionlint unchanged (6, all pre-existing). zizmor net zero vs 27dfa6e (one
pre-existing bot-conditions finding re-keyed by the step rename).
pre-commit green.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PHJFjyY84UkG7zAfXUHPpP
release.yml built and pushed in one job with DOCKERHUB_TOKEN in scope for
the whole ~36-minute build, and scanned the image only after it was on
Docker Hub, where a failure could block ECS but not un-publish anything.

It is now three jobs:

- build-pdp-release: no Docker credential and no way to get one. Builds the
  multi-platform image once into an OCI archive and uploads it as a run
  artifact.
- scan-pdp-release (amd64, arm64): extracts each platform from that archive
  with skopeo and Trivy-scans it with the PR gate's settings. A finding
  fails the job and nothing is published.
- publish-pdp-release: `environment: dockerhub-release` (release tags only,
  R&D approval, self-review blocked). Logs in through OIDC for 10 minutes,
  pushes with `skopeo copy --all --preserve-digests`, moves `latest` for
  official releases only, and checks the registry serves the scanned index
  digest.

update-pdp-api-ecs-service needs the publish, so a failed scan also stops
the rollout. notify-release-failure covers build, scan and push failures and
says which one. Docker grants push only to the subject
repo:permitio/PDP:environment:dockerhub-release; the repo-wide rule stays
pull-only.

Also: workflow-level id-token moved onto the three jobs that use it; the
release tag reaches shell through env instead of `${{ }}` in the script;
setup-qemu, setup-buildx, build-push-action and configure-aws-credentials
are SHA-pinned.

actionlint unchanged (6, pre-existing). zizmor vs 27dfa6e: 8 cleared
(workflow-wide id-token, template injection, 6 unpinned uses), 0 new.
pre-commit green. tests.yml's permit-opa pin parity check still passes.

Unexercised until a release: the publish path. Test with a pre-release
first; if publish fails, nothing was pushed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PHJFjyY84UkG7zAfXUHPpP
No workflow reads it any more - every Docker Hub login, including the
release push, goes through OIDC.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PHJFjyY84UkG7zAfXUHPpP
@zeevmoney zeevmoney changed the title ci: gate releases on CVEs and unify Dependabot, Trivy and Docker Scout (PER-15358) ci: scan images on PRs, releases and a schedule; replace stored CI credentials (PER-15358) Oct 2, 2026
zeevmoney and others added 10 commits October 3, 2026 00:29
Add workflow-hardening.yml, which runs actionlint (rhysd/actionlint
v1.7.12) and zizmor (zizmor-action v0.6.4, regular persona, results
kept in the job log rather than uploaded to code scanning) on every
pull request and on pushes to main.

Pin every action that was still referenced by tag to a full SHA with a
version comment, in deploy_sidecar.yml, helm_release.yml,
test_release.yml, test_release_skipping.yml and tests.yml. Majors
moved along the way: actions/checkout v3/v4 -> v7.0.1,
actions/setup-python v5 -> v7.0.0, actions/upload-artifact v4 ->
v7.0.1, and tests.yml's two actions/download-artifact steps -> v8.0.1
with NODE_OPTIONS=--disable-warning=DEP0005.

Also:
- persist-credentials: false on every checkout this commit pins.
- Workflow-level `permissions: contents: read` in tests.yml,
  test_release.yml and test_release_skipping.yml.
- release.yml's pdp-tests job passes its four secrets by name instead
  of `secrets: inherit`.
- deploy_sidecar.yml reads its workflow_dispatch inputs through env
  instead of expanding them inside `run:`.
- tests.yml's Dependabot credential pre-check keys on
  github.event.pull_request.user.login instead of github.actor.
- `# shellcheck disable=SC2035` on the permit-opa tarball `find *`
  line in tests.yml and release.yml.

At this commit actionlint passes but zizmor does not: five
self-repository findings on the local reusable-workflow calls, and,
with online audits, three pins (helm/kind-action, swatinem/rust-cache,
aws-actions/configure-aws-credentials) that are annotated-tag object
SHAs rather than commits. The commits that follow fix both.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PHJFjyY84UkG7zAfXUHPpP
helm/kind-action and swatinem/rust-cache were pinned to the SHA of the
annotated tag object for `v1` / `v2`, not to the commit it points at.
GitHub's commits API rejects those SHAs (422), and zizmor's online
ref-version-mismatch audit fails on them.

Pin each to the commit its tag dereferences to, with the exact release
tag as the comment. Both are the same code the bare-major refs ran:
- helm/kind-action ef37e7f3 (v1.14.0; `v1` resolves here)
- swatinem/rust-cache 6323deb1 (v2.9.2; `v2` resolves here)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PHJFjyY84UkG7zAfXUHPpP
deploy_sidecar.yml cannot run: it builds and installs the chart from
./helm/, and helm/ was deleted in 570e21b (2024-02-29). GitHub records
no runs of it. It also took a PDP API key as a plain workflow_dispatch
input in a public repository, granted `contents: write` and
`id-token: write` at workflow level, and still used node12/node16
actions (configure-aws-credentials v1.6.1, amazon-ecr-login v1,
k8s-set-context v2).

Remove it, and drop the dependabot.yml note that listed it (and the
checkout@v3 pins the previous commit replaced) as drift to clear.

The repository secrets ROLE_ARN and KUBE_CONFIG were referenced only
by this workflow.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PHJFjyY84UkG7zAfXUHPpP
zizmor's self-repository audit flags every job-level
`uses: ./.github/workflows/...` call and suggests the `$/` form. Its
five findings fail the workflow-hardening zizmor step (exit 12).

`$/` is not an option here: actionlint 1.7.12, the version the same
job pins, rejects it ("not following the format ... nor
./path/to/workflow.yml"), and only with `./` does actionlint check a
caller's named `secrets:` against the callee's workflow_call block.
A `./` call is resolved from the caller's own commit, so there is no
ref to drift.

Add `# zizmor: ignore[self-repository]` on each of the five `uses:`
lines (zizmor only honours the comment on the finding's own line), with
the reason in a comment directly above:
- release.yml: pdp-tests, notify-release-failure
- scheduled-security-scan.yml: notify
- security-digest.yml: notify
- tests.yml: notify-gate-failure

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PHJFjyY84UkG7zAfXUHPpP
The pre-check in build-pdp-image ran only when
github.event.pull_request.user.login was dependabot[bot]. Which secret
store a run reads depends on the actor that triggered it, not on who
opened the PR, so that condition skipped Dependabot-triggered runs on
non-Dependabot PRs and fired on human pushes to Dependabot branches.
The github.actor form it replaced is the one zizmor's bot-conditions
audit rejects.

Drop the `if:`. The step now checks on every run that
CLONE_APP_CLIENT_ID and CLONE_APP_PRIVATE_KEY are non-empty, and its
error names both stores: the Actions store, or on a
Dependabot-triggered run the Dependabot store; fork PRs get neither.
A run without them already failed at the token step that follows, so
this changes the message, not the outcome.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PHJFjyY84UkG7zAfXUHPpP
The previous commit moved some steps to new majors and left others
where they were, so tests.yml carried actions/checkout at both v6.0.3
and v7.0.1, and its artifact handoff crossed majors against
release.yml and scheduled-security-scan.yml. Align every step:
- actions/checkout          -> 3d3c42e5 # v7.0.1
- actions/upload-artifact   -> 043fb46d # v7.0.1
- actions/download-artifact -> 3e5f45b2 # v8.0.1

Checked against each action.yml at those SHAs and the release notes
for every major crossed. checkout v7 only adds a refusal to check out
fork PRs under pull_request_target/workflow_run, which no workflow
here uses. upload-artifact v5-v7 add Node 24 and an `archive` input
that defaults to the old zip behaviour. download-artifact v8 still
extracts a by-name download straight into `path` (release.yml's scan
and publish jobs read $RUNNER_TEMP/pdp-image.oci.tar from there),
`pattern` + `merge-multiple` are unchanged, and a digest mismatch is
now an error by default.

Every download-artifact step sets NODE_OPTIONS=--disable-warning=DEP0005,
with a comment saying why: v8.0.1 bundles unzip-stream, which still
calls the deprecated Buffer() constructor
(actions/download-artifact#484).

scheduled-security-scan.yml's digest download kept
`continue-on-error: true`, but its comment said download-artifact
treats "no artifact matched" as an error. Neither v4.3.0 nor v8.0.1
does: a pattern with no match downloads nothing and succeeds. The
comment now says what continue-on-error actually covers: an artifact
API error, or a digest mismatch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PHJFjyY84UkG7zAfXUHPpP
With persist-credentials: false on the checkout, nothing in the
helm-release job uses GITHUB_TOKEN's write scope. chart-releaser-action
v1.6.0 runs cr v1.6.1, which creates releases through the API with
CR_TOKEN (secrets.PAGES) and pushes gh-pages to a URL it builds from
the same token (https://x-access-token:$CR_TOKEN@...). Lower the job to
`contents: read` and say why next to it.

azure/setup-helm v3 declares node16. v5.0.1 (9bc31f4e, the latest
release) runs on node24 and still declares `version` with the same
`latest` default; this step passes no inputs. Since v4.1.0 it reads
the latest Helm version from get.helm.sh instead of the GitHub API,
so it may install a newer Helm major than v3 did. That does not reach
chart-releaser: cr only execs git, and packages charts through the
Helm v3 Go library, not the helm binary.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PHJFjyY84UkG7zAfXUHPpP
test_release.yml expanded `${{ secrets.PDP_API_KEY }}` directly inside
the `run:` body, which splices the secret's value into the generated
shell script. Map it to the step's env and reference "$PDP_API_KEY"
instead, and wrap the command so it fits on two lines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PHJFjyY84UkG7zAfXUHPpP
- workflow-hardening.yml called its job a CVE gate. It lints workflows,
  and it is advisory: main's ruleset requires Rust CI, helm_test,
  pdp-tester, pytests and pre-commit, not Workflow Hardening. Say what
  it runs and that it blocks nothing until it is added there.
- tests.yml dependency-review said permissions are "per-job, never
  workflow-level" because a workflow default would narrow every other
  job. tests.yml now has a workflow-level `contents: read`; the comment
  now says the job-level block replaces it, which is why the job
  restates `contents: read`.
- tests.yml notify-gate-failure said that without its block the
  notifier would get "whatever the repository default grants". It
  would get the workflow-level `contents: read` now.
- docs/image-vulnerability-management.md said release.yml's pdp-tests
  still uses `secrets: inherit`; it names its four secrets. The same
  paragraph counted six notify-slack.yml callers; there are four.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PHJFjyY84UkG7zAfXUHPpP
Split the pre-check's ::error:: text across echo arguments, the form
scheduled-security-scan.yml already uses for its alerts-API error.
echo joins the arguments with single spaces, so the annotation is still
one line and its text is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PHJFjyY84UkG7zAfXUHPpP
@zeevmoney zeevmoney changed the title ci: scan images on PRs, releases and a schedule; replace stored CI credentials (PER-15358) ci: CVE-gate PRs, releases and published images; harden workflows and credentials (PER-15358) Oct 2, 2026
Comments and docs now explain the build-time-gate blind spot, silent base
rebuilds and scanner disagreement without the history of a specific
release. Also corrects two stale statements in the vulnerability doc: the
Dependabot backlog is reported as a count on every scheduled run (73-hour
window, not 25), and the x/crypto waivers are already gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PHJFjyY84UkG7zAfXUHPpP
@zeevmoney
zeevmoney merged commit b2f8754 into main Oct 4, 2026
16 checks passed
@zeevmoney
zeevmoney deleted the ci/continuous-image-vuln-scanning branch October 4, 2026 15:27
zeevmoney added a commit that referenced this pull request Oct 4, 2026
Refs PER-15358, PER-15886.

Pull requests
- Docker Scout and Trivy gate the image on CRITICAL/HIGH and post one
  sticky report comment. dependency-review gates new dependencies at
  high.
- The image gate also runs on pushes to main and on releases; a failure
  there posts to Slack.

Releases
- release.yml builds the multi-arch image once into an OCI archive,
  scans each platform with Trivy, and publishes only if the scan passes.
- Publishing runs in the dockerhub-release environment (required
  reviewers), pushes through Docker Hub OIDC with skopeo
  --preserve-digests, and checks the published index digest. ECS
  rollout waits for it.

Scheduled scanning
- scheduled-security-scan.yml runs every third day: Trivy on latest and
  the newest release tag, Docker Scout on latest, and the Dependabot
  alerts API, reported in one Slack message when something needs
  attention.
- security-digest.yml posts a weekly roll-up of open Dependabot PRs,
  expiring waivers and the last scheduled-scan result.

Credentials and workflow hardening
- Docker Hub pull and push use OIDC. Private-repo clones use a
  short-lived GitHub App token instead of a personal access token.
- workflow-hardening.yml runs actionlint and zizmor on every PR.
- Every action is pinned to a commit SHA, checkouts set
  persist-credentials: false, and permissions are least-privilege.
- deploy_sidecar.yml is removed; it built the deleted ./helm/ directory.

Supply chain
- Base images are pinned by digest; dependabot.yml is added with a
  7-day cooldown.
- Trivy and OpenVEX waivers must agree (waiver-parity pre-commit hook),
  and every waiver has an expiry date.
- Scan-report, triage, waiver-parity and Dependabot-alert scripts live
  in .github/scripts, with tests in horizon/tests.

Co-authored-by: Zeev Manilovich <zeevmanilovich@gmail.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
zeevmoney added a commit that referenced this pull request Oct 5, 2026
Refs PER-15886. Follow-up to #338.

Dependencies
- Folds in the open Dependabot updates: golang 1.27 and rust 1.98
  builders; redis 1.7, reqwest 0.13 (rustls TLS), nix 0.31, confique
  0.4, utoipa 6, utoipa-axum 0.3, utoipa-scalar 0.4, sha2 0.11;
  scalar-fastapi 1.8.2; aiohttp, uvicorn, protobuf, typer and urllib3
  floors; configure-aws-credentials v6, setup-qemu v4, setup-buildx v4,
  build-push-action v7, slack-github-action v4, chart-releaser 1.7,
  kind 1.15.
- redis 1.x error rename and redis-test 1.0.4; sha2 0.11 keeps the same
  cache keys (hex_prefix, pinned by a test).
- OPAL stays at 0.9.6 (0.9.9 requires Python <3.13). Dependabot skips
  pydantic>=2, tenacity>=9, scalar-fastapi>=1.9 and fastapi>=0.126,
  which OPAL's caps rule out.
- Cargo.lock is committed; the image build and Rust CI use --locked.

Security scanning
- cargo audit on PRs and releases, with a sticky PR comment; a
  vulnerable crate blocks a release.
- Releases are scanned by Docker Scout as well as Trivy on both
  platforms (VEX product @release-scan).
- The scheduled scan sends one Slack report in agent-security's layout
  (Trivy, Scout, cargo audit, Dependabot; one line per advisory) and
  stays green on findings; it fails only when a scan did not complete.
  The weekly digest reads the scan's report artifact.

CI
- prek runs the hooks; hooks are pinned by commit SHA and updated by
  Dependabot.
- Node 24 actions, runners pinned to ubuntu-24.04.

Fixes and docs
- Config tests no longer print PDP_* values (CodeQL #116).
- The CVE-2026-50271 waiver names the real mitigation; release approval
  wording matches the environment (self-review allowed).

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants