Run Your Scanner Twice. Diff the Output.
Try this on whatever tool you're using now. Generate an SBOM (Software Bill of Materials). Generate it again, same commit, same machine, nothing touched in between. Now diff the two files.
You will probably get pages of noise. A generation timestamp that moved. A fresh UUID on every single component. Dependencies in a different order because the directory walk came back differently this time.
None of that matters on a Tuesday afternoon. It matters a lot the first time somebody asks you what changed.
The question you will eventually be asked
You shipped 4.2.1 in March. You shipped 4.2.2 in June. A customer's security team wants to know what moved in the third-party code between those two builds. Not your code. Theirs — the vendored libraries, the SDK, the transitive pulls.
That should be a five-line answer. Two components bumped, one added, one dropped.
If your SBOMs aren't stable, it's a 3,000-line diff and you're reading it by hand on a Friday. I've watched an engineer do exactly that, and the worst part wasn't the time. It was that nobody could tell whether the diff was complete, because nobody could tell which changes were real and which were the generator being chatty.
There's a regulatory edge to this too. Under the CRA (Cyber Resilience Act, Regulation (EU) 2024/2847), market surveillance authorities can ask a manufacturer to hand over the software bill of materials for a product [VERIFY: Annex I Part II(1), and confirm the request power article — see the consolidated text]. They ask about a product, and a product means a version. So you want per-release SBOMs you can actually compare, not a pile of files that happen to have been produced at different times.
Four things that break it
Timestamps. Most generators stamp the metadata block with the moment the file was written. Every run differs by construction.
Random identifiers. CycloneDX wants a bom-ref per component. Generate it randomly and the same zlib you've shipped for three years gets a new identity every night, so nothing can be tracked across releases.
Ordering. Component order usually falls out of directory traversal or hash-map iteration. Change the filesystem, the OS, or the standard library, and the order changes with it.
Paths. Absolute build paths leak into fields. Now the SBOM says something different on the CI runner than on your laptop, and neither is wrong.
What we do about it
bomwerk scan sorts everything before it writes, emits canonical JSON with fixed key order, and derives each component identifier as a UUIDv5 of its purl (Package URL — the pkg:generic/zlib@1.2.11 style string that names a package unambiguously). UUIDv5 is a hash, not a dice roll: same purl in, same UUID out, this year and next. Timestamps honour SOURCE_DATE_EPOCH, the reproducible-builds convention, so your CI can pin them.
The result is that two runs on the same inputs give you the same bytes, and a diff across two releases contains only things that actually changed.
$ bomwerk scan . -o /tmp/a.json && bomwerk scan . -o /tmp/b.json
$ diff /tmp/a.json /tmp/b.json && echo "identical"
identical
$ diff <(jq -r '.components[]|"\(.name) \(.version)"' sbom-4.2.1.json) \
<(jq -r '.components[]|"\(.name) \(.version)"' sbom-4.2.2.json)
< mbedtls 2.28.3
> mbedtls 2.28.8
> tinycbor 0.6.0
That second block is the whole point. Two lines changed, one component added. You can put that in an email.
While we're here: it shouldn't crash either
A related habit. Every component producer in bomwerk returns a result rather than throwing, and a parser that hits something it can't read emits a warning and keeps going. You get partial output plus a list of what it couldn't handle.
This sounds like a small design note. It isn't. A scanner that dies on one malformed archive in a 400-directory tree hands you nothing at all, and in CI that means a red build and an engineer who starts thinking of the SBOM step as the flaky one. Degraded output beats no output, as long as the tool is honest about which parts are degraded. The CLI also separates "ran fine but found problems" from "couldn't run" in its exit codes, so your pipeline can treat those differently instead of failing on both.
Where this stops working
Determinism is only about the tool. If your compiler version changes, or a flag changes, the content of a correct SBOM changes too, and it should.
Upgrading bomwerk itself can also shift results, because heuristic fingerprints improve and a component that came back as unknown last month may be identified this month. That's an improvement, but it will show up in your diff as churn. Pin the scanner version in CI, record which version produced which SBOM, and upgrade deliberately rather than in the same commit as a release.
And the usual caveat: this is build-accurate with evidence and confidence levels attached, not 100% accuracy. Stable output makes errors easier to spot. It doesn't make them go away.
Scanning a C/C++ repo and not sure what your SBOM is missing? We'll scan one repo free and show you the diff against whatever you run today.
Get a free scan of one repo