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.
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
- Built image. Trivy reads the image’s layers: the OS package database (
dpkg,apk,rpm), language lockfiles and installed package metadata (METADATAfor Python,package.jsonfor Node, Go binaries’ embedded module lists), and the files themselves for the secret scanner. - Trivy matches what it found against the vulnerability database and applies the severity and fix filters you
give it. The
--scannersflag chooses which of the three classes (vulnerabilities, secrets, misconfigurations) to run. - Vulnerability DB. Downloaded from an OCI registry (
mirror.gcr.io/aquasec/trivy-db:2, withghcr.io/aquasecurity/trivy-db:2as the fallback in 0.68) and cached. Its age is the first thing to check when a scan looks too good. - Report. Table for humans, JSON or SARIF for machines. The report always contains everything the filters let through; the gate is a separate decision.
- SBOM. Optionally, the inventory Trivy built can be written out as CycloneDX or SPDX and scanned again later without the image.
- Gate fails. With
--exit-code 1and the severity/fix filters, Trivy exits non-zero and the pipeline stops before the push. - 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.ioorghcr.iofor 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 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
| Symptom | Cause | Fix |
|---|---|---|
TOOMANYREQUESTS pulling the DB | Rate limit on ghcr.io from shared CI runners | Cache the DB, mirror it to your registry with oras, or set --db-repository |
| Findings differ between laptop and CI | Different Trivy versions or DB age | Pin the Trivy image tag; print trivy --version in the job |
| Language packages not detected | No lockfile in the image, or only compiled artifacts | Keep lockfiles in the final stage for interpreted languages, or scan the repo with trivy fs |
| Scan takes minutes | Large image layers rescanned each time | Enable 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
verifyImagesor 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