Vulnerability Scanning for Container Images
Why Container Scanning Is Non-Negotiable
Container images accumulate vulnerabilities through three vectors: base images ship with OS-level CVEs, installed packages introduce application-level vulnerabilities, and application dependencies pull in transitive risks. A single unscanned image can contain dozens of known exploits.
The critical insight is that scanning must happen at build time, not after deployment. A vulnerability discovered in production means your attack surface was already exposed. A vulnerability caught in CI means it never reached a cluster.
The Three Scanning Vectors
| Vector | Example | Detection Method |
|---|---|---|
| OS packages | OpenSSL CVE in base image | Image layer scanning (Trivy, Grype) |
| Language dependencies | Vulnerable npm/pip/gem package | SBOM analysis + advisory database |
| Misconfigurations | Running as root, exposed ports | Dockerfile linting (Trivy config, Hadolint) |
A comprehensive scanning strategy covers all three. Most teams start with image scanning and add configuration scanning as they mature.
Trivy: The Swiss Army Knife
Trivy (by Aqua Security) is the most versatile open-source scanner. It handles images, filesystems, Git repositories, and Kubernetes clusters with a single binary.
Basic Scanning
# Scan a local image for vulnerabilities
trivy image --severity HIGH,CRITICAL myapp:latest
# Scan and fail CI if critical vulnerabilities are found
trivy image --exit-code 1 --severity CRITICAL myapp:latest
# Scan a Dockerfile for misconfigurations
trivy config --severity HIGH,CRITICAL ./Dockerfile
# Scan a filesystem (catches vulnerabilities in source code dependencies)
trivy filesystem --severity HIGH,CRITICAL .
# Generate an SBOM (Software Bill of Materials)
trivy image --format spdx-json -o sbom.json myapp:latest
Trivy in CI/CD
# .github/workflows/container-scan.yml
name: Container Security Scan
on:
push:
paths:
- 'Dockerfile*'
- 'package*.json'
- 'requirements*.txt'
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan for vulnerabilities
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'HIGH,CRITICAL'
exit-code: '1'
- name: Upload scan results to GitHub Security
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: 'trivy-results.sarif'
- name: Scan Dockerfile for misconfigurations
uses: aquasecurity/trivy-action@master
with:
scan-type: 'config'
scan-ref: '.'
severity: 'HIGH,CRITICAL'
Ignoring False Positives
Not every CVE is exploitable in your context. Trivy supports a .trivyignore file:
# .trivyignore
# CVE-2024-1234: Not exploitable because we don't use the affected function
CVE-2024-1234
# Temporary ignore until upstream fix is released (expires 2026-03-01)
# CVE-2025-5678: Waiting for Node.js 22.3 patch
CVE-2025-5678
Always document why you are ignoring a CVE. Undocumented ignores accumulate into hidden risk.
Grype: Anchore's Vulnerability Scanner
Grype is another popular scanner from the Anchore ecosystem. It pairs well with Syft (Anchore's SBOM generator):
# Scan a local image
grype myapp:latest --fail-on high
# Scan from an SBOM (faster for repeated scans)
syft myapp:latest -o json > sbom.json
grype sbom:sbom.json --fail-on critical
# Output in table format for human review
grype myapp:latest --output table
# Output in SARIF for CI integration
grype myapp:latest --output sarif > grype-results.sarif
Grype vs Trivy Comparison
| Feature | Trivy | Grype |
|---|---|---|
| Vulnerability databases | NVD, OS-specific, language-specific | NVD, OS-specific, language-specific |
| Dockerfile scanning | Yes (config mode) | No (image only) |
| K8s cluster scanning | Yes | No (use Anchore Enterprise) |
| SBOM generation | Built-in | Separate tool (Syft) |
| Speed | Fast | Fast |
| False positive rate | Low | Low |
| Ignore mechanism | .trivyignore file |
.grype.yaml config |
Many teams use both and compare results. Different scanners have different advisory databases and may catch different CVEs.
Snyk Container: Commercial with Fix Recommendations
Snyk differentiates itself by providing fix recommendations -- it tells you which base image upgrade or package version would resolve the vulnerability:
# Scan with fix recommendations
snyk container test myapp:latest --severity-threshold=high
# Monitor continuously (alerts on new CVEs for deployed images)
snyk container monitor myapp:latest
# Get base image upgrade recommendations
snyk container test myapp:latest --file=Dockerfile
# Output includes:
# Tested 1 image, found 12 vulnerabilities
# Base Image Vulnerabilities Severity
# node:20 42 8 critical, 15 high
#
# Recommendations:
# Minor upgrade: node:20.11 (removes 8 vulnerabilities)
# Alternative: node:20-slim (removes 30 vulnerabilities)
# Best: node:20-alpine (removes 38 vulnerabilities)
Registry Scanning: Continuous Protection
Build-time scanning is necessary but not sufficient. New CVEs are disclosed daily, and an image that was clean yesterday may be vulnerable today. Registry scanning catches this drift.
Scanning Strategies
| Strategy | When | Tool |
|---|---|---|
| Build-time scan | Every CI build | Trivy, Grype, Snyk |
| Registry scan | Daily / on push | Harbor, ECR scanning, Trivy operator |
| Runtime scan | Continuous | Falco, Sysdig, Aqua |
| SBOM audit | On new CVE disclosure | Grype against stored SBOMs |
AWS ECR Scanning
# Enable enhanced scanning on ECR repository
aws ecr put-image-scanning-configuration \
--repository-name myapp \
--image-scanning-configuration scanOnPush=true
# Check scan results
aws ecr describe-image-scan-findings \
--repository-name myapp \
--image-id imageDigest=sha256:abc123
Harbor Registry Scanning
# Harbor automatically scans on push and can block vulnerable images
# Configuration in Harbor UI or API:
# - Scan on push: enabled
# - Prevent vulnerable images from running:
# severity threshold: High
# - Auto-scan schedule: daily at 02:00 UTC
Building a Scanning Pipeline
The recommended approach layers scans at multiple points:
Developer machine CI Pipeline Registry Runtime
| | | |
|-- Hadolint |-- Build image |-- Scan on push |-- Falco
| (Dockerfile lint) |-- Trivy scan |-- Daily rescan | (anomaly
| |-- Grype scan |-- Block pulls | detection)
|-- docker scan |-- Fail on CRITICAL | of vulnerable |
| (quick local check) |-- Generate SBOM | images |
| |-- Upload to registry | |
Sample CI Stage
#!/bin/bash
# scripts/scan-container.sh
set -euo pipefail
IMAGE="$1"
SEVERITY_THRESHOLD="${2:-HIGH}"
echo "=== Scanning $IMAGE ==="
# Stage 1: Dockerfile lint
echo "--- Hadolint ---"
hadolint Dockerfile --failure-threshold warning
# Stage 2: Image vulnerability scan
echo "--- Trivy ---"
trivy image --exit-code 1 --severity "$SEVERITY_THRESHOLD" "$IMAGE"
# Stage 3: SBOM generation
echo "--- SBOM ---"
trivy image --format spdx-json -o "sbom-$(date +%Y%m%d).json" "$IMAGE"
# Stage 4: Cross-reference with Grype
echo "--- Grype ---"
grype "$IMAGE" --fail-on high
echo "=== All scans passed ==="
This layered approach ensures that even if one scanner misses a CVE, another catches it. The SBOM provides an audit trail for future vulnerability disclosures.