Vulnerability remediation
scripts/check-release-vulnerabilities.ts evaluates Trivy image reports against
security/vulnerability-policy.json. For private disclosure, see SECURITY.md.
Supported release rescans
A supported release rescan authenticates the signed release lock before it reads any registry image. The complete manifest must agree with that lock and the archived image inventory. An authentication failure starts no scans.
The archived reader verifies the Cosign signature on SHA256SUMS after subject authentication.
It requires checksum coverage of every consumed file and checks the bytes before using archived evidence.
For releases before v0.86.2, a missing signature is recorded as unauthenticated archive assurance. Its policy, native identity and advisory bytes are not evaluated.
Fresh security results can pass independently. A present invalid signature or a missing signature from v0.86.2 onward still fails the overall check.
The rescan records archive assurance separately from current security results. It checks every subject even if archived validation or an earlier scan fails. Diagnostics distinguish a completed policy refusal from an unavailable scan. A current policy refusal, unavailable scan or archive validation failure still fails the overall check.
What blocks
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.
An unfixable HIGH or CRITICAL remains recorded and needs no exception.
Unfixable findings stay in an image scan report and in the highCritical list of the .policy.json
attached to the release. That filter is in the evaluator, not a --ignore-unfixed flag. The
dependency gate reaches the same verdict through Trivy's own --ignore-unfixed, and the SARIF pass
beside it still records what the gate filtered out.
Where the image gate runs
| When | Subject | Effect |
|---|---|---|
Every image build (reusable-docker-build.yml) | every platform the run just pushed | fails the run |
A pull request touching security/release-images.json or the scan's inputs, and every run that is not a pull request (Pinned upstream images) | both platforms of each pinned upstream digest | fails the run |
The Version PR, and any CI/CD dispatch with release-preflight (cicd.yml) | both platforms of every first-party and upstream image, over the images that run built | fails the run |
Release evidence (release.yml) | both platforms of every first-party and upstream image | no release is created |
Weekly (rescan-images.yml, main job) | both platforms of the image behind each :main tag and of each pinned upstream digest | opens or updates a tracking issue |
Weekly (rescan-images.yml, release job) | every subject in the latest published release | fails and opens or updates the dedicated Supported release image rescan requires vulnerability response tracking issue. Maintainers close it after triage and a successful rescan |
All six evaluate security/vulnerability-policy.json through scripts/check-release-vulnerabilities.ts.
The release, preflight and rescans use the same evaluator. The CI contract checks that scans before
release cover the subject set derived from security/release-images.json.
Advisory database identity
CI/CD image-policy jobs share one advisory database artifact produced before they scan. The producer uses the existing bounded download retries and registry fallback. Consumers restore its immutable artifact ID instead of fetching the moving database tag again. This includes first-party image builds, pinned upstream image scans and release preflight.
A new advisory published during the run cannot change a later image job's advisory set. A later run can still block on that advisory. No severity, fixability or exception policy changes.
A failed-job rerun retains the successful producer's artifact ID. Rerunning all jobs creates a new snapshot named for that run attempt. Snapshots are retained for one day. A missing, expired or incomplete artifact fails rather than silently fetching another database. Rerun all jobs to obtain a new snapshot. Evidence consumers still enforce the 48-hour freshness bound after restoration, so retaining an artifact does not extend the lifetime of acceptable release evidence.
This snapshot contains the advisory database, not the separate Java index that identifies JAR
coordinates. Java preparation and scanning remain enabled where required. Filesystem dependency
scans have their own dependency policy and are outside this image-policy snapshot. The Security
job downloads its own database through the same action, once for both of its scans. Release and
scheduled-rescan workflows already prepare their advisory database once in a single job. They
continue downloading a fresh database through the same action.
The Version PR preflight verifies the complete evidence bundle, not just vulnerability policy — Release management. Scheduled rescans report newly disclosed vulnerabilities without changing a previously passed commit status.
A scanner failure or malformed report never means no vulnerabilities. The evaluator accepts only
Trivy's severity vocabulary,
including UNKNOWN. An unrecognized severity fails validation rather than silently passing the
HIGH/CRITICAL filter.
Pinned upstream images are not built here. Update their digests in security/release-images.json
when a fixed upstream image is available. Renovate proposes these updates.
OS package updates in first-party images
Dockerfile builds bypass the registry cache for the os-packages and exported runtime stages with
the no-cache-filter of every target in docker/docker-bake.hcl. This carries current OS packages
into the shipped image even on a rebuild of the same commit. Independent dependency and application
build stages retain their cache. BuildKit can reuse cached descendants of a refreshed package stage,
so refreshing only the package stage does not establish the final image's package versions.
Debian index updates use APT::Update::Error-Mode=any to fail on an unavailable source rather than
use incomplete indexes. Local builds need the same filter:
docker buildx build --no-cache-filter os-packages,runtime --load -t hephaestus-postgres:test docker/postgres
docker buildx build --no-cache-filter os-packages,runtime --load -t hephaestus-agent-pi:test \
-f docker/agents/pi/Dockerfile docker/agents
Base digests and application lockfiles remain pinned. OS repositories remain live, as before. rebuilding a commit does not promise byte-identical images. A dated package snapshot would need a pin update before a new fix could reach the build. A base-digest refresh would wait for upstream to rebuild.
The tested and shipped artifact is still the same immutable image digest, with its SBOM, scan evidence, signature and provenance. Release management owns that contract.
A fix must be available from the distribution's mirrors to reach a build. The vulnerability gate remains required, and a stale fixed package is not a reason for an exception.
Image exceptions
An exception lets one fixable finding through for at most 90 days. It is an entry in
security/vulnerability-policy.json. Review ownership follows .github/CODEOWNERS. Approval
requirements are defined in Merge policy.
image,platform,vulnerability,package,installedVersion— the match key. All five bind exactly, andinstalledVersionis what makes an exception die when the package moves.digest— the platform manifest digest that was reviewed. It is part of the match key. A rebuilt image is a new manifest. The exception no longer matches it, even when package versions are equal.componentInventory— permits byte-identical component reuse, and only fornot_affectedwithvulnerable_code_not_present. See Component inventories.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.expires—YYYY-MM-DDTHH:MM:SSZ, in the future, at most 90 days out. When it lapses the gate fails again.
not_affected justifications
A not_affected exception requires a justificationCategory from CISA's Minimum Requirements for
Vulnerability Exploitability eXchange (VEX). The policy uses this vocabulary, not the VEX document
format. evidence and justification must support the selected category.
| 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 |
Other values fail validation. An affected exception accepts a risk and must not carry a
justificationCategory.
A deployment-path assessment applies to the shipped Hephaestus launch described in its justification. It does not certify every use of the same vendor image. Reassess it before changing the command, configuration or enabled features on which the claim depends.
Component inventories
The policy is schema version 3. A code-absence claim about Go code can be reused in a rebuilt image
only when that image contains the same Go binaries, byte for byte. Each image and platform has at
most one entry in componentInventories.
The entry has an id, the image, the platform, a captureDigest, and files.
files lists every Go executable and every Trivy Go evidence target, including embedded SBOMs, by absolute image path and SHA-256.
An exception names the entry in componentInventory.
Its required digest must equal the inventory's captureDigest.
The gate reads the subject's Syft scan, bound to the exact repository, platform manifest digest and platform. The exception matches only when all of these conditions are true:
- The finding is reported in a Trivy
gobinarytarget that the inventory lists. - The Go binaries are exactly the inventory. This set is every Go binary that Syft catalogued, plus
every
gobinarytarget that Trivy reported, with or without findings. - Syft recorded exactly one SHA-256 for each binary, and each SHA-256 equals the reviewed value.
An added, removed or changed binary stops the match. So do a missing or ambiguous digest, or a
target path that is empty, relative with . or .., or contains a backslash. The same compiler
version is not enough. The match permits reuse of a reviewed claim. It does not prove that the code
is absent. Every other exception, OS packages included, matches only its exact digest.
Each component-bound exception also names a goAdvisory.
The policy's goAdvisories records each official Go advisory ID and the SHA-256 of its reviewed JSON bytes.
The current bytes must match that hash, identify the requested advisory, alias the vulnerability, and name the affected Go package.
Changed, missing, or malformed bytes stop reuse and require reassessment.
The binding preserves a reviewed absence claim.
It does not prove that claim or cover changes in another advisory source.
For stripped Go binaries, a govulncheck symbol finding can be derived from the advisory rather
than from extracted code. Version 1.9.0 falls back to module-level precision
when symbol extraction fails. Such a report proves neither code presence nor code absence. An absence assessment must
inspect complete retained function names, including inlined callees, and validate the analysis with
positive controls for each platform.
Expiry still requires reassessment within 90 days.
Current scans fetch each required advisory once from https://vuln.go.dev/ID/ and retain the raw bytes beside the reports.
Acquisition has a ten-second timeout and a one-MiB limit, with no redirect or retry.
A failed fetch fails the gate.
Release capture archives one snapshot per advisory for the whole bundle, and bundle verification reads only those archived bytes.
The shared Trivy database snapshot binds Trivy's advisory set within an attempt.
Official Go snapshots are acquired separately.
Parallel scan jobs can observe different revisions. Each revision must match the reviewed hash.
The evaluator runs Syft itself only when an exception for the subject names a component inventory or a jar inventory.
It reads the platform manifest from its registry, with --platform and --scope squashed, and with
the configuration in security/syft.yaml. It runs only the Go binary cataloguer and the file
metadata and digest cataloguers, and keeps Syft's document beside the Trivy report. If Syft fails,
the gate fails. The release evidence uses the same configuration with every default package
cataloguer, and release verification reads the component identity from that SBOM.
Jar inventories
A library inside a JVM archive can ship a vulnerable copy of another library, for example a shaded one. If no fixed release of the archive exists, a reachability claim about it can be reused in a rebuilt image. The claim is reused only when the image holds the same archive, byte for byte, and a test enforces it.
An entry in jarInventories has an id, the image, the platform, a captureDigest, files and enforcedBy.
files lists each archive by absolute image path and SHA-256.
enforcedBy names a server test as <path of the test class>#<test method>.
That test must keep the vulnerable code out of the execute path.
For example, JavaDeserializationArchTest#noJavaDeserialization fails the build on any Java deserialization in server code.
An exception names the entry in jarInventory.
Its digest must equal the inventory's captureDigest.
Its status must be not_affected and its category must be vulnerable_code_not_in_execute_path.
It names neither a component inventory nor a Go advisory.
The exception matches only when all of these conditions are true:
- Trivy reports the finding in a
jarresult. - The finding comes from an inventoried archive, by its file name. This includes a copy that a Spring Boot jar nests. A finding without a path is a record of an SBOM inside the image. It is covered only when every finding of the same package and version that has a path is covered.
- Syft recorded, for each inventoried path, the reviewed SHA-256.
The test is the proof, not the prose of the justification. If the test changes so that it no longer enforces the claim, retire the exception. Retire it when the archive's maintainers ship a fixed copy, and name that condition in the justification.
Stored release evidence
Current gates accept only schema version 3. Verification of an existing signed release bundle can read its stored schema version 2 policy. Each stored exception then matches only the manifest digest it names. A stored pass that relied on an exception for another digest no longer verifies. Writing a new validation always requires schema version 3.
Dependency exceptions
The dependency gate in ci-security-scan.yml reads security/trivy-dependency-ignore.yaml, Trivy's
YAML ignore format. Dependency exceptions belong here, not in the informational dependency-review
check. Each entry requires:
id— the vulnerability the exception is for.purls— the package URLs it applies to, so it dies when the dependency moves.expired_at—YYYY-MM-DD, in the future, at most 90 days out.scripts/ci-contract.test.tsfails the pull request that writes a longer one, and Trivy stops honouring the entry once it lapses.statement— why the risk is accepted, in prose a reviewer can weigh.
Changes to this file select the security gate through .github/workflows/cicd.yml.
To reproduce a finding, run the gate over a clean checkout:
trivy fs --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 \
--ignorefile security/trivy-dependency-ignore.yaml .
The job installs nothing before it scans, so a working tree carrying node_modules/ or build/
reports lockfiles vendored inside dependencies that the gate itself never sees.
Review inactive dependencies
Renovate's abandonment threshold measures release cadence. It does not establish deprecation or a vulnerability. Check direct use, upstream maintenance, security exposure, and maintained replacements before removing a dependency.
Keep declared framework peers explicit. Let transitive dependencies belong to their consumers. Do not replace them with global aliases merely to remove a package name from a bundle. Verify the consumer's import and configuration contract before a replacement.