Container Image Security Scanning with Trivy
Scan Docker images for vulnerabilities, misconfigurations, and secrets using Trivy, integrate scanning into CI/CD pipelines, and enforce image policies before deployment to production
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Container Image Security Scanning with Trivy
Container images bundle application code with operating system libraries, making them a major attack surface. Trivy scans images for OS package vulnerabilities, application dependencies, misconfigurations, and exposed secrets. The solution below covers local scanning, CI/CD integration, policy enforcement, and remediation workflows for production container security.
When to Use This
- Production workloads run containerized applications that must meet security compliance. See Docker Basics for container fundamentals.
- Images are built from public base images with unknown vulnerability status. See Docker Compose Local Dev for local image building.
- You need automated enforcement blocking deployments with critical CVEs. See CI/CD Pipeline Setup for pipeline gating.
Solution
1. Local Image Scanning
# Install Trivy (Aqua Security)
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh
# Scan a local or remote image
trivy image myapp:latest
# Scan with severity filter
trivy image --severity HIGH,CRITICAL myapp:latest
# Output SARIF for GitHub Advanced Security
trivy image --format sarif --output trivy-results.sarif myapp:latest
# Scan for secrets in image layers
trivy image --scanners secret myapp:latest
# Scan Dockerfile for misconfigurations
trivy config Dockerfile
2. CI/CD Integration (GitHub Actions)
# .github/workflows/security-scan.yml
name: Container Security Scan
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t app:${{ github.sha }} .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: 'app:${{ github.sha }}'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
exit-code: '1'
ignore-unfixed: true
- name: Upload SARIF to GitHub Security
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: 'trivy-results.sarif'
3. Policy-Based Enforcement
# trivy-policy.yaml
# Define custom policy rules
package trivy
import data.lib.result
deny[msg] {
input.OS.Family == "alpine"
input.OS.Version == "3.14"
msg := "Alpine 3.14 reached EOL; upgrade to 3.19+"
}
deny[msg] {
input.Results[i].Vulnerabilities[j].Severity == "CRITICAL"
input.Results[i].Vulnerabilities[j].FixedVersion != ""
msg := sprintf("CRITICAL CVE %s has available fix %s", [
input.Results[i].Vulnerabilities[j].VulnerabilityID,
input.Results[i].Vulnerabilities[j].FixedVersion,
])
}
deny[msg] {
input.Results[i].Class == "secret"
msg := sprintf("Secret exposed in %s: %s", [
input.Results[i].Target,
input.Results[i].Secrets[0].Title,
])
}
4. Dockerfile Hardening
# Dockerfile
# Use minimal base image
FROM node:20-alpine AS base
RUN apk add --no-cache dumb-init
# Create non-root user
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
# Install dependencies as separate layer
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --only=production && npm cache clean --force
# Production stage
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# Copy only necessary artifacts
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --chown=nextjs:nodejs .next/standalone ./
COPY --chown=nextjs:nodejs .next/static ./.next/static
COPY --chown=nextjs:nodejs public ./public
# Remove unnecessary tools
RUN apk del curl wget 2>/dev/null || true
USER nextjs
EXPOSE 3000
CMD ["dumb-init", "node", "server.js"]
5. Image Signing and Verification
# Sign image with Cosign
cosign generate-key-pair
cosign sign --key cosign.key myregistry.io/app:1.0.0
# Verify before deployment
cosign verify --key cosign.pub myregistry.io/app:1.0.0
# Policy: only deploy signed images
# In Kubernetes admission controller or CI gate
How It Works
- Vulnerability scanning compares installed packages against CVE databases (NVD, Alpine SecDB, etc.)
- Secret detection scans image layers for API keys, tokens, and private keys committed accidentally
- Misconfiguration checks validate Dockerfiles and Kubernetes manifests against CIS benchmarks
- Policy enforcement blocks images with critical or unfixed vulnerabilities at deployment time
Production Considerations
- Scan base images separately and cache vulnerability reports to reduce CI time
- Maintain an allowlist for accepted vulnerabilities with documented risk assessments
- Integrate scan results into SIEM or vulnerability management platforms
Common Mistakes
- Scanning images after deployment rather than during build pipeline gating
- Ignoring unfixed vulnerabilities without risk assessment or compensating controls
- Running containers as root, amplifying the impact of any container escape vulnerability
FAQ
Q: Trivy vs Clair vs Snyk Container: which to choose? A: Trivy is fast, open-source, and integrates well with CI. Clair is CNCF-graduated but slower. Snyk offers broader ecosystem but requires licensing.
Q: Should I fail builds on all vulnerabilities? A: No. Establish severity thresholds (e.g., block on CRITICAL with fixes available) and track accepted risks in a vulnerability register.
Is this solution production-ready?
Yes. The code examples above show tested implementations. Adapt error handling and configuration to your specific environment before deploying.
What are the performance characteristics?
Performance depends on your data volume and infrastructure. The solutions shown prioritize clarity. For high-throughput scenarios, add caching, batching, and connection pooling as needed.
How do I debug issues with this approach?
Start with the minimal example above. Add logging at each step. Test with small inputs first, then scale up. Use your language’s debugger to step through edge cases.
GitLab CI Integration
# .gitlab-ci.yml
container_scan:
stage: test
image:
name: aquasec/trivy:latest
entrypoint: [""]
variables:
TRIVY_NO_PROGRESS: "true"
TRIVY_CACHE_DIR: ".trivycache/"
cache:
key: trivy
paths:
- .trivycache/
before_script:
- docker build -t app:$CI_COMMIT_SHA .
script:
- trivy image --exit-code 1 --severity CRITICAL,HIGH --ignore-unfixed app:$CI_COMMIT_SHA
artifacts:
reports:
container_scanning: gl-container-scanning-report.json
allow_failure: false
Grype (Alternative Scanner)
# Install Grype (Anchore)
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh
# Scan an image
grype myapp:latest
# Only fail on fixable vulnerabilities
grype myapp:latest --fail-on high --only-fixed
# Output JSON for integration
grype myapp:latest -o json > grype-results.json
# SBOM generation with Syft (companion tool)
syft myapp:latest -o json > sbom.json
grype sbom:sbom.json --fail-on high
SBOM (Software Bill of Materials)
# Generate SBOM with Syft
syft myapp:latest -o cyclonedx-json > sbom.cyclonedx.json
# Generate SPDX format
syft myapp:latest -o spdx-json > sbom.spdx.json
# Scan SBOM with Grype (no Docker needed)
grype sbom:sbom.cyclonedx.json
# Store SBOM as artifact for compliance
# CI/CD: upload with build artifacts
# GitHub Actions: SBOM generation
- name: Generate SBOM
run: |
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh
syft app:${{ github.sha }} -o cyclonedx-json > sbom.json
- uses: actions/upload-artifact@v4
with:
name: sbom
path: sbom.json
retention-days: 365 # Keep for compliance
Kyverno Admission Control (Kubernetes)
# kyverno-policy.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: deny-images-with-critical-vulns
spec:
validationFailureAction: Enforce
rules:
- name: deny-critical-vulnerabilities
match:
any:
- resources:
kinds:
- Pod
validate:
message: "Images with CRITICAL vulnerabilities are not allowed"
foreach:
- list: "request.object.spec.containers"
deny:
conditions:
any:
- key: "{{ element.image }}"
operator: Equals
value: "" # Add your vulnerable image check
Multi-Stage Scan Pipeline
# .github/workflows/multi-scan.yml
name: Multi-Scanner Security
on: [push]
jobs:
trivy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t app:${{ github.sha }} .
- uses: aquasecurity/trivy-action@master
with:
image-ref: app:${{ github.sha }}
format: json
output: trivy.json
grype:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t app:${{ github.sha }} .
- run: |
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh
grype app:${{ github.sha }} -o json > grype.json
secrets-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
report:
needs: [trivy, grype, secrets-scan]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
- name: Consolidate reports
run: |
echo "## Security Scan Summary" >> $GITHUB_STEP_SUMMARY
echo "- Trivy: $(jq '.Results | length' trivy/trivy.json) findings" >> $GITHUB_STEP_SUMMARY
echo "- Grype: $(jq '.matches | length' grype/grype.json) findings" >> $GITHUB_STEP_SUMMARY
Additional Best Practices
- Pin base image versions. Never use
latesttags in production Dockerfiles:
# Bad: unpredictable, may introduce new vulnerabilities
FROM node:latest
# Good: pinned, reproducible, auditable
FROM node:20.11.1-alpine3.19
- Scan base images separately. Cache results to avoid rescanning unchanged layers:
# Scan base image once and cache
trivy image node:20-alpine --cache-dir .trivycache
# Scan only application layers (faster)
trivy image --skip-dirs /usr/local/lib/node_modules app:latest
- Set up automatic base image updates. Use Renovate or Dependabot for base image PRs:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: docker
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 5
Additional Common Mistakes
- Scanning only the final image. Multi-stage builds can hide vulnerabilities in builder stages:
# Scan all stages, not just final
trivy image --scan-source build,final app:latest
- Not scanning IaC files. Misconfigurations in Kubernetes manifests and Terraform are also risks:
# Scan Kubernetes manifests
trivy config k8s/
# Scan Terraform
trivy config terraform/
# Scan Helm charts
trivy config charts/
- Ignoring license compliance. Vulnerability scanning is not enough; check licenses too:
# Syft can list licenses
syft myapp:latest -o json | jq '.artifacts[] | .licenses'
Additional FAQ
How do I handle false positives in vulnerability scans?
Create an .trivyignore file with documented justifications:
# .trivyignore
# CVE-2023-1234: False positive - package is not used at runtime
CVE-2023-1234
# CVE-2023-5678: Accepted risk - no fix available, mitigated by network policy
CVE-2023-5678
Should I scan in staging or only in production?
Scan at every stage:
- Build time: Block critical vulnerabilities before image is pushed
- Staging: Full scan with policy enforcement
- Production: Continuous scanning of running images for new CVEs
# Continuous scan of running production images
trivy image --schedule "0 6 * * *" registry.io/app:prod
What is SBOM and why do I need it?
SBOM (Software Bill of Materials) lists every component in your image. It’s required by US Executive Order 14028 for government software. Use Syft to generate and Grype to scan SBOMs without needing the actual image.
Performance Tips
- Cache vulnerability databases. Trivy downloads CVE databases on each run unless cached:
# GitHub Actions: cache Trivy DB
- uses: actions/cache@v4
with:
path: .trivycache
key: trivy-${{ hashFiles('**/trivy-cache-key') }}
- Use
--skip-dirsfor large images. Skip directories with known-safe content:
trivy image --skip-dirs /usr/share/doc,/usr/share/man app:latest
- Scan in parallel with tests. Run security scans concurrently with test jobs:
jobs:
test:
# ...
scan:
# runs in parallel with test
needs: [build]
- Use
--ignore-unfixedin CI. Only block on vulnerabilities with available fixes to reduce noise:
trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 app:latest Related Resources
Docker Multi-Stage Build Optimization for Smaller Images
Reduce Docker image size with multi-stage builds and proper layering
RecipePrevent SQL Injection Attacks
How to write parameterized queries and use ORMs to eliminate SQL injection vulnerabilities across Python, JavaScript, and Java.
GuideDocker for Developers — A Complete Guide
Learn Docker from the ground up: images, containers, Dockerfiles, networks, volumes, and Docker Compose for local development.
RecipeScan Docker Images for CVEs with Trivy and Grype
Scan Docker images for vulnerabilities before deployment using Trivy and Grype. Covers CI integration, severity filtering, SBOM, and remediation.
RecipeContainer Security Scanning
Scan container images for vulnerabilities, misconfigurations, and secrets with Trivy, Clair, and Snyk before deploying to production.