DEV Community

Rez Moss
Rez Moss

Posted on • Originally published at github.com

Same Version, Different Hash: The SBOM Drift Your Manifest Diff Cannot See

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. the diff matches a component across the baseline and head SBOMs;
  2. its version is unchanged; and
  3. 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
Enter fullscreen mode Exit fullscreen mode

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:

  1. retrieves the baseline SPDX SBOM from the pull request's base SHA;
  2. matches components between the baseline and head documents;
  3. reports one same-version integrity-drift finding;
  4. triggers a deny_integrity_drift policy;
  5. fails the sbom-diff check while still publishing SARIF results.

A real GitHub pull request where an SPDX checksum changes without a version change and SBOMlyze blocks the merge

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 }}
Enter fullscreen mode Exit fullscreen mode

The corresponding policy can be as small as:

{
  "deny_integrity_drift": true
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Generate baseline and head SBOMs at the same lifecycle stage.
  2. Pin the generator and its configuration.
  3. Keep platform, architecture, and build flags comparable.
  4. Prefer cryptographic hashes emitted by the generator.
  5. Test one expected rebuild to expose nondeterminism you can live with.
  6. 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:

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)