Container Security Scanning with Trivy: Find CVEs Before Production
Container security scanning finds known vulnerabilities (CVEs) in your image's base OS packages and language dependencies before you ship them to production. Trivy is the most widely used scanner — it's fast, accurate, covers OS packages and application dependencies, and integrates with every major CI platform. This guide covers setting it up properly, not just running it once.
Why Container Scanning Matters
A typical node:20 image contains hundreds of OS packages. Any of them may have known CVEs. The base image you pulled six months ago has new vulnerabilities discovered since then. Language dependencies (npm, pip, Maven) add more surface area.
Without scanning:
- You ship images with known high-severity CVEs
- Attackers have public exploit code for CVEs that are weeks or months old
- You discover vulnerabilities during incident response, not before deployment
With scanning in CI:
- Every build fails fast if a new critical CVE appears in your base image
- You get an audit trail of when vulnerabilities were introduced
- Dependency upgrades happen proactively, not reactively
Installing Trivy
# macOS
brew install aquasecurity/trivy/trivy
# Ubuntu / Debian
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo apt-key add -
echo "deb https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main" | \
sudo tee /etc/apt/sources.list.d/trivy.list
sudo apt-get update && sudo apt-get install trivy
# Docker (no install needed)
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy image myapp:latestScanning a Docker Image
# Scan a local image
trivy image myapp:latest
# Scan a registry image
trivy image nginx:1.25
# Filter by severity
trivy image --severity HIGH,CRITICAL myapp:latest
# Output formats
trivy image --format json --output results.json myapp:latest
trivy image --format sarif --output results.sarif myapp:latest # GitHub Security tab
trivy image --format table myapp:latest # Default human-readableExample output:
myapp:latest (debian 12.2)
===========================
Total: 15 (HIGH: 8, CRITICAL: 7)
┌─────────────────────┬────────────────┬──────────┬────────────────────┬───────────────────┐
│ Library │ Vulnerability │ Severity │ Installed Version │ Fixed Version │
├─────────────────────┼────────────────┼──────────┼────────────────────┼───────────────────┤
│ openssl │ CVE-2024-0727 │ CRITICAL │ 3.0.11-1~deb12u2 │ 3.0.13-1~deb12u1 │
│ libssl3 │ CVE-2024-0727 │ CRITICAL │ 3.0.11-1~deb12u2 │ 3.0.13-1~deb12u1 │
└─────────────────────┴────────────────┴──────────┴────────────────────┴───────────────────┘CI Integration with Failure Thresholds
# GitHub Actions
name: Security Scan
on:
push:
branches: [main]
pull_request:
schedule:
- cron: '0 6 * * 1' # Weekly scan of main branch
jobs:
scan:
runs-on: ubuntu-latest
permissions:
security-events: write # For SARIF upload
contents: read
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
exit-code: '1' # Fail on findings
ignore-unfixed: true # Don't fail on unfixed CVEs
- name: Upload SARIF to GitHub Security
uses: github/codeql-action/upload-sarif@v3
if: always() # Upload even if scan failed
with:
sarif_file: trivy-results.sarifThe ignore-unfixed: true flag is important — it skips CVEs that have no available fix yet. Failing a build on a CVE with no patch available is frustrating and doesn't improve security; you can't fix what isn't fixed upstream.
Scanning Dockerfiles (Misconfig Detection)
Trivy also scans Dockerfiles for common misconfigurations:
trivy config Dockerfile
trivy config . # Scan directory for all IaC filesIt catches:
- Running as root (no USER directive)
- Using
latesttag in FROM - Secrets in ENV or ARG instructions
- Missing HEALTHCHECK
- No COPY
--chownwhen writing files
Example findings:
Dockerfile (dockerfile)
=======================
Tests: 21 (SUCCESSES: 18, FAILURES: 3)
FAILURE DS002 - Image should not run as root
FAILURE DS026 - Do not use 'latest' image tag
FAILURE DS013 - Add HEALTHCHECK instructionIgnoring False Positives
Some CVEs are in libraries that your code path never executes, or the CVE is in a test tool not deployed to production. Use .trivyignore:
# .trivyignore
# CVE-2023-44487 in h2 — HTTP/2 Rapid Reset, not exploitable in our use case
# We don't expose h2 endpoints publicly
CVE-2023-44487
# This package is only in the builder stage, not the final image
# (but trivy is scanning before multi-stage build)
CVE-2024-1234For more structured ignores with expiry dates:
# .trivyignore.yaml
vulnerabilities:
- id: CVE-2023-44487
paths:
- golang.org/x/net
statement: "h2 not exposed publicly"
expires: "2024-12-31"
- id: CVE-2024-5678
statement: "False positive — library version check incorrect"
expires: "2024-06-30"Multi-Stage Build Scanning
Trivy scans the final stage by default. For multi-stage builds, you may want to scan intermediate stages too:
# Dockerfile
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER node
CMD ["node", "dist/server.js"]# Scan final image (runner stage)
docker build -t myapp:latest --target runner .
trivy image myapp:latest
# Scan builder stage separately
docker build -t myapp:builder --target builder .
trivy image myapp:builderThe runner stage (based on node:20-alpine) should have significantly fewer CVEs than the full Node.js image. Alpine-based images have a smaller attack surface.
Comparing Alpine vs Debian Base Images
# Debian-based node
docker pull node:20
trivy image --severity HIGH,CRITICAL node:20 | grep "Total:"
# Alpine-based node
docker pull node:20-alpine
trivy image --severity HIGH,CRITICAL node:20-alpine | grep "Total:"
# Distroless (no shell at all)
docker pull gcr.io/distroless/nodejs20-debian12
trivy image --severity HIGH,CRITICAL gcr.io/distroless/nodejs20-debian12 | grep "Total:"In practice, Alpine typically has 80–90% fewer CVEs than Debian/Ubuntu-based images. Distroless images have even fewer, since there's no shell or package manager to attack.
Scanning Application Dependencies
Trivy scans language dependencies inside the image without needing a language-specific tool:
# Scans package-lock.json, requirements.txt, go.sum, pom.xml etc.
trivy image --scanners vuln myapp:latest
# Scan just the filesystem (useful in build pipelines before containerizing)
trivy filesystem --scanners vuln .
# Scan a specific requirements file
trivy fs --scanners vuln requirements.txtFor npm specifically, compare Trivy results against npm audit:
# npm audit (built-in)
npm audit --json | jq '.metadata.vulnerabilities'
# Trivy on the same dependencies
trivy fs --scanners vuln package.jsonThey may differ — Trivy uses the OSV database while npm audit uses the npm advisory database. Running both gives broader coverage.
Scheduled Scanning for Production Images
New CVEs are published daily. Your production image from two weeks ago may be vulnerable today:
# .github/workflows/scheduled-scan.yml
name: Scheduled Security Scan
on:
schedule:
- cron: '0 8 * * *' # Daily at 8am UTC
jobs:
scan-production:
runs-on: ubuntu-latest
steps:
- name: Scan production image
uses: aquasecurity/trivy-action@master
with:
image-ref: ghcr.io/myorg/myapp:latest
severity: CRITICAL
exit-code: '1'
ignore-unfixed: true
- name: Create issue if scan fails
if: failure()
uses: actions/github-script@v7
with:
script: |
github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: 'Critical CVE found in production image',
body: 'The daily security scan found critical vulnerabilities. Check the Actions run for details.',
labels: ['security', 'critical'],
});Trivy in Severity-Tiered Policy
Not all CVEs require the same response. Define a policy:
| Severity | Action | SLA |
|---|---|---|
| CRITICAL | Block deploy, notify on-call | Fix within 24h |
| HIGH | Warn in PR, create issue | Fix within 7 days |
| MEDIUM | Report in dashboard | Fix within 30 days |
| LOW | Log only | Address in next dependency refresh |
Implement this with two Trivy runs in CI:
# Block on CRITICAL
trivy image --severity CRITICAL --exit-code 1 --ignore-unfixed myapp:latest
# Report HIGH without blocking
trivy image --severity HIGH --exit-code 0 --format json \
--output high-severity.json myapp:latest# GitHub Actions step for tiered policy
- name: Check critical vulnerabilities (blocking)
run: trivy image --severity CRITICAL --exit-code 1 --ignore-unfixed myapp:${{ github.sha }}
- name: Report high vulnerabilities (non-blocking)
if: always()
run: |
trivy image --severity HIGH --exit-code 0 --format sarif \
--output trivy-high.sarif myapp:${{ github.sha }}
- name: Upload to GitHub Security
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: trivy-high.sarifComparing Trivy vs Alternatives
| Tool | Type | Speed | Coverage | False Positive Rate |
|---|---|---|---|---|
| Trivy | OSS | Fast | OS + app + IaC | Low |
| Grype | OSS | Fast | OS + app | Low |
| Snyk | Commercial | Medium | OS + app + code | Medium |
| Clair | OSS | Slow | OS packages | Low |
| Docker Scout | Commercial | Fast | OS + app | Low |
Trivy and Grype are the two most popular open-source options. Grype is worth running alongside Trivy for broader database coverage — they use different vulnerability databases and occasionally find different CVEs.
# Run both for comprehensive coverage
trivy image --severity HIGH,CRITICAL myapp:latest
grype myapp:latest --fail-on highContainer security scanning is most effective as a regular practice, not a one-time audit. Scan on every build, scan on a schedule for deployed images, and set up automatic issue creation when production images accumulate new CVEs.