How to scan Docker images for vulnerabilities?
Vulnerability scanning answers "do my images contain known security holes?" Modern tooling makes this a near-zero-effort step in CI. Skipping it means shipping known CVEs to production.
Theory
TL;DR
- A scanner inspects an image's installed packages (OS + language) and matches them against CVE databases (NVD, vendor advisories, GitHub Security Advisories).
- Three popular tools: Trivy (Aqua), Grype (Anchore), Docker Scout (Docker Inc).
- Severities: LOW, MEDIUM, HIGH, CRITICAL. Most teams gate on HIGH+ or CRITICAL.
- Three scanning moments: build-time (CI gate), admission-time (cluster gate), continuously (catch new CVEs in already-running images).
- Two CVE categories: OS-level (fix by base-image update) and app-level (fix by package update).
- Always pair with a
.trivyignoreor equivalent for documented exceptions.
Trivy (most popular)
# Scan a local image
trivy image myorg/api:1.0
# Fail on HIGH or CRITICAL
trivy image --severity HIGH,CRITICAL --exit-code 1 myorg/api:1.0
# Ignore unfixed CVEs (no patch available — wait for upstream)
trivy image --ignore-unfixed myorg/api:1.0
# Output formats
trivy image --format json myorg/api:1.0
trivy image --format sarif --output report.sarif myorg/api:1.0Trivy is opinionated and fast. Built-in CI flags. Default severity is everything; production CI usually filters to HIGH+.
Grype
grype myorg/api:1.0
grype --fail-on high myorg/api:1.0From Anchore. Slightly different DB; sometimes catches what Trivy misses (and vice versa). Good for second-opinion scans.
Docker Scout
docker scout cves myorg/api:1.0
docker scout recommendations myorg/api:1.0 # suggests base-image upgrades to fix CVEsDocker Inc's scanner. Tightly integrated with Docker CLI and Docker Hub. The recommendations command is unique and useful: it tells you exactly which base-image bump fixes which CVEs.
CI integration
# GitHub Actions with Trivy
- name: Build image
run: docker build -t myorg/api:${{ github.sha }} .
- name: Scan with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: myorg/api:${{ github.sha }}
severity: 'HIGH,CRITICAL'
exit-code: '1'
ignore-unfixed: true
- name: Upload SARIF to GitHub
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-results.sarif'Uploads to GitHub's Code Scanning tab — vulnerabilities show up alongside the rest of your code-quality signals.
Multi-stage scan during build
# syntax=docker/dockerfile:1.7
FROM golang:1.23-alpine AS build
# ... build app ...
FROM aquasec/trivy:0.59.0 AS scan
COPY / /scan-target
RUN trivy filesystem --severity HIGH,CRITICAL --exit-code 1 /scan-target
FROM alpine:3.21
COPY /out/server /server
ENTRYPOINT ["/server"]The scan stage runs as part of the build. Build fails if HIGH/CRITICAL CVEs are found. Cannot be skipped by careless CI configs.
Admission-time scanning
Kubernetes admission controllers can refuse to deploy unscanned or vulnerable images:
- OPA Gatekeeper / Kyverno with policies that check Cosign attestations of scan results.
- Trivy Operator for K8s — continuously scans running images.
- Cosign + sigstore-policy-controller — verify the image was signed and attested as scanned.
The pipeline: CI signs an attestation ("this image was scanned at time X, found N HIGH CVEs"); admission verifies the attestation and checks the policy.
Continuous re-scanning
A CVE disclosed today affects the image you built last week. The bytes are the same; the threat landscape has changed.
# Cron-driven re-scan in production
0 6 * * * trivy image --severity HIGH,CRITICAL myorg/api:current 2>&1 | mail security@example.comOr use a continuous scanner (Trivy Operator, Snyk Container, Sysdig Secure) that watches running images and re-scans periodically.
OS vs application CVEs
Results usually fall into two buckets:
OS-level (alpine:3.20 → CVE in libssl):
- Fix: bump base image (
alpine:3.21), rebuild. - Or wait for the OS vendor to publish a patched version.
App-level (requests==2.30.0 → CVE in requests):
- Fix: update the dependency in your
requirements.txt/package.json/go.mod. - Often takes longer if the new version has breaking changes.
Scanners detect both. The remediation path is different.
Common mistakes
Scanning but not gating
- run: trivy image myorg/api:${{ github.sha }} || true # ← always passesResults shown but do not fail the build. Devs ignore. Within months you have hundreds of HIGH CVEs in production. Always set --exit-code 1 on a real severity threshold.
Scanning only at build time
A HIGH CVE disclosed last night is sitting in your prod image right now. Your CI passed yesterday; reality changed overnight. Add nightly re-scans of running images.
Not maintaining .trivyignore
Real-world CVEs sometimes have no patch ("won't fix"). You either tolerate them or replace the package. Document the decision in .trivyignore:
# .trivyignore
CVE-2023-12345 # No patch available, mitigated by network policy. Re-evaluate quarterly.Trusting one scanner
Different scanners have different DBs. A CVE in your image database might be missing from one scanner. For high-stakes work, run two scanners and union the results.
Ignoring the SBOM
A scanner result without an SBOM is just "these CVEs exist". With an SBOM (Software Bill of Materials), you have a precise list of what is in the image, queryable later: "which images contain log4j?". Generate SBOM during build (docker buildx build --sbom=true), store with the image.
Real-world setup
Minimal
- Trivy in CI, gate on HIGH/CRITICAL.
.trivyignorefor documented exceptions.- Done.
Mature
- Trivy in CI + scan stage in Dockerfile.
- SBOM generation + storage.
- Cosign attestation of scan results.
- Admission controller verifies attestation.
- Nightly re-scan of running images.
- Trivy Operator on K8s.
- Findings → Slack channel + ticket creation.
Follow-up questions
Q: What is the difference between a CVE and a vulnerability?
A: A CVE (Common Vulnerabilities and Exposures) is a public ID for a known security issue. A vulnerability is the underlying bug. Most modern scanners report by CVE ID, with severity scored via CVSS.
Q: Can scanners find zero-days?
A: No — they only know about publicly disclosed vulnerabilities. Zero-days, by definition, are not yet known. Scanning is one layer; complement with runtime monitoring, sandbox testing, and code review.
Q: What is an SBOM and why generate one?
A: Software Bill of Materials — a list of every package and version in your image. SPDX or CycloneDX format. With an SBOM, you can later answer "which of my images contain $vulnerable_package" without re-pulling and re-scanning.
Q: How do I handle CVEs with no fix available?
A: Document in .trivyignore with rationale and re-evaluation date. Mitigate via runtime controls (network policy, capability drops). Re-check quarterly.
Q: (Senior) How would you build a scanning pipeline that prevents regressions?
A: Three gates: (1) CI build fails on new HIGH/CRITICAL — even fixing-an-unrelated-bug PR cannot merge if it introduces a new CVE. (2) Admission control rejects images without a fresh signed scan attestation. (3) Production runtime scanner alerts on new CVEs against running images and triggers a remediation workflow (auto-PR to bump the base image, ticket to dev team). The result: any CVE that reaches prod was either ignored deliberately (in .trivyignore with sign-off) or disclosed after deployment with a tracked response window.
Examples
Trivy in GitHub Actions
name: ci
on: [push]
jobs:
build-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t myorg/api:${{ github.sha }} .
- name: Trivy scan (gate)
uses: aquasecurity/trivy-action@master
with:
image-ref: myorg/api:${{ github.sha }}
severity: 'HIGH,CRITICAL'
ignore-unfixed: true
exit-code: '1'
- name: Trivy scan (full report, never fails)
if: always()
uses: aquasecurity/trivy-action@master
with:
image-ref: myorg/api:${{ github.sha }}
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'LOW,MEDIUM,HIGH,CRITICAL'
- name: Upload SARIF
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-results.sarif'Two passes: gating on HIGH+, full report uploaded for visibility.
Multi-stage with embedded scan
# syntax=docker/dockerfile:1.7
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server
FROM aquasec/trivy:0.59.0 AS scan
COPY /out /scan
RUN trivy filesystem --severity HIGH,CRITICAL --exit-code 1 --ignore-unfixed /scan
FROM scratch
COPY /out/server /server
USER 65532:65532
ENTRYPOINT ["/server"]docker build fails if HIGH/CRITICAL CVEs found. Can be bypassed with --target for the runtime stage if necessary, but the default build path enforces the gate.
Compare two scanners
#!/bin/bash
IMG=$1
echo "=== Trivy ==="
trivy image --severity HIGH,CRITICAL $IMG --no-progress --quiet
echo "=== Grype ==="
grype $IMG --output table --quietDifferent scanners flag different things. For high-stakes images, the union is your true risk picture.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.
Comments
No comments yet