Image scanning is the most common piece of supply-chain security that teams half-implement and then declare "done." They run Trivy in CI, pipe the output to a dashboard, and ignore the resulting firehose. Six months later the backlog is 40,000 CVEs, nobody trusts the numbers, and the next compliance audit finds an "acceptable CVE drift" email chain that reviewers will not sign off on.
This post is the scanning pipeline I deploy when the goal is actionable signal, not box-checking. It assumes you already have CI and a registry.
What Are You Trying to Find?
You are trying to find CVEs that affect code paths your image actually executes, running with enough privilege or exposure that exploitation is credible, that have fixes available or mitigations your team can deploy today. Every other CVE is context for the ones that matter. A scanner that outputs 4,000 CVEs and does not help you rank them is a CI log generator, not a security tool.
Which Scanner Should You Use?
Run two. Trivy and Grype disagree often enough that using both and taking the union catches issues each misses individually. They have different vulnerability database ingestion pipelines — Trivy heavily leans on OSV and GitHub Security Advisories while Grype uses the Anchore feed plus OSV and NVD. For base OS packages, dnf/apt metadata matters more than the scanner choice; for language ecosystems, differences are bigger.
Step-by-Step Implementation
Step 1: Scan the Built Image, Not the Dockerfile
Scanners that operate on a Dockerfile or source tree give you a preview, not a truth. Only the assembled image reflects the full set of installed OS packages, copied language dependencies, and binaries. Run scans against the pushed digest, not against the local build cache.
Step 2: Pin the Scanner and Its Database
Never use :latest for a scanner in CI. Pin both the scanner binary and the vulnerability database version. Scanner DBs update every few hours; if your nightly scan pulls a different DB than your PR scan, you will see spurious regressions:
name: scan
on: [push, pull_request]
jobs:
trivy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Trivy
uses: aquasecurity/trivy-action@0.28.0
with:
image-ref: ghcr.io/acme/widget@${{ inputs.digest }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH,MEDIUM
ignore-unfixed: true
vuln-type: os,library
scanners: vuln,secret,misconfig
env:
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
TRIVY_JAVA_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-java-db:1
Setting ignore-unfixed: true focuses the output on CVEs with available patches. Do not do this blindly in regulated environments — some compliance regimes want every CVE reported regardless of fix status.
Step 3: Run Grype in Parallel and Merge Results
Grype consumes the Syft SBOM you already generated:
grype:
runs-on: ubuntu-latest
steps:
- uses: anchore/sbom-action@v0.20.0
with:
image: ghcr.io/acme/widget@${{ inputs.digest }}
output-file: sbom.cdx.json
format: cyclonedx-json
- uses: anchore/scan-action@v4
with:
sbom: sbom.cdx.json
output-format: sarif
fail-build: false
Upload both SARIF files to GitHub Advanced Security or your scanner aggregator. The union of findings is your raw list.
Step 4: Apply a VEX Layer
A Vulnerability Exploitability eXchange (VEX) document declares which CVEs apply to your product and which do not. Ship one alongside every release. OpenVEX is the format that has the most tooling in 2026:
{
"@context": "https://openvex.dev/ns/v0.2.0",
"author": "Acme Security <security@acme.com>",
"timestamp": "2026-03-14T08:30:00Z",
"statements": [
{
"vulnerability": {"name": "CVE-2024-45491"},
"products": [
{"@id": "pkg:oci/widget@sha256:abc123?repository_url=ghcr.io/acme"}
],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path",
"impact_statement": "libexpat is pulled in by build tooling only and is not present in the runtime image layer."
}
]
}
Feed this to your scanner with --vex vex.openvex.json. Trivy and Grype both support OpenVEX filtering natively now, and every not_affected statement removes a line from the firehose.
Step 5: Layer In Reachability Prioritization
A CVE in a library that your code imports but never calls has a dramatically lower real-world risk than one your main request path hits on every API call. Reachability analyzers walk the call graph from your entry points to the vulnerable function. Integrate a reachability-aware scanner — govulncheck for Go, opengrep for Python, or Safeguard.sh's 100-level depth engine — and treat reachable CVEs as P1, unreachable as backlog:
govulncheck -scan symbol -show verbose ./...
Step 6: Block Merges on Reachable Criticals
Set your CI to fail merges only on CVEs that are both critical severity and reachable from application code, with no VEX suppression. Everything else becomes a tracked issue, not a blocker. This is the single biggest change that moves scanning from "theatrical CI green light" to "engineers actually read the output."
Step 7: Scan at Runtime, Not Only in CI
Images shipped six months ago may now have CVEs that did not exist at build time. Run a registry-wide scan on a cadence — nightly for production-tagged images, weekly for everything else. Webhook scanners like Chainguard's image-scanning registry tooling or Trivy's operator for Kubernetes do this without polling.
# trivy-operator values.yaml
trivy:
dbRepository: ghcr.io/aquasecurity/trivy-db:2
dbRepositoryInsecure: false
targetNamespaces: ""
excludeNamespaces: "kube-system,trivy-system"
vulnerabilityScannerEnabled: true
scanJobTimeout: 15m
Step 8: Enforce Scan Results at Admission
Use a Kyverno or Gatekeeper policy to refuse pods whose associated VulnerabilityReport has unremediated critical reachable CVEs. Admission-time enforcement is what forces the conversation earlier in the release cycle rather than during customer incident response.
How Often Should You Rescan Production Images?
Rescan daily at minimum, and immediately on new CVE disclosures affecting popular base images. Many exploitable CVEs in images shipped to production come from CVEs disclosed after the build date. Integrating a CISA KEV feed into your scanning cadence — so that any KEV addition triggers an immediate rescan of every image carrying the affected component — closes the worst of this gap.
What Do You Do About False Positives?
Write VEX statements, do not suppress. A suppressed CVE in a scanner config is invisible to the next engineer; a VEX statement is published alongside the artifact, reviewable by customers, and updateable without redeploying. Every suppression request should become a VEX PR in the artifact repo.
How Do You Keep Base Images From Rotting?
Pin base images by digest, not tag, and run a scheduled job that bumps the digest on a weekly cadence with a reachability-gated test suite. This is the pipeline equivalent of patch Tuesday. If the rebuild breaks tests, you know immediately; if not, it ships quietly. The alternative — waiting for a CVE to force the rebuild — is how three-year-old Debian base images end up in production.
Can You Scan Distroless Images?
Yes, but with caveats. Distroless images lack a package manager database, which means OS-level scanners rely on filename heuristics or the embedded SPDX/CycloneDX labels Google ships. For reliable distroless scanning, generate the SBOM at build time from the base image source and concatenate it with your application-layer SBOM. Scanning the final distroless image alone misses components.
How Safeguard.sh Helps
Safeguard.sh turns scanner output into a prioritized queue instead of a dashboard full of noise. The reachability engine walks 100 levels deep across your call graph to determine which CVEs are actually triggerable in your image, cutting typical CVE backlogs by 70-90%. Griffin AI drafts VEX statements for unreachable findings and attaches them to your SBOMs automatically. The TPRM layer ingests your vendors' scans and compares them against independently-generated SBOMs, and our container self-healing runtime rebuilds, rescans, and redeploys affected images the moment a reachable CVE is disclosed — closing the gap between detection and remediation without operator involvement.