SBOM Diff Workflows Between Nix Environment Versions
Track dependency and license changes across Nix versions with precision SBOMs.

Nix builds every package as an immutable, hash-addressed store path, and it runs each build in a sandbox with no network access and no host environment leaking in. That combination turns a software bill of materials from a best-effort inventory into something closer to a precise ledger: the artifact is a pure function of its declared inputs, so two SBOMs generated from the same inputs should describe the same closure, and any diff between them means something actually changed. This piece walks through generating SBOMs from Nix closures, comparing them without losing the Nix-specific detail that generic diff tools miss, and turning that diff into a real security response.
Before any of that, one distinction determines everything downstream: the runtime closure versus the buildtime closure. The runtime closure is the transitive set of store paths a built output actually references when it runs, discovered by scanning the output for references to other store paths. The buildtime closure is everything needed to reproduce the build in the first place, including compilers, the standard environment, glibc, and the bootstrap toolchain. A hello-world C program, built with Nix, pulls in over 150 packages once you count the full buildtime closure. Runtime is always a subset of buildtime, and which one you generate an SBOM against determines what your diff will reveal later.
The natural version boundary for any of this is the flake.lock file. A flake.nix declares inputs; flake.lock pins each one to an exact commit hash. The delta between two flake.lock commits is a precise, auditable definition of "what changed between environment versions," in a way that a changelog or a release note never quite is. Because identical inputs produce identical outputs under Nix, two SBOMs generated from the same lock file should describe the same closure content, even if the SBOM files themselves differ due to embedded timestamps or generated UUIDs, unless the generator was explicitly configured for reproducible output. A byte-for-byte SBOM diff can lie to you if the tool didn't strip non-deterministic metadata. A content diff, on the other hand, should not.
Generating the SBOM: choosing the right tool and target for your environment
Four generation approaches exist right now, and they operate at different layers of the Nix evaluation stack. Depending on the approach, you get more or less metadata, and some approaches require the tool to build the target first, which matters a lot if you're running this in CI.
sbomnix (from tiiuae) takes either a flake reference or a store path as its target. Feed it a flake reference and it can resolve the nixpkgs version behind the build and enrich the output with descriptions, licenses, maintainers, and homepage links. Feed it a bare store path and that enrichment gets skipped entirely, because there's no record of which nixpkgs revision produced it. By default sbomnix generates a runtime SBOM, which requires building the target so it can resolve what the output actually references. The --buildtime flag generates a buildtime SBOM without forcing full realisation, which is the difference between a CI job that takes ten seconds and one that compiles glibc from scratch. Output comes in CycloneDX and SPDX, and sbomnix embeds nixpkgs provenance fields directly in the document metadata, things like nixpkgs:rev, nixpkgs:flakeref, and nixpkgs:version. It means the nixpkgs revision lives inside the SBOM document itself rather than something you have to infer from a build log, which is exactly what makes cross-version diffing tractable later. The same repository ships companion tools: nixgraph for dependency graph visualisation, vulnxscan for vulnerability scanning against the generated SBOM, nix_outdated for finding stale runtime dependencies ranked by how many things depend on them, and provenance for generating SLSA v1.0 attestation files.
FlakeBOM generates CycloneDX 1.5 JSON SBOMs straight from Nix flakes and reaches inside vendored blobs to list their transitive contents with PURL identifiers attached. It's fast enough to emit as a build artifact rather than something maintained as a separate document, which changes the workflow calculus: the SBOM is generated as part of the build output itself rather than maintained separately as a compliance afterthought.
nix-sbom-helper, from Cyberus Technology, integrates directly into a flake through a call like nix-sbom-helper.support.buildSbom { input = pkgs.hello; }, and it outputs CycloneDX JSON, SPDX JSON, and CSV side by side. A helper function called sbomsForFlakeOutputs auto-generates SBOMs for every package in a flake with very little wiring required. One detail matters for later sections: the bom-ref it assigns is the store path of the.drv file, not a synthetic ID, and that choice has consequences for diffing that get covered below. nix-sbom-helper also forces a stable timestamp during the build and uses a stable UUIDv5 when no metadata enrichment applies, which makes its output genuinely reproducible rather than reproducible-looking.
A separate family of tools, including bombon and Tweag's genealogos (built on nixtract), operates at the.nix level rather than the.drv level. Reading the.nix source directly means these tools can pull the meta block, license, description, maintainers, straight from the source. But there's a hard limitation that doesn't go away with better tooling. Dependencies introduced through string interpolation, things fetched or copied inside a postInstall hook, are invisible to a tool reading.nix source without string-context awareness. Falling back to parsing the resulting.drv files to catch those edges works, but it throws away the meta-block advantage that justified using this approach in the first place. These tools earn their keep where metadata richness affects the outcome more than closure completeness does. They're a poor choice as a diff baseline across versions, because the coverage gaps aren't constant: they shift depending on how each version's derivation happens to be written.
For someone deciding where to start: a flake reference and a need for auditor-friendly metadata points to sbomnix. A need for a fast CI artifact without a full build points to sbomnix's --buildtime flag or to FlakeBOM. Wanting SBOMs to live as native derivation outputs inside the build graph points to nix-sbom-helper. Suspicion about what's actually inside a vendored blob points to FlakeBOM's deeper inspection mode.
Experimental work in Nix (issue #16285) and nixpkgs (issue #466932) is starting to expose PURL and CPE identifiers directly in nix derivation show JSON output. Where that's available, sbomnix prefers meta.identifiers.purl over its own inference. As of early 2026 this is not yet universal across nixpkgs, but it's the direction package identity resolution is heading.
The meta-block loss problem and other generation gaps that affect diff quality
The meta block, license, description, maintainers, only exists at the.nix level. Once evaluation produces a.drv, that information is gone. This restriction has not yet been addressed in the tooling available as of early 2026. The practical consequence for anyone running diffs: a license field that appears to change between two SBOM versions might not reflect an upstream licensing change at all. It might just mean version one was generated by a tool with.nix-level access and version two wasn't. That's a false signal, and it wastes an afternoon if nobody flags it upfront.
The string-interpolation blindspot compounds this. A postInstall hook that does something like cp ${google-fonts}/share/fonts ... introduces a dependency that tools without string-context access simply cannot see. So a diff might report a component as "added" in a newer version when what actually happened is that a tool finally became capable of detecting something that was there all along. That's a false positive, and it needs a human to sort out, because no current tool flags the difference between "actually new" and "newly detectable."
There's a related, gnarlier problem: copying versus linking. It's extremely common in Nix derivations to copy files out of a build input into the final output rather than keep a live reference to the input's store path. Once copied, that content isn't automatically tracked as a runtime reference anymore, so the SBOM may simply omit it. This is widely regarded as close to impossible to detect automatically without some kind of marking or tagging convention, and work on the problem is ongoing. Nothing about the sandbox model fixes this on its own; it's a structural gap in how closures get scanned.
Runtime pruning adds its own layer of ambiguity. Traditional package-based operating systems have a fairly clean notion of "installed packages": something is either in the package database or it isn't. Nix doesn't map cleanly onto that idea, because the difference between "actually installed and used at runtime" and "merely present as a build input somewhere in the closure" isn't always obvious from the store alone. Runtime SBOMs are smaller and give a better signal-to-noise ratio for vulnerability scanning, but they can miss content that got copied into the closure through the mechanism described above. Buildtime SBOMs are comprehensive but noisy: they flag every compiler and build-time library in a vulnerability scan, most of which are never reachable at runtime and generate false positives that someone then has to triage away.
Then there's the type classification gap. Both CycloneDX and SPDX expect a type property on each component: library, application, firmware, and so on. Nix packages don't currently carry a structured metadata attribute that maps cleanly onto this field, so a large share of components end up mistyped as "library" by default, regardless of what they actually are. That hurts both the human reading a diff report and the scanner trying to prioritize findings. Nixpkgs PR #459543 is working toward recording type information per derivation output, but it hasn't merged as of this writing.
None of this is secret or unaddressed. A FOSDEM 2026 talk in Brussels covered the work of bridging Nix closures and SBOM standards directly, and the gaps above are the ones under active discussion there. The honest takeaway: treat an SBOM produced by this kind of build system as high-fidelity, not as gospel, and read every diff with these blind spots in mind.
Diffing SBOMs: format-agnostic tools and the node-identity problem specific to Nix
Once two SBOMs exist, the generic diffing layer looks fairly conventional on the surface. sbomdiff, at version 0.6.0 on PyPI as of March 2026, compares two SBOM files and reports packages added, packages removed, version changes, license changes, and checksum changes. It handles SPDX 2.3 across TagValue, RDF, JSON, YAML, and XML, SPDX 3.0 JSON-LD, and CycloneDX from 1.4 through 1.7 in both XML and JSON, with format detection happening automatically from the file extension. Installation is a single pip install sbomdiff, and running it is just sbomdiff FILE1 FILE2. Output comes as text by default, or JSON and YAML if you want to pipe results into a CI gate script. An --exclude-license flag suppresses license-diff noise when that's not what you care about, and a --checksum flag lets you pick the hashing algorithm for comparison.
sbomdiff compares names, versions, licenses, and checksums, full stop. Structural changes or metadata shifts outside those four fields don't register at all. It does handle one wrinkle relevant to this build system correctly, though: when the same package name appears at multiple locations, which happens constantly in this system because the same logical package can exist at several different store paths simultaneously, sbomdiff tracks those instances separately using location information rather than collapsing them into one entry. For CycloneDX documents that carry path properties, the kind syft-style tooling produces, packages at different paths get treated as genuinely distinct entries.
FlakeAudit, from Determinate Systems, takes a different approach. Its flakeaudit compare command diffs two CycloneDX SBOMs using Nix-specific metadata rather than treating them as generic documents, so it answers "what actually changed in this release" with awareness of the nixpkgs provenance fields discussed earlier, not just a name-and-version match. FlakeAudit is positioned as more than a pure differ, extending its analysis beyond simple name-and-version matching.
A Nix-specific pitfall occurs here, and it's the one thing a generic tool will get wrong no matter how good its parser is. In CycloneDX, a component's bom-ref can be set to the store path of its.drv file, which is exactly what nix-sbom-helper does. That's a defensible design choice: the.drv path is a genuinely unique identifier. But it creates a problem the moment something upstream changes. If a compiler gets bumped, every derivation downstream of it rehashes, including packages whose actual source code and version number haven't changed at all. The.drv path is different, but the package is, in every meaningful sense, the same.
A diff tool comparing only by name and version misses this entirely: the package looks unchanged because, in the sense that matters to most vulnerability databases, it is. A diff tool comparing by bom-ref or store path swings the other way and over-reports, flagging every downstream rebuild as a change even when nothing about the package's actual content or behavior differs. Neither approach alone gives you the right picture.
The workable strategy runs in two passes. Diff on name and version first to catch semantic changes, the things that actually matter for a CVE database lookup. Then, for the subset of packages where name and version are identical across both SBOMs, cross-check the bom-ref or store path. Where those differ despite an identical name and version, you've found a "same version, different derivation" case, and that's a candidate for manual review, not an automatic alarm and not something to ignore. nix-sbom-helper's choice to use the.drv path as bom-ref is a concrete example of this design decision, and FlakeAudit's Nix-aware comparison logic is built to handle it rather than treat it as either false positive or non-event.
Granularity matters here too. A runtime diff produces fewer components and less noise, and it maps directly onto deployed attack surface, which makes it the right lens for CVE triage. A buildtime diff is comprehensive and catches toolchain shifts that could affect reproducibility, which makes it the right lens for a supply chain audit. Running both isn't redundant: the buildtime diff tells you whether your build process itself has drifted, while the runtime diff tells you what's actually exposed once the thing is deployed.
Acting on the diff: CVE triage, patch propagation, and hash-level blocking
The diff output itself is only useful if someone reads it with the right priorities attached to each category of change. Components added represent new attack surface and belong at the top of the queue, cross-referenced against vulnerability databases immediately rather than batched for later. Components removed need a sanity check too: if something disappeared without an obvious intentional replacement, especially something security-relevant, that deserves a second look rather than an assumption that removal always means improvement.
Version downgrades are rare under Nix's model but not impossible, since a nixpkgs pin can shift backward across an update, and a downgrade can silently reintroduce a CVE that a previous version had already patched. License changes carry their own weight depending on an organization's compliance posture, and they deserve tracking even when they're not a security issue. The sharpest signal in the whole diff, though, is a checksum change on a component with the same name and the same version. Nix's sandbox is specifically built to prevent nondeterministic builds from producing different content for identical inputs, so when that happens anyway, it points either to upstream tampering or to a build that somehow incorporated ambient state it shouldn't have had access to. Either way, it warrants real investigation, not a shrug.
CVE matching against Nix SBOMs still runs into a structural gap: most package vendors represented in nixpkgs either have no CPE entry in their meta block or have never gone through the formal CPE request process at all. NixOS/nixpkgs issue #472828, filed in December 2025, documents the scope of this problem. Without a CPE, a vulnerability scanner has to guess at package identity by name matching, which is imprecise and generates both false positives and missed matches. A practical workaround exists for grype users: passing --distro nixos:<version> gives the scanner distro context that meaningfully cuts down on false positives. sbomnix's vulnxscan tool integrates repology_cve lookups alongside vulnerability database queries as a working demonstration of the whole pipeline running end to end, from SBOM generation through to a vulnerability report. Manifest Platform, which launched automated vulnerability mapping for Nix packages in March 2026, is a newer entrant aimed at the same cross-environment CVE tracking problem.
None of this replaces judgment. The tooling described across this piece gets you a precise, closure-based diff between two environment versions, something most software ecosystems can't offer with anywhere near the same confidence. But precision in the diff doesn't remove the need for a human to look at a rehashed derivation, a missing CPE, or a copied font file and decide what it actually means for the system running in production. The closure model gives you an honest starting point. What you do with it is still the job.
Sources
- GitHub - tiiuae/sbomnix: SBOM, provenance, dependency graph, and vulnerability tools for Nix.
- Nix State of the SBOM
- GitHub - cyberus-technology/nix-sbom-helper: Tooling producing SBOMs outputs for Standard Nix and Flakes
- sbomdiff
- FOSDEM 2026 - Describing Nix closures using SBOMs
- tiiuae/sbomnix | DeepWiki
- github.com
- Nix State of the SBOM | Engelen Open Source


