Rez Moss

Rez Moss

Digital Reflections: Exploring Tech, Innovation & Ideas

How to Compare Container SBOMs and Detect Drift Between Image Versions

Jan 2026

Every time you pull a container image, you’re trusting hundreds of packages you didn’t explicitly choose. The base image brings its OS packages, your dependencies bring their dependencies, and somewhere in that stack is the next CVE waiting to be discovered.

An SBOM (Software Bill of Materials) inventories everything inside a container. But a single snapshot only tells you what’s there now. The real value comes from comparing them: what changed? What got added? What got removed?

I went looking for a tool that could diff SBOMs, flag integrity drift, enforce policies, and slot into CI without friction. Found plenty of SBOM generators, but nothing that handled comparison the way I needed. So I built sbomlyze for myself. Turns out others had the same gap.

This post covers using sbomlyze to answer those questions,from basic diffs to drift detection, policy enforcement, and CI integration.

Generating SBOMs

Before diffing, you need SBOMs. syft from Anchore is a solid choice:

syft nginx:1.29.0 -o json > /tmp/old.json
syft nginx:1.29.4 -o json > /tmp/new.json

sbomlyze supports Syft’s native format, CycloneDX, and SPDX (all JSON). The format is auto-detected.

Single SBOM Analysis

Start by understanding what’s in a single image:

./sbomlyze /tmp/old.json
๐Ÿ“ฆ SBOM Statistics
==================

Total Components: 150

By Package Type:
  deb          149
  maven        1

Licenses:
  With license:    149
  Without license: 1

  Top Licenses:
    GPL-2                          79
    GPL-2+                         59
    BSD-3-clause                   44
    GPL-3                          42
    GPL-3+                         37
    LGPL-2.1                       32
    public-domain                  30
    ...

Integrity:
  With hashes:    0
  Without hashes: 150

Dependencies:
  Components with deps: 0
  Total dep relations:  0

150 components, almost all Debian packages. The license breakdown matters for compliance - GPL-2 dominates this image. The integrity section shows whether packages have cryptographic hashes (useful for verification). Dependencies tracks the relationship graph if present in the SBOM.

For scripting, use JSON output:

./sbomlyze /tmp/old.json --json
{
  "stats": {
    "total_components": 150,
    "by_type": {
      "deb": 149,
      "maven": 1
    },
    "by_license": {
      "GPL-2": 79,
      "GPL-2+": 59,
      "BSD-3-clause": 44
    },
    "without_license": 1,
    "with_hashes": 0,
    "without_hashes": 150,
    "total_dependencies": 0,
    "with_dependencies": 0,
    "duplicate_count": 0
  }
}

Comparing Two SBOMs

The core use case , what changed between versions:

./sbomlyze /tmp/old.json /tmp/new.json
๐Ÿ“Š Drift Summary:
  ๐Ÿ“ฆ Version drift:   120 components

+ Added (31):
  + fonts-dejavu-mono 2.37-8
  + gcc-14-base 14.2.0-19
  + libssl3t64 3.5.4-1~deb13u1
  + libcurl4t64 8.14.1-2+deb13u2
  ...

- Removed (30):
  - gcc-12-base 12.2.0-14+deb12u1
  - libssl3 3.0.17-1~deb12u2
  - libcurl4 7.88.1-10+deb12u12
  - gpgv 2.2.40-1.1
  ...

~ Changed (120):
  ~ apt
      version: 2.6.1 -> 3.0.3
      licenses: [BSD-3-clause Expat GPL-2 GPL-2+] -> [...GPL-2+ curl]
  ~ curl
      version: 7.88.1-10+deb12u12 -> 8.14.1-2+deb13u2
  ~ libc6
      version: 2.36-9+deb12u10 -> 2.41-12
  ~ nginx
      version: 1.29.0-1~bookworm -> 1.29.4-1~trixie
  ~ openssl
      version: 3.0.17-1~deb12u2 -> 3.5.4-1~deb13u1
  ...

Several things become visible:

Base OS changed. The ~bookworm โ†’ ~trixie suffixes reveal a jump from Debian 12 to Debian 13. That’s not obvious from the image tag nginx:1.29.4.

Compiler toolchain upgraded. gcc-12-base was removed, gcc-14-base was added.

64-bit time_t transition. Packages like libssl3t64 and libcurl4t64 replace their predecessors - Debian’s fix for the 2038 problem.

Major library upgrades. OpenSSL went from 3.0.x to 3.5.x, curl from 7.88 to 8.14, glibc from 2.36 to 2.41.

License changes are tracked. The apt package picked up a new curl license entry.

Understanding Drift Types

sbomlyze classifies changes into three categories:

TypeIndicatorMeaning
Version๐Ÿ“ฆVersion number changed - normal update
Integrityโš ๏ธHash changed WITHOUT version change - investigate
Metadata๐Ÿ“Only metadata (licenses, etc.) changed

Extract the drift summary programmatically:

./sbomlyze /tmp/old.json /tmp/new.json --json | jq '.diff.drift_summary'
{
  "version_drift": 120,
  "integrity_drift": 0,
  "metadata_drift": 0
}

Integrity drift is the security signal. When a package hash changes but its version stays the same, something unusual happened:

  • Supply chain compromise - the package was replaced
  • Rebuild without version bump - poor practice but sometimes legitimate
  • Non-reproducible builds - different build environment produced different output

If you see integrity drift, investigate before deploying.

Policy Enforcement

Define rules and fail CI when they’re violated:

cat > /tmp/strict-policy.json << 'EOF'
{
  "max_added": 5,
  "max_removed": 5,
  "deny_licenses": ["GPL-3.0", "AGPL-3.0"],
  "require_licenses": true,
  "deny_duplicates": true
}
EOF

./sbomlyze /tmp/old.json /tmp/new.json --policy /tmp/strict-policy.json
๐Ÿ“Š Drift Summary:
  ๐Ÿ“ฆ Version drift:   120 components

+ Added (31):
  ...

- Removed (30):
  ...

~ Changed (120):
  ...

โŒ Policy Errors (2):
  [max_added] too many components added: 31 > 5
  [max_removed] too many components removed: 30 > 5

The diff still prints, but sbomlyze exits with code 1. Policy rules available:

RuleTypeDescription
max_addedintMaximum new components (0 = unlimited)
max_removedintMaximum removed components
max_changedintMaximum changed components
deny_licenses[]stringForbidden license identifiers
require_licensesboolAll added components must have licenses
deny_duplicatesboolFail if duplicate packages exist

A strict policy for production images might look like:

{
  "max_added": 10,
  "max_removed": 5,
  "deny_licenses": ["GPL-3.0", "AGPL-3.0", "SSPL-1.0"],
  "require_licenses": true,
  "deny_duplicates": true
}

Output Formats

Different formats for different workflows.

Markdown for Pull Request Comments

./sbomlyze /tmp/old.json /tmp/new.json --format markdown
## ๐Ÿ“ฆ SBOM Diff Report

### Summary

| Metric | Count |
|--------|-------|
| Added | 31 |
| Removed | 30 |
| Changed | 120 |

<details>
<summary>โž• Added Components (31)</summary>

| Name | Version |
|------|--------|
| fonts-dejavu-mono | 2.37-8 |
| gcc-14-base | 14.2.0-19 |
...
</details>

Collapsible sections keep the PR comment readable while preserving full details.

SARIF for GitHub Code Scanning

./sbomlyze /tmp/old.json /tmp/new.json --format sarif
{
  "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
  "version": "2.1.0",
  "runs": [
    {
      "tool": {
        "driver": {
          "name": "sbomlyze",
          "version": "dev",
          "rules": [
            {
              "id": "integrity-drift",
              "name": "Integrity Drift Detected",
              "shortDescription": {
                "text": "Component hash changed without version change"
              },
              "defaultConfiguration": {
                "level": "error"
              },
              "properties": {
                "tags": ["security", "supply-chain"]
              }
            }
          ]
        }
      }
    }
  ]
}

Upload this to GitHub’s code scanning API and integrity drift shows up as security alerts.

JUnit for CI Test Results

./sbomlyze /tmp/old.json /tmp/new.json --format junit
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="sbomlyze" tests="3" failures="0" errors="0" time="0.01">
  <testsuite name="SBOM Analysis" tests="3" failures="0" errors="0" time="0.01">
    <testcase name="No Integrity Drift" classname="sbomlyze.security" time="0.001"></testcase>
    <testcase name="No Deep Transitive Dependencies" classname="sbomlyze.dependencies" time="0.001"></testcase>
    <testcase name="SBOM Diff Summary" classname="sbomlyze.diff" time="0.001"></testcase>
  </testsuite>
</testsuites>

Works with any CI system that understands JUnit XML - Jenkins, GitLab, CircleCI, etc.

JSON Patch (RFC 6902) for Automation

./sbomlyze /tmp/old.json /tmp/new.json --format patch

Machine-readable patch format if you need to programmatically apply or track changes.

CI/CD Integration

GitHub Actions

name: SBOM Check
on: [pull_request]

jobs:
  sbom-diff:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Generate SBOM
        run: |
          curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
          syft . -o json > current.json
        
      - name: Download baseline
        run: curl -o baseline.json ${{ vars.BASELINE_SBOM_URL }}
        
      - name: Compare SBOMs
        run: |
          go install github.com/rezmoss/sbomlyze@latest
          sbomlyze baseline.json current.json --policy policy.json

Integrity Drift Alert Script

if ./sbomlyze baseline.json current.json --json | jq -e '.diff.drift_summary.integrity_drift > 0' > /dev/null; then
  echo "โš ๏ธ INTEGRITY DRIFT DETECTED"
  exit 1
fi

GitLab CI with Artifact

sbom-diff:
  stage: test
  script:
    - syft . -o json > current.json
    - sbomlyze baseline.json current.json --policy policy.json --json > sbom-report.json
  artifacts:
    paths:
      - sbom-report.json
    when: always

Component Identity Matching

When comparing SBOMs from different tools or formats, component matching matters. sbomlyze uses a precedence-based system:

PriorityIdentifierExample
1PURLpkg:npm/lodash
2CPEcpe:vendor:product
3BOM-ref / SPDXIDref:component-123
4Namespace + Namecom.example/mypackage
5Namesimple-package

PURL (Package URL) is preferred because it’s the most specific. If two SBOMs use different identifiers for the same package, sbomlyze works down the precedence list to find a match.

Dependency Graph Analysis

For SBOMs that include dependency relationships (more common with npm, Maven, Go than with OS packages), sbomlyze tracks:

  • Edge diff - added/removed direct dependencies
  • Transitive reachability - new indirect dependencies through the graph
  • Path tracking - how each transitive dep is reached
  • Depth tracking - how many hops from your code

Depth matters for supply chain security. A dependency introduced at depth 4 (your code โ†’ A โ†’ B โ†’ C โ†’ malicious-pkg) is harder to audit than a direct dependency. The depth summary helps prioritize review:

DepthRiskDescription
1LowDirect dependencies you chose
2MediumDependencies of your dependencies
3+HighDeep transitive - review carefully

Tolerant vs Strict Parsing

By default, sbomlyze continues on parse errors and collects warnings:

./sbomlyze broken.json
โš ๏ธ Parse Warnings (1):
  [broken.json] unknown SBOM format

For CI where you want hard failures:

./sbomlyze broken.json --strict
# exit status 1

Practical Patterns

Pre-upgrade Assessment

# Before bumping base image
syft myapp:current -o json > before.json
syft myapp:candidate -o json > after.json
./sbomlyze before.json after.json --format markdown > upgrade-report.md

License Audit

cat > audit-policy.json << 'EOF'
{
  "deny_licenses": ["GPL-2.0", "GPL-3.0", "LGPL-2.1", "AGPL-3.0"],
  "require_licenses": true
}
EOF

./sbomlyze old.json new.json --policy audit-policy.json

Zero-drift Enforcement

cat > no-drift.json << 'EOF'
{
  "max_added": 0,
  "max_removed": 0,
  "max_changed": 0
}
EOF

./sbomlyze baseline.json current.json --policy no-drift.json

Quick CVE Check

# Find all openssl-related packages
./sbomlyze image.json --json | jq '.stats.by_type'

# Or search the raw SBOM
./sbomlyze before.json after.json --json | jq '.diff.changed[] | select(.name | contains("ssl"))'


{
  "id": "pkg:deb/debian/openssl",
  "name": "openssl",
  "before": {
    "id": "pkg:deb/debian/openssl",
    "name": "openssl",
    "version": "3.0.17-1~deb12u2",
    "purl": "pkg:deb/debian/[email protected]~deb12u2?arch=arm64&distro=debian-12",
    "licenses": [
      "Apache-2.0",
      "Artistic",
      "GPL-1",
      "GPL-1+"
    ],
    "cpes": [
      "cpe:2.3:a:openssl:openssl:3.0.17-1\\~deb12u2:*:*:*:*:*:*:*"
    ]
  },
  "after": {
    "id": "pkg:deb/debian/openssl",
    "name": "openssl",
    "version": "3.5.4-1~deb13u1",
    "purl": "pkg:deb/debian/[email protected]~deb13u1?arch=arm64&distro=debian-13",
    "licenses": [
      "Apache-2.0",
      "Artistic",
      "GPL-1",
      "GPL-1+"
    ],
    "cpes": [
      "cpe:2.3:a:openssl:openssl:3.5.4-1\\~deb13u1:*:*:*:*:*:*:*"
    ]
  },
  "changes": [
    "version: 3.0.17-1~deb12u2 -> 3.5.4-1~deb13u1"
  ],
  "drift": {
    "type": "version",
    "version_from": "3.0.17-1~deb12u2",
    "version_to": "3.5.4-1~deb13u1"
  }
}

Interactive Mode

For deeper exploration, there’s a TUI:

./sbomlyze /tmp/old.json -i

Navigate with arrow keys, search with /, filter by package type with t. Press j on any component to see the raw JSON with all original fields from syft. Useful when you need to dig into a specific package’s metadata.

Interactive Mode Keys:

KeyAction
โ†‘/โ†“, j/kNavigate components
EnterView component details
jView raw SBOM JSON
dBack to detail view
/Search by name, PURL, license
tFilter by package type
cClear all filters
EscGo back
qQuit

One-liner Workflow

For quick comparisons, chain everything together:

syft nginx:1.29.0 -o json > /tmp/old.json && \
syft nginx:1.29.4 -o json > /tmp/new.json && \
./sbomlyze /tmp/old.json /tmp/new.json

If you’re doing this frequently, wrap it in a function:

sbom-diff() {
  local img1=$1 img2=$2
  syft "$img1" -o json > /tmp/sbom1.json && \
  syft "$img2" -o json > /tmp/sbom2.json && \
  sbomlyze /tmp/sbom1.json /tmp/sbom2.json "${@:3}"
}

# Usage
sbom-diff nginx:1.29.0 nginx:1.29.4 --format markdown

Knowing what’s in a container is step one. What changed is where problems usually start, and you need tools that catch them without friction.

My mostused features the diff output (added/removed/changed laid out clearly), interactive mode for exploring SBOMs when I’m researching or investigating something specific, and CI failures based on policy. Before this, I was wrangling thousand-line SBOM files with jq and cat and chained bash commands. Life’s easier now.

Shared an early version with a few people, got feature requests, added them all. I know there’s more that could be better,if you hit a rough edge or need something it doesn’t do yet, open an issue.

github.com/rezmoss/sbomlyze does what I needed. If you’ve got the same gap, it might work for you too. Issues and PRs welcome.


comments powered by Disqus