Scanning Container Images for CVEs with Trivy in CI
Quick answer
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.
- Step 1: Run Trivy Locally First
- Step 2: Add Trivy to GitHub Actions
- Step 3: Upload Results as SARIF for GitHub Security Tab
- Step 4: Scan for Misconfigurations
- Step 5: Handle False Positives with .trivyignore
beginner · 20 min
Before you begin
- A GitHub Actions workflow that builds Docker images
- Basic Docker knowledge
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:
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:localTrivy 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
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/[email protected]
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 availableThe 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:
1 - name: Run Trivy (SARIF output)
2 uses: aquasecurity/[email protected]
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 failedAfter 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:
1 - name: Scan Dockerfile for misconfigs
2 uses: aquasecurity/[email protected]
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 rootwithout switching back) - Using
latesttag - Exposing privileged ports (< 1024)
COPY . .— copies sensitive files like.envinto the image- Kubernetes manifests with
privileged: trueor 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:
1{
2 "@context": "https://openvex.dev/ns/v0.2.0",
3 "@id": "https://codingprotocols.com/vex/2026-04-01",
4 "author": "[email protected]",
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}VEX suppression is not available as an input to the trivy-action GitHub Action. To use VEX in CI, call the Trivy CLI directly:
1 - name: Scan with VEX
2 run: |
3 trivy image \
4 --vex vex.json \
5 --exit-code 1 \
6 --severity CRITICAL,HIGH \
7 my-app:${{ github.sha }}Step 6: Scan Helm Charts
1 - name: Scan Helm chart
2 uses: aquasecurity/[email protected]
3 with:
4 scan-type: config
5 scan-ref: ./charts/my-app
6 format: table
7 severity: "HIGH,CRITICAL"Or the CLI:
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:
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 productionThe 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:
severity: "CRITICAL"
ignore-unfixed: trueAfter a sprint of remediating criticals, expand to HIGH:
severity: "CRITICAL,HIGH"
ignore-unfixed: trueignore-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:
# Before
FROM node:18-alpine3.17
# After — use the latest patch
FROM node:18-alpine3.20Explicitly install a patched version:
RUN apk upgrade --no-cache libssl3Remove unused packages:
RUN apk add --no-cache curl \
&& apk del curl # Remove after useUse distroless images — no package manager, no shell, minimal attack surface:
FROM node:18-alpine AS build
# ... build steps ...
FROM gcr.io/distroless/nodejs18-debian12
COPY --from=build /app/dist /app
CMD ["/app/index.js"]Frequently Asked Questions
Should a CVE finding fail the build?
Fail on severity you have agreed to act on, and only for vulnerabilities with a fix available. Failing on every finding including unfixable ones trains people to bypass the gate, which is worse than no gate. Start by failing on critical with a fix, and tighten once the backlog is under control.
How do I handle a vulnerability with no patch?
Record it in an ignore file with an expiry and a reason, so it resurfaces for review rather than disappearing. An ignore entry without a date is a permanent exemption nobody revisits. If the finding is in a component you do not use, that is worth noting explicitly — it is still exposure if an attacker can reach it.
Why do scan results differ between my laptop and CI?
The vulnerability database changes constantly, so two scans minutes apart can differ. Cache the database in CI for speed but let it refresh regularly, and expect new findings on unchanged images — that is the scanner working, not a false alarm. Pinning the database to avoid surprises means not learning about new vulnerabilities.
Should I scan the image or the filesystem?
Both catch different things. Scanning the built image reflects what actually ships, including base image layers. Scanning the repository catches dependency manifests before an image exists, which gives a faster signal in pull requests. Running the manifest scan on pull requests and the image scan before push is a reasonable split.
Official References
- Trivy Documentation — Official Trivy docs: scanners, targets, output formats, and CI integration guides
- Trivy GitHub Action — The official GitHub Action for running Trivy scans in CI workflows
- Trivy Configuration Scanning — How Trivy scans Dockerfiles, Kubernetes manifests, and Helm charts for misconfigurations
- NVD — National Vulnerability Database — The authoritative CVE database that Trivy uses as a data source
- SBOM Generation with Trivy — How to generate CycloneDX and SPDX SBOMs from container images
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.