Secure the PipelineSCAStep 11 of 31

Grype and Syft — SBOM-Driven Scanning

difficulty intermediatehands-on 30 min hands-on

35% complete

prereqs · trivy-fs-one-scanner

concepts · SBOM · SPDX · CycloneDX · lossy inventory · audit evidence · scan-time vs build-time

Every scanner so far has answered "what is wrong with this thing" by opening the thing. An SBOM inverts that: you produce a list of what is inside once, at build time, and everything downstream reasons about the list.

That inversion is what makes an SBOM useful — you can scan an artefact months after it was built, without the artefact — and it is also where the trap is. This lesson runs the same scanner both ways on the same image and lands on a number that should not be there.

Verified against Syft 1.51.0 and Grype 0.117.0, against the Range image. Every number is copied from a real run.

Step 1 — Generate the SBOM

bash
brew install syft grype

git clone https://github.com/jaybilgaye/aiopsone-range
cd aiopsone-range
docker build -f docker/Dockerfile -t range-app:vulnerable .

syft range-app:vulnerable -o syft-json=sbom.syft.json

Check your disk first. This image is 1.85 GB and Syft extracts layers to a temp directory. If your boot volume is tight, send them elsewhere — export TMPDIR=/path/on/a/bigger/disk — before you run it. Filling the disk mid-scan is a bad afternoon.

Step 2 — Read the inventory

bash
syft range-app:vulnerable
text
NAME                  VERSION            TYPE
...
1075 packages

The breakdown is the interesting part:

Type Count
deb 414
go-module 384
npm 275
python 1
binary 1

384 Go modules in a Node.js application. Nobody wrote any Go here. Those are compiled into binaries that came with node:18 — Go embeds its own dependency list in the binary, and Syft reads it back out.

This is the argument for inventory as its own step. You cannot reason about 384 dependencies you did not know existed, and no package.json will ever mention them. The Dockle lesson made this point with setuid binaries; this is the same base image, counted a different way.

It also does not agree with the other tools. Docker Scout indexed 1,464 packages where Syft found 1,075. Package counting is as tool-dependent as vulnerability counting — different ideas about what constitutes a package, and whether a vendored copy is one.

Step 3 — Pick a format, and know what it costs

Three formats, same image, same command:

bash
syft range-app:vulnerable \
  -o syft-json=sbom.syft.json \
  -o spdx-json=sbom.spdx.json \
  -o cyclonedx-json=sbom.cdx.json
Format Version Entries Size
Syft native 1,075 packages 16 MB
SPDX 2.3 1,076 packages 16 MB
CycloneDX 1.7 20,891 components 7.0 MB

That CycloneDX number needs explaining, because it looks like it found twenty times more:

text
library:          1,074
file:            19,815
application:          1
operating-system:     1

19,815 of the 20,891 are individual files, not packages. Same inventory, different granularity — CycloneDX carries file-level detail for provenance, and is smaller on disk than SPDX despite holding more entries, because SPDX is verbose per record.

Which to emit, briefly: SPDX if the consumer is a compliance or procurement process (it is an ISO standard and what most vendor questionnaires expect). CycloneDX if the consumer is security tooling (richer vulnerability and provenance extensions). Syft's native format for nothing except round-tripping into Grype, since it is the only one guaranteed to be lossless.

That last point is about to matter.

Step 4 — Scan the image, then scan the SBOM

Same scanner. Same image. Two paths in:

bash
grype range-app:vulnerable                  # straight at the image
grype sbom:sbom.syft.json                   # at the inventory
text
grype IMAGE: 3995   (Critical 267, High 1167, Medium 1379, Low 237, Negligible 915, Unknown 30)
grype SBOM : 4054   (Critical 280, High 1180, Medium 1409, Low 238, Negligible 915, Unknown 32)

Fifty-nine more findings from the SBOM, including thirteen more criticals. Nothing changed but the input format.

The obvious reading is that the SBOM caught more. It is the wrong one. Compare the sets rather than the totals — the habit from the Grype versus Trivy lesson:

bash
grype range-app:vulnerable -o json --file grype-image.json
grype sbom:sbom.syft.json  -o json --file grype-sbom.json

python3 - <<'PY'
import json, collections
key = lambda m: (m['vulnerability']['id'], m['artifact']['name'], m['artifact']['version'])
img = {key(m) for m in json.load(open('grype-image.json'))['matches']}
sbo = {key(m) for m in json.load(open('grype-sbom.json'))['matches']}
print(f"both:       {len(img & sbo)}")
print(f"image only: {len(img - sbo)}")
print(f"SBOM only:  {len(sbo - img)}")
PY
text
both:       3995
image only: 0
SBOM only:  59

The SBOM scan is a strict superset. Nothing was lost — 59 things were added. And every one of the 59 is a go-module.

Terminal showing grype reporting 3995 findings against the image and 4054 against its SBOM, with all 59 extra findings being go-module false positives

Step 5 — Why the extra 59 are not real

Grype said so itself, in a warning easy to scroll past:

text
WARN  go binary packages were found but none carry function symbols; go
      vulnerability matching falls back to module granularity and may report
      false positives. if scanning an SBOM, regenerate it with symbol capture
      enabled for more precise results.

Scanning the image, Grype reads the Go binaries and sees which functions are actually compiled in. If a CVE affects a function the binary does not contain, it can rule it out.

The SBOM recorded module X at version Y. The function symbols were not in it. So Grype falls back to "this module at this version is affected" — and reports 59 vulnerabilities in code that is not in the binary.

This is the defining property of an SBOM, and it is not a bug. An SBOM is a lossy snapshot. It records what the generator chose to record. Anything a scanner would have derived by looking at the artefact directly is gone, and downstream tools cannot know what was dropped — they can only get less precise, quietly.

Syft can capture more, at the cost of a larger SBOM:

bash
syft range-app:vulnerable --select-catalogers "+binary" -o syft-json=sbom.syft.json

The general rule survives the specific flag: generate the richest SBOM you can afford to store, because you cannot add detail later. The artefact may be gone by the time somebody asks.

Step 6 — So when is scanning the SBOM the right call

Given the above, why ever scan the inventory instead of the thing?

Because the thing is often not available. That is the whole case, and it is a strong one.

  • The image is 1.85 GB; its SBOM is 16 MB. Storing an SBOM per build for two years is cheap. Storing the images is not.
  • Audit evidence outlives artefacts. "Which of our releases shipped log4j 2.14?" is answerable in seconds against a directory of SBOMs, and unanswerable if the registry has been garbage-collected.
  • New CVEs land against old builds. The vulnerability database changes daily; a build from March gets re-scanned in August without rebuilding March's image — which you may not be able to reproduce anyway.
  • It moves scanning off the critical path. Catalogue once at build, scan the SBOM as often as you like.

So the two are not alternatives:

yaml
# .github/workflows/supply-chain.yml
- name: Generate SBOM (build time, once)
  run: syft app:${{ github.sha }} -o spdx-json=sbom.spdx.json

- name: Gate on the image, not the SBOM
  run: grype app:${{ github.sha }} --only-fixed --fail-on critical

- name: Retain the SBOM as evidence
  uses: actions/upload-artifact@v4
  with:
    name: sbom-${{ github.sha }}
    path: sbom.spdx.json
    retention-days: 90

Gate on the artefact — it is more precise, and the 59 false positives are exactly the noise that gets a gate switched off. Keep the SBOM as the record, and re-scan it later for CVEs that did not exist at build time.

Step 7 — Attach it to the image

An SBOM in a CI artefact is evidence. An SBOM attached to the image is supply chain:

bash
docker buildx build \
  --provenance=mode=max \
  --sbom=true \
  -f docker/Dockerfile -t range-app:vulnerable .

This is the same flag the Docker Scout lesson recommended for base-image attribution, and it earns its keep twice. The SBOM travels with the image in the registry, so whoever pulls it can answer "what is in this" without trusting a build log — and Scout stops inferring the base image and starts reading an attestation.

What you learned

  • Syft found 1,075 packages, including 384 Go modules in a Node app, all from the base image. Docker Scout counted 1,464 — package counts are tool-dependent too.
  • SPDX for compliance consumers, CycloneDX for security tooling (19,815 of its 20,891 entries are files, not packages), Syft's native format for lossless round-trips into Grype.
  • 3,995 findings from the image, 4,054 from its SBOM — a strict superset, and all 59 extra were go-module false positives.
  • An SBOM is a lossy snapshot. The Go function symbols were not in it, so Grype fell back to module granularity. Downstream tools cannot tell what was dropped.
  • Generate the richest SBOM you can store. You cannot add detail after the artefact is gone.
  • Gate on the artefact; retain the SBOM as evidence and re-scan it for CVEs that did not exist at build time.
  • Attach it with --sbom=true --provenance=mode=max so the inventory travels with the image.