Docker Container Security Scanning: Finding Vulnerabilities Before Production

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:latest

Scan 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:latest

Sample 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
  • ADD instead of COPY for 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:latest

With --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.sarif

The 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
fi

Generating 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:latest

Store 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-21626

Trivy 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 only

Distroless 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: true

Docker 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.sarif

New 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.

Read more

Start now free