Repository navigation
ci: CVE-gate PRs, releases and published images; harden workflows and credentials (PER-15358) - #338
Conversation
…358) A customer CPE scan of permitio/pdp-v2:0.9.14 on 2026-09-09 reported 21 findings / 12 unique CVEs, all OS packages, flagged 2 CRITICAL + 10 HIGH. Verified: the published tag really does contain them, but NOT because of a source defect. 0.9.14 was built 2026-08-04 and froze libcrypto3/libssl3 3.5.7-r0 and libuuid 2.41.4-r0. Alpine 3.23 has since published openssl 3.5.8-r0 and util-linux 2.41.6-r1. A rebuild of the UNCHANGED Dockerfile resolves both and scans clean, so this is pure base-image drift on a tag that was never rebuilt. All nine OpenSSL CVEs (CVE-2026-14456, -14457, -18798, -54874, -63072, -63073, -63075, -63076, -75803) are fixed in OpenSSL 3.5.8; the three util-linux ones in 2.41.6. Worth noting for the customer reply: upstream OpenSSL rates six of these Low and three Moderate - NONE is Critical. Both CVEs the scanner called CRITICAL (CVE-2026-63073 CMP sender-DN format string, CVE-2026-75803 AEAD tag bypass on empty ciphertext via EVP_Cipher) are OpenSSL-severity Low, and neither code path is one the PDP drives - it runs no CMP client and calls no raw EVP_Cipher. While verifying, found something the customer's OS-only scan did NOT report and which a rebuild would only have fixed by luck: GitPython 3.1.57 carries CVE-2026-78676 - a genuine CRITICAL (CVSS 3.1 9.8) argument-injection RCE via git-config re-serialization into core.hooksPath. GitPython is transitive via opal-common (`gitpython<4,>=3.1.32`) and nothing else bounded it, so the unpinned build took whatever was current. Now floored at >=3.1.59,<4, which clears that CRITICAL plus six HIGHs. Changes: - requirements.txt: floor GitPython >=3.1.59,<4 (1 CRITICAL + 6 HIGH). - release.yml: `no-cache-filter: main,opa_build` on both build steps. The GHA cache key is instruction text + parent layer, so a release cut weeks later could replay the old `apk upgrade`/`pip install` layers and re-ship the exact packages a customer just flagged. Rust stages still cache normally. - vex.json v4 -> v5: drop the CVE-2026-15308 waiver. CPython backported the html.parser fix into 3.13.15, which the floating base tag now resolves - the waiver itself said to remove it at this point. - Dockerfile: record why a correct Dockerfile still shipped stale packages. Verified by building the exact base + apk + pip layers with no cache: libcrypto3/libssl3 3.5.8-r0, libuuid 2.41.6-r1, Python 3.13.15, GitPython 3.1.62, cryptography 50.0.1 trivy os+library CRITICAL/HIGH: 12 customer CVEs -> 0 remaining. Only 3 HIGHs remain, all pre-existing and already VEX-waived as unreachable behind OPAL's caps (starlette CVE-2026-48818/-54283, ddtrace CVE-2026-50271). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🔍 Vulnerabilities of
|
| digest | sha256:9a55b612b2a629439eb020d066d2fee7c7460c37d95bbd4bea4aafd43c496697 |
| vulnerabilities | |
| platform | linux/amd64 |
| size | 139 MB |
| packages | 247 |
📦 Base Image python:3.13-alpine3.23
| also known as |
|
| digest | sha256:c694844958d415b76979bb3e0927273b013294ab36e715ca1fc4962713a8ee69 |
| vulnerabilities |
Description
Description
Description
Description
Description
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Description
Description
Description
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Description
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Description
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
340ed77 to
af30d7f
Compare
…5358) The docker-scout gate on this PR fails on 5 HIGH that do NOT come from this repo: /app/bin/opa is compiled from permitio/permit-opa, so its Go module CVEs land in our scans. The gate has been red on them since 2026-09-06 - #333 and #336 both went red and were merged anyway - so this is pre-existing drift, not a regression from this PR. It matters now because the companion PR makes the gate block releases. Split by what is actually fixable: permitio/permit-opa#49 bumps grpc 1.80.0 -> 1.83.2 and x/crypto 0.53.0 -> 0.55.0, clearing 3 of the 5 (CVE-2026-84304, CVE-2026-84445, CVE-2026-56854). Note 1.83.1 is NOT enough for CVE-2026-84445 - its affected range covers >=1.83.0 <1.83.2 - which only showed up by scanning the rebuilt binary. The remaining two, CVE-2026-78662 and CVE-2026-56855, are fixed only in x/crypto >= 0.56.0, which declares `go >= 1.26.0`. Go 1.26 is not released (there is no golang:1.26 image) and this image builds on golang:1.25-bookworm, so that upgrade does not exist yet. Waived here as not_affected / vulnerable_code_not_in_execute_path, on evidence rather than assertion: both live in golang.org/x/crypto/ssh, and `go list -deps ./cmd/opa` in permit-opa shows x/crypto/ssh is NOT among the linked x/crypto packages (cryptobyte, chacha20(+poly1305), pbkdf2, curve25519, blake2b, salsa20, nacl/box, nacl/secretbox, hkdf, sha3). The SSH transport is never linked into the binary, and the PDP speaks no SSH of its own. Each statement names the removal gate: drop it when a Go 1.26 toolchain lands. vex.json v5 -> v6. Trivy does not currently flag these two at module level, so the .trivyignore.yaml counterparts are added in the companion PR to keep the two waiver files saying the same thing. NOTE ON CI: this PR cannot go green until permitio/permit-opa#49 merges, because tests.yml clones permit-opa@main at build time to produce custom_opa.tar.gz. Once #49 is in, the two grpc CVEs disappear from the build and these two waivers cover the rest. Verified locally: trivy on the rebuilt OPA binary with grpc 1.83.2 + x/crypto 0.55.0 reports 0 CRITICAL/HIGH, down from 5. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
a61019b to
91c7a9a
Compare
…-15358) The two waivers added in the previous commit suppressed nothing. The gate run on 81056d6 still reported CVE-2026-78662 and CVE-2026-56855, and the reason is the subcomponent PURL: docker scout matches VEX statements on the FULL PURL string, and the one I wrote matched neither the version in the image nor scout's spelling of it. Scout's own output names the component pkg:golang/golang.org/x/crypto@0.53.0 while the waiver said `@v0.55.0`. Two independent mismatches: 1. NO `v` PREFIX. Go module versions conventionally carry one and the purl spec permits it, but scout emits Go PURLs without it. Confirmed against the waivers that DO work in the same run - pkg:pypi/starlette@0.50.0 and pkg:pypi/ddtrace@3.19.8 are suppressed because they match byte-for-byte. `@v0.55.0` bound to nothing at all, and would have kept binding to nothing even after permit-opa#49 merged - a silent tripwire that would have looked like the waiver "not working" weeks later. 2. Wrong version. The build resolves x/crypto 0.53.0 until permit-opa#49 merges and 0.55.0 after, so a statement naming one version stops suppressing the moment the other is in play. Both PURLs are therefore listed as subcomponents, which is the sanctioned pattern for one CVE spanning several package versions. Deliberately NOT solved by dropping `subcomponents` altogether: per Docker's VEX guide that broadens the statement to the entire image, which would suppress these CVEs wherever they appeared rather than only in the OPA binary. Scope is the point of the waiver. Both impact statements now record the `v`-prefix trap and why two versions are listed, so the next person editing this file does not rediscover it. vex.json v6 -> v7. Verified by scanning the gate log rather than assuming: docker scout needs a login this environment does not have, so the PURL format was taken from scout's own printed output and cross-checked against the pypi waivers that already suppress correctly in the same run. This does not turn the gate green on its own - the 2 grpc CVEs and CVE-2026-56854 still need permit-opa#49. It makes these two waivers actually bind once it lands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…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
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
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
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>
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>
Linear: PER-15886 · umbrella PER-15358. Rollout steps and repository configuration are tracked there.
Why
Three gaps in CI:
tests.yml'sdocker-scoutjob hasif: github.event_name == 'pull_request', andrelease.ymlreaches it throughworkflow_call, whereevent_nameisrelease.What this changes
1. Pull requests (
tests.yml)dependency-reviewgates the dependency diff athigh. It comments only on failure.mainand on releases. A failure onmainor 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:
build-pdp-releasebuilds the multi-platform image once, into an OCI archive. It holds no Docker credential.scan-pdp-releaseruns Trivy on each platform from that archive. A finding fails the job and nothing is published.publish-pdp-releaseruns in a protected environment: release tags only, required reviewers, self-review blocked. It does the following:skopeo copy --all --preserve-digests;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-failureposts to Slack when the build, scan or push fails, and names which one.3. Scheduled scanning (
scheduled-security-scan.yml,security-digest.yml)latestand the newest release tag, Docker Scout onlatest, and the Dependabot alerts API. One Slack message covers all three. It is sent only when something is not clean..trivyignore.yaml, so advisories that are already waived don't repeat on every run.4. Credentials
permit-opa/pdp-testerNo workflow uses
secrets: inherit.5. Workflow hardening (aligned with
permit-python)workflow-hardening.ymlruns actionlint and zizmor (persona: regular) on every PR and every push tomain, and fails on any finding. It is a separate workflow, so a lint result can never block a release.actions/checkoutsetspersist-credentials: false.azure/setup-helmgoes from v3 to v5.0.1, because v3 declared node16.contents: read;id-token: writeonly on the jobs that mint tokens;helm_release.ymlreduced tocontents: read.${{ }}expansion of secrets, inputs or event data inside anyrun:body. Everything goes throughenv:.deploy_sidecar.ymlis deleted. It built./helm/, which was removed in 570e21b, so it could not run.# zizmor: ignore[self-repository]with a justification. actionlint 1.7.12 rejects the$/form that zizmor suggests.6. Supply chain
rust,golang,python) are pinned by digest..github/dependabot.ymlis added:docker /daily;pip,cargo,github-actionsanddocker /test_offline_modeweekly; 7-day cooldown..trivyignore.yamland the OpenVEX doc must agree. Awaiver-paritypre-commit hook enforces this, and every waiver has an expiry date.7. Scripts and tests
.github/scripts/holdsformat_scan_report.py,classify_image_cves.py,check_waiver_parity.pyandcheck_dependabot_alerts.py. All of them fail closed when a report can't be read.horizon/tests/.Rationale and runbook:
docs/image-vulnerability-management.md.Verification
docker-scout,pdp-testerand CodeQL.build → scan → publishpath. 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