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
1# Install Trivy
2brew install trivy   # macOS
3# Or:
4curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin v0.50.0
5
6# Scan a local image
7trivy image nginx:1.25
8
9# Scan with severity filter
10trivy image --severity HIGH,CRITICAL nginx:1.25
11
12# Output as JSON for further processing
13trivy image --format json --output results.json nginx:1.25
14
15# Scan your own built image
16docker build -t my-app:local .
17trivy 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
1# .github/workflows/security.yml
2name: Security Scan
3
4on:
5  push:
6    branches: [main]
7  pull_request:
8    branches: [main]
9
10jobs:
11  scan:
12    runs-on: ubuntu-latest
13
14    steps:
15      - name: Checkout
16        uses: actions/checkout@v4
17
18      - name: Build image
19        run: docker build -t my-app:${{ github.sha }} .
20
21      - name: Scan image with Trivy
22        uses: aquasecurity/trivy-action@master
23        with:
24          image-ref: my-app:${{ github.sha }}
25          format: table
26          exit-code: "1"          # Fail the job if vulnerabilities found
27          severity: "CRITICAL,HIGH"
28          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
1      - name: Run Trivy (SARIF output)
2        uses: aquasecurity/trivy-action@master
3        with:
4          image-ref: my-app:${{ github.sha }}
5          format: sarif
6          output: trivy-results.sarif
7          severity: "CRITICAL,HIGH,MEDIUM"
8
9      - name: Upload SARIF to GitHub Security
10        uses: github/codeql-action/upload-sarif@v3
11        with:
12          sarif_file: trivy-results.sarif
13        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
1      - name: Scan Dockerfile for misconfigs
2        uses: aquasecurity/trivy-action@master
3        with:
4          scan-type: config
5          scan-ref: .           # Scan the entire repo
6          format: table
7          exit-code: "1"
8          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
1{
2  "@context": "https://openvex.dev/ns/v0.2.0",
3  "@id": "https://codingprotocols.com/vex/2026-04-01",
4  "author": "security@codingprotocols.com",
5  "timestamp": "2026-04-01T00:00:00Z",
6  "statements": [
7    {
8      "vulnerability": {"name": "CVE-2023-XXXX"},
9      "products": [{"@id": "pkg:oci/my-app"}],
10      "status": "not_affected",
11      "justification": "vulnerable_code_not_in_execute_path",
12      "impact_statement": "The vulnerable XML parser is not invoked in our code path"
13    }
14  ]
15}
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
1      - name: Scan Helm chart
2        uses: aquasecurity/trivy-action@master
3        with:
4          scan-type: config
5          scan-ref: ./charts/my-app
6          format: table
7          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
1helm repo add aqua https://aquasecurity.github.io/helm-charts
2helm install trivy-operator aqua/trivy-operator \
3  --namespace trivy-system \
4  --create-namespace \
5  --set trivy.ignoreUnfixed=true
6
7# See scan results
8kubectl get vulnerabilityreports -A
9kubectl 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"]

Official References

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.