Docker Container Security Scanning: Finding Vulnerabilities Before Production
Container security scanners (Trivy, Grype, Docker Scout) analyze Docker images for known CVEs in OS packages and application dependencies. This guide covers running scans locally, integrating into GitHub Actions, enforcing severity thresholds, and reducing attack surface by choosing minimal base images.
Every Docker image is a bundle of OS packages and application dependencies. Each package is a potential vulnerability. Container security scanning matches your image's package versions against CVE databases (NVD, OSV, GitHub Advisory Database) and reports which packages have known exploits—before those images reach production.
The Tools: Trivy, Grype, and Docker Scout
Three tools dominate container scanning:
- Trivy (Aqua Security): Open source, fast, comprehensive. Scans OS packages, language dependencies (npm, pip, go.sum), Dockerfiles, and Kubernetes manifests. The most widely adopted.
- Grype (Anchore): Open source, focused on container and filesystem scanning. Strong SBOM integration.
- Docker Scout: Built into Docker CLI. Easiest to get started, integrates with Docker Hub. Less configurable than Trivy.
This guide focuses on Trivy—it's the most feature-complete for CI integration—with Docker Scout as an alternative for simpler setups.
Trivy: Quick Start
Install
# macOS
brew install trivy
# Linux (script)
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh
# Docker (no install)
docker run --rm aquasec/trivy image nginx:latestScan an Image
# Scan a public image
trivy image nginx:latest
# Scan a local image
docker build -t myapp:latest .
trivy image myapp:latest
# Scan only HIGH and CRITICAL vulnerabilities
trivy image --severity HIGH,CRITICAL myapp:latestSample output:
myapp:latest (ubuntu 22.04)
Total: 12 (UNKNOWN: 0, LOW: 3, MEDIUM: 4, HIGH: 4, CRITICAL: 1)
┌─────────────────┬────────────────┬──────────┬──────────────────┬──────────────────┬────────────────────────────────────┐
│ Library │ Vulnerability │ Severity │ Installed Version│ Fixed Version │ Title │
├─────────────────┼────────────────┼──────────┼──────────────────┼──────────────────┼────────────────────────────────────┤
│ libssl3 │ CVE-2024-0727 │ CRITICAL │ 3.0.2-0ubuntu1.9 │ 3.0.2-0ubuntu1.15│ OpenSSL: Issue summary... │
│ curl │ CVE-2023-38545 │ HIGH │ 7.81.0-1ubuntu1.9│ 7.81.0-1ubuntu1.15│ curl: SOCKS5 heap buffer overflow │
└─────────────────┴────────────────┴──────────┴──────────────────┴──────────────────┴────────────────────────────────────┘Scan Dockerfiles (Misconfiguration Detection)
trivy config Dockerfile
# Or scan entire directory for Dockerfiles
trivy config .Catches common misconfigurations:
- Running as root
ADDinstead ofCOPYfor local files- Sensitive environment variables in image layers
- Missing USER instruction
Fail on Severity Thresholds
Block builds that introduce high-severity vulnerabilities:
# Exit code 1 if any CRITICAL vulnerabilities found
trivy image --exit-code 1 --severity CRITICAL myapp:latest
# Exit code 1 if HIGH or CRITICAL
trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:latestWith --exit-code 1, Trivy exits non-zero when vulnerabilities matching the severity are found—failing your CI pipeline.
GitHub Actions Integration
Basic Scan on Push
# .github/workflows/security-scan.yml
name: Security Scan
on:
push:
branches: [main]
pull_request:
jobs:
trivy-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: 'CRITICAL,HIGH'
exit-code: '1'
- name: Upload SARIF results to GitHub Security
uses: github/codeql-action/upload-sarif@v3
if: always() # Upload even on scan failure
with:
sarif_file: trivy-results.sarifThe SARIF upload shows vulnerabilities directly in GitHub's Security tab, integrated with your repository's security overview.
Scan with Table Output for PR Comments
- name: Run Trivy (table format for PR)
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: table
output: trivy-results.txt
severity: 'CRITICAL,HIGH'
- name: Comment on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const results = fs.readFileSync('trivy-results.txt', 'utf8');
if (results.trim()) {
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `## Security Scan Results\n\`\`\`\n${results.slice(0, 4000)}\n\`\`\``
});
}Scanning in a Multi-Stage Build Pipeline
Scan before pushing to registry:
# Build
docker build -t myapp:latest .
# Scan — fail if CRITICAL found
trivy image --exit-code 1 --severity CRITICAL myapp:latest
# Only push if scan passed
if [ $? -eq 0 ]; then
docker push myregistry.io/myapp:latest
else
echo "Security scan failed — image not pushed"
exit 1
fiGenerating SBOMs
A Software Bill of Materials (SBOM) lists every package in your image. Required for supply chain compliance (SLSA, NTIA):
# Generate SBOM in CycloneDX format
trivy image --format cyclonedx --output sbom.json myapp:latest
# Generate in SPDX format
trivy image --format spdx-json --output sbom.spdx.json myapp:latestStore SBOMs as build artifacts alongside your images. They enable retrospective analysis when new CVEs are published—you can determine which deployed images are affected without rescanning.
Ignoring False Positives
Some vulnerabilities are unfixable (no fixed version exists) or don't apply to your usage. Create a .trivyignore file:
# .trivyignore
# Format: CVE-ID [expiry-date] [comment]
# Unfixable in current Ubuntu LTS, not exploitable in our context
CVE-2022-37434
# Expires 2026-12-31 while we wait for upstream fix
CVE-2023-52425 exp:2026-12-31
# Only affects Windows builds
CVE-2024-21626Trivy skips ignored CVEs in its exit code calculation. Be conservative with ignores—document why each one is safe.
Reducing Attack Surface: Minimal Base Images
The best vulnerability is the one that's not installed. Choose minimal base images:
# Instead of:
FROM ubuntu:22.04 # ~200 packages, 100+ vulnerabilities
# Use:
FROM gcr.io/distroless/java17 # Java runtime only, no shell
# or
FROM alpine:3.19 # ~14 packages, minimal attack surface
# or
FROM scratch # Truly minimal — static binaries onlyDistroless images from Google contain only the application runtime. They have no shell, no package manager, no debugging tools—which means no CVEs from those tools either.
# Multi-stage: build with full image, ship with distroless
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o server .
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
CMD ["/server"]Trivy scanning this image typically returns 0 vulnerabilities vs. 50+ for the equivalent ubuntu:22.04-based image.
Docker Scout (Alternative)
If you're Docker Hub-centric, Docker Scout is built into the Docker CLI:
# Enable Docker Scout (one-time)
docker scout quickview myapp:latest
# Get CVE list
docker scout cves myapp:latest
# Compare to a previous version
docker scout compare myapp:new --to myapp:old
# GitHub Actions
- uses: docker/scout-action@v1
with:
command: cves
image: myapp:latest
only-severities: critical,high
exit-code: trueDocker Scout integrates with Docker Hub's policy framework—you can define policies (no CRITICAL CVEs allowed) and enforce them organization-wide.
Scheduled Registry Scanning
Vulnerabilities are published daily. An image that passed scanning on build day may be vulnerable a month later:
# .github/workflows/registry-scan.yml
name: Nightly Registry Scan
on:
schedule:
- cron: '0 6 * * *' # 6am UTC daily
jobs:
scan-production-images:
runs-on: ubuntu-latest
strategy:
matrix:
image:
- myregistry.io/app:latest
- myregistry.io/worker:latest
- myregistry.io/api:latest
steps:
- name: Scan ${{ matrix.image }}
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ matrix.image }}
severity: 'CRITICAL'
exit-code: '1'
format: sarif
output: scan-results.sarif
- name: Upload results
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: scan-results.sarifNew CRITICAL CVEs trigger GitHub Security alerts, paging on-call to rebuild and redeploy.
Summary
Container security scanning is CI enforcement, not a one-time audit. Scan every image build with Trivy, block pushes that introduce CRITICAL vulnerabilities, and scan registry images nightly for newly published CVEs. Reduce your baseline vulnerability count by switching from ubuntu or debian to alpine or distroless base images—fewer installed packages means fewer CVEs. Store SBOMs with every release for supply chain accountability. The teams that treat image scanning like unit tests (automated, blocking, in CI) are the ones that don't show up in breach headlines.