Module 5: Docker in CI/CD
6. Image Scanning in CI
Overview
Your pipeline builds, tags, and pushes images automatically. But what's inside those images? Every Docker image contains a base operating system, system packages, Python dependencies, and your code. Any of those layers can have known vulnerabilities — from an outdated openssl in the base image to a Python package with a published CVE.
Without scanning, those vulnerabilities reach production silently. With scanning in CI, you detect vulnerabilities before pushing the image to the registry. A quality gate that blocks the push when there are CRITICAL vulnerabilities is the last line of defense between your code and production.
The tool: Trivy is the most popular scanner for Docker images in CI. It's open source, fast, and it integrates natively with GitHub Actions. It scans for vulnerabilities in the OS, system packages, and language dependencies (pip, npm, etc.).
This capsule teaches you to integrate trivy into your pipeline, to interpret the results, to distinguish between vulnerabilities you must block and the ones you can temporarily accept, and to configure reasonable quality gates.
Why scan images
What can be vulnerable
Your Docker image contains:
1. The base image (python:3.12-slim)
└── Debian Bookworm packages
└── openssl, zlib, curl, libc, ...
└── They can have published CVEs
2. System packages (apt-get install)
└── curl, build-essential, etc.
└── They can have vulnerabilities
3. Python dependencies (pip install)
└── openai, fastapi, uvicorn, langchain, ...
└── Transitive dependencies (httpx, pydantic, starlette, ...)
└── Any of them can have a CVE
4. Your code
└── Trivy doesn't scan business logic
└── But it detects hardcoded secrets and misconfigurations
The reality of vulnerabilities
When you scan an image for the first time, you're going to see something like this:
Total: 47 vulnerabilities (CRITICAL: 0, HIGH: 3, MEDIUM: 18, LOW: 26)
47 vulnerabilities sounds alarming. But most of them are:
- 📋 In transitive dependencies — packages you didn't install directly
- 📋 Of low severity — a theoretical risk, not exploitable in your context
- 📋 With no fix available — the maintainer hasn't published a patch yet
- 📋 In the base image — inherited from Debian, not from your code
What matters are the CRITICAL and HIGH ones — especially those with a fix available. Those are the actionable ones.
Trivy: Setup in GitHub Actions
Installation and the first scan
Trivy has an official action: aquasecurity/trivy-action. One step:
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: table
output: trivy-results.txt
severity: CRITICAL,HIGH,MEDIUM,LOW
The complete workflow: Build + Scan
# .github/workflows/docker.yml
name: Docker Build & Scan
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
docker:
runs-on: ubuntu-latest
timeout-minutes: 25
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build image
uses: docker/build-push-action@v6
with:
context: .
load: true
tags: myapp:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: table
output: trivy-results.txt
severity: CRITICAL,HIGH,MEDIUM,LOW
- name: Display scan results
run: cat trivy-results.txt
- name: Upload scan results
if: always()
uses: actions/upload-artifact@v4
with:
name: trivy-scan-results
path: trivy-results.txt
retention-days: 30
Trivy's output
myapp:abc1234def (debian 12.4)
Total: 12 (CRITICAL: 0, HIGH: 2, MEDIUM: 5, LOW: 5)
┌──────────────┬────────────────┬──────────┬────────────┬───────────────┬──────────────────────────────────────┐
│ Library │ Vulnerability │ Severity │ Installed │ Fixed Version │ Title │
├──────────────┼────────────────┼──────────┼────────────┼───────────────┼──────────────────────────────────────┤
│ libssl3 │ CVE-2024-0727 │ HIGH │ 3.0.11-1 │ 3.0.13-1 │ openssl: denial of service via null │
│ │ │ │ │ │ dereference │
├──────────────┼────────────────┼──────────┼────────────┼───────────────┼──────────────────────────────────────┤
│ libexpat1 │ CVE-2023-52425 │ HIGH │ 2.5.0-1 │ 2.5.0-1+deb12 │ expat: parsing large tokens can │
│ │ │ │ │ u1 │ trigger a denial of service │
├──────────────┼────────────────┼──────────┼────────────┼───────────────┼──────────────────────────────────────┤
│ curl │ CVE-2024-2004 │ MEDIUM │ 7.88.1-10 │ │ curl: Usage of disabled protocol │
├──────────────┼────────────────┼──────────┼────────────┼───────────────┼──────────────────────────────────────┤
│ ... │ ... │ ... │ ... │ ... │ ... │
└──────────────┴────────────────┴──────────┴────────────┴───────────────┴──────────────────────────────────────┘
Python (pip)
Total: 3 (CRITICAL: 0, HIGH: 0, MEDIUM: 2, LOW: 1)
┌──────────────┬────────────────┬──────────┬────────────┬───────────────┬──────────────────────────────────────┐
│ Library │ Vulnerability │ Severity │ Installed │ Fixed Version │ Title │
├──────────────┼────────────────┼──────────┼────────────┼───────────────┼──────────────────────────────────────┤
│ urllib3 │ CVE-2024-37891 │ MEDIUM │ 2.0.7 │ 2.2.2 │ urllib3: proxy-authorization header │
│ │ │ │ │ │ not stripped on cross-origin redirect │
├──────────────┼────────────────┼──────────┼────────────┼───────────────┼──────────────────────────────────────┤
│ ... │ ... │ ... │ ... │ ... │ ... │
└──────────────┴────────────────┴──────────┴────────────┴───────────────┴──────────────────────────────────────┘
Interpreting the results
Severity levels
| Level | What it means | Recommended action |
|---|---|---|
| CRITICAL | Remotely exploitable, no authentication, total impact | 🔴 Block the push. Fix immediately. |
| HIGH | Exploitable under certain conditions, significant impact | 🟠 Evaluate case by case. Fix soon. |
| MEDIUM | It requires specific conditions to exploit | 🟡 Monitor. Fix in the next cycle. |
| LOW | A theoretical risk, hard to exploit in practice | 🟢 Accept temporarily. Review periodically. |
What to do with each vulnerability
Does it have a "Fixed Version"?
YES → Update the package to the fixed version
NO → There's no fix available yet
Is it in the base image (Debian packages)?
YES → Update the base image: FROM python:3.12-slim (a forced rebuild)
NO → It's in your dependencies
Is it in a direct dependency (requirements.txt)?
YES → Update the version in requirements.txt
NO → It's a transitive dependency (another dependency installed it)
Is it in a transitive dependency?
YES → Check whether your direct dependency has an update that resolves it
NO → Investigate case by case
Vulnerabilities in base images vs your code
The base image (python:3.12-slim):
→ Vulnerabilities in openssl, zlib, curl, etc.
→ You didn't install them
→ The solution: update the base image periodically
→ FROM python:3.12-slim → a rebuild detects the latest version
Your dependencies (pip install):
→ Vulnerabilities in urllib3, requests, cryptography, etc.
→ You installed them (directly or transitively)
→ The solution: pip install --upgrade <package>
Your code:
→ Trivy doesn't scan business logic
→ But it detects hardcoded secrets (API keys in the code)
→ The solution: move the secrets to environment variables
Quality Gates: Blocking vs Warning
A CRITICAL-only quality gate (recommended to start)
It blocks the push only if there are CRITICAL vulnerabilities:
- name: Scan for CRITICAL vulnerabilities
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: table
exit-code: 1
severity: CRITICAL
The key parameter is exit-code: 1 — if trivy finds vulnerabilities that match the indicated severity, the step fails with exit code 1, which blocks the workflow.
A CRITICAL + HIGH quality gate
Stricter — it blocks if there are CRITICAL or HIGH:
- name: Scan for CRITICAL and HIGH vulnerabilities
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: table
exit-code: 1
severity: CRITICAL,HIGH
Warning without blocking
It shows every vulnerability but never blocks:
- name: Scan image (informational)
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: table
exit-code: 0
severity: CRITICAL,HIGH,MEDIUM,LOW
exit-code: 0 means the step always passes, regardless of the findings.
The dual strategy: block on CRITICAL, warn about the rest
- name: Block on CRITICAL vulnerabilities
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: table
exit-code: 1
severity: CRITICAL
- name: Report all vulnerabilities (informational)
if: always()
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: table
exit-code: 0
output: trivy-full-report.txt
severity: CRITICAL,HIGH,MEDIUM,LOW
- name: Upload full report
if: always()
uses: actions/upload-artifact@v4
with:
name: trivy-report
path: trivy-full-report.txt
retention-days: 30
The first step blocks if there are CRITICAL. The second always runs (if: always()) and generates a complete report as an artifact.
The SARIF format: Integration with GitHub Security
Trivy can generate results in SARIF format, which GitHub's Security tab consumes natively:
- name: Scan and upload to GitHub Security
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH,MEDIUM
- name: Upload SARIF to GitHub Security
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarif
With SARIF, the vulnerabilities appear in your repo's "Security" → "Code scanning alerts" tab. This gives you a visual dashboard of vulnerabilities per image and per commit.
The complete workflow: Build + Scan + Conditional push
This workflow builds, scans, and only pushes if the scan passes:
# .github/workflows/docker.yml
name: Docker Build, Scan & Push
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
docker:
runs-on: ubuntu-latest
timeout-minutes: 25
permissions:
contents: read
packages: write
security-events: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build image
uses: docker/build-push-action@v6
with:
context: .
load: true
tags: myapp:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan for CRITICAL vulnerabilities (blocking)
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: table
exit-code: 1
severity: CRITICAL
- name: Full vulnerability report
if: always()
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH,MEDIUM
- name: Upload SARIF to GitHub Security
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarif
- name: Login to GHCR
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Push image
if: github.event_name == 'push'
run: |
docker tag myapp:${{ github.sha }} ghcr.io/${{ github.repository }}:sha-${{ github.sha }}
docker push ghcr.io/${{ github.repository }}:sha-${{ github.sha }}
The flow:
Build → Scan CRITICAL → Does it pass? → Push
↓
❌ Block (no push)
If the scan finds CRITICAL, the step fails and the later steps (login, push) don't run. The image with critical vulnerabilities never reaches the registry.
Comparisons
trivy vs docker scout vs grype
| Aspect | Trivy | Docker Scout | Grype |
|---|---|---|---|
| Maintainer | Aqua Security | Docker Inc | Anchore |
| GitHub Action | ✅ Official | ✅ Official | ✅ Official |
| Database | NVD + distro advisories | Docker's own DB | NVD + distro |
| Speed | Fast (~30s) | Medium (~45s) | Fast (~30s) |
| Formats | table, json, sarif | json, sarif | table, json, sarif |
| Docker integration | Via an action | Native (docker scout) | Via an action |
| Cost | Free | Free (basic) | Free |
| Popularity in CI | ⭐⭐⭐ High | ⭐⭐ Medium | ⭐⭐ Medium |
exit-code 0 vs exit-code 1
| Config | Behavior | When to use it |
|---|---|---|
exit-code: 0 | It always passes (informational) | A vulnerability report without blocking |
exit-code: 1 | It fails if it finds vulns of the indicated severity | A quality gate that blocks the push |
Severity filtering
| Config | What it blocks | Strictness level |
|---|---|---|
severity: CRITICAL | Only CRITICAL | 🟢 Minimal — it only blocks the critical stuff |
severity: CRITICAL,HIGH | CRITICAL + HIGH | 🟡 Moderate — recommended |
severity: CRITICAL,HIGH,MEDIUM | CRITICAL + HIGH + MEDIUM | 🟠 Strict |
severity: CRITICAL,HIGH,MEDIUM,LOW | Everything | 🔴 Very strict — it can be impractical |
Troubleshooting
1. Trivy finds vulnerabilities with no fix available
Symptom: The report shows HIGH vulnerabilities but the "Fixed Version" column is empty.
Cause: The package's maintainer hasn't published a patch yet.
Solution: Use --ignore-unfixed to exclude vulnerabilities with no fix:
- name: Scan (only fixable)
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: table
exit-code: 1
severity: CRITICAL,HIGH
trivy-config: trivy.yaml
# trivy.yaml
vulnerability:
ignore-unfixed: true
2. Too many vulnerabilities in the base image
Symptom: 40+ vulnerabilities, all in Debian packages like openssl, curl, zlib.
Cause: The base image has outdated system packages.
Solution: Update the base image or use a more minimal one:
# Option 1: Force an update of the system packages
FROM python:3.12-slim
RUN apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/*
# Option 2: Use a more recent image with digest pinning
FROM python:3.12-slim@sha256:abc123...
# Option 3: Use Alpine (fewer packages = less attack surface)
FROM python:3.12-alpine
3. The scan takes too long (> 5 minutes)
Symptom: The trivy step takes a long time to complete.
Cause: Trivy downloads its vulnerability database on every run.
Solution: Cache trivy's database:
- name: Cache Trivy DB
uses: actions/cache@v4
with:
path: ~/.cache/trivy
key: trivy-db-${{ github.run_id }}
restore-keys: trivy-db-
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: table
exit-code: 1
severity: CRITICAL
4. False positives: a vulnerability is reported but doesn't apply
Symptom: Trivy reports a vulnerability in a package your code doesn't use directly.
Cause: It's a transitive dependency or a system package you don't expose.
Solution: Create a .trivyignore file to exclude specific CVEs:
# .trivyignore
# CVE-2024-0727: openssl DoS — we're not exposed, our endpoint doesn't accept client certs
CVE-2024-0727
# CVE-2023-52425: expat DoS — we don't parse XML from users
CVE-2023-52425
Always document WHY you're ignoring each CVE.
Exercises
Exercise 1: Add a trivy scan to an existing build
You have this workflow. Add a scan with trivy that blocks on CRITICAL and reports everything as an artifact:
name: Docker
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
load: true
tags: myapp:test
See solution
name: Docker
on: push
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
load: true
tags: myapp:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan for CRITICAL (blocking)
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: table
exit-code: 1
severity: CRITICAL
- name: Full vulnerability report
if: always()
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: table
exit-code: 0
output: trivy-report.txt
severity: CRITICAL,HIGH,MEDIUM,LOW
- name: Display report
if: always()
run: cat trivy-report.txt
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: trivy-report
path: trivy-report.txt
retention-days: 30
Key points:
- The first scan with
exit-code: 1andseverity: CRITICALblocks if there are critical ones - The second scan with
exit-code: 0generates a complete report without blocking if: always()guarantees that the report gets generated even if the first scan blocked- The report gets saved as an artifact for later review
Exercise 2: A CRITICAL + HIGH quality gate with ignore-unfixed
Configure a quality gate that blocks on CRITICAL and HIGH, but ignores vulnerabilities with no fix available.
See solution
Create trivy.yaml at the project's root:
# trivy.yaml
vulnerability:
ignore-unfixed: true
The workflow:
- name: Scan with quality gate
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: table
exit-code: 1
severity: CRITICAL,HIGH
trivy-config: trivy.yaml
Key points:
trivy-config: trivy.yamlloads the custom configurationignore-unfixed: trueexcludes vulnerabilities where no patch is available- It only blocks if there are CRITICAL or HIGH with a fix available — actionable and reasonable
Exercise 3: A scan with SARIF and GitHub Security
Configure trivy to generate results in SARIF format and upload them to GitHub's Security tab.
See solution
name: Docker Scan
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
scan:
runs-on: ubuntu-latest
timeout-minutes: 25
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build image
uses: docker/build-push-action@v6
with:
context: .
load: true
tags: myapp:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan and generate SARIF
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH,MEDIUM
- name: Upload SARIF to GitHub Security
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: trivy-results.sarif
- name: Block on CRITICAL
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: table
exit-code: 1
severity: CRITICAL
Key points:
permissions: security-events: writeis necessary for uploading SARIFgithub/codeql-action/upload-sarif@v3uploads the file to GitHub Security- The vulnerabilities appear in your repo → Security → Code scanning alerts
- The quality gate is still a separate step with
exit-code: 1
Exercise 4: Create a documented .trivyignore
You have these three vulnerabilities you want to ignore. Create a .trivyignore documenting why each one is ignored:
- CVE-2024-0727 (openssl DoS) — your API doesn't accept client certificates
- CVE-2023-44487 (HTTP/2 rapid reset) — your app only uses HTTP/1.1
- CVE-2024-2004 (curl disabled protocol) — you don't use curl in your production code
See solution
# .trivyignore
#
# This file documents vulnerabilities we have evaluated and decided
# to accept temporarily. Every CVE has a justification.
# Review this file monthly.
# Last review: 2026-03-08
# CVE-2024-0727: openssl denial of service via null dereference
# Severity: HIGH | Package: libssl3
# Justification: The attack requires client certificates. Our API
# doesn't accept client certs — we terminate TLS at the load balancer.
# Review: When we update the base image to Debian Trixie
CVE-2024-0727
# CVE-2023-44487: HTTP/2 rapid reset attack (DoS)
# Severity: HIGH | Package: nghttp2
# Justification: Our API runs behind a reverse proxy that
# handles HTTP/2. Uvicorn only receives HTTP/1.1. We're not exposed.
# Review: When we migrate to HTTP/2 end-to-end
CVE-2023-44487
# CVE-2024-2004: curl usage of disabled protocol
# Severity: MEDIUM | Package: curl
# Justification: curl is installed for the health checks in Docker's
# HEALTHCHECK. We don't use it in production code. The disabled
# protocol (LDAP) isn't relevant to our usage.
# Review: The next base image update
CVE-2024-2004
Key points:
- Each CVE has: the ID, the severity, the package, the justification, and when to review
- The justification explains WHY you're not exposed — it's not just "I'm ignoring it"
- "Last review" at the top of the file reminds you when it was last evaluated
- Lines starting with
#are comments (trivy ignores them)
Summary
- ✅ Image scanning detects vulnerabilities before they reach production
- ✅ Trivy is the most popular scanner for Docker in CI — fast, free, well integrated
- ✅ Severity levels: CRITICAL (block), HIGH (evaluate), MEDIUM/LOW (monitor)
- ✅ The recommended quality gate:
exit-code: 1withseverity: CRITICALto start - ✅
ignore-unfixed: trueexcludes vulnerabilities with no fix — it only blocks what's actionable - ✅
.trivyignoredocuments exceptions with a justification — it's not "ignore and forget" - ✅ SARIF + GitHub Security give you a visual dashboard of vulnerabilities per image
- ✅ Most vulnerabilities are low-risk — focus on CRITICAL with a fix available
- ✅ Scanning is a habit, not a checkbox — review the reports as part of your regular flow
Additional resources
- Trivy documentation — Trivy's complete documentation
- aquasecurity/trivy-action — The official action for GitHub Actions
- SARIF specification — The standard format for security results
- GitHub Code Scanning — SARIF integration with GitHub Security
- CVE Database (NVD) — The national vulnerability database
- Docker Scout docs — Docker Inc's alternative for scanning