Sachin Chaurasiya

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.

Author
Sachin Chaurasiya
Sachin Chaurasiya
Published
Time budget
~35 minutes
Difficulty
intermediate

Reviewed Tested with Syft 1.51.1, Grype 0.118.0, Docker 29, jq 1.8

Related path: Software Supply Chain Security

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

Tools Docker Grype Syft

Interactive environment

Practice this lab in a temporary browser-based environment. Nothing needs to be installed on your machine.

Launch on Killercoda(opens in a new tab)

Opens in a new tab · Temporary environment

Practice environment hosted on Killercoda.

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.json reports CycloneDX and py311.spdx.json reports SPDX-2.3
  • The CycloneDX document contains 141 non-file components (15 + 125 + 1)
  • --only-fixed --fail-on critical exits 0 and --fail-on high exits 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 status shows 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

ProblemFix
Grype re-downloads the database on every runThe container is discarded each time; mount a cache directory and set GRYPE_DB_CACHE_DIR as shown
Database download times out on a slow connectionIt is around 2 GB; let it finish once, then the cache is reused. grype db update retries safely
Syft reports fewer packages than expectedBinary-only images lack package metadata; keep lockfiles in the final stage or use language-specific catalogers
A downstream tool rejects the CycloneDX documentPin the schema version it expects: -o cyclonedx-json@1.5=/out/file.json
Grype cannot reach Docker HubUse sbom: mode against a file you generated earlier; that is the whole point