Container Security Scanning with Trivy: Find CVEs Before Production

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:latest

Scanning 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-readable

Example 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.sarif

The 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 files

It catches:

  • Running as root (no USER directive)
  • Using latest tag in FROM
  • Secrets in ENV or ARG instructions
  • Missing HEALTHCHECK
  • No COPY --chown when 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 instruction

Ignoring 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-1234

For 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:builder

The 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.txt

For 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.json

They 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.sarif

Comparing 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 high

Container 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.

Read more

Start now free