A dependency in your pull request keeps the same version while its recorded
hash changes, and your checks report no dependency change at all.
A manifest diff, an ordinary SBOM component diff, and an integrity-drift check
answer three different questions. Treat them as interchangeable and you inherit
a blind spot: a component can keep the same name and version while its recorded
bytes change.
That contradiction is not proof of an attack. It does need an explanation
before you merge.
TL;DR
- A manifest diff shows dependency intent.
- An SBOM component diff shows how the reported build inventory changed.
- An integrity-drift check finds matched components whose versions stayed the same while their cryptographic hashes changed.
- Same-version hash drift can be benign. Filing it under "no dependency change" without looking is how you miss the case that is not.
Three comparisons, three questions
| Comparison | Question it answers | What it catches well | What it can miss |
|---|---|---|---|
| Manifest or lockfile diff | What dependency resolution did the developer request or record? | Direct and resolved version changes | What entered the finished artifact |
| Ordinary SBOM component diff | What components does each build report? | Added, removed, and version-changed components | Same-name, same-version content replacement |
| Hash/integrity drift | Did recorded content change without a version change? | Unexpected same-version hash changes | The reason for the change |
Each layer supplies evidence the other two cannot. You want all three.
Layer 1: manifest diff shows intent
Imagine a Go pull request with this change:
- github.com/google/uuid v1.5.0
+ github.com/google/uuid v1.6.0
You can review that line. Read the upstream release notes, check the
vulnerability history, and ask the author why the application needs the
upgrade.
A lockfile gives you more: the resolved transitive graph and, depending on the
ecosystem, registry integrity values.
A source manifest still does not describe the final build. It can omit:
- operating-system packages from a container base image;
- copied binaries and vendored files;
- build-stage tools;
- generated assets;
- components introduced later in the pipeline;
- packages installed outside the primary package manager.
A manifest diff tells you what the developer asked the resolver for. It does
not prove what reached the artifact.
Layer 2: an SBOM diff shows inventory
An SBOM generator observes a source tree, filesystem, package database,
container image, or built artifact and emits an inventory. Comparing two SBOMs
can reveal changes the manifest never showed:
- a new deep transitive dependency;
- an OS package added by a base-image refresh;
- a component removed from the final image;
- a resolved version different from the source declaration;
- license, supplier, or dependency-graph changes.
That gets you closer to the release-review question:
What is present in this build that was not present in the trusted baseline?
A raw JSON diff is too noisy to answer it. Timestamps, serial numbers,
generated references, ordering, and format-specific metadata all shift even
when the component inventory holds still.
A semantic SBOM diff first matches the same component across both documents,
then classifies the changes that matter. Package URLs, CPEs, and
format-specific component references make good stable identities.
Name-and-version comparison leaves one blind spot. A component named example
at version 1.4.2 in both builds reads as unchanged, whatever bytes the
generator recorded.
Layer 3: integrity drift shows a contradiction
Integrity drift is the narrower case where all three hold:
- the diff matches a component across the baseline and head SBOMs;
- its version is unchanged; and
- its recorded cryptographic hash set changed.
component: pkg:golang/example/project@1.4.2
version: 1.4.2 -> 1.4.2
hash: c4e3...aa89 -> 345c...fa36
verdict: integrity drift
The version promises your consumers the same release. The hash says the
content differs.
Possible explanations include:
- someone replaced a registry, mirror, or package without a version bump;
- a later build step changed or replaced the component;
- the build is not reproducible and embeds environmental data;
- the SBOM generator changed its hash semantics;
- architecture, platform, or build flags differ from the baseline;
- your pipeline produced the two SBOMs at different lifecycle stages.
Some of these are routine engineering problems, others are supply-chain
incidents. The check does not tell you which one you hit. It puts the mismatch
in front of a reviewer.
A real pull request that gets blocked
This public
Go + SPDX demonstration pull request
changes one SPDX checksum while leaving the component identity and version
unchanged.
The workflow:
- retrieves the baseline SPDX SBOM from the pull request's base SHA;
- matches components between the baseline and head documents;
- reports one same-version integrity-drift finding;
- triggers a
deny_integrity_driftpolicy; - fails the
sbom-diffcheck while still publishing SARIF results.
The failing check hands the reviewer one question to answer before merge:
why did the bytes change?
Add the check to a pull request
The public examples use this file-based workflow, with every third-party Action
pinned to a full commit SHA.
name: SBOM review
on:
pull_request:
permissions:
contents: read
security-events: write
jobs:
sbom-diff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- id: sbomlyze
uses: rezmoss/sbomlyze@31503690611fda8ebba4ed2bd186eda000442594 # v0.5.1
with:
sbom-path: build/sbom.cdx.json
policy: .github/sbom-policy.json
fail-on: policy
sarif: true
# Fork PR tokens cannot upload SARIF. The Job Summary still works.
- uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
if: >-
always() &&
steps.sbomlyze.outputs.report-sarif != '' &&
github.event.pull_request.head.repo.full_name == github.repository
with:
sarif_file: ${{ steps.sbomlyze.outputs.report-sarif }}
The corresponding policy can be as small as:
{
"deny_integrity_drift": true
}
This example never runs a generator supplied by the pull request. If you
generate SBOMs in CI, keep generation in a separate pinned workflow and compare
against a trusted default-branch artifact.
Avoid noisy integrity checks
Hash comparison works when both sides of the comparison are built the same way.
Before you make it a required check:
- Generate baseline and head SBOMs at the same lifecycle stage.
- Pin the generator and its configuration.
- Keep platform, architecture, and build flags comparable.
- Prefer cryptographic hashes emitted by the generator.
- Test one expected rebuild to expose nondeterminism you can live with.
- Update the baseline only after reviewing and accepting the change.
If a generator upgrade changes what gets hashed, land that migration on its
own. Fold a broad baseline refresh into a dependency upgrade and you bury the
security review under it.
A practical triage rule
The three layers give you a triage rule:
- Manifest changed and component version changed: review the intentional upgrade.
- Manifest did not change but SBOM components changed: identify the build, base-image, or transitive source of the inventory change.
- Component version did not change but its hash changed: stop and explain the content mismatch before merge.
The last case is narrow and easy to scroll past. Name it in policy and a
reviewer sees it instead of skimming an ambiguous JSON change.
The implementation is open source:
- SBOMlyze repository
- GitHub Marketplace Action
- Go/SPDX demonstration
- Node/CycloneDX demonstration
- Container demonstration
If you already review generated SBOMs, tell me one thing: would a
same-version hash change earn its place as a required check in your repository,
or would it be too noisy?

Top comments (0)