By the end you'll know
- What a container image scan finds, and where in the image it finds it
- How to read severity, "fixable" and end of life, and which to act on first
- Why the base image decides most of the result
- Why a tag isn't a version, and what a digest is
- How to scan manifests and charts for misconfigurations
- What an SBOM is, and why your scanner is part of your supply chain
Before you start
- You need: the kind cluster running, and Docker. Trivy runs from its container image, so there's nothing to install.
- This lesson changes nothing in the cluster or in Git. It downloads about thirty images to scan them.
- Maps to Security+: domain 4 (vulnerability management, application security) and domain 2 (supply chain threats).
The short version
- 28 images, 7 registries, 1 pinned by digest.
- 61 critical, 1,598 high, and 1,459 of those critical and high findings have a fix already published.
- The worst image is the guestbook, an example app from the Argo CD track: Debian 10, past end of life, built in 2024, 18 critical. It's still running.
- The bookshop runs
nginx:1.29: 7 critical, 209 high, built May 8. The same app on Chainguard's nginx: 0, and 23 packages instead of 151. - The manifests: 57 misconfiguration findings, most of them nginx running as root.
- The scanner: Trivy itself was compromised in March 2026. This lesson pins a version from before.
What a scanner looks at
A container image is a stack of files: an operating system's packages (Debian, Alpine, Ubuntu), plus whatever the app adds (a Go binary, Python packages, a Java JAR). Every one of those has a version. An image scanner lists them, then looks each one up in a database of known vulnerabilities, each with a CVE number (Common Vulnerabilities and Exposures) and a severity.
So a scan answers: which known flaws are in the software in this image? It doesn't answer whether they can be reached, whether they matter for how you run it, or whether there are unknown ones. Keep that in mind when the numbers come in.
Trivy, from Aqua Security, is free and the most widely used. It scans images, folders, Git repositories and Kubernetes manifests. Grype, from Anchore, is the main alternative.
1. Every image the cluster runs
kubectl get pods -A -o jsonpath='{range .items[*]}{range .spec.initContainers[*]}{.image}{"\n"}{end}{range .spec.containers[*]}{.image}{"\n"}{end}{end}' | sort -u
range .items[*]walks every pod, then every init container and container in it, printing one image per line.
docker.io/bitnami/sealed-secrets-controller:0.41.2
docker.io/grafana/alloy:v1.21.3
docker.io/grafana/grafana:13.3.0-distroless
docker.io/grafana/loki:3.7.9
docker.io/kindest/kindnetd:v20251212-v0.29.0-alpha-105-g20ccfc88
docker.io/kindest/local-path-provisioner:v20251212-v0.29.0-alpha-105-g20ccfc88
gcr.io/google-samples/gb-frontend:v5
ghcr.io/dexidp/dex:v2.45.1
nginx:1.29
nginx/nginx-prometheus-exporter:1.5.3
public.ecr.aws/docker/library/redis:8.2.3-alpine
python:3.13-alpine
quay.io/argoproj/argocd:v3.5.7
...
registry.k8s.io/kube-apiserver:v1.35.0
...
registry.k8s.io/kube-state-metrics/kube-state-metrics:v2.20.0
28
9 quay.io
7 registry.k8s.io
6 docker.io
3 docker.io (no registry named)
1 public.ecr.aws
1 ghcr.io
1 gcr.io
1 pinned by digest
27 pinned by tag
Twenty-eight images from seven registries, for one small practice cluster. Each registry is someone you trust to serve the right image. Three images name no registry at all (nginx:1.29), which means Docker Hub by default.
2. The scanner, pinned
docker pull aquasec/trivy:0.69.3
docker image inspect --format '{{index .RepoDigests 0}}' aquasec/trivy:0.69.3
aquasec/trivy@sha256:bcc376de8d77cfe086a917230e818dc9f8528e3c852f7b1aff648949b6258d1c
Why 0.69.3, and why the digest matters. In March 2026, attackers used stolen credentials to publish a malicious Trivy 0.69.4, then malicious Docker Hub images tagged 0.69.5 and 0.69.6, and moved 75 tags of the trivy-action GitHub Action to point at code that stole the secrets of every pipeline that ran it (CVE-2026-33634). The scanner people ran to find supply chain problems was the supply chain problem. 0.69.3 predates the attack; the digest above is the exact image, whatever happens to the tag later.
Then the vulnerability database, updated every few hours:
Version: 0.69.3
Vulnerability DB:
Version: 2
UpdatedAt: 2026-11-07 18:32:58.034005789 +0000 UTC
A scan is only as current as its database. The same image scanned next month will show more findings, without anything in it changing.
3. Known vulnerabilities, image by image
trivy image --scanners vuln --format json --quiet IMAGE
--scanners vulnlooks for known vulnerabilities only (Trivy can also look for secrets and misconfigurations).--format jsongives every finding with its package, versions and severity, which the script summarized one line per image.
IMAGE OS EOL CRIT HIGH MED LOW FIXABLE(C+H) BUILT
gcr.io/google-samples/gb-frontend:v5 debian-10.13 EOL 18 344 477 90 230 2024-11-26
nginx:1.29 debian-13.4 - 7 209 308 247 150 2026-05-08
registry.k8s.io/etcd:3.6.6-0 debian-12.9 - 6 136 113 9 142 2025-11-12
ghcr.io/dexidp/dex:v2.45.1 alpine-3.23.3 - 5 151 121 44 156 2026-03-03
registry.k8s.io/kube-proxy:v1.35.0 debian-12.12 - 4 85 88 44 89 2025-12-17
docker.io/kindest/kindnetd:v20251212-... debian-12.10 - 4 74 101 44 78 2025-12-12
registry.k8s.io/kube-apiserver:v1.35.0 debian-12.12 - 3 74 56 7 77 2025-12-17
...
quay.io/argoproj/argocd:v3.5.7 ubuntu-26.04 - 2 127 180 14 129 2026-11-02
docker.io/kindest/local-path-provisioner:v2025... debian-11.10 EOL 2 49 86 52 49 2025-12-12
public.ecr.aws/docker/library/redis:8.2.3-alpine alpine-3.22.3 - 2 24 24 32 26 2026-01-28
docker.io/grafana/grafana:13.3.0-distroless debian-13.7 - 0 54 114 7 54 2026-10-26
quay.io/prometheus/node-exporter:v1.12.1-distr... debian-13.5 - 0 16 16 1 16 2026-07-14
...
python:3.13-alpine alpine-3.24.4 - 0 5 3 1 5 2026-11-05
docker.io/grafana/alloy:v1.21.3 ubuntu-24.04 - 0 3 23 4 3 2026-10-28
quay.io/prometheus/alertmanager:v0.34.2 -- - 0 2 2 0 2 2026-10-29
quay.io/prometheus/prometheus:v3.15.1-distroless debian-13.7 - 0 2 2 0 2 2026-10-21
docker.io/bitnami/sealed-secrets-controller:0.41.2 debian-13.7 - 0 1 1 0 1 2026-10-26
docker.io/grafana/loki:3.7.9 debian-13.7 - 0 1 1 0 1 2026-10-20
...
critical 61 high 1598 medium 1917 low 619 fixable critical+high 1459
How to read a line:
- CRIT, HIGH, MED, LOW: findings by severity, from the CVE's score. A finding is one CVE in one package, so one flaw in a library several packages share counts several times.
- FIXABLE: critical and high findings where a fixed version of the package already exists. 1,459 of 1,659. Most of this isn't waiting on anyone but whoever builds the image.
- EOL: the operating system is past its end of life. Nobody publishes fixes for it any more, so its findings only grow.
- BUILT: when the image was built. An image doesn't get fixes; it gets replaced by a newer build.
What stands out:
- Fewer packages, fewer findings. The images at the bottom are distroless (
grafana:13.3.0-distroless,prometheus:v3.15.1-distroless) or close to it (alertmanagerand the Prometheus operator show--for their OS: no package list Trivy recognizes): just the program and the few files it needs, no shell or package manager. Prometheus: 2 high. The same project's images, built that way on purpose. - Recent builds help, base images matter more. Most images built in the last few weeks sit near the bottom, and the ones built a year or more ago sit near the top. The exception proves the point: Argo CD, built five days ago on a full Ubuntu, still has 2 critical and 127 high.
- kind's own images aren't yours to fix.
etcd,kube-apiserverand the rest come with the Kubernetes version kind installs. On a real cluster, upgrading Kubernetes is how those get fixed, and managed clusters do it for you.
4. The worst image, up close
gcr.io/google-samples/gb-frontend:v5
CRITICAL CVE-2019-8457 libdb5.3 5.3.28+dfsg1-0.5 no fix yet sqlite: heap out-of-bound read in function rtreenode()
CRITICAL CVE-2021-46848 libtasn1-6 4.13-3 4.13-3+deb10u1 libtasn1: Out-of-bound access in ETYPE_OK
CRITICAL CVE-2022-32221 curl 7.64.0-4+deb10u3 7.64.0-4+deb10u4 curl: POST following PUT confusion
CRITICAL CVE-2022-36760 apache2 2.4.38-3+deb10u8 2.4.38-3+deb10u9 httpd: mod_proxy_ajp: Possible request smuggling
CRITICAL CVE-2022-45063 xterm 344-1+deb10u2 no fix yet xterm: code execution via OSC 50 input sequences
CRITICAL CVE-2023-25690 apache2 2.4.38-3+deb10u8 2.4.38-3+deb10u10 httpd: HTTP request splitting with mod_rewrite and mod_proxy
CRITICAL CVE-2023-25775 linux-libc-dev 4.19.260-1 4.19.304-1 kernel: irdma: Improper access control
CRITICAL CVE-2023-42117 exim4-base 4.92-8+deb10u9 no fix yet Exim: Improper Neutralization of Special Elements Remote Cod
CRITICAL CVE-2023-45853 zlib1g 1:1.2.11.dfsg-1+deb10u2 no fix yet zlib: integer overflow and resultant heap-based buffer overf
...
os-pkgs debian gcr.io/google-samples/gb-frontend:v5 (debian 10.13) 934
lang-pkgs composer-vendor var/www/html/vendor/composer/installed.json 0
The guestbook: the example app the Argo CD track deployed to show what an Application is. All 934 findings are in the operating system's packages, none in the app's own PHP libraries. Look at what's installed: an email server (exim4), a terminal emulator (xterm), the Linux kernel's headers, part of a C compiler (cpp-8). A small PHP web page doesn't need any of that; it's there because the image was built on a full Debian. And Debian 10 reached end of life in 2024, so "no fix yet" there means "no fix ever".
It's also not doing anything. Lesson 2 listed removing it as the first quick fix. One git rm removes 18 of the cluster's 61 critical findings. The cheapest vulnerability to fix is the one in software you don't need.
5. The bookshop: one app, four base images
The bookshop is nginx serving a page. The same nginx, built four ways:
IMAGE OS EOL CRIT HIGH MED LOW FIXABLE(C+H) BUILT PACKAGES
nginx:1.28 debian-13.4 - 10 222 314 247 166 2026-04-07 151
nginx:1.29 debian-13.4 - 7 209 308 247 150 2026-05-08 151
nginx:1.29-alpine alpine-3.23.4 - 1 54 84 96 55 2026-04-15 72
cgr.dev/chainguard/nginx:latest wolfi-20230201 - 0 0 0 0 0 2026-11-07 23
nginx:1.28 255 MB
nginx:1.29-alpine 92 MB
cgr.dev/chainguard/nginx:latest 26 MB
- The Debian image: 151 packages, 255 MB. The Chainguard image: 23 packages, 26 MB, no known vulnerabilities, built today. Chainguard builds minimal images on its own Linux, Wolfi (the
20230201is a fixed label, not a date), and rebuilds them every day. - Alpine is in between: a smaller Linux, so fewer packages, fewer findings.
nginx:1.29, the tag the bookshop runs, was built on May 8. A tag like1.29is supposed to move to newer builds as fixes come out; this one hasn't moved in six months, so it carries every Debian fix released since. 150 of its findings are fixable by a rebuild nobody did.
The trade-off with minimal images is real: no shell, no package manager, often a non-root user and a different port by default. That's harder to debug (kubectl exec ... sh won't work) and the app may need small changes. It's also the point: an attacker who gets in finds no tools either. And Chainguard's free images are the latest tag only; pinned versions are a paid product.
6. Misconfigurations
Trivy also checks Kubernetes manifests and Helm charts against good practice, the same things Lesson 1 found by hand:
trivy config /repo
57
HIGH KSV-0014 4 Root file system is not read-only apps/bookshop/deployment.yaml, apps/monitoring/alert-receive
HIGH KSV-0118 7 Default security context configured apps/bookshop/deployment.yaml, apps/monitoring/alert-receive
LOW KSV-0003 4 Default capabilities: some containers do not drop all apps/bookshop/deployment.yaml, apps/monitoring/alert-receive
...
MEDIUM KSV-0001 4 Can elevate its own privileges apps/bookshop/deployment.yaml, apps/monitoring/alert-receive
MEDIUM KSV-0012 4 Runs as root user apps/bookshop/deployment.yaml, apps/monitoring/alert-receive
MEDIUM KSV-0104 4 Seccomp policies disabled apps/bookshop/deployment.yaml, apps/monitoring/alert-receive
MEDIUM KSV-0117 2 Prevent binding to privileged ports apps/bookshop/deployment.yaml, charts/bookshop/templates/dep
MEDIUM KSV-0125 1 Restrict container images to trusted registries charts/bookshop/templates/deployment.yaml
57 findings in three files, and nearly all of them say one thing: these containers run with the defaults. As root, with a writable file system, every Linux capability, and nothing stopping them gaining more. That's Lesson 1's "nginx runs as root", by rule number. Fixing it is a securityContext block, which Lesson 9 adds.
Two to read with care. KSV-0110, "Workloads in the default namespace", fires because the files don't name a namespace; Argo CD sets it when it applies them, so it's a false alarm here. And KSV-0117, binding to privileged ports: nginx listens on port 80, below 1024, which non-root processes traditionally can't use. That's exactly what makes moving nginx off root more than a one-line change.
Scanning manifests is cheap and catches problems before anything runs. It's one of the most useful checks to put in a pipeline.
7. Tags move; digests don't
docker.io/library/nginx:1.29 -> docker.io/library/nginx@sha256:1881968aff6f7cdcc4b888c00a11f4ce241ad7ec957e0cb4a9e19e93a3ff87ea
docker.io/nginx/nginx-prometheus-exporter:1.5.3 -> docker.io/nginx/nginx-prometheus-exporter@sha256:85666e7fde7e253f1c6d978f9a961245df4c9cdb7677e1866d9bb28e8f9ee41d
A tag (1.29) is a label the publisher can move to a different image at any time. A digest (sha256:1881...) is a fingerprint of the image's contents: it can only ever mean that exact image. Pulling by tag on Monday and Friday can give two different images; pulling by digest can't.
That cuts both ways, as this lesson shows. A tag that moves picks up fixes, and a tag that moves can pick up an attacker's image, as Trivy's did. Pinning by digest stops both, so teams pin by digest and use a bot (Renovate or Dependabot) that opens a pull request whenever a newer digest appears. Every change gets reviewed, and nothing goes stale silently.
Only one image here is pinned by digest: the Prometheus operator pins its own config reloader, which is why it appears twice in the list (once by tag, once by tag and digest).
8. An SBOM: what's inside one image
A software bill of materials (SBOM) is a list of every component in a piece of software, with versions, in a standard format (CycloneDX or SPDX).
trivy image --format cyclonedx nginx:1.28 > nginx-1.28.cdx.json
152
151 library
1 operating-system
libssl3t64 3.5.5-1~deb13u1
nginx 1.28.3-1~trixie
openssl 3.5.5-1~deb13u1
zlib1g 1:1.3.dfsg+really1.3.1-1+b1
152 components, each with an exact version. Why it's worth having:
- When the next big vulnerability lands (a Log4Shell, an OpenSSL bug), "are we affected?" becomes a search across SBOMs you already have, not a scan of everything you run.
- It can be scanned without the image. Trivy scanned the SBOM file directly: 917 findings, against 915 for the image itself (the extra two are low-severity, from how the two scans match packages). No download needed.
- It's increasingly asked for. US federal agencies can require one from software suppliers, and registries and build tools can attach one to every image.
In real systems
- Scan in the pipeline and in the registry. Fail the build on new critical findings with a fix available; scan running images daily, because the database changes even when the image doesn't. Harbor, ECR, GCR and GitHub all scan images on push.
- Choose base images on purpose. Distroless, Chainguard, or Docker Hardened Images, with a policy that says which are allowed (Kyverno or Gatekeeper can enforce it, like the KSV-0125 rule above).
- Pin by digest, and let a bot keep it current. Renovate and Dependabot do both for images and Helm charts.
- Sign and verify. Cosign signs images; a policy at the cluster checks the signature before anything runs. A stolen tag can't fake a signature.
- Prioritize by risk, not count. Is it reachable from outside? Is it being exploited (CISA's Known Exploited Vulnerabilities list, EPSS scores)? Is there a fix? A critical in a package the program never loads matters less than a high in its web server.
- Your tools are dependencies. Pin scanners and CI actions by digest or commit, not by tag. The Trivy compromise worked because thousands of pipelines trusted a tag.
Spot the problem
Work out what's wrong before you read the explanation.
1,459 tickets. A security team runs a scanner across the cluster for the first time and files one ticket per fixable critical or high finding: 1,459 of them, split between three teams, with a deadline. A month later, about 40 are closed, the teams have stopped reading the tickets, and the scanner's next run shows more than before. What went wrong? They treated findings as the unit of work, when the unit of work is the image. In this lesson's own numbers: deleting one unused example app (the guestbook) takes out 18 critical and 344 high. Moving the bookshop to a minimal base image takes it from 216 critical and high to 0. Rebuilding or upgrading each remaining image to its newest version clears most of the rest at once, because "fixable" means "a newer package exists". What's left after that is the short list worth a ticket each: findings with no fix, in images you can't replace (like kind's own), judged by whether they're reachable and being exploited. Three fixes: remove what you don't use, then change base images, then update, in that order; count images that need work, not findings; and gate new problems in the pipeline so the number stops growing while the old ones get fixed.
Keep this
| To | Use |
|---|---|
| List every image a cluster runs | kubectl get pods -A -o jsonpath=... over containers and init containers, sort -u |
| Run Trivy without installing it | docker run --rm -v trivy-cache:/root/.cache/ aquasec/trivy:VERSION ... |
| Scan an image for known vulnerabilities | trivy image --scanners vuln IMAGE |
| Only what's serious and fixable | trivy image --severity CRITICAL,HIGH --ignore-unfixed IMAGE |
| Scan manifests and charts | trivy config PATH |
| Make an SBOM | trivy image --format cyclonedx IMAGE > sbom.json |
| Scan an SBOM | trivy sbom sbom.json |
| See an image's digest | docker image inspect --format '{{index .RepoDigests 0}}' IMAGE |
| See what a pod is really running | .status.containerStatuses[*].imageID |
Where you are now
You can list everything a cluster runs, find what's known to be wrong with it, and decide what to do first: remove, rebase, update, then triage what's left. You know why a tag isn't a version, and why the scanner needs the same care as everything it scans. The next lesson moves from what's inside the containers to what they serve: the OWASP Top 10, on a deliberately insecure web shop.
Check yourself
What does FIXABLE mean in a scan result?
Why does Debian 10 in the guestbook image make its findings worse?
Why did Chainguard's nginx have no findings?
What's the difference between a tag and a digest?
What does an SBOM let you do without the image?
Why does this lesson pin Trivy to 0.69.3 by digest?
Key terms
- CVE (Common Vulnerabilities and Exposures): the public catalog of known vulnerabilities, one ID each.
- Severity: critical, high, medium or low, from the vulnerability's score (CVSS).
- Fixable: a version of the package with the fix has been published.
- End of life (EOL): a release that no longer gets fixes.
- Base image: the image an app's image is built on, usually an operating system.
- Distroless image: an image with only the app and what it needs to run: no shell, no package manager.
- Tag and digest: a movable name for an image, and a fingerprint that can only mean one image.
- SBOM (software bill of materials): a list of every component in a piece of software, with versions.
- Supply chain attack: attacking the software, tools or services others depend on, to reach everyone who uses them.
Go deeper
- Trivy documentation (opens in a new tab), and Grype (opens in a new tab) to compare results.
- The Trivy compromise: SafeDep's write-up (opens in a new tab) and CVE-2026-33634 (opens in a new tab).
- Chainguard Images (opens in a new tab) and Google's distroless (opens in a new tab) images.
- CISA's Known Exploited Vulnerabilities catalog (opens in a new tab), for deciding what to fix first.
- Try: scan
nginx:1.29again with--ignore-unfixed --severity CRITICAL. What's left, and would you accept it? - Try: point the bookshop chart at
cgr.dev/chainguard/nginx:latestin staging. What breaks, and why? (Hint: section 6, KSV-0117.)