DevOps & Platform

Scanning Container Images for CVEs with Trivy in CI

Beginner20 min to complete8 min read

Add Trivy to your CI pipeline to catch known vulnerabilities before images reach production. Covers severity thresholds, ignoring false positives, scanning Helm charts, and breaking the build on critical CVEs.

Before you begin

  • A GitHub Actions workflow that builds Docker images
  • Basic Docker knowledge
Trivy
Security
CI/CD
Container Security
DevSecOps

Trivy is a vulnerability scanner for container images, filesystems, Git repositories, and Kubernetes clusters. It's fast, has no daemon, and works in CI without any setup beyond installing the binary.

This tutorial adds Trivy to a GitHub Actions workflow, configures severity thresholds, and shows you how to handle false positives.

Step 1: Run Trivy Locally First

Before adding it to CI, understand what it finds:

bash
# Install Trivy
brew install trivy   # macOS
# Or:
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin v0.50.0

# Scan a local image
trivy image nginx:1.25

# Scan with severity filter
trivy image --severity HIGH,CRITICAL nginx:1.25

# Output as JSON for further processing
trivy image --format json --output results.json nginx:1.25

# Scan your own built image
docker build -t my-app:local .
trivy image my-app:local

Trivy checks:

  • OS packages (apt, apk, rpm)
  • Language packages (npm, pip, gem, go modules, Maven)
  • Container configuration (Dockerfile misconfigs)

Step 2: Add Trivy to GitHub Actions

yaml
# .github/workflows/security.yml
name: Security Scan

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  scan:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Build image
        run: docker build -t my-app:${{ github.sha }} .

      - name: Scan image with Trivy
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: my-app:${{ github.sha }}
          format: table
          exit-code: "1"          # Fail the job if vulnerabilities found
          severity: "CRITICAL,HIGH"
          ignore-unfixed: true    # Don't fail on vulns with no fix available

The exit-code: "1" flag makes Trivy fail the job when vulnerabilities matching the severity are found. Remove it to run in audit-only mode (always passes, just reports).

Step 3: Upload Results as SARIF for GitHub Security Tab

GitHub's Code Scanning can display Trivy results in the Security tab — no third-party tool required:

yaml
      - name: Run Trivy (SARIF output)
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: my-app:${{ github.sha }}
          format: sarif
          output: trivy-results.sarif
          severity: "CRITICAL,HIGH,MEDIUM"

      - name: Upload SARIF to GitHub Security
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: trivy-results.sarif
        if: always()   # Upload even if the scan step failed

After this runs, go to your GitHub repo → Security → Code scanning. You'll see CVEs grouped by severity with links to the package and the CVE description.

Step 4: Scan for Misconfigurations

Trivy also checks Dockerfiles and Kubernetes manifests for security issues:

yaml
      - name: Scan Dockerfile for misconfigs
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: config
          scan-ref: .           # Scan the entire repo
          format: table
          exit-code: "1"
          severity: "HIGH,CRITICAL"

Common findings:

  • Running as root (USER root without switching back)
  • Using latest tag
  • Exposing privileged ports (< 1024)
  • COPY . . — copies sensitive files like .env into the image
  • Kubernetes manifests with privileged: true or no resource limits

Step 5: Handle False Positives with .trivyignore

Some CVEs have no fix, are in a library your code doesn't exercise, or are disputed. Create .trivyignore at the root of your repo:

# .trivyignore

# CVE-2023-XXXX: Affects feature X which we don't use
# Expires: 2026-06-01
CVE-2023-XXXX

# This is a build-time dependency only, not in the runtime image
CVE-2024-YYYY

Trivy reads .trivyignore automatically. Add an expiry date comment so you revisit the decision rather than accumulating stale ignores.

For more structured suppression, use a VEX document:

json
{
  "@context": "https://openvex.dev/ns/v0.2.0",
  "@id": "https://codingprotocols.com/vex/2026-04-01",
  "author": "security@codingprotocols.com",
  "timestamp": "2026-04-01T00:00:00Z",
  "statements": [
    {
      "vulnerability": {"name": "CVE-2023-XXXX"},
      "products": [{"@id": "pkg:oci/my-app"}],
      "status": "not_affected",
      "justification": "vulnerable_code_not_in_execute_path",
      "impact_statement": "The vulnerable XML parser is not invoked in our code path"
    }
  ]
}
yaml
      - name: Scan with VEX
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: my-app:${{ github.sha }}
          vex: vex.json

Step 6: Scan Helm Charts

yaml
      - name: Scan Helm chart
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: config
          scan-ref: ./charts/my-app
          format: table
          severity: "HIGH,CRITICAL"

Or the CLI:

bash
trivy config --severity HIGH,CRITICAL charts/my-app/

Trivy understands Helm chart values and templates, rendering them before checking for misconfigurations.

Step 7: Scan a Running Cluster

Trivy has a Kubernetes operator mode that continuously scans workloads:

bash
helm repo add aqua https://aquasecurity.github.io/helm-charts
helm install trivy-operator aqua/trivy-operator \
  --namespace trivy-system \
  --create-namespace \
  --set trivy.ignoreUnfixed=true

# See scan results
kubectl get vulnerabilityreports -A
kubectl describe vulnerabilityreport <name> -n production

The operator creates VulnerabilityReport CRDs for each workload, which you can query with kubectl or view in tools like Lens.

Step 8: Break the Build Correctly

Don't fail on MEDIUM vulnerabilities in the first week — you'll spend all your time triaging instead of shipping. Start with:

yaml
severity: "CRITICAL"
ignore-unfixed: true

After a sprint of remediating criticals, expand to HIGH:

yaml
severity: "CRITICAL,HIGH"
ignore-unfixed: true

ignore-unfixed: true is essential: many OS package CVEs exist in versions where the upstream hasn't released a fix yet. Failing on those trains developers to click "skip" rather than investigate real issues.

Common Fixes

Update the base image:

dockerfile
# Before
FROM node:18-alpine3.17

# After — use the latest patch
FROM node:18-alpine3.20

Explicitly install a patched version:

dockerfile
RUN apk upgrade --no-cache libssl3

Remove unused packages:

dockerfile
RUN apk add --no-cache curl \
    && apk del curl   # Remove after use

Use distroless images — no package manager, no shell, minimal attack surface:

dockerfile
FROM node:18-alpine AS build
# ... build steps ...

FROM gcr.io/distroless/nodejs18-debian12
COPY --from=build /app/dist /app
CMD ["/app/index.js"]

We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.

Struggling with this in production?

We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.