In short

Compare two SBOMs only when both describe the same product boundary, platform and scan profile; otherwise rescan the old artifact with the new generator first. Group components by package family, then classify each change as a version change, a same-version byte change, a discovery correction or a removal. Hold the release for any unexplained same-version hash change, publish a corrected revision for anything the old SBOM missed, and never overwrite the old snapshot.

  • A new generator or profile can make existing packages look new; rescan the old artifact before calling anything an addition.
  • Group by package family (type, namespace, name and architecture) so an upgrade shows as one version change, not a removal plus an addition.
  • A same-version hash change can be a rebuild, a local patch or a substituted package, and the version string cannot tell them apart.
  • An SBOM without package hashes cannot reveal a substitution: Syft image SBOMs carry file digests but no hashes for APK packages.
  • Correct an incomplete CycloneDX SBOM with a new revision: keep the serialNumber and increment version by 1.

Verify it

  1. Run python sbom_diff.py old.cdx.json new.cdx.json and expect exit code 0; every BYTES or COPIES line needs a recorded explanation before release.
  2. Confirm that both SBOMs name the same platform and generator profile, or rescan the old image digest with the new generator first.
  3. Alter one package hash in a copy of the new SBOM and confirm that the diff reports BYTES and exits with status 1.
  4. Run cyclonedx diff old.cdx.json new.cdx.json --component-versions as a second view of the version changes.
  5. For a corrected SBOM, confirm that serialNumber is unchanged and version increased by 1.
Applies to

Release-to-release inventory review and distribution. Renames and changed identifiers need evidence before treating them as removals.

Part of
Learning path: From source to trusted release (step 4 of 12). Workflow: Respond to a vulnerability
Learning goals and prerequisitesAbout 27 minutes: 5 to read and 22 for the exercise

What you will learn

  • Distinguish software change from cataloger coverage change.
  • Investigate same-version hash substitution separately from ordinary upgrades.
  • Publish corrected inventory history without inventing introduction dates.

Before you start

Release engineers and security reviewers who approve releases from SBOM changes and publish SBOMs to customers

  • Match component identities across two inventories and bind each inventory to an artifact.

Refresh first: Choose SBOM formats and identify components accurately

Go to the guided practice

Establish comparability before counting change

A release diff mixes software changes with observation changes unless the baseline controls both. Compare the same product boundary, platform, inventory scope and discovery settings. Record the generator and schema versions. A new cataloger may discover packages already present in the old image; counting them all as new dependencies invents a change in attack surface. Re-scan the older immutable artifact with the newer profile where possible, retaining both original and normalized observations.

Package identity also needs two keys. Use ecosystem, namespace, name and relevant qualifiers to group a component family; use version and content hash to detect a changed instance. Treat one-to-many copies and platform variants explicitly. Comparing full versioned identifiers as an unstructured set produces a removal plus addition for every upgrade and obscures its relationship. The exercise supplies already comparable inventories, except for a deliberately documented generator discovery, so the causal decisions can be made without tools.

Separate upgrades, substitutions and discoveries

An upgrade changes the version of the same component family. A same-version hash change may be a repack, rebuild, local patch or unauthorized replacement; the version string cannot distinguish them. A dependency edge change may expose an existing component to a new runtime path even if the component list is unchanged. A removal is meaningful only when the component is absent from shipped bytes, not merely missing from the new scanner's output.

For Harbor 8.2, parsekit moves from 4.1.0 to 4.1.2 with a corresponding approved dependency change. The helper package retains version 2.0.0 but changes from H20 to H99; no build or supplier record explains it. That is a release hold under the fixture policy. The newly visible libc is a discovery correction because a re-scan of 8.1 also finds it. Testkit disappears because the final image now excludes development tools. These four events deserve different review questions even when a naive set diff calls all of them additions and removals.

Classify the changes with a script you can audit

The script below groups the components of two CycloneDX SBOMs by package family, using the purl type, namespace, name and arch qualifier, and prints one line per change: VERSION, BYTES for a same-version hash change, ADDED, REMOVED, or COPIES when a family has several instances. It exits with status 1 when a change must hold the release. Version comparison is left to people because ordering rules differ between ecosystems, and a version change may be a downgrade.

Run on Trivy 0.75 SBOMs of alpine:3.22.4 and alpine:3.22.6, it reported six version changes, including libcrypto3 and libssl3 from 3.5.6-r0 to 3.5.8-r0, and confirmed ten unchanged packages by their checksums. With one checksum altered in the newer SBOM it reported BYTES for musl and exited with status 1. On Syft SBOMs of the same image it could confirm nothing, because Syft records no APK package hashes. cyclonedx diff --component-versions from cyclonedx-cli lists version changes as well, but it does not report same-version hash changes.

Testsbom_diff.pyPython
"""Classify component changes between two CycloneDX JSON SBOMs of the same product boundary."""
import json
import sys

from packageurl import PackageURL  # pip install packageurl-python


def inventory(path):
    with open(path, encoding="utf-8") as f:
        bom = json.load(f)
    items = {}
    for c in bom.get("components", []):
        if c.get("purl"):
            p = PackageURL.from_string(c["purl"])
            # A family is one package on one architecture; version and hashes describe an instance.
            family = (p.type, p.namespace, p.name, (p.qualifiers or {}).get("arch"))
            hashes = {h["alg"]: h["content"] for h in c.get("hashes", [])}
            items.setdefault(family, []).append((p.version, hashes))
    return items


old, new = inventory(sys.argv[1]), inventory(sys.argv[2])
hold, same, unverified = False, 0, 0
for family in sorted(old.keys() | new.keys(), key=str):
    name = "/".join(part for part in family[:3] if part)
    before, after = old.get(family, []), new.get(family, [])
    if len(before) > 1 or len(after) > 1:
        print(f"COPIES    {name}: {len(before)} -> {len(after)} instances, review by hand")
        hold = True
    elif not before:
        print(f"ADDED     {name} {after[0][0]} (rescan the old artifact before calling it new)")
    elif not after:
        print(f"REMOVED   {name} {before[0][0]} (confirm against the shipped files)")
    elif before[0][0] != after[0][0]:
        print(f"VERSION   {name} {before[0][0]} -> {after[0][0]}")
    else:
        (_, h1), (_, h2) = before[0], after[0]
        shared = h1.keys() & h2.keys()
        if any(h1[alg] != h2[alg] for alg in shared):
            print(f"BYTES     {name} {before[0][0]}: same version, different hash: hold")
            hold = True
        elif shared:
            same += 1
        else:
            unverified += 1
print(f"{same} unchanged with matching hashes; {unverified} unchanged with no hash to compare")
sys.exit(1 if hold else 0)

Turn the difference into an owned decision

Assign policy consequences to the kind of evidence available. In this example, an unexplained runtime byte substitution blocks promotion. A known upgrade can proceed after the designated functional and security tests; it is not automatically safe because the version number increased. A removed development component should be checked against the final artifact and any build dependency that remains. A discovery-only change requires correcting the older inventory so downstream consumers do not infer a false introduction date. Check license changes as well as version changes, because a new version can change its license: /domains/supply-chain/dependencies/open-source-license-compliance/.

A useful change record includes product and artifact identities, old/new component identities and hashes, relationship changes, explanation evidence, vulnerability and license review references, decision owner, outcome and unresolved facts. Severity scores alone cannot explain a byte substitution or ownership transfer. The decision should survive a reviewer asking why a high-severity known issue was accepted while an unscored substitution blocked: one may have bounded exposure and verified mitigation, whereas the other's origin is not established at all.

Preserve the history consumers need

Publish each release's inventory as an immutable snapshot and keep a separately versioned diff or decision report. If inventory 8.1 was incomplete, issue a corrected 8.1 document bound to the same artifact and explain the correction. In CycloneDX, a modified BOM keeps its serialNumber and increments version by 1, and consumers use the highest version for a serial number; the CISA 2026 minimum elements likewise expect an SBOM Version that changes with every edit and prompt corrections of errors. Do not silently overwrite history or relabel the older artifact as a new release. Consumers need to distinguish new software, new vulnerability knowledge and improved inventory coverage.

No amount of normalization should remove a fact that changes a security decision. Excluding timestamps and document-local IDs may reduce noise; excluding hashes or architecture can hide material change. Pin the normalization rules and test them with a known substitution. Inventory generation and graph capabilities are documented by Syft and CycloneDX, while the release policy here is an explicit fictional organizational choice. The SBOM inventory lab at /labs/sbom-inventory/ practices finding shipped components that an SBOM omits; the complete decision data below is sufficient on its own.

Guided practice

Harbor must review four changes before shipping 8.2. The policy blocks unexplained changes to runtime component bytes.

Supplied scenario material
Plain text
Old artifact A81 and candidate A82: linux/amd64, runtime scope.
parsekit: 4.1.0/H10 -> 4.1.2/H12. E1 approved update ticket; E2 supplied integration result 120/120 passed; E3 regression probe passes.
helper: 2.0.0/H20 -> 2.0.0/H99. No explanation, build record or publisher change notice supplied.
libc: absent in original A81 BOM -> 1.2.5/H30 in A82. E4 rescanned A81 with new profile finds 1.2.5/H30.
testkit: 7.0/H70 -> absent. E5 final image manifest excludes it; build stage still uses it.
Generator changed G3 -> G4; new profile adds OS package detection.
Policy D2: unexplained runtime hash substitution blocks; upgrades need approval plus functional/regression evidence; discovery corrections require older BOM revision.
Fictional owner: release reviewer Noor.
  1. 1. Normalize the baseline

    Identify the one apparent addition that is not a software introduction.

    Expected result and reasoning

    libc 1.2.5/H30 already existed in A81 according to E4. Treat it as improved discovery, amend the A81 inventory, and do not label it newly introduced in A82.

  2. 2. Classify each change

    Give four distinct classifications with evidence references.

    Expected result and reasoning

    parsekit is an upgrade supported by E1–E3; helper is an unexplained same-version byte substitution; libc is a discovery correction via E4; testkit is runtime removal with retained build use via E5.

  3. 3. Apply the release rule

    Make a decision for A82 and identify the decisive field.

    Expected result and reasoning

    Hold A82 because helper changed H20 to H99 with no explanation. The other three classifications do not remove that hold. A matching name/version or absent vulnerability alert cannot authenticate H99.

  4. 4. Bound the requested investigation

    List what evidence could change the helper decision.

    Expected result and reasoning

    Require trusted publisher or internal rebuild record binding helper 2.0.0/H99 to authorized inputs, explanation of H20/H99 difference and appropriate tests. If no such record exists, restore an approved component through a fresh build and inventory its resulting artifact.

  5. 5. Write the customer update

    Describe exactly what the corrected documents say.

    Expected result and reasoning

    A81 BOM revision 2 adds previously omitted libc. A82 remains a candidate, with parsekit update and testkit runtime removal documented. Noor’s D2 record holds release for helper origin. Do not publish a statement that all changes were approved.

Your deliverable: A four-row causal diff, an A82 hold decision and an explicit correction notice for A81.

Hashes are symbolic labels. Test outcomes are fictional supplied evidence, not tests performed by this guide; real releases require full digests and retained test output.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. CycloneDX specification overview, version 1.7. Version 1.7 (October 2025).
  2. SPDX specification 3.0.1
  3. Syft: supported targets and SBOM output
  4. CycloneDX 1.7 JSON reference: serialNumber and version
  5. CycloneDX CLI
  6. Package URL specification
  7. CISA: 2026 Minimum Elements for a Software Bill of Materials. 29 July 2026.

Reference information

SDLC phase
Release

Download this guide as Markdown