sbom-diff
Compares two CycloneDX or SPDX SBOMs to surface net-new, removed, and version-changed components between image or build versions; uses cyclonedx-cli diff for structured output and syft-based generation for the input SBOMs; gates CI on net-new component introduction; enables supply-chain alerting when unexpected dependencies appear across releases. Use when the team needs to detect dependency drift between container image builds, release candidates, or dependency-update branches.
sbom-diff
Overview
SBOM diffing is the supply-chain primitive that turns two point-in-time inventory snapshots into an actionable change signal: which components are net-new in the target image, which were removed, and which changed version. Without a diff step, an SBOM is a compliance artifact; with it, the SBOM becomes a change-control gate.
Per github.com/CycloneDX/cyclonedx-cli, the CycloneDX CLI provides a first-class diff subcommand that accepts any two CycloneDX BOMs (XML, JSON, or Protobuf) and emits structured added/removed/modified output in text or JSON form.
Per github.com/anchore/syft, Syft generates the per-image CycloneDX or SPDX SBOMs that feed the diff step; the two tools compose naturally in CI.
Differentiation vs sibling skills:
This skill covers the SBOM-to-SBOM comparison workflow end to end.
When to use
Step 1 - Install cyclonedx-cli
Per cdx-cli-gh install options:
# Homebrew
brew install cyclonedx/cyclonedx/cyclonedx-cli
# Docker (no local install required)
docker run cyclonedx/cyclonedx-cli diff sbom-from.json sbom-to.json --component-versions
# Binary - download from releases page
# https://github.com/CycloneDX/cyclonedx-cli/releases
# Latest stable: 0.32.0 (2024-05-14)Syft is also required to generate input SBOMs. Per sf-gh:
curl -sSfL https://get.anchore.io/syft | sudo sh -s -- -b /usr/local/bin
# or: brew install syftStep 2 - Generate the two SBOMs
Per sf-gh, generate one SBOM per image version in CycloneDX JSON format (the format accepted by cyclonedx-cli diff):
# Baseline image (e.g., the last release)
syft myapp:1.0 -o cyclonedx-json=sbom-v1.0.json
# Target image (e.g., the release candidate)
syft myapp:1.1 -o cyclonedx-json=sbom-v1.1.jsonFor local directory scans (non-container builds):
syft dir:./release-1.0 -o cyclonedx-json=sbom-v1.0.json
syft dir:./release-1.1 -o cyclonedx-json=sbom-v1.1.jsonUse consistent format and source type across both runs so the diff is comparing equivalent inventories.
Step 3 - Run the diff
Per cdx-cli-gh, the diff subcommand syntax is:
cyclonedx diff <from-file> <to-file> [options]Key flags per cdx-cli-gh:
| Flag | Values | Purpose |
|---|---|---|
--from-format | autodetect, json, xml, protobuf | Override format detection for the baseline file |
--to-format | autodetect, json, xml, protobuf | Override format detection for the target file |
--output-format | text (default), json | Output style - use json for CI parsing |
--component-versions | (flag, no value) | Report added, removed, and version-changed components |
Human-readable diff (text output, default):
cyclonedx diff sbom-v1.0.json sbom-v1.1.json --component-versionsMachine-readable diff (JSON output, for CI gate logic):
cyclonedx diff sbom-v1.0.json sbom-v1.1.json \
--component-versions \
--output-format json \
> diff-result.jsonThe --component-versions flag is the signal flag: without it the diff reports structural BOM changes; with it it reports the dependency inventory delta (added, removed, modified components).
Step 4 - Interpret the output
With --component-versions --output-format json, the JSON output contains three categories of change per cdx-cli-gh:
Any non-empty "added" list requires review. A component may be:
Removals and version changes warrant the same review, especially in regulated or security-sensitive contexts.
Step 5 - CI gate on net-new components
The JSON output can be parsed to fail the pipeline when net-new components appear. Example GitHub Actions step using jq:
- name: SBOM diff gate
run: |
ADDED=$(jq '.added | length' diff-result.json)
echo "Net-new components: $ADDED"
if [ "$ADDED" -gt "0" ]; then
echo "::error::Net-new components detected. Review diff-result.json."
jq '.added' diff-result.json
exit 1
fiTo allow a pre-approved set of additions (e.g., a known intentional dependency upgrade), maintain an allowlist and subtract matches before the count check:
UNAPPROVED=$(jq --rawfile allow allowlist.txt \
'[.added[] | select(.name as $n | $allow | test($n) | not)] | length' \
diff-result.json)Adjust the gate threshold and allowlist policy to the team's change-control requirements; the CI step above enforces zero-tolerance as the strictest form.
Step 6 - Alerting pattern (nightly drift detection)
For production image monitoring, run a nightly diff against the last known-good SBOM rather than comparing two build artifacts:
jobs:
sbom-drift:
runs-on: ubuntu-latest
schedule:
- cron: "0 2 * * *"
steps:
- name: Generate current SBOM
run: syft myapp:production -o cyclonedx-json=sbom-current.json
- name: Download last known-good SBOM
run: |
aws s3 cp s3://sbom-store/sbom-last-good.json sbom-baseline.json
- name: Diff
run: |
cyclonedx diff sbom-baseline.json sbom-current.json \
--component-versions --output-format json \
> drift-result.json
- name: Alert on drift
run: |
ADDED=$(jq '.added | length' drift-result.json)
REMOVED=$(jq '.removed | length' drift-result.json)
if [ "$ADDED" -gt "0" ] || [ "$REMOVED" -gt "0" ]; then
echo "Supply-chain drift detected"
cat drift-result.json
# Pipe to Slack/PagerDuty/JIRA as needed
exit 1
fi
- name: Rotate known-good on clean diff
if: success()
run: aws s3 cp sbom-current.json s3://sbom-store/sbom-last-good.jsonThe "rotate known-good on clean diff" step ensures the baseline advances only when the image passes the gate, catching regressions introduced in a later build.
Example
Comparing myapp:2.3.1 (baseline) to myapp:2.4.0 (candidate):
syft myapp:2.3.1 -o cyclonedx-json=sbom-2.3.1.json
syft myapp:2.4.0 -o cyclonedx-json=sbom-2.4.0.json
cyclonedx diff sbom-2.3.1.json sbom-2.4.0.json --component-versions --output-format jsonTruncated example JSON output per cdx-cli-gh documented output shape (added/removed/modified keys):
{
"added": [
{"name": "protobuf", "version": "4.25.3", "type": "library"}
],
"removed": [],
"modified": [
{"name": "openssl", "from-version": "3.1.4", "to-version": "3.3.0", "type": "library"}
]
}The team reviews: protobuf is net-new. If it is a known transitive dep of a gRPC library the team deliberately upgraded, it is added to the allowlist. If unexplained, the release is blocked pending investigation.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Diff without --component-versions | Reports only structural BOM metadata changes, not dependency inventory delta | Always pass --component-versions |
| Compare SBOMs generated by different tools or formats without normalizing | Format differences appear as false-positive diffs | Use the same generator and --output-format for both runs |
| Treat removed components as low-priority | Removals can indicate silent packaging changes hiding a dep under a new name | Review removed list alongside added list |
| Gate only on count, not content | A single legitimate dep addition will block valid releases permanently | Pair count gate with an allowlist (Step 5) |
Generate SBOMs at different scan depths (dir: vs image) | Inconsistent cataloger scope produces noisy diffs | Match source type across baseline and target runs |