Secure the PipelineContainerStep 22 of 31

Scan a Container Image for CVEs, Live (Trivy)

difficulty intermediatehands-on 25 min hands-on

71% complete

prereqs · docker-and-images

concepts · image CVE scanning · base image blast radius · build context hygiene · ignore-unfixed

Dockle checked how the image is configured. This checks what is inside it — every OS package and application dependency, resolved against vulnerability feeds.

The first run is usually a shock, and the useful skill is what you do in the ten minutes afterwards.

Verified against Trivy 0.72.0, against the Range image built from docker/Dockerfile. Output copied from a real terminal.

Step 1 — Scan it

bash
git clone https://github.com/jaybilgaye/aiopsone-range
cd aiopsone-range

docker build -f docker/Dockerfile -t range-app:vulnerable .
trivy image --scanners vuln range-app:vulnerable
text
range-app:vulnerable (debian 12.11)
Total: 7696 (UNKNOWN: 56, LOW: 1536, MEDIUM: 4253, HIGH: 1622, CRITICAL: 229)

7,696. Two hundred and twenty-nine of them CRITICAL.

Almost none of that is your code. It is node:18 — a full Debian userland with a compiler toolchain, a package manager and a shell, all of which have CVEs. The application contributed 54 of them.

This is the number that makes people give up on image scanning, and giving up is the wrong response. The right one is to notice that 7,696 is a property of the base image choice, not of your application, and that it is therefore fixable with one line rather than 7,696 tickets.

Step 2 — Make it actionable

Two flags do most of the work:

bash
trivy image --scanners vuln \
  --severity HIGH,CRITICAL \
  --ignore-unfixed \
  range-app:vulnerable

--severity HIGH,CRITICAL drops the 5,845 low and medium findings out of the gate.

--ignore-unfixed removes vulnerabilities with no released patch. This is the important one. A CVE with no fix available is not work — there is no action to take, no version to upgrade to. Leaving them in the gate means failing builds on things nobody can resolve, which trains everyone to ignore the gate.

What remains is a list where every row names a package and a version to move to.

Step 3 — The finding I did not expect

The scan reported more than the OS and the app:

text
app/terraform/.terraform/providers/registry.terraform.io/hashicorp/aws/5.100.0/
    darwin_arm64/terraform-provider-aws_v5.100.0_x5 (gobinary)
Total: 78 (MEDIUM: 39, HIGH: 32, CRITICAL: 2)

app/terraform/.terraform/providers/registry.terraform.io/hashicorp/random/3.9.0/
    darwin_arm64/terraform-provider-random_v3.9.0_x5 (gobinary)
Total: 42 (MEDIUM: 15, HIGH: 26)

There are Terraform provider binaries inside a Node.js container image. Note darwin_arm64 — those are macOS binaries, in a Linux image, that can never execute.

They got there because the Dockerfile does COPY . . and there is no .dockerignore. I had run terraform init in that directory before building, which created .terraform/, and docker build . sends the entire directory as build context regardless of .gitignore. .gitignore and .dockerignore are different files and Docker only reads the second one.

That single omission added 120 CVEs, ~200 MB, and two binaries that would be very interesting to anyone who got a shell in the container.

The fix is three lines:

text
# .dockerignore
.git
.terraform
node_modules

Rebuild and those results disappear entirely. This is the most valuable finding in the lesson, and it only shows up because the scan looks at what is actually in the image rather than what the Dockerfile intended to put there.

Step 4 — Fix the cause, not the findings

For the remaining 7,576, the lever is the base image:

Base What it carries
node:18 full Debian, compiler, package manager, shell, 9 setuid binaries
node:18-slim Debian minus most of the userland
node:22-alpine musl, busybox, supported runtime
gcr.io/distroless/nodejs22 runtime and your app. No shell, no package manager

Moving to a supported, minimal base is one line and typically removes the large majority of the count — not by suppressing findings, but by removing the packages that had them. There is nothing to patch in software that is not installed.

The trade is real: no shell means no docker exec to debug, which is a genuine operational cost and the reason distroless is a decision rather than a default.

Step 5 — Gate it

yaml
# .github/workflows/container.yml
- name: Build
  run: docker build -f docker/Dockerfile -t app:${{ github.sha }} .

- name: Trivy image
  uses: aquasecurity/trivy-action@0.28.0
  with:
    image-ref: app:${{ github.sha }}
    severity: HIGH,CRITICAL
    ignore-unfixed: true
    exit-code: 1
    vuln-type: os,library

Scan the tag you just built, by digest or commit SHA — never :latest. A gate that scans a floating tag is not scanning the artefact it is about to promote.

Step 6 — Ignore with an expiry

yaml
# .trivyignore.yaml
vulnerabilities:
  - id: CVE-2022-29078
    statement: "EJS RCE. Lab fixture, deliberately vulnerable. Reviewed 2026-08-14."
    expired_at: 2026-11-14

expired_at is what stops an ignore list becoming permanent. Without it, the finding is suppressed forever and nobody revisits the reasoning.

What you learned

  • 7,696 CVEs, and almost all of it is the base image rather than your code. That is a one-line problem, not 7,696 tickets.
  • --severity HIGH,CRITICAL --ignore-unfixed turns an unreadable list into work someone can do. A CVE with no patch is not a task.
  • COPY . . with no .dockerignore shipped macOS Terraform binaries into a Linux image — 120 CVEs and 200 MB from one missing file. .gitignore does not apply to docker build.
  • Fix the cause: a smaller, supported base removes packages rather than suppressing findings. Distroless costs you a shell.
  • Scan the immutable tag you built, never :latest.