- how-to
- SBOM
- Syft
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.
StackRadar Team
10 min read
The SBOM your pipeline attaches to an image describes an image you built. The question a security review, a customer questionnaire or the Cyber Resilience Act asks is different: what is running? On the cluster this post was written against, the kubelets report 46 distinct images. Perhaps a third are ours. The rest are the ingress controller, the GitOps agent, the log shipper, the metrics stack, the database operator and its sidecars, pulled from four registries and built by people who have never heard of us.
An SBOM of a cluster is therefore not one document. It is one SBOM per image digest the kubelets are running, plus a record of where each digest runs. This post builds that with Syft, kubectl and jq: first for one image, then for every running image, then kept current without regenerating anything that has not changed, then put to use. The whole thing is one script; the rest is the reasoning and the traps.
Step 1: an SBOM of one image
Start with the single-image case, because the cluster version is the same command in a loop. Syft (Anchore, Apache-2.0) is the reference generator for container images and the one most other tools wrap, so it is the one used here. The same job in Trivy and Docker Scout follows.
# One CycloneDX 1.6 SBOM for one image, pulled straight from the registry.
syft registry:docker.io/library/nginx:1.27-alpine --platform linux/amd64 \
-o cyclonedx-json@1.6=nginx.cdx.json
# What is in it
jq '{
subject: .metadata.component.name,
packages: [.components[] | select(.type == "library")] | length,
os: [.components[] | select(.type == "operating-system") | .name + " " + .version],
files: [.components[] | select(.type == "file")] | length,
dependency_edges: .dependencies | length
}' nginx.cdx.jsonRun on 18 October 2026 with Syft 1.42.1, that prints:
{
"subject": "docker.io/library/nginx",
"packages": 68,
"os": ["alpine 3.21.3"],
"files": 978,
"dependency_edges": 62
}Three parts of the command matter and are easy to leave out:
registry:pulls from the registry directly, with the credentials in your Docker config, and needs no Docker daemon. Without the prefix Syft looks for a local daemon first, and on a laptop that can hand you a different variant of the image than your nodes pulled.--platformpicks the architecture. Most public tags are multi-arch manifest lists, and the default is the machine you run Syft on. An SBOM of the arm64 variant generated on a MacBook is an SBOM of an image your amd64 nodes have never run.cyclonedx-json@1.6pins the spec version, and=nginx.cdx.jsonwrites the file. Syft defaults to 1.6 today; pinning it means a Syft upgrade cannot silently change what your consumers receive. The.cdx.jsonsuffix is what some consumers key on, as you will see in step 4.
What is in the file: 68 Alpine packages as library components, each with a Package URL that carries the ecosystem, distro and exact version, one operating-system component, 62 dependency edges from the apk database, and 978 file components recording the binaries Syft saw. The CycloneDX reference walks through the record a vulnerability matcher actually reads, and has this exact file to download.
What is not in it: anything about the running process, environment variables, mounted config or secrets. An image SBOM is an inventory of the filesystem the container starts from. Nor is it the dependency graph your build resolved: a shaded jar or a statically linked Go binary shows up as what Syft can identify from the binary, which is less than the lockfile knew. The SBOM tools comparison covers when to keep a build-time SBOM as well.
If Syft is not the tool you already have:
# Trivy: the same inventory from the scanner you may already run in CI.
trivy image --platform linux/amd64 --format cyclonedx \
--output nginx.cdx.json nginx:1.27-alpine
# Docker Desktop: docker scout sbom. The old "docker sbom" plugin has been removed.
docker scout sbom --format cyclonedx --output nginx.cdx.json \
registry://docker.io/library/nginx:1.27-alpineExpect different component counts for the same image across generators. The catalogers differ, and they disagree about what counts as a package inside a Go binary. Pick one generator and use it for every image, so the inventories are comparable with each other.
Step 2: every image the cluster is running
The list of images comes from the kubelets, not from your manifests, Helm values or GitOps repo. Those say which tag was requested. The kubelet knows which digest the container runtime pulled, and it reports it per container, including the init containers and the sidecars a mesh or a logging agent injected. This is the same first step as the KEV check, and the same command:
# Every distinct image digest the kubelets are running — app, sidecar and init
# containers alike — as reported by the container runtime, not by the manifest.
kubectl get pods -A -o jsonpath='{range .items[*]}{range .status.containerStatuses[*]}{.imageID}{"\n"}{end}{range .status.initContainerStatuses[*]}{.imageID}{"\n"}{end}{end}' \
| sed 's#^docker-pullable://##' | grep . | sort -u > running-images.txt
wc -l running-images.txtstatus.containerStatuses[].imageID is the image the runtime actually pulled, digest included. spec.containers[].image is what the manifest asked for. The two differ whenever a tag has moved, and on a real cluster they always differ in form:
# Where the tag in the manifest and the digest the kubelet pulled part company.
kubectl get pods -A -o json | jq -r '
.items[] | .status.containerStatuses[]?
| [.image, .imageID] | @tsv' | sort -u | head -3quay.io/argoproj/argocd:v3.5.3 quay.io/argoproj/argocd@sha256:dd3f47d5a5e4…
ghcr.io/dexidp/dex:v2.45.1 ghcr.io/dexidp/dex@sha256:8499afd690c4…
docker.io/alpine/socat:latest docker.io/alpine/socat@sha256:c5a091e1e735…The digest is the only identifier worth building an inventory on. :latest on the third line names a different image every time it is pulled; the digest names one forever. Nodes that run Docker as the runtime prefix the field with docker-pullable://, hence the sed. On containerd and CRI-O it is already clean.
On the cluster used for this post, 135 container statuses collapsed to 46 distinct digests. That ratio is typical: a DaemonSet runs the same image on every node, a Deployment with three replicas is one digest, and the same base image serves several charts. Every one of those 46 needs exactly one Syft run.
The script: one SBOM per running digest
The whole job, with the attribution table built first so that nothing is ever an anonymous file:
#!/usr/bin/env bash
# sbom-cluster.sh — one CycloneDX SBOM per image digest running in this cluster.
# Needs: kubectl (read access to pods), jq, syft (https://github.com/anchore/syft),
# and pull access to the cluster's registries (docker login).
set -euo pipefail
export LC_ALL=C
OUT="${OUT:-sboms}" # where the SBOMs go; keep it between runs
PLATFORM="${PLATFORM:-linux/amd64}" # your nodes' architecture, not your laptop's
mkdir -p "$OUT"
# 1. Where each digest runs: digest, namespace, pod, container, image as written.
# This is the attribution table. Keep it next to the SBOMs.
kubectl get pods -A -o json \
| jq -r '.items[] | .metadata.namespace as $ns | .metadata.name as $pod
| ((.status.containerStatuses // []) + (.status.initContainerStatuses // []))[]
| [.imageID, $ns, $pod, .name, .image] | @tsv' \
| sed 's#^docker-pullable://##' | sort -u > "$OUT/where.tsv"
# 2. The distinct digests.
cut -f1 "$OUT/where.tsv" | sort -u > "$OUT/images.txt"
echo "Running images: $(wc -l < "$OUT/images.txt" | tr -d ' ')" >&2
# 3. One SBOM per digest. A digest's SBOM never changes, so an existing file is final.
while read -r image; do
case "$image" in
*@sha256:*) digest="${image##*@sha256:}" ;;
*) echo "NO DIGEST: $image (locally built image?)" >&2; continue ;;
esac
file="$OUT/sha256-$digest.cdx.json"
[ -s "$file" ] && continue
if ! syft -q "registry:$image" --platform "$PLATFORM" \
-o "cyclonedx-json@1.6=$file" < /dev/null 2>/dev/null; then
echo "SBOM FAILED: $image (check registry credentials)" >&2
rm -f "$file"
fi
done < "$OUT/images.txt"
echo "SBOMs: $(ls "$OUT"/*.cdx.json | wc -l | tr -d ' ')" >&2Four details are deliberate:
- The attribution table comes first.
where.tsvrecords digest, namespace, pod, container and the image reference as the manifest wrote it. The SBOM files are named by digest alone, so this table is how you get from a finding back to a pod, and from a pod to its SBOM. Without it you have a directory of hashes. - An existing file is final. The SBOM of a digest cannot change, because the digest is the hash of the image content. So
[ -s "$file" ]skips every digest the script has already seen, and the second run of the day costs nothing. That line is the whole caching strategy. - Failures are printed, not swallowed. An image Syft cannot pull, almost always for lack of registry credentials, is reported as
SBOM FAILEDand its empty file removed. A silent skip would look exactly like a complete inventory, which is the worst outcome for a document whose only job is to be complete. - A status without a digest is reported too. A locally built image on a Docker-runtime node can carry an
imageIDwith no registry reference. The script cannot pull it, so it says so instead of guessing.
A run against the 46-image cluster from a laptop with no credentials for the cluster's private registry, so the six images in it fail loudly:
$ time ./sbom-cluster.sh
Running images: 46
SBOM FAILED: registry.example.com/shop/charts-site@sha256:57c85bcb… (check registry credentials)
SBOM FAILED: registry.example.com/shop/charts-worker@sha256:50ffb1cf… (check registry credentials)
SBOM FAILED: registry.example.com/shop/charts-worker@sha256:7f38dc3e… (check registry credentials)
SBOM FAILED: registry.example.com/shop/charts-worker@sha256:c499bae9… (check registry credentials)
SBOM FAILED: registry.example.com/shop/client@sha256:ed1e3266… (check registry credentials)
SBOM FAILED: registry.example.com/shop/server@sha256:df4be271… (check registry credentials)
SBOMs: 40
real 13m05s
$ ls sboms | head -4
images.txt
sha256-06b52bd4dbe3ed6dd5905aadaf8b9987d9da4c31d37bb86deaadae68cee2d26b.cdx.json
sha256-08ad0b1d280850169a790dba1393ff7a90aef951fc19632cf4d3ce4f78e679ba.cdx.json
sha256-0d1b560759bcad02cc09b3d5cf2bb4ba4a6ec1af61f9884dd62b398c09a56668.cdx.json
$ du -sh sboms
24M sbomsRun time is image pulls. Syft pulls each layer once, so the first run against a cluster is the slow one, and the per-digest cache makes every later run proportional to what changed. Twenty-four megabytes for 40 images is mostly the file components; the package records themselves are a fraction of that. And the six failures are the next job, not a footnote: until they are fixed with the pull secret below, the inventory is 40 of 46.
PLATFORM=linux/arm64 for the images the arm64 nodes run, and the kubelet usually reports the manifest-list digest for both, so the digest alone does not tell you which variant a node unpacked. kubectl get nodes -o jsonpath='{.items[*].status.nodeInfo.architecture}' tells you whether you have the problem.For private registries, the simplest fix is docker login on the machine that runs the script, since Syft reads the same config.json. The cluster already has working credentials, in the form of its pull secrets, and Syft honours DOCKER_CONFIG, so you can borrow them:
# Borrow the cluster's own pull secret for the registries the nodes can reach.
kubectl get secret -n payments regcred -o jsonpath='{.data.\.dockerconfigjson}' \
| base64 -d > /tmp/regcred/config.json
DOCKER_CONFIG=/tmp/regcred ./sbom-cluster.shOn EKS, GKE and AKS the nodes often pull with the instance identity rather than a secret, in which case the machine running the script needs the cloud CLI's credential helper configured instead. The private registries guide lists the per-cloud mechanics; they are the same whether the puller is a scanner or Syft.
Step 3: keep it current without regenerating it
Most SBOM efforts die here. A one-off directory of files is a snapshot, and a snapshot is stale after the next deploy. The fix is not to regenerate everything nightly. It is to separate the two things that change at different rates:
- The set of running digests changes on every deploy. Re-run the script and only the new digests cost a pull. On most days that is a handful of images.
- The advisories change hourly. Nothing about the SBOMs changes when a CVE is published; what changes is the answer you get when you match them. So re-match the stored SBOMs, and never re-pull an image because of a new CVE.
Run it from cron or a nightly CI job, keep the output directory between runs, and diff the digest list to see what the deploys changed:
# What changed since the last run: digests that appeared and digests that went away.
cp sboms/images.txt /tmp/images.before # before the run
./sbom-cluster.sh
comm -13 /tmp/images.before sboms/images.txt # new digests, each with a fresh SBOM
comm -23 /tmp/images.before sboms/images.txt # retired digests; their SBOMs are still on diskDo not delete the SBOMs of retired digests. Dated and kept, they are the answer to "what was running on the 3rd of March?", which is the question an auditor and an incident review both ask, and the one a CRA reporting duty needs answered within 24 hours. Storage is cheap at these sizes: a directory in object storage keyed by digest, or a git repository if you want the history for free. Name files by digest and never by tag, and keep where.tsv dated next to them, because the attribution is the part that changes.
If you already run Dependency-Track, it is a good consumer for this directory: one project per image, upload each digest's SBOM as a new version, and let it do the matching and the policy. It will not discover the images for you; this script is the discovery step it lacks.
From one SBOM file back to the pods, through the table:
# Which pods run the image behind one SBOM file?
digest=f111cce835516c5f99166342be7038496b52ced16667be5a11e19258a3e4cd30
grep "@sha256:$digest" sboms/where.tsv | cut -f2-4monitoring alloy-pmz98 alloy
monitoring alloy-pwczr alloy
monitoring alloy-q4wqt alloyStep 4: what the SBOMs are for
Vulnerability matching without pulling. Every container matcher reads CycloneDX. Grype is the natural partner for a Syft SBOM, and both OSV-Scanner and Trivy accept the same file:
# Grype: the Syft SBOM in, findings out. No image pull.
grype sbom:./sboms/sha256-f111cce8….cdx.json
# OSV-Scanner 2.x: the file name must end in .cdx.json so it picks the CycloneDX parser.
osv-scanner scan source -L ./sboms/sha256-f111cce8….cdx.json
# Trivy reads either generator's output.
trivy sbom ./sboms/sha256-f111cce8….cdx.json
# All of them, with the pods that run each image listed above the findings.
for f in sboms/*.cdx.json; do
d=${f##*/sha256-}; d=${d%.cdx.json}
echo "== $(grep -m1 "@sha256:$d" sboms/where.tsv | cut -f5)"
grep "@sha256:$d" sboms/where.tsv | cut -f2,3 | sed 's/^/ /'
grype -q "sbom:$f" --fail-on high || echo " ^ has high or critical findings"
doneTwo things to know. OSV-Scanner 2 chooses its parser from the file name, so the .cdx.json suffix is load-bearing; a CycloneDX document called inventory.json is a parse error. And the three matchers will not agree on the count for the same file, because they match differently and read different feeds. That is not a bug in your SBOM. Pick one matcher, and read why zero findings is not a clean image before trusting a zero. The sibling post on finding which running images a new CVE affects is this loop run for one CVE ID.
"Where do we run X?" The directory is a searchable inventory. When the next Log4Shell names a package, a jq over the component PURLs tells you which digests contain it and where.tsv tells you which pods. That search, with the version ranges handled properly, is the subject of finding every pod running a vulnerable package.
Compliance and customers. The CRA's SBOM requirement applies from 11 December 2027 and asks for a machine-readable SBOM covering at least top-level dependencies of what you ship; for a product delivered as container images, this directory is it. The CRA post goes through the obligations. When a customer asks for SPDX, or for one file:
# A customer wants SPDX: convert, or regenerate with -o spdx-json.
cyclonedx-cli convert --input-file sboms/sha256-f111cce8….cdx.json \
--output-format spdxjson --output-file alloy.spdx.json
# One document for the whole cluster, if someone insists on one file.
cyclonedx-cli merge --input-files sboms/*.cdx.json --output-file cluster.cdx.jsonPrefer the per-image files for everything except that one request. A merged document has no field for which image a component came from, has to be rebuilt whenever any image changes, and grows with every deploy. The format question itself, and what regulators accept, is covered in CycloneDX vs SPDX.
Five ways the inventory lies
- Zero packages.
busybox:1.37yields nolibrarycomponents at all, anddistroless/staticyields five. Neither is clean; neither has a package database to read. For images like these, generate the SBOM at build time, when the package manager still knows what went in. - The wrong architecture. Covered above, and the most common silent error. If the SBOM's
operating-systemcomponent or a PURL'sarch=qualifier saysaarch64and your nodes are amd64, the file describes an image you do not run. - The squashed view. Syft's default scope is
squashed: the filesystem as the container sees it. A package installed in one layer and deleted in a later one is gone from the SBOM, as it is from the container.--scope all-layerslists it anyway, which is what you want for a supply-chain question and not for a vulnerability one. - Missing containers. The script reads init containers and app containers. It does not read ephemeral debug containers, and it only sees pods that exist at the moment it runs. A CronJob whose pod lives for ten seconds is in your cluster and not in your inventory. Run the script more than once a day if that matters.
- An SBOM you never finished. Every
SBOM FAILEDline is a hole in the inventory. Count them, and treat the inventory as incomplete until the count is zero.
What StackRadar does
The script above is the inner loop of the StackRadar scanner, which runs it continuously from inside the cluster. It is one Helm install. It watches the pods, keys everything on the image digest, and runs Syft, unmodified, once per digest. The output is the same CycloneDX 1.6 JSON this post generates, validated against the schema and uploaded once: before pulling anything, the scanner asks whether this cluster already has an SBOM for that digest, so an image in five namespaces is one pull, one Syft run and one document.
Two things the script leaves to you are built in. The attribution table is the inventory the scanner reports alongside the SBOMs, namespace, workload, container and the Helm release or ArgoCD application that delivers it, so a finding shows against every place the image runs. And the re-matching is done for you: the OSV mirror updates hourly, EPSS and KEV daily, and every stored SBOM is re-evaluated when they change. Each image's SBOM can be downloaded from the dashboard, and the upload endpoint accepts SBOMs your pipeline generated, so images built and catalogued in CI need not be pulled twice.
Only the SBOM and the inventory leave the cluster; the architecture page lists exactly what does and does not. The quick start is the install, and the whole product is open source if you would rather run the server yourself.
Frequently asked questions
How do I generate an SBOM from a container image?
Run Syft against the image reference: `syft registry:nginx:1.27-alpine --platform linux/amd64 -o cyclonedx-json@1.6=nginx.cdx.json`. The registry: prefix pulls from the registry without a Docker daemon, --platform picks the architecture your servers run, and @1.6 pins the CycloneDX version. Trivy does the same with `trivy image --format cyclonedx --output nginx.cdx.json nginx:1.27-alpine`, and Docker Desktop with `docker scout sbom --format cyclonedx`. Scan the image by digest rather than by tag if you want the SBOM to describe a specific build.
How do I generate an SBOM for a whole Kubernetes cluster?
Ask the kubelets which image digests are running (kubectl get pods -A, reading status.containerStatuses[].imageID and the init containers), deduplicate them, and run Syft or Trivy once per digest, with --platform set to your nodes’ architecture and credentials for your registries. Keep one file per digest and a table of which namespace, pod and container runs each digest. The script in this post does exactly that. A cluster-level tool such as the StackRadar scanner or Trivy Operator does the same job continuously inside the cluster.
Should a Kubernetes SBOM be CycloneDX or SPDX?
CycloneDX for vulnerability work: it was designed for security use, every container scanner emits and reads it, and it carries VEX natively. SPDX when a customer or a licence-compliance process asks for it; Syft can write it from the same scan with -o spdx-json, and cyclonedx-cli converts an existing document. Both are accepted by regulators. Check the version if Germany’s BSI TR-03183-2 applies: it requires CycloneDX 1.6 or SPDX 3.0.1 or later, and Syft writes SPDX only up to 2.3.
How often should I regenerate SBOMs for a cluster?
As often as the set of running digests changes, which is every deploy. The SBOM of a given digest never changes, so a daily run that generates SBOMs only for digests it has not seen before is cheap: most days it pulls a handful of images. What does change daily is the vulnerability data, so re-match the stored SBOMs against the advisory feed as often as it updates, instead of regenerating them.
Can I merge many SBOMs into one document?
Yes, with `cyclonedx-cli merge --input-files a.cdx.json b.cdx.json --output-file all.cdx.json`, and --hierarchical keeps each input as a nested component. Do it only when someone needs one file. For vulnerability tracking keep one SBOM per image digest: a merged document has no place for which image a component came from, grows with every deploy, and has to be rebuilt whenever one image changes.
Why does my SBOM show zero packages?
The image has no package database for the generator to read. busybox, scratch-based images and some distroless variants ship binaries without apk, dpkg or rpm metadata, so an image SBOM lists what Syft can identify from the binaries themselves, which can be nothing. Zero packages is not a clean image; it is an image the tool cannot see into. Build such images with the SBOM attached at build time, or keep a build-time SBOM next to the image SBOM.
More from the blog
All postsHow 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.
12 min read
SBOM tools compared: Syft, Trivy, Docker Scout, cdxgen and cluster-level SBOMs
Syft, Trivy, Docker Scout and cdxgen compared: the current command for each, where each is best, and why an SBOM of what your cluster runs needs a different tool.
8 min read
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.
11 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