Sachin Chaurasiya

Security Tools Part 1 of 2 · Container Security

Container Image Scanning with Trivy

How Trivy finds OS and application vulnerabilities, secrets and misconfigurations in container images, how to make the results actionable, and how to wire it into CI without slowing builds down.

Author
Sachin Chaurasiya
Sachin Chaurasiya
Published
Reading time
9 min read
Difficulty
beginner

Reviewed Tested with Trivy 0.68.2, Docker 29, GitLab.com shared runners

On this page

Overview

Trivy is an open-source scanner from Aqua Security that inspects container images, filesystems, Git repositories, Kubernetes clusters and SBOMs. For images it detects:

  • OS package vulnerabilities (Alpine, Debian/Ubuntu, RHEL family, SUSE, Amazon Linux, Wolfi and others)
  • Application dependency vulnerabilities from lockfiles and package metadata (npm, pip, Go, Java, Ruby, Rust, .NET, PHP…)
  • Secrets such as API keys and private keys baked into layers
  • Misconfigurations in Dockerfiles, Kubernetes manifests, Terraform and Helm charts

It runs as a single binary, needs no server, and downloads a vulnerability database on first use. That combination makes it the easiest scanner to put into a pipeline, and also the easiest to configure badly, producing thousands of findings nobody reads. This article is about the configuration, not the install.

Architecture

Diagram · Trivy image scanning in a pipeline
Trivy image scanning in a pipelineA Dockerfile is built into an image. Trivy reads the image layers, the OS package database and language lockfiles, and matches them against a vulnerability database it downloads and caches. It also scans layers for secrets and the Dockerfile for misconfigurations. Findings are written as a report and optionally as an SBOM; a gate configured for fixable HIGH and CRITICAL findings decides whether the pipeline continues to push the image.buildscanexit 0matchexit 1Dockerfilebase + app layersBuilt imageOS pkgs · lockfiles1Trivyvuln · secret · iac2Push imageregistry6VulnerabilityDBcached · refreshed3Report + SBOMsarif · cyclonedx4Gate failsfixable HIGH/CRIT5

A Dockerfile is built into an image. Trivy reads the image layers, the OS package database and language lockfiles, and matches them against a vulnerability database it downloads and caches. It also scans layers for secrets and the Dockerfile for misconfigurations. Findings are written as a report and optionally as an SBOM; a gate configured for fixable HIGH and CRITICAL findings decides whether the pipeline continues to push the image.

  1. Built image. Trivy reads the image’s layers: the OS package database (dpkg, apk, rpm), language lockfiles and installed package metadata (METADATA for Python, package.json for Node, Go binaries’ embedded module lists), and the files themselves for the secret scanner.
  2. Trivy matches what it found against the vulnerability database and applies the severity and fix filters you give it. The --scanners flag chooses which of the three classes (vulnerabilities, secrets, misconfigurations) to run.
  3. Vulnerability DB. Downloaded from an OCI registry (mirror.gcr.io/aquasec/trivy-db:2, with ghcr.io/aquasecurity/trivy-db:2 as the fallback in 0.68) and cached. Its age is the first thing to check when a scan looks too good.
  4. Report. Table for humans, JSON or SARIF for machines. The report always contains everything the filters let through; the gate is a separate decision.
  5. SBOM. Optionally, the inventory Trivy built can be written out as CycloneDX or SPDX and scanned again later without the image.
  6. Gate fails. With --exit-code 1 and the severity/fix filters, Trivy exits non-zero and the pipeline stops before the push.
  7. Push image. Only an image that passed the gate reaches the registry.

Commands below were run with Trivy 0.68.2; outputs are real.

Prerequisites

  • Docker or another container runtime able to pull images
  • Trivy 0.6x (trivy --version)
  • Network access to mirror.gcr.io or ghcr.io for the vulnerability database (or a mirror)

Implementation

Install

# macOS
brew install trivy

# Debian/Ubuntu (official repository)
sudo apt-get install -y wget gnupg
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | sudo tee /usr/share/keyrings/trivy.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee /etc/apt/sources.list.d/trivy.list
sudo apt-get update && sudo apt-get install -y trivy

# Or run the container image without installing anything
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:latest image nginx:1.27

First scan

trivy image nginx:1.27
Scans OS packages and language dependencies. The first run downloads the vulnerability database (~50 MB).

The default table output is grouped by target (nginx:1.27 (debian 12.x) for OS packages, then one block per detected language ecosystem). Each row shows the library, the CVE, severity, installed version and — importantly — the fixed version, if one exists.

Make it actionable

Three flags turn a wall of findings into a gate people respect:

trivy image \
  --severity HIGH,CRITICAL \   # ignore LOW/MEDIUM in the gate (still report them elsewhere)
  --ignore-unfixed \           # only fail on vulnerabilities with an available fix
  --exit-code 1 \              # non-zero exit → pipeline fails
  registry.example.com/platform/app:1.4.2

--ignore-unfixed is the important one. A vulnerability without a fixed version cannot be resolved by rebuilding; failing the build on it only trains engineers to ignore the scanner. Track unfixed findings separately and revisit them when the distribution publishes a patch.

Ignore specific findings, with a reason and an expiry

Use .trivyignore.yaml committed next to the Dockerfile. The YAML form lets each entry carry a statement and an expiry date, after which Trivy reports the finding again:

vulnerabilities:
  - id: CVE-2025-47273
    statement: 'setuptools.PackageIndex is not imported by this service; tracked in SEC-142'
    expired_at: 2026-12-01

The plain .trivyignore text file still works (one ID per line) but has no expiry, and an exception without an expiry is a permanent hole that nobody revisits.

Scan everything the image contains

# Secrets and misconfigurations as well as vulnerabilities
trivy image --scanners vuln,secret,misconfig registry.example.com/platform/app:1.4.2

# Produce a CycloneDX SBOM and scan the SBOM later (faster, reproducible)
trivy image --format cyclonedx --output app-1.4.2.cdx.json registry.example.com/platform/app:1.4.2
trivy sbom app-1.4.2.cdx.json

Scanning the SBOM instead of re-pulling the image is how you re-check yesterday’s release against today’s CVEs without rebuilding anything.

Configuration

GitLab CI job

image-scan:
  stage: security
  image:
    name: aquasec/trivy:0.65.0 # pin; renovate can bump it
    entrypoint: ['']
  variables:
    TRIVY_CACHE_DIR: .trivycache
  cache:
    key: trivy-db
    paths: [.trivycache/]
  script:
    - trivy image --download-db-only
    - trivy image --exit-code 0 --format template --template "@/contrib/gitlab.tpl"
      --output gl-container-scanning-report.json "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
    - trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed
      "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
  artifacts:
    reports:
      container_scanning: gl-container-scanning-report.json

The first trivy image call writes a report GitLab can render in the merge request; the second is the actual gate. Caching TRIVY_CACHE_DIR avoids downloading the database on every job.

Reduce findings at the source

Scanning tells you what is wrong; the Dockerfile decides how much there is to find. Two things determine the baseline before your application adds a single package: which base image, and how old the copy you built from is. The python:3.13-slim tag as published on 2026-08-31, scanned on 2026-09-13 with that day’s database:

trivy image --severity HIGH,CRITICAL --ignore-unfixed --quiet python:3.13-slim
Report Summary
┌────────────────────────────────┬────────────┬─────────────────┬─────────┐
│             Target             │    Type    │ Vulnerabilities │ Secrets │
├────────────────────────────────┼────────────┼─────────────────┼─────────┤
│ python:3.13-slim (debian 13.6) │   debian   │       12        │    -    │
├────────────────────────────────┼────────────┼─────────────────┼─────────┤
│ Python                         │ python-pkg │        2        │    -    │
└────────────────────────────────┴────────────┴─────────────────┴─────────┘

python:3.13-slim (debian 13.6)
Total: 12 (HIGH: 9, CRITICAL: 3)
┌──────────────┬────────────────┬──────────┬────────┬───────────────────┬──────────────────┐
│   Library    │ Vulnerability  │ Severity │ Status │ Installed Version │  Fixed Version   │
├──────────────┼────────────────┼──────────┼────────┼───────────────────┼──────────────────┤
│ gzip         │ CVE-2026-41992 │ HIGH     │ fixed  │ 1.13-1            │ 1.13-1+deb13u1   │
│ libpcre2-8-0 │ CVE-2026-86145 │ HIGH     │ fixed  │ 10.46-1~deb13u1   │ 10.46-1~deb13u2  │
│ libsqlite3-0 │ CVE-2026-11822 │ HIGH     │ fixed  │ 3.46.1-7+deb13u1  │ 3.46.1-7+deb13u2 │
│ perl-base    │ CVE-2026-13221 │ CRITICAL │ fixed  │ 5.40.1-6          │ 5.40.1-6+deb13u1 │
│ …            │                │          │        │                   │                  │
└──────────────┴────────────────┴──────────┴────────┴───────────────────┴──────────────────┘

Python (python-pkg)
Total: 2 (HIGH: 2, CRITICAL: 0)
│ msgpack      │ GHSA-6v7p-g79w-8964 │ HIGH │ fixed │ 1.1.2  │ 1.2.1  │
│ setuptools   │ CVE-2025-47273      │ HIGH │ fixed │ 70.3.0 │ 78.1.1 │

Fourteen fixable findings in a two-week-old slim image: twelve in Debian packages for which a patched version now exists (gzip, pcre2, sqlite, perl-base) and two in Python packages the base ships. None of them are in your application. Three conclusions follow: build with --pull so you start from the current tag; run apt-get upgrade (or pick a base that is rebuilt more often) in the build stage; and prefer a runtime image that does not contain perl, gzip or setuptools at all.

FROM golang:1.27-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/app ./cmd/app

FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]

A distroless or scratch runtime image has no shell, no package manager and very few OS packages, so most of the CVE surface disappears. Where you need a shell for debugging, use ephemeral debug containers (kubectl debug) rather than baking tools into the runtime image. The next article in this series, Building Minimal Container Images with Multi-Stage Builds, builds and scans both variants of this Dockerfile.

Verification

# Expect a clean exit: distroless image with a single static binary
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed gcr.io/distroless/static-debian13:nonroot; echo "exit=$?"

# Expect failure: a base image with known fixable CVEs (14 on 2026-09-13, see above)
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed python:3.13-slim; echo "exit=$?"

Security Considerations

  • Scan the image you actually deploy (by digest), not just the Dockerfile. Base images change under the same tag.
  • Re-scan released images on a schedule; new CVEs are published daily and a passing scan at build time expires quickly.
  • Treat secret findings as incidents: rotate the credential first, then remove it from the image and history.

Troubleshooting

SymptomCauseFix
TOOMANYREQUESTS pulling the DBRate limit on ghcr.io from shared CI runnersCache the DB, mirror it to your registry with oras, or set --db-repository
Findings differ between laptop and CIDifferent Trivy versions or DB agePin the Trivy image tag; print trivy --version in the job
Language packages not detectedNo lockfile in the image, or only compiled artifactsKeep lockfiles in the final stage for interpreted languages, or scan the repo with trivy fs
Scan takes minutesLarge image layers rescanned each timeEnable the cache directory; use --skip-files for vendored test fixtures

Production Recommendations

  • Start the gate at CRITICAL + fixable, then tighten to HIGH once the backlog is manageable. A gate that is always red is a gate that gets bypassed.
  • Store SBOMs as build artifacts alongside images; they are your inventory when the next log4shell lands.
  • Combine with Kubernetes admission control (Kyverno verifyImages or an image-policy webhook) so only scanned, signed images can run.

Conclusion

Trivy makes image scanning almost free to adopt. The engineering work is in the policy — which severities block, how exceptions expire, and how often you re-scan what is already running. Get those three decisions written down and the tool does the rest.

References

Keep reading