Skip to content

fix(ci): scan images against the vulnerability policy where they are built - #1710

Merged
FelixTJDietrich merged 2 commits into
mainfrom
ci/scan-images-at-build-time
Sep 1, 2026
Merged

fix(ci): scan images against the vulnerability policy where they are built#1710
FelixTJDietrich merged 2 commits into
mainfrom
ci/scan-images-at-build-time

Conversation

@FelixTJDietrich

@FelixTJDietrich FelixTJDietrich commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Description

The v0.75.0 release failed at tag-images with webapp does not satisfy vulnerability policy.
Main was green because no container image is scanned anywhere before release:
ci-security-scan.yml runs Trivy with scan-type: "fs" and continue-on-error: true, so it
never sees an image. This fixes both defects behind that, in one PR because both rewrite
evaluate() in scripts/check-release-vulnerabilities.ts.

#1704 — unfixable findings no longer block. evaluate() rejected any HIGH or CRITICAL
whether or not upstream had published a fix, and security/release-images.json runs four
upstream images — alpine, nats, nginx, traefik — through the same gate. Those cannot be
patched at all, so failing on them removes no risk and leaves nobody an action. Finding now
carries FixedVersion, and a finding needs a non-empty one to reach rejected. The filter is
in the evaluator, not on Trivy's command line (--ignore-unfixed), so unfixable findings
stay counted in highCritical and visible in the signed evidence bundle. On this run that
distinction is doing most of the work: agent-pi reports 59 HIGH/CRITICAL and rejects 1,
postgres reports 99 and rejects 23.

#1703 — the gate runs where images are built. That needed the exception schema fixed first.
Exceptions matched on image | platform | digest | vulnerability | package | installedVersion,
including the release image digest — which does not exist until tag-images runs. A
pull-request-time exception could therefore never match a release-time digest, and an exception
could only be authored after the gate had already failed a release. digest is dropped from
the match key (and from the duplicate key) and kept in the recorded exception for auditability;
installedVersion already gives the "dies when the package moves" property.

A new scan job in reusable-docker-build.yml then blocks after the push, on the linux/amd64
image behind the run-<run_id>-<run_attempt> tag the workflow already produces. It calls the
same script and the same security/vulnerability-policy.json
the release and the weekly
rescan call; ci-contract.test.ts now asserts there is no second copy. Both architectures are
still scanned at release, where the evidence bundle has to be complete.

Also: a failing gate names the rejected CVEs in $GITHUB_STEP_SUMMARY and uploads its
.policy.json — diagnosing v0.75.0 required rebuilding and rescanning the base image by hand.
And public.ecr.aws/aquasecurity/trivy-db is added as a TRIVY_DB_REPOSITORY fallback, because
a blocking gate inherits Trivy's database availability as a hard dependency and GHCR returns
TOOMANYREQUESTS often enough that Harbor, Rancher and New Relic all override it. The existing
3× retry stays.

Closes #1703
Closes #1704

⚠️ This PR cannot pass its own CI, and #1702 is not enough

The gate is blocking on a policy that has never run at build time, so this PR fails on the
images it is meant to protect. The mechanism itself works end to end — subject resolution,
database download, scan, evaluation, step summary and artifact upload all succeeded on every
image; the four Scan linux/amd64 Image jobs failed on the policy, which is the point.
Measured on this run's images:

Image HIGH/CRITICAL Rejected What is rejected Fixed by
webapp 4 4 libcrypto3/libssl3 3.5.7-r0 (CVE-2026-14456), libexpat 2.8.2-r0 (CVE-2026-66046, CVE-2026-76641) #1702
agent-pi 59 1 libexpat1 2.5.0-1+deb12u2 (CVE-2026-56408) #1702
postgres 99 23 libexpat1 (same CVE) — and 22 Go stdlib CVEs in usr/local/bin/gosu, built with Go 1.24.6 #1702 covers only the libexpat1 one, and only if it includes docker/postgres/
application-server 6 6 org.bouncycastle:bcprov-jdk18on 1.82 — CVE-2025-14813 (CRITICAL) and CVE-2026-5598 (HIGH), both fixed in 1.84 nothing yet

So the dependency is: #1702 first (it clears webapp and agent-pi outright), and then two
things no issue covers today:

  1. application-server: bump Bouncy Castle 1.82 → 1.84. This is an ordinary dependency
    bump in the server's tree, not an image concern.
  2. postgres: gosu is a Go binary baked into the upstream postgres:*-bookworm image and
    is not reachable by apt-get upgrade; the fix has to come from a docker-library rebuild, a
    base-image bump that carries one, or dropping/replacing gosu. If none of those lands in
    time, this is exactly the case the exception mechanism exists for — and because of this
    PR's match-key fix, such an exception can now be authored before a release instead of only
    after one has already failed.

I have deliberately not weakened the gate, added --ignore-unfixed, or written exceptions
on someone else's behalf to get this green.

Worth noting what this measurement already bought: application-server and postgres were
both going to block the very next release attempt, behind webapp in the manifest order, with
no warning anywhere. That is the invisibility this PR removes.

How to test

CI covers this once the blockers above clear. Locally:

  • pnpm run format && pnpm run check — both pass.
  • Tooling tests 270 → 274. scripts/check-release-vulnerabilities.test.ts goes 4 → 7 and covers
    the fixable/unfixable split (including a HIGH with no FixedVersion that appears in
    highCritical but not in rejected, a whitespace-only FixedVersion, an absent field, and a
    mixed report), the exception match key with and without digest (every other field still
    binds, and two exceptions differing only by digest are now a duplicate), and the run summary.
  • scripts/ci-contract.test.ts gains an assertion that the scan job is blocking, amd64-only,
    scans the run tag, uploads its result, and that every workflow call site evaluates
    security/vulnerability-policy.json — there is no second copy.
  • scripts/verify-release-evidence.ts still consumes evaluate() unchanged, and its own tests
    pass. The result keeps its exact shape, which matters: the weekly rescan re-derives a
    published release's .policy.json and fails on any difference. rejected can only shrink and
    a published release had rejected: [], so older evidence still re-derives byte for byte.

Checklist

  • No changeset. verify-changesets.yml scopes SHIPPED_PATHS to server, webapp and
    docker; this PR touches .github/, scripts/ and docs/ only, so shipped_changed is
    empty and the release-note check does not fire. Confirmed by reading the workflow, and by
    the Verify changesets job passing on this PR.
  • If operators must act, the changeset and migration entry give actionable upgrade
    instructions — n/a, no operator-visible change.
  • I did not commit generated-artifact changes that this PR did not cause.

Deliberately not changed

release.yml and rescan-release-images.yml did not get the ECR fallback. In both, the
Trivy database download shares one run: block — and therefore one env: — with the image
scan, which sets TRIVY_USERNAME/TRIVY_PASSWORD for GHCR. Trivy applies those credentials to
every registry, so a fallback to public.ecr.aws would present a GHCR token and fail auth
instead of pulling anonymously. Giving those two workflows a real fallback means splitting the
database download into its own credential-free step, a separate change to a release-critical
path. The new job here is already structured that way.

🤖 Generated with Claude Code

…built

The v0.75.0 release failed at `tag-images` on four HIGH CVEs in the pinned
nginx base. Main was green because no container image is scanned anywhere
before release: `ci-security-scan.yml` runs Trivy with `scan-type: "fs"` and
`continue-on-error: true`, so it never sees an image. A gate whose first
execution is at release is a tripwire, not a safety net.

Two defects, fixed together because both rewrite `evaluate()`.

Unfixable findings no longer block (#1704). `evaluate()` rejected any HIGH or
CRITICAL regardless of whether upstream had published a fix, and
`security/release-images.json` runs four upstream images through the same
gate — alpine, nats, nginx and traefik cannot be patched at all. `Finding`
now carries `FixedVersion`, and a finding needs a non-empty one to reach
`rejected`. The filter is in the evaluator rather than on Trivy's command
line, so unfixable findings stay counted in `highCritical` and visible in the
signed evidence bundle.

The gate now runs where images are built (#1703). That needed the exception
schema fixed first: exceptions matched on the release image digest, which
does not exist until `tag-images` runs, so a pull-request-time exception could
never match a release-time digest and an exception could only be authored
after the gate had already failed. `digest` is dropped from the match key and
kept in the recorded exception for auditability; `installedVersion` already
gives the "dies when the package moves" property. A new `scan` job in
`reusable-docker-build.yml` then blocks on the `linux/amd64` image behind the
run tag, calling the same script and the same
`security/vulnerability-policy.json` the release and the weekly rescan call.
Both architectures are still scanned at release.

A failing gate now names the rejected CVEs in `$GITHUB_STEP_SUMMARY` and
uploads its `.policy.json`; diagnosing v0.75.0 required rebuilding and
rescanning the base image by hand. `public.ecr.aws/aquasecurity/trivy-db` is
a `TRIVY_DB_REPOSITORY` fallback, because a blocking gate inherits Trivy's
database availability as a hard dependency and GHCR answers TOOMANYREQUESTS.

The evaluation result keeps its shape, so a published release's `.policy.json`
still re-derives byte for byte in the weekly rescan.

Closes #1703
Closes #1704

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 55 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: c760db02-e4cf-426b-a25a-6c240f1e9646

📥 Commits

Reviewing files that changed from the base of the PR and between 33f30c2 and 236bd89.

📒 Files selected for processing (6)
  • .github/workflows/reusable-docker-build.yml
  • docs/contributor/ci-cd.mdx
  • docs/contributor/release-management.mdx
  • scripts/check-release-vulnerabilities.test.ts
  • scripts/check-release-vulnerabilities.ts
  • scripts/ci-contract.test.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added documentation Improvements or additions to documentation maintenance Chores, cleanup, non-functional improvements ci GitHub Actions, workflows, build pipeline changes labels Sep 1, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved automatically: @FelixTJDietrich is listed in the REVIEW_POLICY_MAINTAINERS repository variable, which the repository treats as satisfying the review requirement. See the review policy in docs/contributor/ci-cd.mdx.

@github-actions github-actions Bot added size:L This PR changes 100-499 lines, ignoring generated files. bug Something isn't working labels Sep 1, 2026
@github-project-automation github-project-automation Bot moved this from Backlog to In Review in Hephaestus Sep 1, 2026
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📚 Documentation Preview

Preview has been removed (PR closed)

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

🧩 Storybook Preview

Preview has been removed (PR closed)

@FelixTJDietrich
FelixTJDietrich added this pull request to the merge queue Sep 1, 2026
Merged via the queue into main with commit 795f71b Sep 1, 2026
43 checks passed
@FelixTJDietrich
FelixTJDietrich deleted the ci/scan-images-at-build-time branch September 1, 2026 15:42
@github-project-automation github-project-automation Bot moved this from In Review to Done in Hephaestus Sep 1, 2026
FelixTJDietrich added a commit that referenced this pull request Sep 1, 2026
The policy existed only as `evaluate()` in
`scripts/check-release-vulnerabilities.ts`. NIST SSDF RV.1.3 asks for a
documented remediation policy and RV.2.1 for analysis leading to
"remediation or other risk response"; neither prescribes thresholds, only
that ours are written down. The practical payoff is that whoever writes the
next exception knows what a justification has to contain.

`docs/contributor/vulnerability-remediation.mdx` states the severity floor
and the fixable/unfixable split with its reasoning, where the gate runs, the
90-day exception cap and its required evidence, and why VEX is not adopted.
It sits under Operations beside the two pages #1710 already amended, and
does not restate them.

`Exception` gains an optional `justificationCategory` carrying CISA's five
`not_affected` justifications, required for `not_affected` and forbidden for
`affected`. That vocabulary is the one genuinely valuable part of VEX and it
stops "not reachable" standing in for an analysis nobody did.

Closes #1707

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working ci GitHub Actions, workflows, build pipeline changes documentation Improvements or additions to documentation maintenance Chores, cleanup, non-functional improvements size:L This PR changes 100-499 lines, ignoring generated files.

Projects

Status: Done

1 participant