• how-to
  • CVE
  • OSV
  • Kubernetes

How to find which running Kubernetes images are affected by a new CVE

A CVE ID lands in Slack. Four steps answer “are we running it, and where?” from what the kubelets report: digests, one SBOM each, the ID and its aliases, then the pods.

StackRadar Team

11 min read

CVE-2025-4802

Someone pastes a CVE ID into the channel and asks whether you are affected. The honest answer is a list: the images that contain the vulnerable package, the version each one has, the version that fixes it, and the pods that are running it right now. Anything less is a guess, and anything that starts from a Deployment manifest is a guess about the wrong thing.

This post builds that list with kubectl, jq, Syft and Grype in four steps: collect the image digests the kubelets report, inventory each digest once, match the CVE and every alias it travels under, and join the hits back to pods and their controllers. The script at the end does the whole thing as cve-check.sh CVE-2025-4802. Most of the words in between are about the places where the obvious approach returns a clean-looking answer that is wrong.

Why the manifests cannot answer

A Deployment says image: nginx:1.27. That is a request, not a fact. The tag may have moved since the pod was scheduled, the ReplicaSet may be mid-rollout with two versions running, an admission webhook may have injected a sidecar no manifest mentions, and an init container may have run a different image entirely. The only record of what a node actually pulled is status.containerStatuses[].imageID, which the container runtime fills in with the repository and the digest. The KEV check starts from the same field for the same reason.

So the unit of this whole exercise is the digest, not the tag. Two Deployments that both say nginx:1.27 can be running two different digests; one pod can be running three. Everything below is keyed on the digest and joined back to pods at the end.

Step 1: the digests the kubelets are running, per pod

One kubectl call produces both files the rest of the check needs: a map from each pod to the digests it runs, and the distinct list of digests to inventory.

pod-images.sh
# namespace, pod, image digest — one line per running container, init
# containers and sidecars included, as the container runtime reports them.
kubectl get pods -A -o json | jq -r '
  .items[]
  | .metadata.namespace as $ns | .metadata.name as $pod
  | ((.status.containerStatuses // []) + (.status.initContainerStatuses // []))[]
  | select(.imageID != "")
  | [$ns, $pod, (.imageID | sub("^docker-pullable://"; ""))] | @tsv' \
  | sort -u > pod-images.tsv

# The distinct digests: the list to inventory.
cut -f3 pod-images.tsv | sort -u > running-images.txt
wc -l pod-images.tsv running-images.txt

Three details matter:

  • Init containers are included. A migration image that ran at startup is no longer running, but it is still in the pod's status and it still ran with the pod's credentials. Whether it belongs on your list is a judgement call; the script includes it so you make that call knowingly.
  • Containers that have not started yet have an empty imageID, hence the select. They will have one on the next run.
  • The docker-pullable:// prefix is what older Docker-based nodes emit. containerd and CRI-O report repo@sha256:… directly.
If a line shows a bare sha256:… with no repository, the runtime could not name the repository the digest came from. Pair it with spec.containers[].image for that pod and pull by tag, and treat the result as less certain.

Step 2: one inventory per digest, kept

You could scan each image directly with Grype or Trivy and skip the SBOM. It works, and for a one-off question on a small cluster it is the shortest path. The reason to produce a CycloneDX file per digest instead is that the next CVE arrives next week. An image's contents never change, so its SBOM is good for as long as the digest runs, and the second question costs no image pulls at all.

sbom-per-digest.sh
PLATFORM="${PLATFORM:-linux/amd64}"   # your nodes' architecture, not your laptop's
mkdir -p sboms
while read -r image; do
  f="sboms/$(echo "$image" | sed 's#.*@sha256:##').cdx.json"
  [ -s "$f" ] && continue                # an image's contents never change: one SBOM per digest, ever
  syft scan -q --platform "$PLATFORM" -o "cyclonedx-json=$f" "registry:$image" < /dev/null \
    || echo "SBOM FAILED: $image (check registry credentials)" >&2
done < running-images.txt

registry: pulls straight from the registry with your docker login credentials and needs no Docker daemon. --platform matters because the kubelet reports the manifest-list digest for a multi-arch image, and Syft on an Apple Silicon laptop would otherwise inventory the arm64 variant of an image your amd64 nodes run. If you already keep SBOMs, from a build pipeline or from the method in how to generate an SBOM from a running cluster, point the script at that directory and this step is a no-op.

Step 3: match the CVE, and every ID it travels under

This is the step people get wrong, because the instinct is to look the CVE up, read the affected version range, and grep the SBOMs for the package. Here is what the lookup returns for a glibc vulnerability from May 2025:

the upstream record
$ curl -s https://api.osv.dev/v1/vulns/CVE-2025-4802 | jq '{id, affected: [.affected[] | {package, ranges}]}'
{
  "id": "CVE-2025-4802",
  "affected": [
    {
      "package": null,
      "ranges": [
        {
          "type": "GIT",
          "repo": "https://github.com/bminor/glibc",
          "events": [
            { "introduced": "23158b08a0908f381459f273a984c6fd328363cb" },
            { "last_affected": "36f2487f13e3540be9ee0fb51876b1da72176d3f" }
          ]
        }
      ]
    }
  ]
}

No package name, no ecosystem, and a range expressed as two git commits. That is not an OSV quirk; it is what the CVE record is. NVD describes the upstream project, and the versions in your image are a distro's. The record a scanner actually matches your Debian package against is a different one, and it points back:

the Debian record
$ curl -s https://api.osv.dev/v1/vulns/DEBIAN-CVE-2025-4802 | jq '{id, upstream, affected: [.affected[] | {package, ranges}]}'
{
  "id": "DEBIAN-CVE-2025-4802",
  "upstream": ["CVE-2025-4802"],
  "affected": [
    {
      "package": { "name": "glibc", "ecosystem": "Debian:12", "purl": "pkg:deb/debian/glibc?arch=source&distro=bookworm" },
      "ranges": [{ "type": "ECOSYSTEM", "events": [{ "introduced": "0" }, { "fixed": "2.36-9+deb12u11" }] }]
    },
    {
      "package": { "name": "glibc", "ecosystem": "Debian:13", "purl": "pkg:deb/debian/glibc?arch=source&distro=trixie" },
      "ranges": [{ "type": "ECOSYSTEM", "events": [{ "introduced": "0" }, { "fixed": "2.39-4" }] }]
    }
  ]
}
WHAT YOU LOOK UPWHAT A SCANNER MATCHESCVE-2025-4802git commit rangeno package, no distroDEBIAN-CVE-2025-4802glibc · Debian:12 · fixed 2.36-9+deb12u11UBUNTU-CVE-2025-4802glibc · Ubuntu:22.04 · fixed 2.35-0ubuntu3.10related: ALSA-2025:8655, SUSE-SU-…vendor advisories, not matchesupstream
The record you look up names no package. The records a scanner matches against do.

The same CVE has an Ubuntu record with 2.35-0ubuntu3.10 as the fix for 22.04, and a list of AlmaLinux and SUSE advisories under related. A library CVE goes the other way: CVE-2025-22869 in golang.org/x/crypto is a record with no package, and its aliases field names GHSA-hcg3-q754-cr77, which is the one that says fixed in 0.35.0. Every scanner resolves this graph for you; what you must do is check the answer against the CVE and its aliases, because the ID the scanner prints is not always the one you searched for.

With Grype, against the SBOMs from step 2:

match.sh (Grype)
CVE=CVE-2025-4802
for f in sboms/*.cdx.json; do
  grype -q -o json --by-cve "sbom:$f" | jq -r --arg cve "$CVE" --arg f "$f" '
    .matches[]
    | select(.vulnerability.id == $cve or ([.relatedVulnerabilities[]?.id] | index($cve)))
    | "\($f)\t\(.artifact.name) \(.artifact.version)\tfixed: \(.vulnerability.fix.versions | join(", ") | if . == "" then "none" else . end)"'
done | sort -u

--by-cve makes Grype print the CVE as the primary ID where it can, and the relatedVulnerabilities check catches the cases where it cannot. Trivy reads the same file:

match.sh (Trivy)
# Trivy reads the same CycloneDX file. It reports distro findings under the CVE id
# and language findings under whichever id the advisory source uses; there is no
# alias list in its JSON, so check both the CVE and its GHSA alias if it has one.
trivy sbom -q -f json "$f" | jq -r --arg cve "$CVE" '
  .Results[]?.Vulnerabilities[]?
  | select(.VulnerabilityID == $cve)
  | "\(.PkgName) \(.InstalledVersion)\tfixed: \(.FixedVersion // "none")"'

Both report the binary package they found (libc6, libc-bin) while the advisory names the source package (glibc). That is correct, and it is why a name-based grep misses: the advisory's package name is not in the SBOM at all. If the fixed version the scanner prints looks older than the one in the CVE text, that is the backport at work; CVE patching in Kubernetes explains why the version numbers disagree and which one to trust.

Step 4: back to pods, and to whoever owns them

A digest with a hit is a fact about an artifact. The fix is owed by a workload, so the last step joins the hit back through pod-images.tsv and then up the owner chain: a pod is owned by a ReplicaSet, which is owned by the Deployment someone will actually edit.

pods-for-image.sh
# Which pods run an affected digest, and what controls them.
IMAGE='docker.io/library/debian@sha256:…'
grep -F "$IMAGE" pod-images.tsv | cut -f1,2 | while IFS=$'\t' read -r ns pod; do
  owner=$(kubectl get pod -n "$ns" "$pod" -o jsonpath='{.metadata.ownerReferences[0].kind}/{.metadata.ownerReferences[0].name}')
  # A ReplicaSet is owned by its Deployment; one more hop names the thing someone edits.
  case "$owner" in
    ReplicaSet/*) owner=$(kubectl get rs -n "$ns" "${owner#ReplicaSet/}" -o jsonpath='{.metadata.ownerReferences[0].kind}/{.metadata.ownerReferences[0].name}') ;;
    Job/*)        owner=$(kubectl get job -n "$ns" "${owner#Job/}" -o jsonpath='{.metadata.ownerReferences[0].kind}/{.metadata.ownerReferences[0].name}') ;;
  esac
  echo "$ns/$pod  <- ${owner:-bare pod}"
done

StatefulSets and DaemonSets own their pods directly, so the first hop is enough; a CronJob is two hops through its Job. A Helm release is one more lookup, on the app.kubernetes.io/instance label or the meta.helm.sh/release-name annotation of the owner. If you need the inverse question, which pods run a given package regardless of any CVE, that is a different query on the same SBOMs: how to find every pod running a vulnerable package.

The script: one CVE in, images and pods out

STEP 1STEP 2STEP 3OUTPUTRunning digestskubelet imageID,per podSBOM per digestsyft, once,kept on diskMatch the IDgrype --by-cve,aliases includedImages + podswhat to fix,who owns itpod-images.tsvsboms/<digest>.cdx.jsonjoin pod-images.tsv
cve-check.sh
#!/usr/bin/env bash
# cve-check.sh — which running images in this cluster contain a given CVE, and which pods run them?
# Usage: ./cve-check.sh CVE-2025-4802
# Needs: kubectl (list pods), jq, syft, grype, and pull access to the cluster's registries (docker login).
set -euo pipefail
CVE="${1:?usage: cve-check.sh CVE-YYYY-NNNNN}"
PLATFORM="${PLATFORM:-linux/amd64}"   # your nodes' architecture; run once per arch on a mixed cluster
SBOMS="${SBOMS:-./sboms}"              # one CycloneDX file per digest, reused across runs
mkdir -p "$SBOMS"

# 1. namespace, pod, digest for every running container — app, init and sidecar alike.
kubectl get pods -A -o json | jq -r '
  .items[] | .metadata.namespace as $ns | .metadata.name as $pod
  | ((.status.containerStatuses // []) + (.status.initContainerStatuses // []))[]
  | select(.imageID != "")
  | [$ns, $pod, (.imageID | sub("^docker-pullable://"; ""))] | @tsv' | sort -u > pod-images.tsv
cut -f3 pod-images.tsv | sort -u > running-images.txt
echo "Running images: $(wc -l < running-images.txt | tr -d ' ') across $(wc -l < pod-images.tsv | tr -d ' ') containers" >&2

sbom_for() { echo "$SBOMS/$(echo "$1" | sed 's#.*@sha256:##').cdx.json"; }

# 2. An SBOM per digest. Skipped when one is already on disk: an image's contents never change.
while read -r image; do
  f=$(sbom_for "$image")
  [ -s "$f" ] && continue
  syft scan -q --platform "$PLATFORM" -o "cyclonedx-json=$f" "registry:$image" < /dev/null \
    || echo "SBOM FAILED: $image (check registry credentials)" >&2
done < running-images.txt

# 3. Match: scan each SBOM, keep the findings whose id or alias is the CVE.
# 4. For each hit, the pods running that digest, from the map in step 1.
while read -r image; do
  f=$(sbom_for "$image")
  [ -s "$f" ] || continue
  hits=$(grype -q -o json --by-cve "sbom:$f" | jq -r --arg cve "$CVE" '
    .matches[]
    | select(.vulnerability.id == $cve or ([.relatedVulnerabilities[]?.id] | index($cve)))
    | "\(.artifact.name) \(.artifact.version)  fixed: \(.vulnerability.fix.versions | join(", ") | if . == "" then "none" else . end)"' \
    | sort -u)
  [ -n "$hits" ] || continue
  echo "$image"
  echo "$hits" | sed 's/^/    /'
  grep -F "$image" pod-images.tsv | cut -f1,2 | sort -u | sed $'s/\t/\//; s/^/    pod: /'
done < running-images.txt

An example run against a small test cluster, where one namespace still had an old debian:12 shell image and one image lived in a registry the laptop was not logged in to:

example output
$ ./cve-check.sh CVE-2025-4802
Running images: 14 across 31 containers
SBOM FAILED: 012345678901.dkr.ecr.eu-north-1.amazonaws.com/billing@sha256:7c1e…a90f (check registry credentials)
docker.io/library/debian@sha256:3a1c…9f0e
    libc-bin 2.36-9+deb12u10  fixed: 2.36-9+deb12u11
    libc6 2.36-9+deb12u10  fixed: 2.36-9+deb12u11
    pod: default/debian-shell
    pod: batch/nightly-export-29311200-x7k2q

The failed SBOM is printed, not swallowed. A silent skip would look exactly like "not affected", and the images you cannot pull from a laptop are, more often than not, the ones built in-house.

Six ways to get a clean answer that is wrong

  1. Reading the manifest instead of the status. Covered above. The tag in the spec is a request; the digest in the status is what ran.
  2. Matching on the ID alone. A Go module finding is a GHSA with the CVE as an alias. Grype without --by-cve prints the GHSA. A grep CVE-2025-22869 over that output finds nothing.
  3. Comparing against the upstream version range. 2.36 is inside the upstream range for CVE-2025-4802, and 2.36-9+deb12u11 is fixed. Only the distro record knows.
  4. Scanning with yesterday's database. A CVE published this morning may not be in your scanner's database until the distro tracker has triaged it. Run grype db update first, and treat "no match" on a CVE younger than a day as "not yet". A new CVE should not need a new scan is about how long that gap is and what closes it.
  5. The wrong architecture. Without --platform, a tool on an arm64 laptop inventories the arm64 image. Package versions are usually the same across architectures, but not always, and a mixed cluster needs one run per architecture.
  6. Private registries. The script uses your local credentials. The images it cannot pull are listed on stderr; if your pods use imagePullSecrets for them, log in to those registries first or run the check from somewhere that already has access.

The one-command version

The free, Apache-2.0 StackRadar CLI does steps 1 to 4 in one run: it reads the digests from the kubelets, pulls each image for the architecture of the node it runs on, matches OS and language packages against OSV.dev, and attributes every image to its pods and to the Deployment, StatefulSet, DaemonSet or CronJob behind them. Its JSON carries each finding's id and aliases, so one jq answers the question:

bash
# Every running image, pods and controllers attributed, as JSON — then one jq for the CVE.
stackradar scan -q -o json > report.json
jq -r --arg cve CVE-2025-4802 '
  .images[]
  | select(any(.findings[]?; .id == $cve or (.aliases // [] | index($cve))))
  | "\(.image.ref)\n" + ([.image.owners[]? | "    \(.kind) \(.namespace)/\(.name)"] | join("\n"))' report.json

It sends nothing to StackRadar. Image pulls go to your registries, package names and versions go to api.osv.dev, and one GET fetches the KEV catalogue from cisa.gov. With --use-pull-secrets it can use the cluster's own imagePullSecrets for the registries your laptop cannot reach, which closes trap six. It is still a point-in-time answer: the OSV lookup happens when you run it.

Making the answer continuous

Everything above answers the question once. The CVE after this one needs the loop to run again, and the step that saves the most time, keeping an SBOM per digest, is the step most teams skip because nobody owns the directory.

To build it yourself: keep sboms/ somewhere durable, re-run step 1 on a schedule so new digests get inventoried as they appear, update the scanner database before every match, and run the match for every open CVE you care about rather than one. The storage and the schedule are the part worth owning; the inner loop is the script above.

What StackRadar does: the in-cluster scanner uploads one CycloneDX SBOM per running image, and the server keeps a full mirror of OSV.dev that is updated every hour. When the mirror changes, every stored SBOM is re-matched against it, so a CVE published tonight against a package you deployed in March is a finding by morning, with the pods and the fixed version attached and no image pulled. If the new finding is critical or on the KEV list, a Critical & KEV alert goes to email or Slack, grouped by advisory with the images, clusters and workloads it landed on. Searching the dashboard for a CVE ID is the same join this post builds by hand. The mechanics are on the continuous scanning page.

Frequently asked questions

How do I check if my Kubernetes cluster is affected by a CVE?

List the image digests the kubelets report (status.containerStatuses[].imageID, not the manifest tag), build or reuse a package inventory for each digest, scan each inventory and keep the findings whose ID or alias is the CVE, then map the affected digests back to pods. kubectl alone cannot do it: it knows which images run, not which packages are inside them. The script in this post does all four steps with kubectl, jq, Syft and Grype.

Why does the scanner report a different ID from the CVE I searched for?

One vulnerability has several records. The CVE record from NVD describes the upstream project. Distros publish their own records against their package names and versions (OSV lists them as DEBIAN-CVE-, UBUNTU-CVE- and ALPINE-CVE-), and language ecosystems publish GitHub advisories (GHSA-) with the CVE as an alias. Grype reports the GHSA with the CVE under relatedVulnerabilities unless you pass --by-cve; Trivy reports whichever ID its source uses. Match on the ID and its aliases, never the ID alone.

The upstream fix is version 2.39 but my image has 2.36. Is it vulnerable?

Not necessarily. Debian, Ubuntu, Alpine and Red Hat backport fixes into the version they ship, so the fixed version for Debian 12 is 2.36-9+deb12u11, not 2.39. The CVE record on NVD and OSV describes the upstream range; the distro record carries the real fixed version for your package. A scanner that matches distro packages against distro records gets this right; a grep of the SBOM against the upstream range does not.

How soon after a CVE is published can a scanner find it?

When the scanner’s database contains a record that names your package. For a language library that is usually the GitHub advisory, often published the same day. For a distro package it is when the distro’s security tracker has triaged it, which can be hours or days after the CVE ID appears. Update the database before you conclude you are clean: grype db update, or trivy image --download-db-only. A scan that ran before the record existed returns nothing, which looks exactly like not affected.

Do I have to rescan every image each time a new CVE comes out?

No. An image’s contents never change, so its SBOM does not either. Keep one CycloneDX file per digest and re-run the match against the stored files; no image is pulled. The script keeps them under ./sboms and skips any digest it already has. That is also how StackRadar works: one SBOM per running image, re-matched whenever its OSV.dev mirror changes.

Can I use kubectl to find vulnerable images?

kubectl gives you the first and last steps: which image digests are running and which pods run them. It cannot tell you what packages an image contains or whether a CVE applies to them. You need an SBOM tool or a scanner for the middle, and the join back to pods is a kubectl query on status.containerStatuses[].imageID.

All posts