How to Compare Container SBOMs and Detect Drift Between Image Versions

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.jsonsbomlyze 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: 0150 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:
| Type | Indicator | Meaning |
|---|---|---|
| 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 > 5The diff still prints, but sbomlyze exits with code 1. Policy rules available:
| Rule | Type | Description |
|---|---|---|
max_added | int | Maximum new components (0 = unlimited) |
max_removed | int | Maximum removed components |
max_changed | int | Maximum changed components |
deny_licenses | []string | Forbidden license identifiers |
require_licenses | bool | All added components must have licenses |
deny_duplicates | bool | Fail 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 patchMachine-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.jsonIntegrity 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
fiGitLab 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: alwaysComponent Identity Matching
When comparing SBOMs from different tools or formats, component matching matters. sbomlyze uses a precedence-based system:
| Priority | Identifier | Example |
|---|---|---|
| 1 | PURL | pkg:npm/lodash |
| 2 | CPE | cpe:vendor:product |
| 3 | BOM-ref / SPDXID | ref:component-123 |
| 4 | Namespace + Name | com.example/mypackage |
| 5 | Name | simple-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:
| Depth | Risk | Description |
|---|---|---|
| 1 | Low | Direct dependencies you chose |
| 2 | Medium | Dependencies of your dependencies |
| 3+ | High | Deep 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 formatFor CI where you want hard failures:
./sbomlyze broken.json --strict
# exit status 1Practical 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.mdLicense 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.jsonZero-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.jsonQuick 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 -iNavigate 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:
| Key | Action |
|---|---|
| โ/โ, j/k | Navigate components |
| Enter | View component details |
| j | View raw SBOM JSON |
| d | Back to detail view |
| / | Search by name, PURL, license |
| t | Filter by package type |
| c | Clear all filters |
| Esc | Go back |
| q | Quit |
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.jsonIf 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 markdownKnowing 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.
