Generate an SBOM for any container image with Syft
Produce a complete software bill of materials in SPDX or CycloneDX and feed it to Grype for fast CVE triage.
The question that an SBOM exists to answer arrives on a bad day: a CVE is published for a library, and somebody asks which of the two hundred images in the registry contain it. Without an inventory the answer is a week of pulling images and reading layers. With one SBOM stored per image digest, it is a query over JSON files, and the rebuilds can start the same afternoon. Syft produces the inventory and Grype reads it, and the operational choices are about formats, storage and thresholds rather than about the tools.
syft registry.acme.dev/shop/api:1.4.0 -q | head -5NAME VERSION TYPEalpine-baselayout 3.6.5-r0 apkbusybox 1.36.1-r29 apkca-certificates 20240705-r0 apkexpress 4.19.2 npmsyft registry.acme.dev/shop/api:1.4.0 -o syft-json -q | jq "[.artifacts[].type] | group_by(.) | map({(.[0]): length}) | add"{ "apk": 42, "binary": 1, "npm": 187 }the Node app is 187 packages; the 42 apk packages and the node binary are what nobody chose and everybody patches. Run for this article on a bare alpine:3.18.0, the same jq printed {"apk":15}One format, one file per digest
Syft output formats and when each fits
-o | Standard | Use it for |
|---|---|---|
cyclonedx-json | CycloneDX (OWASP) | the default choice for security tooling: Grype, Dependency-Track, VEX linkage, attestations |
spdx-json | SPDX (ISO/IEC 5962) | licence and compliance exchange; procurement asks for this one |
syft-json | Syft native | the richest detail, and the best input to Grype; not portable to other tools |
spdx-tag-value, cyclonedx-xml | the same standards, older encodings | a downstream tool that cannot read JSON |
purls, github-json | lists | feeding a package URL into another scanner; GitHub dependency submission |
#!/usr/bin/env bashset -euo pipefailIMAGE="registry.acme.dev/shop/api@$DIGEST" # the digest CI just pushed, never a tagSHORT="${DIGEST#sha256:}"# one run, two files: the security format and the compliance formatsyft "$IMAGE" -o cyclonedx-json="sbom-${SHORT}.cdx.json" -o spdx-json="sbom-${SHORT}.spdx.json" -q# archive next to the image, under the same retention as the imageaws s3 cp "sbom-${SHORT}.cdx.json" "s3://acme-sboms/shop/api/${SHORT}.cdx.json"
Pick CycloneDX or SPDX once for the organisation and emit the other only when a consumer needs it; Syft writes several formats from one scan, so the cost of both is one flag. The file is named after the digest because a tag moves and an SBOM describes bytes. It is archived with the image's retention because an SBOM for an image that no longer exists is history, and an image with no SBOM is the question you cannot answer. Scanning at build time, against the pushed digest, is what makes the file describe what runs rather than what the Dockerfile intended.
Grype reads the file, and the exit code is 2
syft docker-archive:image.tar -o cyclonedx-json=sbom.cdx.json -o spdx-json=sbom.spdx.json -q && ls -l sbom.*.json53358 sbom.cdx.json90886 sbom.spdx.jsongrype sbom:sbom.cdx.json --fail-on high --only-fixed -o table | head -4; echo "exit ${PIPESTATUS[0]}"NAME INSTALLED FIXED IN TYPE VULNERABILITY SEVERITY EPSS RISKlibcrypto3 3.1.0-r4 3.1.7-r0 apk CVE-2024-6119 High 66.6% (99th) 49.9libssl3 3.1.0-r4 3.1.7-r0 apk CVE-2024-6119 High 66.6% (99th) 49.9libcrypto3 3.1.0-r4 3.1.1-r0 apk CVE-2023-2650 Medium 75.1% (99th) 43.2exit 2grype sbom:sbom.cdx.json --only-fixed -o table >/dev/null; echo "exit $?"exit 0grype sbom:does-not-exist.json --fail-on high; echo "exit $?"ERROR failed to catalog: unable to open file does-not-exist.json: open does-not-exist.json: no such file or directoryexit 1three exit codes, three meanings: 2 is findings at or above the threshold, 0 is a scan with findings but no --fail-on (or none above it), 1 is the tool failing. A step that tests only for 1 passes on findings and fails on a missing file. Counts on this image: 63 matches, 49 with a fix (5 Critical, 8 High, 36 Medium)grype sbom: matches the stored inventory against the vulnerability database, so a re-scan costs seconds and needs no registry access; that is how the archive gets queried on the bad day (grype db update first, then the same command against the same file). --fail-on high is the merge gate, and its exit code is 2, which matters for any wrapper script that treats only exit 1 as a failure; without the flag the same findings exit 0, so the gate is the flag, not the table. --only-fixed keeps the gate on findings with an available fix, which is the set a rebuild can act on; on the image above it removed 14 of 63 matches, and the unfixed ones go to a tracked list rather than a red pipeline. The same gate against the syft-json SBOM of the same image returned the same exit code. When a component is present but the vulnerable code path is not, a VEX document passed with --vex records that judgement where the scanner can apply it, instead of in a wiki nobody re-reads.
When the gate is wrong: recovery in order of preference
| Situation | Do | Do not |
|---|---|---|
| a finding is not reachable in this image | a VEX statement (not_affected, with a justification) in a file the job passes with --vex, reviewed like code and scoped to the digest | --ignore for the CVE id in the job: it hides the CVE in every image the job scans, including the next one where it is reachable |
| a base image bump fixes it but cannot ship today | the finding stays red on the merge request; a dated exception in the same VEX file (under_investigation, ticket in the note) with the bump scheduled | --fail-on critical for the whole project because one HIGH is inconvenient |
| the database update itself breaks the job (registry unreachable, a bad DB build) | pin the last good database with GRYPE_DB_AUTO_UPDATE=false and a cached GRYPE_DB_CACHE_DIR until the source recovers; the job then fails loudly on a missing DB (exit 1) rather than passing on an empty one | add || true after the scan: the gate is now decorative |
The day the CVE lands
# every archived SBOM that contains openssl below the fixed versionaws s3 sync s3://acme-sboms ./sboms --quietfind ./sboms -name '*.cdx.json' | while read -r f; dojq -r --arg f "$f" '.components[] | select(.name=="openssl" and (.version < "3.3.2-r0")) | "\($f)\t\(.version)"' "$f"done | sort -u
The loop is deliberately crude: version comparison on Alpine strings is not lexicographic in general, and the list it prints is a candidate set to confirm with Grype, not a verdict. What matters is that the set exists in minutes and names digests, so the rebuild order can start with the images that are running. The fix is a base image bump, and the SBOM from the rebuilt image is the evidence that the bump took, which is worth more than the pipeline's green tick.
The inventory becomes enforceable when it is attached to the image as a signed attestation and checked at admission; SBOM and provenance attestations continues from there. The same Grype gate runs on an image reference in a Trivy or Grype GitLab job for teams that prefer scanning the registry directly; the SBOM route is the one that survives the day the registry is the thing under load.