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
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:vulnerablerange-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:
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:
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:
# .dockerignore
.git
.terraform
node_modulesRebuild 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
# .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,libraryScan 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
# .trivyignore.yaml
vulnerabilities:
- id: CVE-2022-29078
statement: "EJS RCE. Lab fixture, deliberately vulnerable. Reviewed 2026-08-14."
expired_at: 2026-11-14expired_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-unfixedturns an unreadable list into work someone can do. A CVE with no patch is not a task.COPY . .with no.dockerignoreshipped macOS Terraform binaries into a Linux image — 120 CVEs and 200 MB from one missing file..gitignoredoes not apply todocker 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.