Skip to main content

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​

WhenSubjectEffect
Every image build (reusable-docker-build.yml)every platform the run just pushedfails 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 digestfails 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 builtfails the run
Release evidence (release.yml)both platforms of every first-party and upstream imageno release is created
Weekly (rescan-images.yml, main job)both platforms of the image behind each :main tag and of each pinned upstream digestopens or updates a tracking issue
Weekly (rescan-images.yml, release job)every subject in the latest published releasefails 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, and installedVersion is 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 for not_affected with vulnerable_code_not_present. See Component inventories.
  • owner — who is accountable for retiring it.
  • status — affected or not_affected, plus a justification in 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.

ValueMeans
component_not_presentthe vulnerable component is not in the image
vulnerable_code_not_presentthe component ships, the affected code does not
vulnerable_code_not_in_execute_paththe affected code ships but is never called
vulnerable_code_cannot_be_controlled_by_adversaryit is called, but never with attacker-controlled input
inline_mitigations_already_existit 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 gobinary target that the inventory lists.
  • The Go binaries are exactly the inventory. This set is every Go binary that Syft catalogued, plus every gobinary target 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 jar result.
  • 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.ts fails 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.