- how-to
- SBOM
- Kubernetes
How to find every pod running a vulnerable package across a Kubernetes cluster
Given a package name, list every pod that runs it, its Deployment or DaemonSet and its Helm release: one SBOM per running digest, a jq query, and a join on the digest.
StackRadar Team
12 min read
The question arrives in a chat message, not a scanner report. "Are we running xz 5.6?" "Which services have log4j-core?" "Do any of our images still ship OpenSSL 3.0?" Often there is no CVE ID yet, or the advisory names a project and your scanner names packages. Someone has to turn a library name into a list of pods, namespaces and owners, and they have to do it before the meeting.
A vulnerability scanner does not answer that question. It answers "which CVEs match what I scanned", which is the sibling task covered in finding the running images affected by a new CVE. This post is package-first: given a package name and optionally a fixed version, find every pod that runs it, attribute each one to its Deployment, StatefulSet or DaemonSet and its Helm release, and get the answer in seconds the next time. The whole thing is one script on top of kubectl, Syft and jq. The hard part is not the join. It is knowing what the package is called.
The method: a join on the image digest
Three inputs. A pod map from the kubelet: which namespace and pod run which image digest, and who owns the pod. One SBOM per distinct digest: the packages inside each image, with their versions and Package URLs. And the question: a package name, perhaps with the version that fixes it. The digest is the key that joins the first two, so the method is a grep over the SBOMs followed by a join back to the pods.
Each input is cheap on its own, and the second one is cacheable forever: a digest names one immutable image, so its SBOM never changes. Generating SBOMs is the slow step, and it only runs for digests you have not seen.
Step 1: the pod map and one SBOM per digest
Ask the kubelets what they run, not the manifests. spec.containers[].image is what someone asked for, often a moving tag. status.containerStatuses[].imageID is what the container runtime pulled, digest included. Init containers and injected sidecars are in the status too, and a pipeline scanner never saw either.
# Every running container, with the digest the kubelet actually pulled: namespace,
# pod, owner kind, owner name, image@sha256. Init containers and sidecars included.
kubectl get pods -A -o json | jq -r '
.items[] as $p
| (($p.status.containerStatuses // []) + ($p.status.initContainerStatuses // []))[]
| [$p.metadata.namespace, $p.metadata.name,
($p.metadata.ownerReferences[0].kind // "Pod"),
($p.metadata.ownerReferences[0].name // $p.metadata.name),
(.imageID | sub("^docker-pullable://"; ""))]
| @tsv' > pods.tsv
cut -f5 pods.tsv | sort -u | wc -l # distinct digests = SBOMs to generateThe owner columns come from metadata.ownerReferences. A pod created by a Deployment names its ReplicaSet there, not the Deployment; step 3 collapses that hop. Now one SBOM per digest:
# One CycloneDX SBOM per distinct digest. Cached by digest: an image's SBOM never changes.
mkdir -p sboms
cut -f5 pods.tsv | sort -u | while read -r ref; do
d="${ref##*@sha256:}"
[ -s "sboms/$d.cdx.json" ] && continue
syft -q "registry:$ref" --platform linux/amd64 -o "cyclonedx-json@1.6=sboms/$d.cdx.json" \
|| echo "SBOM FAILED: $ref (check registry credentials)" >&2
doneTwo choices in that loop matter. --platform must be your nodes' architecture, because the kubelet reports a multi-arch image by its manifest-list digest, and Syft on an Apple Silicon laptop would otherwise read the arm64 variant of an image your amd64 nodes run. And the output is CycloneDX 1.6 with the schema version pinned, so the jq below works the same on every file. The sibling post on generating an SBOM from a running cluster covers registry credentials, private registries and what to keep; SBOM tools compared covers why Syft and not something else. Trivy works too: trivy image --format cyclonedx writes the same shape.
Step 2: the query, and what the package is actually called
A CycloneDX component has a name, a version and a purl. The name is what the package database calls it, and that is rarely what the advisory calls it. OpenSSL is the standard example:
Syft writes the answer into the PURL. For apk, deb and rpm packages the upstream qualifier carries the source package (Alpine calls it the origin), and for Debian and RHEL it carries the source version too. Go modules, npm packages and Maven artifacts have no such split, but their names are paths, so the full module path is the thing to match. These are real PURLs from Syft 1.42 on 18 October 2026:
pkg:apk/alpine/libssl3@3.3.3-r0?arch=x86_64&distro=alpine-3.21.3&upstream=openssl
pkg:deb/debian/libuuid1@2.38.1-5%2Bb1?arch=amd64&distro=debian-12&upstream=util-linux%402.38.1-5
pkg:rpm/redhat/openssl-libs@3.5.8-2.el9_8?arch=x86_64&distro=rhel-9.8&epoch=1&upstream=openssl-3.5.8-2.el9_8.src.rpm
pkg:golang/golang.org/x/crypto@v0.31.0
pkg:maven/org.apache.logging.log4j/log4j-core@2.17.1So the query matches on three things: the component name, the upstream in the PURL, and the PURL path for ecosystems whose names contain slashes. Searching openssl then finds libssl3 and libcrypto3 on Alpine, openssl-libs on RHEL and libssl3 on Debian, and searching golang.org/x/crypto finds the module inside a Go binary:
# Which SBOMs contain package NAME? Match the package name, the upstream/source
# package in the PURL, or the full module path for Go, npm and Maven.
NAME=openssl
for f in sboms/*.cdx.json; do
jq -r --arg n "$NAME" --arg d "$(basename "$f" .cdx.json)" '
.components[]? | select(.purl != null)
| ($n | gsub("[.]"; "\\.")) as $re
| select(.name == $n
or (.purl | test("[?&]upstream=" + $re + "([&%@]|-[0-9]|$)"))
or (.purl | test("/" + $re + "@")))
| [$d, .name, .version, .purl] | @tsv' "$f"
doneFour cases that this query handles badly, and what to do about each:
- Statically linked C libraries. A Go or Rust binary that vendored a C library has no package-database entry for it. Syft reads the module list Go embeds in every binary, which is why
golang.org/x/cryptoshows up, and its binary classifier recognises a short list of well-known executables, but an arbitrary static copy of zlib inside someone's binary is invisible to every SBOM tool. For those, the image's build is the only source of truth. - Shaded and repackaged jars. Syft finds
log4j-coreinside jars, nested jars and wars by readingpom.propertiesand the manifest. A shaded copy whose Maven metadata was stripped carries no name. If the answer must be certain, scan the jars for the class itself as well. - Renamed forks. Debian ships
xz-utilsasliblzma5, and the PURL's upstream says so. But a vendor who rebuilt a library under their own name has broken the link, and only the vendor's changelog can restore it. - The same version string meaning different things. Debian's
3.0.15-1~deb12u1is OpenSSL 3.0.15 plus Debian's backported patches; whether a given CVE is fixed in it is a question for the Debian security tracker, not for the version number. CVE patching in Kubernetes covers why distro versions lie.
Comparing versions without lying to yourself
"Below the fixed version" sounds like one comparison. It is three, because apk, dpkg and rpm each order versions by their own rules, and none of them is semver. Epochs (1:) outrank everything after them. Debian's tilde sorts before the empty string, so 3.0.16-1~deb12u1 is older than 3.0.16-1. Alpine's -r1 is a package revision, not a patch release. sort -V gets the common cases right and the edge cases wrong, so use it for the first pass and the distro's own comparator for the verdict:
# Three comparators, three answers. sort -V is close enough for a first pass;
# ask the distro's own tool before you tell anyone they are patched.
printf '%s\n' 3.0.15-1~deb12u1 3.0.16-1~deb12u1 3.0.9-1 | sort -V | head -1
# 3.0.9-1 (sort -V: oldest first, and it understands ~ by luck, not by rule)
docker run --rm debian:12-slim dpkg --compare-versions 1:1.2.13.dfsg-1 lt 1.2.13 \
&& echo older || echo "newer: the epoch wins"
# newer: the epoch wins (1:x sorts after any version with no epoch)
docker run --rm alpine:3.21 apk version -t 3.3.2-r1 3.3.3-r0
# < (apk prints <, = or >)The script below uses sort -V for its optional version filter. Before you tell anyone they are patched, run the two or three borderline versions through dpkg --compare-versions or apk version -t by hand.
Step 3: from digest to pod to owner to release
A digest with a hit maps back to pods through pods.tsv. The pod maps to a controller through its owner reference, and that is where two details trip people up.
First, a Deployment's pods are owned by a ReplicaSet, so there is one extra hop. A StatefulSet owned by an operator's custom resource (Prometheus Operator's Alertmanager, for instance) is another, and the script stops at the first owner for those; walk one more level if you want the custom resource.
Second, "which Helm release" has three answers depending on how the chart was applied. Helm 3 writes meta.helm.sh/release-name onto every resource it installs itself. A chart rendered by helm template and applied by kubectl or ArgoCD never gets that annotation; what it has is the app.kubernetes.io/instance label that well-behaved charts set to the release name, and, under ArgoCD, the argocd.argoproj.io/instance label naming the Application. Read all three, in that order:
# Pod → owner. A ReplicaSet is one hop below its Deployment; collapse it.
kubectl get replicasets -A -o json | jq -r '
.items[] | select(.metadata.ownerReferences != null)
| [.metadata.namespace, .metadata.name,
.metadata.ownerReferences[0].kind, .metadata.ownerReferences[0].name] | @tsv' > rs.tsv
# Owner → release. Three places the release name can be, in order of trust.
kubectl get deployment -n cert-manager cert-manager -o jsonpath='
{.metadata.annotations.meta\.helm\.sh/release-name}
{.metadata.labels.app\.kubernetes\.io/instance}
{.metadata.labels.argocd\.argoproj\.io/instance}'On the cluster used for this post, which ArgoCD manages, every chart had the label and none had the annotation. If you only check the annotation, an ArgoCD-managed cluster looks as if nothing came from Helm.
The script
All five steps, with the SBOMs cached by digest so the second run only generates SBOMs for images that changed:
#!/usr/bin/env bash
# find-package.sh — which pods in this cluster run package <name>, and in which version?
# Usage: find-package.sh <package> [<fixed-version>]
# Needs: kubectl (list pods, replicasets and the owning workloads), syft, jq, and pull
# access to the cluster's registries (docker login). SBOMs are cached in $SBOMS by digest.
set -euo pipefail
NAME="${1:?usage: find-package.sh <package> [<fixed-version>]}"
FIXED="${2:-}"
SBOMS="${SBOMS:-./sboms}" # one CycloneDX JSON per running digest
PLATFORM="${PLATFORM:-linux/amd64}" # your nodes' architecture, not your laptop's
# 1. The pod map, from status.containerStatuses: what the kubelet runs, not what the manifest says.
kubectl get pods -A -o json | jq -r '
.items[] as $p
| (($p.status.containerStatuses // []) + ($p.status.initContainerStatuses // []))[]
| [$p.metadata.namespace, $p.metadata.name,
($p.metadata.ownerReferences[0].kind // "Pod"),
($p.metadata.ownerReferences[0].name // $p.metadata.name),
(.imageID | sub("^docker-pullable://"; ""))]
| @tsv' > pods.tsv
# A ReplicaSet is a Deployment's hop; collapse it to the Deployment.
kubectl get replicasets -A -o json | jq -r '
.items[] | select(.metadata.ownerReferences != null)
| [.metadata.namespace, .metadata.name, .metadata.ownerReferences[0].kind, .metadata.ownerReferences[0].name]
| @tsv' > rs.tsv
awk -F'\t' 'NR==FNR { owner[$1 FS $2] = $3 FS $4; next }
{ k = $1 FS $4; if ($3 == "ReplicaSet" && k in owner) { split(owner[k], o, FS); $3 = o[1]; $4 = o[2] }
print }' OFS='\t' rs.tsv pods.tsv > workloads.tsv
# 2. One SBOM per distinct digest. Cached: a digest's SBOM never changes.
mkdir -p "$SBOMS"
cut -f5 workloads.tsv | sort -u | while read -r ref; do
d="${ref##*@sha256:}"
[ -s "$SBOMS/$d.cdx.json" ] && continue
syft -q "registry:$ref" --platform "$PLATFORM" -o "cyclonedx-json@1.6=$SBOMS/$d.cdx.json" \
|| echo "SBOM FAILED: $ref (check registry credentials)" >&2
done
# 3. The query: components whose name, upstream/source package, or purl path is NAME.
for f in "$SBOMS"/*.cdx.json; do
jq -r --arg n "$NAME" --arg d "$(basename "$f" .cdx.json)" '
.components[]? | select(.purl != null)
| ($n | gsub("[.]"; "\\.")) as $re
| select(.name == $n
or (.purl | test("[?&]upstream=" + $re + "([&%@]|-[0-9]|$)"))
or (.purl | test("/" + $re + "@")))
| [$d, .name, .version] | @tsv' "$f"
done | sort -u > hits.tsv
# 4. Optional: keep only versions older than FIXED (sort -V; see the caveats in the post).
if [ -n "$FIXED" ]; then
awk -F'\t' -v fixed="$FIXED" '{
cmd = "printf \"%s\\n%s\\n\" \"" $3 "\" \"" fixed "\" | sort -V | head -1"
cmd | getline first; close(cmd)
if ($3 != fixed && first == $3) print }' hits.tsv > hits.filtered.tsv
mv hits.filtered.tsv hits.tsv
fi
# 5. Join hits to workloads and look up the release on the owner.
printf 'NAMESPACE\tWORKLOAD\tRELEASE\tIMAGE\tPACKAGE\tVERSION\n'
while IFS=$'\t' read -r d pkg ver; do
grep -F "@sha256:$d" workloads.tsv | cut -f1,3,4,5 | sort -u | while IFS=$'\t' read -r ns kind name ref; do
rel=$(kubectl get "$kind" -n "$ns" "$name" -o jsonpath='{.metadata.annotations.meta\.helm\.sh/release-name}{"|"}{.metadata.labels.app\.kubernetes\.io/instance}{"|"}{.metadata.labels.argocd\.argoproj\.io/instance}' 2>/dev/null || true)
helm="${rel%%|*}"; rest="${rel#*|}"; inst="${rest%%|*}"; argo="${rest#*|}"
release="${helm:-${inst:-${argo:--}}}"
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$ns" "$kind/$name" "$release" "${ref%%@*}" "$pkg" "$ver"
done
done < hits.tsv | sort -u | column -t -s $'\t'This is the run on a three-node development cluster on 18 October 2026, trimmed from 62 rows to twelve. The search term was openssl; no row is called that, which is the point of step 2:
$ time ./find-package.sh openssl
SBOM FAILED: registry.example.com/shop/server@sha256:df4be271… (check registry credentials)
… 5 more SBOM FAILED lines
NAMESPACE WORKLOAD RELEASE IMAGE PACKAGE VERSION
argocd Deployment/argocd-server - quay.io/argoproj/argocd libssl3t64 3.5.5-1ubuntu3.5
argocd Deployment/argocd-server - quay.io/argoproj/argocd openssl 3.5.5-1ubuntu3.5
argocd Deployment/argocd-redis - public.ecr.aws/docker/library/redis libssl3 3.5.5-r0
default Pod/pg-proxy - docker.io/alpine/socat libssl3 3.5.8-r0
ingress-nginx Deployment/ingress-nginx-controller ingress-nginx registry.k8s.io/ingress-nginx/controller libssl3 3.5.5-r0
kube-system DaemonSet/cilium - ghcr.io/digitalocean-packages/cilium libssl3t64 3.0.13-0ubuntu3.6
kube-system DaemonSet/csi-do-node - ghcr.io/digitalocean-packages/do-csi-plugin libssl1.1 1.1.1w-r1
kube-system DaemonSet/do-node-agent - ghcr.io/digitalocean-packages/do-agent libssl3t64 3.5.5-1~deb13u1
monitoring DaemonSet/alloy alloy docker.io/grafana/alloy libssl3t64 3.0.13-0ubuntu3.15
monitoring Deployment/loki-gateway loki docker.io/nginxinc/nginx-unprivileged libssl3 3.5.8-r0
monitoring StatefulSet/loki loki docker.io/kiwigrid/k8s-sidecar libssl3 3.5.8-r0
stackradar Deployment/stackradar-scanner-watcher stackradar-scanner ghcr.io/lockdep/stackradar-scanner libssl3 3.5.7-r0
… 50 more rows
real 14m24s (first run: 46 digests, 40 SBOMs generated, 6 private images not pullable from a laptop)
real 0m22s (second run, SBOMs cached: a kubectl call per workload and a jq loop)Four things in that output are worth knowing about before you need them:
- Ubuntu renamed the package. Since the 64-bit time_t transition in 24.04, Ubuntu's OpenSSL library is
libssl3t64. A query onlibssl3would have missed every Ubuntu-based image in the cluster, which here is Argo CD, Cilium and Grafana Alloy. The upstream match caught them. - One image, two versions. The Argo CD image contains
golang.org/x/cryptotwice: v0.53.0 in theargocdandhelmbinaries and v0.36.0 ingit-lfs. Syft'ssyft:location:0:pathproperty says which binary each one came from. An image is not a version; a binary is. - Someone else's image still ships OpenSSL 1.1.1. The CSI node plugin carries
libssl1.11.1.1w, three years past OpenSSL's end of support. It is the cloud provider's image, so the fix is a ticket, not a rebuild. Most of your container vulnerabilities are not yours to fix covers that split. - The failures are printed, not swallowed. Six images live in a private registry the laptop has no credentials for, and each one is a line on stderr. A silent skip would look exactly like "not running it".
sboms/ directory.The emergency path: kubectl exec
If the question lands and there are no SBOMs, the honest alternative is to ask every container's package manager directly. It works, up to a point:
# The emergency path: ask each pod's package manager directly. One container per pod,
# only pods with a shell, only if you may exec. Slow, noisy, and blind to distroless.
kubectl get pods -A -o jsonpath='{range .items[*]}{.metadata.namespace}{" "}{.metadata.name}{"\n"}{end}' \
| while read -r ns pod; do
out=$(kubectl exec -n "$ns" "$pod" -- sh -c '
command -v dpkg-query >/dev/null && dpkg-query -W -f="\${Package}\t\${Version}\t\${Source}\n" 2>/dev/null
command -v apk >/dev/null && apk list --installed 2>/dev/null
command -v rpm >/dev/null && rpm -qa --qf "%{NAME}\t%{VERSION}-%{RELEASE}\t%{SOURCERPM}\n" 2>/dev/null
command -v pip >/dev/null && pip list --format=freeze 2>/dev/null
find / -name "*.jar" -not -path "*/proc/*" 2>/dev/null' 2>/dev/null) || { echo "$ns/$pod: no shell or no exec" >&2; continue; }
grep -i "openssl\|libssl" <<< "$out" | sed "s#^#$ns/$pod\t#"
doneThe point is reached quickly:
- Distroless and scratch images have no shell, so
kubectl execfails on exactly the images that were built with care. Many have no package database either, so even an ephemeral debug container would find nothing to query. - It needs
pods/execin every namespace, which is a far broader permission thanlist pods, and most clusters audit it. The SBOM route needs read access to pods and pull access to the registry, nothing inside the container. - It runs in production, once per pod rather than once per digest. Three hundred pods running forty images is three hundred execs for forty answers.
- It answers for now. Nothing is kept, so tomorrow's question costs the same again.
Use it once, to answer the question in front of you. Then generate the SBOMs so the next question is a jq call.
Making it a ten-second answer next time
The script already caches SBOMs by digest. Two more habits make the answer continuous. Regenerate pods.tsv on a schedule, so the map is never older than an hour. And keep the sboms/ directory somewhere shared, because the person asked at 2 a.m. is not the person whose laptop holds the cache.
For one cluster and a few dozen images, a directory and a cron job are entirely adequate. The reasons it stops being adequate are predictable: several clusters, each with its own kubeconfig and registry credentials; images on two architectures, where the same digest has two different package lists; a history question ("were we running it in March?"), which a directory of current SBOMs cannot answer; and the moment someone wants the result as a dashboard rather than a terminal. That is the point where it is worth owning or buying the storage and the join, and keeping the script as the audit of whatever you buy.
What StackRadar does
StackRadar is the stored version of this method. The in-cluster scanner keys everything on the digest: one pull, one Syft run and one CycloneDX 1.6 SBOM per running image, uploaded with the inventory that attributes it to namespaces, workloads and containers, the Helm release that packaged each one and the ArgoCD application that delivers it. Package search across the fleet is the query from step 2 with the join already done: search a library and read the blast radius off the image and workload counts, including which versions are affected and which are already clean. Each SBOM is dated and superseded rather than overwritten, so the history question has an answer. The continuous scanning page covers how the same stored SBOMs are re-matched when a CVE does arrive.
For a one-off from a laptop, the free Apache-2.0 StackRadar CLI runs the pod map and the scan in one command and opens an interactive report that filters by image, package, CVE or pod. It sends nothing to StackRadar: image pulls go to your registries and package names and versions go to api.osv.dev.
# A one-off, from your laptop, no account: the interactive report filters by package and pod.
stackradar scan --serveWhichever you use, check the result against what the SBOM can miss: a package search is only as good as the inventory, and an image the scanner could not pull is a gap, not a clean answer.
Frequently asked questions
How do I find which pods use a specific package version in Kubernetes?
List the image digest each running container reports in status.containerStatuses[].imageID, generate one SBOM per distinct digest with Syft or Trivy, search the SBOMs for the package name and version, then join the matching digests back to the pods and walk metadata.ownerReferences up to the Deployment, StatefulSet or DaemonSet. The script in this post does all five steps with kubectl, syft and jq.
How do I check which version of OpenSSL a container is running?
With an SBOM, search for the upstream project rather than the binary package: on Alpine the packages are libssl3 and libcrypto3, on Debian libssl3 and openssl, on RHEL openssl-libs. Syft records the upstream in each PURL. Without an SBOM, exec into the container and run apk list --installed, dpkg-query -W or rpm -qa, which works only for images that have a shell and a package database.
How do I find log4j in a Kubernetes cluster?
Generate an SBOM for every running image and search for the Maven coordinate org.apache.logging.log4j/log4j-core, which Syft finds inside jars, nested jars and war files by reading their pom.properties and manifests. A copy that was shaded or repackaged with its Maven metadata stripped will not appear by name, so for log4j specifically also search the SBOMs for the file evidence and, if in doubt, scan the jars for the JndiLookup class.
Can kubectl show which image a pod is actually running?
Yes. spec.containers[].image is what the manifest asked for, which may be a moving tag. status.containerStatuses[].imageID is what the container runtime pulled, with its sha256 digest. Use the digest, because two pods with the same tag can run different images after a tag has been republished.
How do I find the Helm release that owns a pod?
Walk the ownerReferences from the pod to its ReplicaSet and then to the Deployment, or directly to the StatefulSet, DaemonSet or Job. On that owner, Helm 3 writes the meta.helm.sh/release-name annotation when it installed the chart itself. If the chart was rendered by helm template or applied by ArgoCD, that annotation is absent and the release name is in the app.kubernetes.io/instance label, with ArgoCD adding argocd.argoproj.io/instance.
Is an SBOM query better than a vulnerability scan for this?
They answer different questions. A scan tells you which known CVEs match your images today. A package query tells you where a library is, which is what you need in the hours before a CVE ID exists, when the advisory names a project rather than a package, or when you simply need to know what you run. Keep the SBOMs and you can do both without pulling an image again.
More from the blog
All postsHow 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.
11 min read
How to generate an SBOM from a running Kubernetes cluster
One CycloneDX SBOM per image digest the kubelets run, not per Dockerfile: list the digests, run Syft once each, keep the files by digest, and re-match them.
10 min read
How to run a scheduled vulnerability scan of a Kubernetes cluster without filling etcd
One CronJob, a read-only ServiceAccount, Trivy, and results in a bucket instead of etcd. The Dockerfile, the YAML and the script, with SBOMs built once per digest and re-matched daily.
12 min read
How to prioritise container vulnerabilities with EPSS and KEV
Four tiers, the thresholds behind each, and a script that joins Grype or Trivy JSON with the EPSS and KEV feeds. On a real nginx image, 497 CVEs became 12 for this week.
11 min read