Every container you ship carries a dependency tree that started growing before you wrote your first line of code. Base images bundle hundreds of packages, and some of those packages have CVEs. Without automated scanning in your CI pipeline, those vulnerabilities ship silently — and you only find out during an incident or a third-party audit.
This guide walks through a production-ready setup using Trivy inside GitHub Actions, including how to block builds on critical findings, handle accepted risks properly, and surface results as PR annotations.
Choosing a scanner
You have three realistic free options for CI scanning: Trivy (Aquasec), Grype (Anchore), and Docker Scout. All three work in GitHub Actions. Trivy tends to win on coverage breadth, update frequency, and support for non-OS manifests (Go modules, pip, npm, Cargo). The patterns in this article apply equally to Grype if you prefer it.
Trivy scans:
- OS packages across Alpine, Debian, Red Hat, and Wolfi families
- Language-specific lockfiles —
requirements.txt,go.sum,package-lock.json,Cargo.lock - Misconfigurations in Dockerfiles and Helm charts
- Secrets accidentally baked into image layers
The key distinction between scanning your go.sum locally and scanning your built image: the image scan catches packages installed by your base image that your language manifest does not track.
The basic workflow
Create .github/workflows/scan.yml:
name: Container Security Scan
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t app:${{ github.sha }} .
- name: Cache Trivy vulnerability database
uses: actions/cache@v4
with:
path: ~/.cache/trivy
key: trivy-db-${{ github.run_id }}
restore-keys: trivy-db-
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@0.28.0
with:
image-ref: app:${{ github.sha }}
format: table
exit-code: "1"
ignore-unfixed: true
severity: CRITICAL,HIGH
cache-dir: ~/.cache/trivy
Two flags do most of the work here. exit-code: "1" is what makes scanning useful rather than decorative — the job fails if Trivy finds any matching vulnerability. Without it, you get a report that nobody reads. ignore-unfixed: true removes CVEs where the upstream package maintainer hasn't released a patch yet. Skipping this flag produces hundreds of alerts you cannot act on, which trains your team to ignore the whole output.
The cache block is not optional in practice. Trivy downloads a ~35MB vulnerability database on every run. Without caching, you add 25-40 seconds per job and create a dependency on Aquasec's CDN uptime.
SARIF output for PR annotations
The table format is readable locally. For pull requests, SARIF output generates inline annotations that point reviewers to the exact package and version:
- name: Run Trivy (SARIF)
uses: aquasecurity/trivy-action@0.28.0
with:
image-ref: app:${{ github.sha }}
format: sarif
output: trivy-results.sarif
ignore-unfixed: true
severity: CRITICAL,HIGH
cache-dir: ~/.cache/trivy
- name: Upload results to Security tab
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: trivy-results.sarif
The if: always() on the upload step is critical. When Trivy finds vulnerabilities the previous step exits with code 1, which normally skips subsequent steps. Without if: always(), the SARIF file is never uploaded and you get no annotations on the PR — which defeats the purpose of running the scan.
Findings appear in the "Security" tab of your repository under "Code scanning alerts." Each entry links to the installed package version, the fixed version, and the CVE detail page.
Handling accepted risks with .trivyignore
Not every CVE you encounter is immediately actionable. Upstream packages have patch cycles, base images lag distro releases, and sometimes a vulnerability affects a code path your application does not use. For those cases, .trivyignore lets you explicitly suppress specific findings:
# CVE-2024-45490: expat < 2.6.3, base image fix pending upstream Alpine
# Triaged 2026-10-27, accepted risk, not exploitable via our HTTP surface
# Review by: 2027-01-01
CVE-2024-45490
# CVE-2024-12345: libssl timing side-channel, no network-accessible crypto in container
# Accepted risk documented in security/accepted-risks.md
CVE-2024-12345
The comment format is not cosmetic. Six months from now someone will ask why those CVEs are suppressed. If the comment just says "accepted," that person has no signal about whether the original reasoning still holds.
Review .trivyignore entries whenever you rotate your base image or update a major dependency. A good practice is to add a "review by" date in the comment, then enforce it with a separate CI check that alerts when entries are stale. Check our security hardening checklists for a full container security baseline to pair with this workflow.
Parsing results programmatically
For teams that need more control — for instance, blocking only on CRITICAL findings while logging HIGH findings without failing the build — you can parse Trivy's JSON output directly:
#!/usr/bin/env python3
import json
import sys
from dataclasses import dataclass
from typing import Optional
@dataclass
class Finding:
pkg: str
installed: str
fixed: Optional[str]
severity: str
cve_id: str
def parse_trivy_json(path: str) -> list[Finding]:
with open(path) as f:
data = json.load(f)
findings = []
for result in data.get("Results", []):
for vuln in result.get("Vulnerabilities", []):
findings.append(Finding(
pkg=vuln.get("PkgName", ""),
installed=vuln.get("InstalledVersion", ""),
fixed=vuln.get("FixedVersion"),
severity=vuln.get("Severity", ""),
cve_id=vuln.get("VulnerabilityID", ""),
))
return findings
def main() -> int:
path = sys.argv[1] if len(sys.argv) > 1 else "trivy-results.json"
findings = parse_trivy_json(path)
criticals = [f for f in findings if f.severity == "CRITICAL" and f.fixed]
highs = [f for f in findings if f.severity == "HIGH" and f.fixed]
if highs:
print(f"[WARN] {len(highs)} HIGH findings (non-blocking):")
for f in highs:
print(f" {f.cve_id} in {f.pkg} {f.installed} -> fix: {f.fixed}")
if criticals:
print(f"[FAIL] {len(criticals)} CRITICAL findings — blocking build:")
for f in criticals:
print(f" {f.cve_id} in {f.pkg} {f.installed} -> fix: {f.fixed}")
return 1
print(f"[PASS] No blocking vulnerabilities (checked {len(findings)} total).")
return 0
if __name__ == "__main__":
sys.exit(main())
Generate the JSON output by adding format: json and output: trivy-results.json to the Trivy action step, then add a workflow step: run: python3 scripts/check_vulns.py trivy-results.json.
This script is straightforward to extend: filter by package name, skip CVEs already in .trivyignore, or post a Slack notification for HIGH findings that don't fail the build but need a follow-up ticket.
The takeaway
Automated image scanning blocks the most common class of container security failure: shipping known vulnerabilities because nobody checked. The setup above takes under an hour and gives you hard build failures on critical findings, inline PR annotations, and a documented process for accepted risks.
The gap most teams fall into is stopping at "we have scanning" without configuring exit-code: "1". A scan that does not block deploys is a report that will not be read. Make it break the build — that is the only way it stays effective over time.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)