A software bill of materials sitting in an artefact store answers no useful question on its own. The value is in the comparison: what changed between the release you shipped last month and the one going out today, and did a risky dependency slip in while nobody was looking.
Most teams that adopt SBOMs generate one per build, push it somewhere, and never look at it again until an auditor or a CVE forces the issue. That is storage, not governance. An SBOM earns its keep when you diff two of them and get a precise, machine-readable answer to a question your change log cannot: which components were added, which were removed, and which quietly changed version. This is the part of the living-SBOM pipeline that turns an inventory into a control.
Generating a CycloneDX SBOM with Syft
Syft, from Anchore, is the tool I reach for. It scans a container image, a directory or an archive and catalogues the packages it finds, emitting the result in whichever format you ask for. The invocation is deliberately boring:
Free · 4 minutes
Would you survive contact with a determined attacker — or an auditor?
Fourteen questions on access, patching, detection, and recovery — the basics that prevent most real incidents, and the ones most often assumed rather than verified. Banded finding on screen, full sheet by email.
# Scan a built image and write CycloneDX JSON, pinned to schema 1.6
syft myapp:1.4.0 -o cyclonedx-json@1.6=sbom-1.4.0.cdx.json
# You can emit more than one format in a single pass
syft myapp:1.4.0
-o cyclonedx-json@1.6=sbom-1.4.0.cdx.json
-o spdx-json@2.3=sbom-1.4.0.spdx.json
The =<path> suffix writes to a file instead of stdout; the @<version> suffix pins the schema version so a Syft upgrade does not silently change your output shape. Both matter in CI, where reproducibility is the whole point.
CycloneDX or SPDX?
Syft speaks both, so the choice is about what you do downstream, not about capability. SPDX is the ISO-standardised licensing lineage format; if your primary consumer is a legal or procurement function tracking licence obligations, or a customer contract that names SPDX, generate SPDX. CycloneDX, from OWASP, is the format the security tooling ecosystem has largely converged on — it carries vulnerability, VEX and dependency-graph data more naturally, and the scanners you will feed it into tend to prefer it.
For a diff-and-scan workflow I default to CycloneDX and, where a stakeholder needs it, emit SPDX alongside in the same Syft run. There is no cost to producing both. What you should not do is pick one at random and discover six months later that your vulnerability scanner ingests the other more cleanly.
Producing and storing one per release
The rule is one SBOM per released artefact, named for the release, retained for as long as the artefact is in the field. A GitHub Actions job triggered on a version tag does this cleanly, attaching the SBOM as a build artefact you can retrieve later:
name: sbom
on:
push:
tags: ['v*']
jobs:
sbom:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t myapp:${{ github.ref_name }} .
- name: Install Syft
run: curl -sSfL https://get.anchore.io/syft | sh -s -- -b /usr/local/bin
- name: Generate CycloneDX SBOM
run: syft myapp:${{ github.ref_name }} -o cyclonedx-json@1.6=sbom-${{ github.ref_name }}.cdx.json
- name: Store the SBOM per release
uses: actions/upload-artifact@v4
with:
name: sbom-${{ github.ref_name }}
path: sbom-${{ github.ref_name }}.cdx.json
retention-days: 90
Ninety days is a floor, not a target; retain for the supported life of the release. The artefact store is what lets you fetch any two releases later and compare them.
Diffing two releases
Syft generates SBOMs; it does not diff them. Rather than reach for a heavyweight tool, a short script over the CycloneDX components array does exactly what you need — key each component on its Package URL with the version stripped off, then compare the two sets:
#!/usr/bin/env python3
"""Diff two CycloneDX SBOMs: added, removed and version-bumped components."""
import json
import sys
def key(component):
# A purl looks like pkg:npm/lodash@4.17.21 - drop the @version for the key
purl = component.get("purl")
if purl:
return purl.split("@", 1)[0]
group = component.get("group", "")
name = component.get("name", "")
return f"{group}:{name}" if group else name
def load(path):
with open(path, encoding="utf-8") as handle:
document = json.load(handle)
return {key(c): c.get("version", "") for c in document.get("components", [])}
old, new = load(sys.argv[1]), load(sys.argv[2])
added = sorted(new.keys() - old.keys())
removed = sorted(old.keys() - new.keys())
bumped = sorted(k for k in old.keys() & new.keys() if old[k] != new[k])
for k in added:
print(f"+ added {k} {new[k]}")
for k in removed:
print(f"- removed {k} {old[k]}")
for k in bumped:
print(f"~ bumped {k} {old[k]} -> {new[k]}")
# Fail the check when anything new appears, so a slipped-in dependency is seen
sys.exit(1 if added or bumped else 0)
Run it against the two SBOMs you fetched from the artefact store:
python3 sbom_diff.py sbom-1.3.0.cdx.json sbom-1.4.0.cdx.json
The non-zero exit on additions and version bumps is deliberate. Wired into a release gate the way you would enforce any other check in CI, it forces a human to acknowledge a new transitive dependency rather than let it arrive unremarked. That is the moment most supply-chain incidents could have been caught: not at generation, but at the diff.
Feeding the SBOM into a scan
Once you have the inventory, you should not re-catalogue the image to scan it. Grype, Syft’s sibling, reads an existing SBOM directly, which means the scan sees exactly the components you shipped rather than a fresh, possibly different catalogue:
grype sbom:./sbom-1.4.0.cdx.json
The pattern that pays off is the two combined. The diff tells you a component was added or bumped; the scan tells you whether that specific change introduced a known vulnerability. A version bump on its own is noise. A version bump that pulls in a package with a fresh critical CVE is the thing you built the pipeline to surface.
Generate one per build, keep it as long as the release lives, diff every release against the last, and fail loudly when the dependency graph changes. An SBOM you never compare is a receipt. An SBOM you diff is a control.
Free interactive tool
Website compliance checklist
What your site has to do, based on what it actually does
Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.
Everything that applies
Ordered by what to do first: legal requirements you can close quickly, then larger pieces of work, then what is expected rather than required. Not exhaustive, and not a legal audit.
Dated PDF, yours to keep or circulate.
Free interactive tool
Interactive deadline calculator
Check which regulations apply to you and when
Regulation across the EU, UK, US and Asia-Pacific has moved considerably in the past eighteen months, and several headline dates have shifted more than once. Twelve questions, about three minutes.
Results are shown on screen — no email required. A dated summary is available to download, and can be sent on if that's more useful. What we do with your answers.
Most technology problems are not technology problems. They are control problems.
The systems exist. The investment has been made. The question is whether leadership can understand, direct, evidence, and sustain what those systems produce. Find out where control exists — and where it only appears to.
Full Governance by Sixteen Pillars
Govern your business. Prove your compliance.
A board assurance cockpit for EU-regulated financial firms — tamper-evident, hash-chained proof of governance across DORA, GDPR, NIS2, ISO 27001, the EU AI Act and MiCA. In development.
See what's coming