Container Image Scanning: Trivy, Grype, and Snyk Container
A technical guide to scanning container images for vulnerabilities—understanding base layer vs application layer findings, integrating Trivy into GitHub Actions, signing images with cosign, and building a pragmatic policy for unfixable vulnerabilities.
Why Container Image Scanning Matters
When you build a Docker image, you inherit all the vulnerabilities of everything inside it: the base OS packages, the language runtime, every library your application depends on, and every library those libraries depend on. A Node.js application image built on node:18 contains hundreds of packages from Debian or Alpine, each with its own CVE history.
Container image scanning makes this inherited risk visible. Without it, teams routinely ship images containing dozens of known high and critical vulnerabilities—not because they wrote vulnerable code, but because they did not know what was in their base image.
The scanning problem has two parts: discovery (what is in the image?) and matching (which CVEs apply to what I found?). Modern scanners handle both, but with different databases, different package detection approaches, and different false positive rates.
Understanding Image Layers
A container image is composed of layers. Understanding which layer a vulnerability lives in determines your remediation path.
Base image layer: The first FROM instruction in your Dockerfile pulls a base image (e.g., node:20-alpine, python:3.12-slim). This layer contains the OS packages, package manager, and sometimes the language runtime. You do not control what goes into the base image—your remediation is to update to a newer base image tag or switch to a different base.
Dependency layer: The COPY package.json + RUN npm install layer installs your application's direct and transitive dependencies. Vulnerabilities here come from your package.json. Remediation is updating your dependencies.
Application layer: Your own code. Static analysis tools (SAST) are more appropriate here than CVE scanners, but misconfigurations (exposed secrets, world-readable files) can be detected by container scanners.
Understanding layer provenance helps prioritize findings: a critical CVE in a package you do not actually call at runtime is lower priority than a critical CVE in a library directly in your request path.
Trivy: The Most Widely Used Open Source Scanner
Trivy (by Aqua Security) has become the de facto standard for open source container scanning. It is fast, comprehensive, and has excellent CI/CD integration.
What Trivy scans:
- OS packages (Alpine apk, Debian dpkg, Red Hat rpm)
- Language ecosystems: Node.js (package-lock.json, yarn.lock), Python (pip), Go (go.sum), Java (pom.xml, gradle), Ruby, Rust, PHP
- Infrastructure as Code: Terraform, Kubernetes manifests, Dockerfiles, Helm charts
- Secrets: hardcoded AWS keys, GitHub tokens, passwords in environment variables
- License compliance
Running Trivy Locally
# Install
brew install aquasecurity/trivy/trivy
# Scan a local image
trivy image myapp:latest
# Scan with severity filter (only HIGH and CRITICAL)
trivy image --severity HIGH,CRITICAL myapp:latest
# Fail if vulnerabilities above threshold
trivy image --exit-code 1 --severity CRITICAL myapp:latest
# Scan with SARIF output (for GitHub Code Scanning upload)
trivy image --format sarif --output results.sarif myapp:latest
# Scan a Dockerfile
trivy config Dockerfile
# Scan a filesystem (useful for scanning during build, before image assembly)
trivy fs --scanners vuln,secret,misconfig .
Trivy in GitHub Actions
The recommended pattern for CI integration:
name: Container Security
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
scan:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # Required for SARIF upload
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build image
run: docker build -t ${{ github.repository }}:${{ github.sha }} .
- name: Run Trivy vulnerability scan
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ github.repository }}:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: HIGH,CRITICAL
exit-code: 1
ignore-unfixed: false
- name: Upload Trivy results to GitHub Code Scanning
if: always() # Upload even if previous step failed
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarif
category: container-scanning
The exit-code: 1 setting makes the job fail when HIGH or CRITICAL vulnerabilities are found. Set ignore-unfixed: true to only fail on vulnerabilities that have available fixes.
Trivy Ignore Files
Not all findings require immediate action. Trivy supports .trivyignore files to suppress specific CVEs with justification:
# .trivyignore
# CVE-2023-12345 - Not exploitable in our configuration (no network exposure)
# Upstream fix expected in next release. Review: 2025-Q1
CVE-2023-12345
# CVE-2024-56789 - Only affects Windows, our image is Linux-only
CVE-2024-56789 until 2025-06-01
Entries should always include a comment explaining why the CVE is suppressed and optionally an expiry date. Review suppressed CVEs as part of your quarterly security review.
Grype: Anchore's Open Source Alternative
Grype (by Anchore) is another widely used open source scanner. It uses Anchore's vulnerability database (grypedb) which aggregates from NVD, GitHub Advisory Database, Alpine secdb, Ubuntu USN, Red Hat, and others.
Trivy and Grype frequently produce different results for the same image because they use different databases and different package detection logic. Running both and taking the union of findings gives better coverage.
# Install
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
# Scan an image
grype myapp:latest
# Scan with severity threshold
grype myapp:latest --fail-on high
# Output in SARIF format
grype myapp:latest -o sarif > grype-results.sarif
# Scan a directory
grype dir:./myapp
Grype vs Trivy: Key Differences
| Feature | Trivy | Grype |
|---|---|---|
| Database | ghcr.io/aquasecurity/trivy-db | grype.db (Anchore) |
| IaC scanning | Yes | No |
| Secret scanning | Yes | No |
| SBOM generation | Yes (with Syft) | Pairs with Syft |
| Speed | Fast | Fast |
| False positive rate | Low | Low |
Snyk Container
Snyk Container offers commercial-grade scanning with additional features: fix PRs that upgrade base images, developer-friendly UX, and integration with Snyk's broader security platform.
# Authenticate
snyk auth
# Scan image
snyk container test myapp:latest
# Get a fix recommendation (suggests a less-vulnerable base image)
snyk container test myapp:latest --file=Dockerfile
# Monitor (persist results in Snyk dashboard)
snyk container monitor myapp:latest
Snyk's "upgrade base image" recommendations are particularly useful: it will suggest switching from node:18-bullseye to node:20-alpine and quantify the reduction in vulnerable packages (often 80%+ reduction in finding count from switching to a minimal base).
Image Signing with cosign
Scanning tells you whether an image is vulnerable. Signing ensures that the image deployed to production is exactly the image that was scanned—not a tampered substitute.
Sigstore's cosign tool provides keyless image signing using short-lived certificates backed by transparency logs (Rekor).
Signing in CI (Keyless)
Keyless signing in GitHub Actions uses the OIDC token from the GitHub Actions workflow as the identity:
sign:
runs-on: ubuntu-latest
needs: scan
permissions:
contents: read
id-token: write # Required for keyless signing
packages: write
steps:
- name: Install cosign
uses: sigstore/cosign-installer@v3
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Sign image
run: |
cosign sign --yes ghcr.io/${{ github.repository }}:${{ github.sha }}
Verifying Signatures at Deploy Time
# Verify a signature before pulling/deploying
cosign verify \
--certificate-identity-regexp "https://github.com/myorg/myrepo/.github/workflows/build.yml@refs/heads/main" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/myorg/myrepo:sha256:abc123
# In Kubernetes, use Kyverno or OPA Gatekeeper policies to enforce signing
# Example Kyverno ClusterPolicy:
# kind: ClusterPolicy
# spec:
# rules:
# - name: check-image-signature
# match:
# resources:
# kinds: [Pod]
# verifyImages:
# - imageReferences: ["ghcr.io/myorg/*"]
# attestors:
# - entries:
# - keyless:
# issuer: "https://token.actions.githubusercontent.com"
# subject: "https://github.com/myorg/myrepo/.github/workflows/build.yml@refs/heads/main"
Handling Unfixable Vulnerabilities
A significant percentage of container image vulnerabilities are "unfixable"—the vulnerability has been disclosed but no patched version of the affected package is available. This is common for OS packages in base images where the distro's patching cycle lags CVE disclosure.
A pragmatic policy for unfixable vulnerabilities:
1. Verify the vulnerability is genuinely unfixable. Check if a newer base image tag has the fix. Trivy's --ignore-unfixed flag filters these out for the gate decision, but you should still review them.
2. Assess actual exploitability. Many CVEs require specific conditions: a particular configuration, a specific call path, a specific environment. EPSS (Exploit Prediction Scoring System) scores estimate the probability a CVE will be exploited in the wild within 30 days. A CVE with EPSS score of 0.002 (0.2% exploit probability) is de-prioritized relative to a CVE with EPSS score of 0.82.
3. Apply compensating controls. If a vulnerable package cannot be updated, can the attack surface be reduced? Running the container with a read-only filesystem, dropping unnecessary Linux capabilities, and using seccomp profiles all reduce exploitability of many CVEs.
4. Document and track. Use .trivyignore with expiry dates. Set a calendar reminder to re-evaluate when the package maintainer's next release is expected. Do not silently ignore unfixable vulnerabilities—make the acceptance decision explicit and time-bounded.
5. Minimize base image. The best long-term remedy is switching to a minimal base image (Alpine, Distroless, Wolfi) that has fewer packages and therefore fewer potential CVE surface area. A node:20-alpine image typically has 60–80% fewer findings than a node:20-bullseye image. A Google Distroless image (no shell, no package manager) has the fewest findings of all.
Container image scanning, combined with signing and a clear policy for handling findings, transforms your container pipeline from a black box of inherited risk into a transparent, auditable supply chain where you know exactly what you are shipping.