Hands-on lab
Generate an SBOM with Syft and Scan It with Grype
Produce CycloneDX and SPDX bills of materials for a container image without a Docker socket, scan the SBOM with Grype, gate on fixable findings only, and see why the SBOM is worth keeping after the release.
Before you start
- Docker (to run the Syft and Grype images) or the syft and grype binaries
- jq
- About 2.5 GB of disk: the Grype vulnerability database is large, and this lab caches it
Interactive environment
Practice this lab in a temporary browser-based environment. Nothing needs to be installed on your machine.
Steps on this page
Goal
An SBOM is the inventory that lets you answer “are we affected?” in minutes when a new CVE drops, without pulling
the image again. In this lab you generate one for a real image, scan it with Grype, set a gate that fails on the
findings you can act on, and compare the SBOM scan with a direct image scan. Every command was run with Syft 1.51.1
and Grype 0.118.0 against python:3.11-slim-bookworm; the numbers shown are from a database built on 2026-09-12 and
will drift upward as new CVEs are published, which is the point of the last step.
Environment
Both tools are run from their official images so nothing is installed and no Docker socket is mounted: Syft reads the image straight from the registry, and Grype reads files you mount. The cache directory keeps the vulnerability database between runs, which matters because it is about two gigabytes.
mkdir -p ~/labs/sbom/grype-cache && cd ~/labs/sbom
syft() { docker run --rm -v "$PWD:/out" anchore/syft:v1.51.1 "$@"; }
grype() { docker run --rm -e GRYPE_DB_CACHE_DIR=/cache -v "$PWD/grype-cache:/cache" -v "$PWD:/out" anchore/grype:v0.118.0 "$@"; }
syft version | head -3 && grype version | head -3
If you have the binaries installed, skip the functions and replace /out/ with ./ in the paths below.
Step 1: generate the SBOM in two formats
syft registry:python:3.11-slim-bookworm \
-o cyclonedx-json=/out/py311.cdx.json \
-o spdx-json=/out/py311.spdx.json \
-o table
NAME VERSION TYPE
Simple Launcher 1.1.0.14 binary (+5 duplicates)
adduser 3.134 deb
apt 2.6.1 deb
autocommand 2.2.2 python
base-files 12.4+deb12u15 deb
...
wheel 0.45.1 python
zlib1g 1:1.2.13.dfsg-1 deb
The registry: source pulls layers directly from Docker Hub, so this works on a CI runner with no container runtime
at all. (The “Simple Launcher” entries are the Windows .exe launchers that ship inside the pip package; Syft
identifies them from their embedded version resource. They are noise on Linux and a good first candidate for an
ignore rule.) Look at what was written:
jq -r '.bomFormat, .specVersion, (.components | length)' py311.cdx.json
jq -r '.components[] | .type' py311.cdx.json | sort | uniq -c
jq -r '.spdxVersion, (.packages | length)' py311.spdx.json
CycloneDX
1.7
3432
15 application
3291 file
125 library
1 operating-system
SPDX-2.3
141
The CycloneDX document lists 3,432 components, but only 141 of them are packages: 105 Debian packages, 20 Python packages, 9 binaries the catalogers identified by their embedded version strings, and the operating system itself. The other 3,291 entries are files, which Syft includes for images so an SBOM consumer can trace a package to what it put on disk. The SPDX document has the 141 packages without the file entries, which is why it is a tenth of the size. Both are valid inventories; which one you keep depends on what consumes it.
Step 2: scan the SBOM with Grype
grype sbom:/out/py311.cdx.json -o json --file /out/grype-py311.json
jq '.matches | length' grype-py311.json
jq -r '[.matches[].vulnerability.severity] | group_by(.) | map("\(.[0]): \(length)") | join(", ")' grype-py311.json
287
Critical: 10, High: 66, Low: 14, Medium: 93, Negligible: 80, Unknown: 24
Two hundred and eighty-seven matches in a slim image is normal for a Debian base that has been out for a while. It is also useless as a gate. Ask a narrower question:
jq '[.matches[] | select(.vulnerability.fix.state == "fixed")] | length' grype-py311.json
jq -r '[.matches[] | select(.vulnerability.fix.state == "fixed") | .vulnerability.severity]
| group_by(.) | map("\(.[0]): \(length)") | join(", ")' grype-py311.json
29
High: 6, Low: 6, Medium: 16, Negligible: 1
Twenty-nine findings have a fixed version available; six of them are High and none are Critical. Those six are the ones a rebuild can remove.
Step 3: a gate that fails on what you can fix
grype sbom:/out/py311.cdx.json --only-fixed --fail-on critical -q; echo "exit=$?"
grype sbom:/out/py311.cdx.json --only-fixed --fail-on high -q; echo "exit=$?"
exit=0
NAME INSTALLED FIXED IN TYPE VULNERABILITY SEVERITY
python 3.11.16 *3.13.13, 3.14.4, 3.15.0a8 binary CVE-2026-4224 High
jaraco-context 5.3.0 6.1.0 python GHSA-58pv-8j8x-9vj2 High
libpcre2-8-0 10.42-1 10.42-1+deb12u1 deb CVE-2026-86145 High
wheel 0.45.1 0.46.2 python GHSA-8rrh-rw8j-w5fx High
...
[0603] ERROR discovered vulnerabilities at or above the severity threshold
exit=2
--only-fixed is Grype’s equivalent of Trivy’s --ignore-unfixed; --fail-on high makes the process exit non-zero
(2 in this version) when any remaining finding is High or above. At critical the same SBOM passes. Which threshold
you start with is a policy decision; the mechanism is the same either way, and the report above tells you exactly
what a rebuild needs to change: a newer Python image (the *3.13.13 marks the fixed line the base image would need),
pip install --upgrade wheel jaraco.context, and a Debian package update for libpcre2.
Step 4: compare with a direct image scan
grype registry:python:3.11-slim-bookworm -o json --file /out/grype-direct.json
jq '.matches | length' grype-direct.json grype-py311.json
287
287
Identical, because Grype’s direct scan runs Syft internally to build the same inventory. The only difference is that the SBOM scan needed no registry access, no image and no container runtime. That is what makes it possible to answer “does last month’s release contain the package in today’s advisory?” from a laptop, during an incident, in seconds.
Step 5: the SBOM ages well, the image does not
Re-run Step 2 in a week. The SBOM file has not changed; the database has, and the counts will be higher. That is not a bug in the lab. It is the reason to store the SBOM next to the release artifact and scan it on a schedule:
grype db status # note the "Built" date
grype db update
grype sbom:/out/py311.cdx.json --only-fixed --fail-on high -q; echo "exit=$?"
An image that passed the gate at build time can fail it a week later without a single byte changing. The scheduled scan of stored SBOMs is how you find out before someone else does.
Verification
-
py311.cdx.jsonreportsCycloneDXandpy311.spdx.jsonreportsSPDX-2.3 - The CycloneDX document contains 141 non-file components (15 + 125 + 1)
-
--only-fixed --fail-on criticalexits0and--fail-on highexits non-zero - The SBOM scan and the direct image scan report the same number of matches (they may differ by a few if the database updated between runs)
-
grype db statusshows a build date within the last few days
Cleanup
rm -rf ~/labs/sbom # includes the 2 GB Grype cache
unset -f syft grype 2>/dev/null
Troubleshooting
| Problem | Fix |
|---|---|
| Grype re-downloads the database on every run | The container is discarded each time; mount a cache directory and set GRYPE_DB_CACHE_DIR as shown |
| Database download times out on a slow connection | It is around 2 GB; let it finish once, then the cache is reused. grype db update retries safely |
| Syft reports fewer packages than expected | Binary-only images lack package metadata; keep lockfiles in the final stage or use language-specific catalogers |
| A downstream tool rejects the CycloneDX document | Pin the schema version it expects: -o cyclonedx-json@1.5=/out/file.json |
| Grype cannot reach Docker Hub | Use sbom: mode against a file you generated earlier; that is the whole point |