Skip to main content

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 .trivyignore or equivalent for documented exceptions.
bash
# 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.0

Trivy is opinionated and fast. Built-in CI flags. Default severity is everything; production CI usually filters to HIGH+.

Grype

bash
grype myorg/api:1.0 grype --fail-on high myorg/api:1.0

From Anchore. Slightly different DB; sometimes catches what Trivy misses (and vice versa). Good for second-opinion scans.

Docker Scout

bash
docker scout cves myorg/api:1.0 docker scout recommendations myorg/api:1.0 # suggests base-image upgrades to fix CVEs

Docker 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

yaml
# 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

dockerfile
# syntax=docker/dockerfile:1.7 FROM golang:1.23-alpine AS build # ... build app ... FROM aquasec/trivy:0.59.0 AS scan COPY --from=build / /scan-target RUN trivy filesystem --severity HIGH,CRITICAL --exit-code 1 /scan-target FROM alpine:3.21 COPY --from=build /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.

bash
# Cron-driven re-scan in production 0 6 * * * trivy image --severity HIGH,CRITICAL myorg/api:current 2>&1 | mail security@example.com

Or 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

yaml
- run: trivy image myorg/api:${{ github.sha }} || true # ← always passes

Results 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.
  • .trivyignore for 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

yaml
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

dockerfile
# 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 --from=build /out /scan RUN trivy filesystem --severity HIGH,CRITICAL --exit-code 1 --ignore-unfixed /scan FROM scratch COPY --from=build /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

bash
#!/bin/bash IMG=$1 echo "=== Trivy ===" trivy image --severity HIGH,CRITICAL $IMG --no-progress --quiet echo "=== Grype ===" grype $IMG --output table --quiet

Different scanners flag different things. For high-stakes images, the union is your true risk picture.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Comments

No comments yet