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

LevelWhat it meansRecommended action
CRITICALRemotely exploitable, no authentication, total impact🔴 Block the push. Fix immediately.
HIGHExploitable under certain conditions, significant impact🟠 Evaluate case by case. Fix soon.
MEDIUMIt requires specific conditions to exploit🟡 Monitor. Fix in the next cycle.
LOWA 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

AspectTrivyDocker ScoutGrype
MaintainerAqua SecurityDocker IncAnchore
GitHub Action✅ Official✅ Official✅ Official
DatabaseNVD + distro advisoriesDocker's own DBNVD + distro
SpeedFast (~30s)Medium (~45s)Fast (~30s)
Formatstable, json, sarifjson, sariftable, json, sarif
Docker integrationVia an actionNative (docker scout)Via an action
CostFreeFree (basic)Free
Popularity in CI⭐⭐⭐ High⭐⭐ Medium⭐⭐ Medium

exit-code 0 vs exit-code 1

ConfigBehaviorWhen to use it
exit-code: 0It always passes (informational)A vulnerability report without blocking
exit-code: 1It fails if it finds vulns of the indicated severityA quality gate that blocks the push

Severity filtering

ConfigWhat it blocksStrictness level
severity: CRITICALOnly CRITICAL🟢 Minimal — it only blocks the critical stuff
severity: CRITICAL,HIGHCRITICAL + HIGH🟡 Moderate — recommended
severity: CRITICAL,HIGH,MEDIUMCRITICAL + HIGH + MEDIUM🟠 Strict
severity: CRITICAL,HIGH,MEDIUM,LOWEverything🔴 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: 1 and severity: CRITICAL blocks if there are critical ones
  • The second scan with exit-code: 0 generates 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.yaml loads the custom configuration
  • ignore-unfixed: true excludes 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: write is necessary for uploading SARIF
  • github/codeql-action/upload-sarif@v3 uploads 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:

  1. CVE-2024-0727 (openssl DoS) — your API doesn't accept client certificates
  2. CVE-2023-44487 (HTTP/2 rapid reset) — your app only uses HTTP/1.1
  3. 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: 1 with severity: CRITICAL to start
  • ignore-unfixed: true excludes vulnerabilities with no fix — it only blocks what's actionable
  • .trivyignore documents 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

  1. Trivy documentation — Trivy's complete documentation
  2. aquasecurity/trivy-action — The official action for GitHub Actions
  3. SARIF specification — The standard format for security results
  4. GitHub Code Scanning — SARIF integration with GitHub Security
  5. CVE Database (NVD) — The national vulnerability database
  6. Docker Scout docs — Docker Inc's alternative for scanning