Container Image Vulnerability Management: What to Do When Trivy Shows 200 CVEs
Trivy showing 200 CVEs on a fresh container build is normal and does not mean 200 things to fix. Container image vulnerability management starts with understanding what you're looking at: base OS packages that your application never touches, vulnerabilities with no available fix, and true application-layer issues that require immediate attention. A triage workflow gets that 200-CVE count down to 5-10 actionable items within 30 minutes.
Running Trivy and Understanding the Output
Installation:
# macOS
brew install trivy
# Linux
sudo apt-get install trivy
# Docker (no install required)
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy image myapp:latest
Basic scan with severity filter:
trivy image --severity CRITICAL,HIGH myapp:latest
Output columns:
- Library: the package name containing the vulnerability
- Vulnerability ID: the CVE identifier
- Severity: CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN
- Installed Version: the version currently in the image
- Fixed Version: the version that contains the fix (empty = no fix available)
- Title: a brief description of the vulnerability
Useful flags for daily use:
# Filter to only fixable CVEs (removes no-fix-available noise)
trivy image --ignore-unfixed --severity CRITICAL,HIGH myapp:latest
# Machine-readable output for automation
trivy image --format json myapp:latest | jq '.Results[].Vulnerabilities[] | {id: .VulnerabilityID, severity: .Severity, pkg: .PkgName, fixed: .FixedVersion}'
# Add secret detection alongside vulnerability scanning
trivy image --scanners vuln,secret myapp:latest
# Generate SBOM in CycloneDX format alongside scan
trivy image --format cyclonedx --output sbom.json myapp:latest
# Scan a specific Dockerfile for misconfigurations
trivy config ./Dockerfile
Quick severity count:
# Count findings by severity in one command
trivy image --format json myapp:latest 2>/dev/null | \
jq '[.Results[].Vulnerabilities[]?.Severity] | group_by(.) | map({severity: .[0], count: length})'
Triaging OS-Layer vs. Application-Layer CVEs
Trivy organizes output by layer type. The section headers in the text output or the 'Type' field in JSON output identify whether a finding is OS-layer or application-layer.
OS-layer identifiers in Trivy output:
debian,ubuntu,alpine,centos,rhel: OS package manager findings- Package manager:
dpkg(Debian/Ubuntu),apk(Alpine),rpm(RHEL/CentOS) - Example:
libssl3 (deb)-- this is an OS-level library
Application-layer identifiers:
node-pkg: npm/yarn dependenciespython-pkg: pip dependenciesgobinary: Go binaries and their embedded dependenciesjava-jar: Maven/Gradle JARsruby-bundler: Ruby gems
Isolating application-layer findings:
# JSON output filtered to app-layer findings only
trivy image --format json myapp:latest 2>/dev/null | \
jq '.Results[] | select(.Type == "node-pkg" or .Type == "python-pkg" or .Type == "gobinary" or .Type == "java-jar") | .Vulnerabilities[]? | {id: .VulnerabilityID, severity: .Severity, pkg: .PkgName}'
OS package relevance check:
Before prioritizing an OS-layer CVE, ask: does my application call this library at runtime? A CVE in perl when your application is a Python API is unlikely to be exploitable even if Trivy flags it as CRITICAL. Check whether the package is present in the final image layer (not just a build-stage artifact):
# Check which layer a package was installed in
docker history myapp:latest
# Inspect what packages are installed in the final image
docker run --rm myapp:latest dpkg -l 2>/dev/null || \
docker run --rm myapp:latest apk list --installed 2>/dev/null
Briefings like this, every morning before 9am.
Threat intel, active CVEs, and campaign alerts, distilled for practitioners. 50,000+ subscribers. No noise.
Moving to Slim or Distroless Base Images
The most effective way to reduce container CVE count is to shrink the base image. The hierarchy of options:
Current state: Full OS base (most CVEs)
FROM ubuntu:22.04 # ~200-400 CVEs typical
FROM python:3.12 # ~150-300 CVEs (built on Debian)
Better: Slim variants
FROM python:3.12-slim # ~40-80 CVEs -- removes build tools, documentation
FROM node:20-slim # ~30-60 CVEs
Best for production: Distroless
# Available distroless base images:
# gcr.io/distroless/base-debian12 -- minimal runtime, no package manager or shell
# gcr.io/distroless/python3 -- Python 3 runtime only
# gcr.io/distroless/nodejs20 -- Node.js 20 runtime only
# gcr.io/distroless/java17 -- Java 17 runtime only
# gcr.io/distroless/cc-debian12 -- C/C++ runtime (libgcc, libstdc++)
# Debug variants (include busybox shell for development use)
# gcr.io/distroless/python3:debug
# gcr.io/distroless/nodejs20:debug
Multi-stage build pattern for distroless migration:
# Stage 1: Builder (full OS, all build tools available)
FROM python:3.12 AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --target=/app/packages -r requirements.txt
# Stage 2: Production (distroless, no shell, minimal attack surface)
FROM gcr.io/distroless/python3
WORKDIR /app
# Copy installed packages from builder stage
COPY --from=builder /app/packages /usr/local/lib/python3.11/site-packages
# Copy application code
COPY . .
CMD ["app.py"]
Verifying the reduction:
# Before: scan your current image
trivy image --ignore-unfixed --severity CRITICAL,HIGH myapp:ubuntu 2>/dev/null | tail -5
# After: scan the distroless version
trivy image --ignore-unfixed --severity CRITICAL,HIGH myapp:distroless 2>/dev/null | tail -5
Debugging distroless containers:
Since distroless has no shell, traditional docker exec -it container /bin/bash does not work. Use the debug variant during development, or use ephemeral debug containers:
# Kubernetes: attach a debug container to a running distroless pod
kubectl debug -it pod/myapp-pod --image=busybox --target=myapp
Integrating Container Scanning into CI/CD
Container scanning in CI/CD works on a threshold model: define what severity level fails a build, what gets reported but does not block, and what gets accepted and documented.
GitHub Actions -- recommended configuration:
jobs:
container-scan:
runs-on: ubuntu-latest
steps:
- name: Build container image
run: docker build -t myapp:${{ github.sha }} .
# Step 1: Gate on CRITICAL with available fix (fails build)
- name: Trivy scan -- fail on CRITICAL
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: table
severity: CRITICAL
ignore-unfixed: true
exit-code: 1
# Step 2: Report HIGH/MEDIUM to GitHub Security tab (does not fail build)
- name: Trivy scan -- report HIGH to Security tab
uses: aquasecurity/trivy-action@master
if: always()
with:
image-ref: myapp:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: HIGH,MEDIUM
ignore-unfixed: true
exit-code: 0
- name: Upload Trivy results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: trivy-results.sarif
Using .trivyignore to document accepted risks:
# .trivyignore file in your repository root
# Format: CVE-ID followed by optional comment
# CVE-2023-45853 affects zlib in libpng -- only used for build-time image processing,
# not present in final production image layer. Accepted risk: 2026-06-01
CVE-2023-45853
# CVE-2024-12345 -- no fix available as of 2026-05-20, monitoring for fix
# Acceptable because this library is only called for internal admin endpoints
CVE-2024-12345
GitLab CI equivalent:
container-scan:
stage: test
image: aquasec/trivy:latest
script:
- trivy image --exit-code 1 --severity CRITICAL --ignore-unfixed myapp:$CI_COMMIT_SHA
artifacts:
reports:
container_scanning: trivy-report.json
Threshold progression strategy:
- Month 1: Gate on CRITICAL only. Build your fix backlog.
- Month 2-3: Resolve CRITICAL backlog. Extend gate to CRITICAL + HIGH with fix.
- Month 4+: Gate includes HIGH. Report MEDIUM. Review LOW quarterly.
Keeping Up with New CVEs in Deployed Images
A container image built clean today accumulates new CVEs as vulnerabilities are discovered in packages it contains. An image that passes scanning at build time may have a CRITICAL CVE 30 days later.
Solution 1: Registry-native scanning Major container registries offer continuous scanning that re-evaluates images when new CVEs are published:
# AWS ECR: check current scan findings for a deployed image
aws ecr describe-image-scan-findings \
--repository-name myapp \
--image-id imageTag=latest \
--query 'imageScanFindings.findings[?severity==`CRITICAL`].[name,severity,uri]' \
--output table
# ECR enhanced scanning uses Inspector and re-scans automatically
# Enable: ECR console > Repositories > Edit scanning configuration > Enhanced
# GCR / Artifact Registry: findings appear in Security Command Center
# ACR: integrated with Microsoft Defender for Containers
Solution 2: Scheduled Trivy scans on registry images
# Example: daily cron job scanning your 5 production images
#!/bin/bash
IMAGES=(
"123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest"
"123456789.dkr.ecr.us-east-1.amazonaws.com/worker:latest"
)
for image in "${IMAGES[@]}"; do
echo "Scanning $image"
trivy image --ignore-unfixed --severity CRITICAL,HIGH \
--format json --output "scan-$(date +%Y%m%d).json" \
"$image"
done
Solution 3: SBOM-first continuous scanning Generate an SBOM (Software Bill of Materials) at build time and scan the SBOM daily without pulling the full image:
# Generate SBOM at build time
trivy image --format cyclonedx --output sbom-myapp-v1.2.3.json myapp:v1.2.3
# Scan the SBOM against current vulnerability database daily
trivy sbom --severity CRITICAL,HIGH --ignore-unfixed sbom-myapp-v1.2.3.json
# This is faster than pulling the full image and works even for images in air-gapped registries
Image freshness policy: Adopt a policy that base images are rebuilt on a schedule regardless of known CVEs:
- Production images: rebuild weekly from current base
- Long-running services: rebuild monthly minimum
- Automation: use Docker's
--pull alwaysflag in your CI build steps to ensure the base image layer is refreshed on each build
The bottom line
Container vulnerability management is a triage problem, not a count problem. A 200-CVE Trivy result becomes 5-10 actionable items when you filter to CRITICAL/HIGH with available fixes, separate OS-layer from app-layer CVEs, and check whether the vulnerable package is actually reachable from your application. Distroless images remove the OS-layer noise entirely. CI/CD integration stops debt accumulation. The rest is scheduling.
Frequently asked questions
Why does my container image have hundreds of CVEs when I just built it?
Base OS images ship with hundreds of packages, and most packages have known CVEs at any given time. A fresh ubuntu:22.04 pull typically shows 150-250 CVEs in a Trivy scan; alpine:3.18 shows fewer (40-80) because it ships fewer packages. The raw count drops dramatically when you apply practical filters: limit to CRITICAL and HIGH severity, add --ignore-unfixed to remove CVEs with no available fix, and review whether the vulnerable packages are ones your application code actually invokes. Most organizations find that a 200-CVE result becomes 8-15 actionable items after triage.
What is the difference between OS-layer and application-layer CVEs in containers?
OS-layer CVEs are vulnerabilities in the packages installed by the base image's package manager (apt, apk, rpm). Examples: a CVE in libssl, glibc, or libexpat that came with ubuntu:22.04. These are real vulnerabilities but often not exploitable in a container context if your application never calls the affected library function. Application-layer CVEs are vulnerabilities in your language-specific dependencies: your npm packages, pip packages, go modules, or Java JARs. These are almost always more relevant because they are in code your application actively executes. Trivy labels these separately by package type (dpkg/apk for OS layer; node-pkg/python-pkg/gobinary for application layer). Prioritize application-layer CVEs for immediate remediation.
Should I use Trivy or Grype?
Both are strong open-source tools with wide adoption. Trivy has broader scope: it scans container images, IaC files (Terraform, Dockerfile, Kubernetes manifests), Git repositories, and can generate SBOMs in CycloneDX or SPDX format alongside vulnerability results. Grype, from Anchore, focuses specifically on container image and SBOM vulnerability scanning and is generally faster for that use case. Use Trivy if you want a single tool that covers container scanning, IaC scanning, and secret detection in one pipeline step. Use Grype if you are building a focused container scanning pipeline and want speed and minimal configuration. Both use the same underlying vulnerability databases (NVD, GitHub Advisory Database, OS-specific advisories) so results are comparable.
What is a distroless image and should I use one?
Google's distroless images contain only the application runtime (Node.js, Python, Java, or a static binary environment) with no shell, no package manager, no coreutils, and no OS utilities. The attack surface is dramatically reduced: an attacker who achieves code execution in a distroless container has no shell to exec into, no wget or curl to download additional tools, and no package manager to install them. CVE count typically drops 60-80% compared to a full OS base image. The trade-off: you cannot exec into a running distroless container with /bin/bash for debugging. The solution: use the :debug variant of distroless images in development (includes a busybox shell via --entrypoint) and the standard variant in production. For most production workloads, distroless is the right choice.
How do I prioritize which container CVEs to actually fix?
Use this filter sequence: (1) Severity: start with CRITICAL and HIGH only. (2) Fixability: filter to vulnerabilities where a fixed version is available -- Trivy's --ignore-unfixed flag does this automatically. (3) Reachability: is the vulnerable package in the OS layer (potentially unused) or the application layer (likely used)? (4) Exploitability: check NVD (nvd.nist.gov) for the CVE -- does it have a known exploit, a CVSS exploitability score above 8.0, and a network attack vector? Deprioritize: build-time-only packages that are not present in the final image layer, OS packages your application never calls, CVEs with no known exploit and no fix. Prioritize: application dependencies with HIGH or CRITICAL severity + available fix + network attack vector.
How do I integrate container scanning into CI/CD without failing every build?
Set a realistic fail threshold and enforce it consistently. The recommended starting configuration: fail builds on CRITICAL severity findings where a fixed version is available. Use `trivy image --exit-code 1 --severity CRITICAL --ignore-unfixed myapp:$SHA` as the gate step. Create a separate non-blocking step that reports HIGH and MEDIUM findings to a dashboard or GitHub Security tab without failing the build. This approach stops new CRITICAL vulnerabilities from being deployed while giving your team visibility into the HIGH/MEDIUM backlog without blocking development. Once your CRITICAL backlog reaches zero, extend the gate to include HIGH severity. Use a .trivyignore file to document accepted risks with justification.
Sources & references
Free resources
Critical CVE Reference Card 2025–2026
25 actively exploited vulnerabilities with CVSS scores, exploit status, and patch availability. Print it, pin it, share it with your SOC team.
Ransomware Incident Response Playbook
Step-by-step 24-hour IR checklist covering detection, containment, eradication, and recovery. Built for SOC teams, IR leads, and CISOs.
Get threat intel before your inbox does.
50,000+ security professionals read Decryption Digest for early warnings on zero-days, ransomware, and nation-state campaigns. Free, weekly, no spam.
Unsubscribe anytime. We never sell your data.

Founder & Cybersecurity Evangelist, Decryption Digest
Cybersecurity professional with expertise in threat intelligence, vulnerability research, and enterprise security. Covers zero-days, ransomware, and nation-state operations for 50,000+ security professionals weekly.
The Mythos Brief is free.
AI that finds 27-year-old zero-days. What it means for your security program.
