| id | vulnerability-remediation |
|---|---|
| sidebar_position | 5 |
| title | Vulnerability remediation |
| description | What blocks a build or a release, why unfixable findings do not, and how to write an exception. |
The policy is executable: scripts/check-release-vulnerabilities.ts evaluates a Trivy report against
security/vulnerability-policy.json. This page states it in prose, which is what
NIST SSDF RV.1.3 asks for. RV.2.1 accepts "remediation or
other risk response" and prescribes no thresholds — only that ours are written down.
A finding blocks when it is HIGH or CRITICAL and upstream has published a fix — Trivy reports a
non-empty FixedVersion. MEDIUM and below never block, at any scan.
The fixable/unfixable split is the part most easily read wrong, so plainly: an unfixable HIGH is not
an exception and does not need one. Four of the images in security/release-images.json are upstream
— alpine, nats, nginx, and traefik — and cannot be patched here at all, so failing on a finding that
has no fix removes no risk and leaves nobody an action. The share is not marginal: when the gate was
first measured against real images, agent-pi reported 59 HIGH/CRITICAL findings of which 1 was
fixable, and postgres 99 of which 23 were.
Gating on fix availability is the ordinary shape of this control. Google Binary Authorization exposes
maximumFixableSeverity and maximumUnfixableSeverity as separate settings; Docker Scout's default
policy is "no fixable critical or high vulnerabilities"; Snyk separates --fail-on=upgradable from
--severity-threshold; Chainguard's remediation SLA starts when a patch exists rather than when the
CVE is published.
Unfixable findings are still counted. The filter lives in the evaluator, not in a --ignore-unfixed
flag on Trivy, so every HIGH and CRITICAL stays in the scan report and in the highCritical list of
the .policy.json attached to the release. Suppressing the failure is the intent; suppressing the
record is not, and the evidence bundle is signed on the assumption that it is complete.
| When | Subject | Effect |
|---|---|---|
Every image build (reusable-docker-build.yml) |
the linux/amd64 image the run just pushed |
fails the pull request |
Release evidence (release.yml) |
both platforms of every first-party and upstream image | no release is created |
Weekly (rescan-main-images.yml) |
the linux/amd64 image behind each :main tag |
opens or updates a tracking issue |
Weekly (rescan-release-images.yml) |
every subject in the latest published release | reports on the vulnerability response issue |
All four call the same script with the same policy file; scripts/ci-contract.test.ts asserts there is
no second copy of either. The two scheduled rescans deliberately do not block: a finding published
after a clean merge is nobody's commit and no revert removes it, so a red status would name no
culprit and demand no action — Release management
has how the drift is routed instead. A scanner failure is never treated as no findings.
An exception lets one fixable finding through for a bounded time. It is an entry in
security/vulnerability-policy.json, and security/ has no CODEOWNERS rule of its own, so the default
one applies: a member of the @ls1intum/hephaestus-maintainers team has to approve it.
image,platform,vulnerability,package,installedVersion— the match key. All five bind exactly, andinstalledVersionis what makes an exception die when the package moves.digest— recorded so the exception names the artefact it was reviewed against, but deliberately not matched on. A release digest does not exist until the images are tagged, so a digest-keyed exception could only ever be written after the gate had already failed a release.owner— who is accountable for retiring it.status—affectedornot_affected, plus ajustificationin prose.evidence— an HTTPS URL: the upstream advisory, the tracking issue, or the analysis the claim rests on. A link a reviewer can follow, not a sentence.expires— RFC 3339 UTC, in the future, and at most 90 days out. The cap is not a renewal-free window; at 90 days the gate fails again and the finding is argued again with whatever upstream has published since.
A not_affected exception also carries a justificationCategory, one of the five in CISA's Minimum
Requirements for Vulnerability Exploitability eXchange (VEX). Naming one forces the claim to say which
way the finding does not apply, so prose like "not reachable" cannot stand in for an analysis nobody
did.
| Value | Means |
|---|---|
component_not_present |
the vulnerable component is not in the image |
vulnerable_code_not_present |
the component ships, the affected code does not |
vulnerable_code_not_in_execute_path |
the affected code ships but is never called |
vulnerable_code_cannot_be_controlled_by_adversary |
it is called, but never with attacker-controlled input |
inline_mitigations_already_exist |
it is reachable and controllable, but a compensating control blocks exploitation |
A value outside those five is a malformed policy and throws. An affected exception carries no
category — it defers a risk we concede applies, and claiming non-exploitability at the same time is a
contradiction.
The vocabulary above is all we take. OpenVEX has been frozen at
v0.2.0 since 2023-08-22, and Trivy's --vex still carries an EXPERIMENTAL banner and matches on
digest-based PURLs that a locally built image does not have. aquasecurity/vexhub, Trivy's default VEX
repository, stopped updating on 2026-03-23 and has no OS-distro coverage at all — no Alpine, no Debian,
no nginx — and Alpine publishes no VEX itself, which is where most findings here originate. Adopting it
would mean hand-authoring documents that duplicate security/vulnerability-policy.json, minus the
expiry field OpenVEX does not define. Revisit if the distros start publishing.