Generating and Diffing SBOMs Across Releases With Syft

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.

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