SPDX Document Structure for Nix-Built Packages
Nix's deterministic builds align naturally with SPDX supply-chain transparency standards.

SPDX is the open standard for describing what's inside a piece of software, published by the Linux Foundation and recognized internationally as ISO/IEC 5962:2021. It started as a way to track software licenses, but it now covers supply-chain transparency, vulnerability management, and the regulatory paperwork that's starting to require all three. This piece is about what happens when you point SPDX tooling at packages built with Nix, the functional package manager and build system, because the fit between the two is closer than most people assume. Version 3.0 of the spec, released in April 2024 (now at 3.0.1), reorganized the whole model around profiles and typed relationships, and that redesign happens to line up with how Nix already thinks about builds.
An SPDX document, at bottom, is answering four questions about a piece of software: what components make it up, how those components relate to each other, where each one came from, and what license or security facts attach to it. Most build systems can answer these only by inference, digging through logs and package manager metadata after the fact. Nix answers them before the build even starts.
The facts SPDX needs, produced by Nix's purely functional build model
Every package Nix installs lands in its own directory under /nix/store. The name of that directory is a cryptographic hash derived from the inputs that went into building it. Changing any one input, even a compiler flag, changes the hash. The resulting store path changes too. That's the whole trick. The store path isn't just a label somebody assigned. It identifies content.
The mechanism behind this is the derivation, a data structure that spells out an executable to run, the arguments and environment variables to hand it, and the exact set of other store paths it's allowed to touch. Nix runs that build in a restricted environment that limits what the build process can access. Two things fall out of this that matter enormously for SBOM accuracy. First, because nothing outside the derivation spec can sneak into the build, the entire dependency graph can be enumerated before a single line of build code runs. Second, because outputs are content-addressed, the store path itself proves what got built rather than merely what someone intended to build. A drift in output produces a different hash, full stop.
Document metadata: what Nix gives the SPDX document header for free
Every SPDX document needs a header: who or what created it, when, under what namespace, at what version. SPDX 3.0's Build Profile is designed to carry this provenance context, and Nix happens to have all of it sitting around already.
The tool identity comes for free: a derivation records the exact builder executable used, and because that executable is itself a Nix store path, its identity is hash-addressed rather than a fuzzy "gcc, some version" guess. When builds run through Nix flakes, the flake reference and revision are structured data too. The sbomnix toolkit, for instance, records these directly in document metadata fields like nixpkgs:flakeref, nixpkgs:rev, nixpkgs:version, and nixpkgs:path. And because Nix store paths are content-addressed, they make a natural, ready-made anchor for the document namespace SPDX requires.
Compare that to a build driven by a Makefile or a shell script. There's no derivation-equivalent spec sitting alongside the output. The tool that ran, the version it was, the exact inputs it touched: all of that has to be reconstructed from build logs after the fact, assuming anyone thought to keep them.
Package records: translating Nix store paths and derivation attributes into SPDX Package elements
An SPDX Package element needs, at minimum, a name, a download location, and a unique identifier. Version, supplier, and checksums are optional extras in the spec, but Nix hands them over anyway, as a side effect of how it names things.
A Nix store path looks like /nix/store/<hash>-<name>-<version>, which packs name, version, and a content hash into one string. That hash portion already satisfies SPDX's uniqueness requirement without any extra tooling bolted on. And it does something most package identifiers can't: two SPDX documents that reference the same store path are, provably, talking about the identical artifact. A version string like 1.2.3 tells you nothing about which exact build produced it. A Nix hash does.
The name and version fields map straight across from derivation attributes, no guesswork needed. Nix ties a checksum to a package's source as part of defining the derivation. So the checksum going into the SPDX record was there before the build ran, not reconstructed from whatever showed up afterward.
Relationship elements: how the Nix dependency closure maps onto SPDX relationship types
SPDX relationships are the connective tissue: DEPENDS_ON, BUILD_TOOL_OF, DEV_TOOL_OF, GENERATED_FROM, and others, describing how elements relate to one another and to the top-level artifact. SPDX 3.0 made relationships stand-alone elements instead of properties tucked inside something else. New relationship types can be added later without breaking documents that already exist.
Nix's closure, the complete set of store paths that some output depends on transitively, is a full relationship graph already, and it's computed deterministically straight from the derivation graph. A build-time dependency appears in the derivation's inputs, a runtime dependency appears in the output closure, and propagated dependencies get flagged as such, based on the specific meaning each edge in that closure carries. None of that has to be guessed at. It maps onto SPDX relationship types directly, because the typed graph already exists inside Nix's own data structures. The nixgraph tool, part of the sbomnix toolkit, queries and visualizes these graphs, and it's working with the same underlying graph that ends up populating the SPDX relationship elements.
This is where a lot of generic SBOM generation quietly falls apart. Running pip freeze or apt list tells you what's installed, but not which package depends on which. Those DEPENDS_ON edges have to be inferred from package metadata that may or may not be reliable, or they get left out of the SBOM.
File elements and snippets: where Nix's store structure makes them useful
SPDX also supports File elements (each with its own checksum and license) and Snippet elements, which cover a portion of a file rather than the whole thing. Whether these are worth generating for a Nix package depends heavily on what the SBOM is for.
They earn their place in license compliance work. Files inside a single package can carry different licenses, and SPDX File elements let you assert that per file rather than per package. Because the Nix store is immutable and content-addressed, those per-file assertions stay stable no matter which machine you check them from. The same logic applies to fetched source tarballs: Nix pulls sources by a known hash, so file-level SPDX records built on top of that source are checkable against the hash later.
Where File and Snippet elements start to feel like busywork is everywhere else. Nix treats the store path, not the individual file, as the atomic unit of identity and integrity, and most Nix SBOM tooling (sbomnix included) operates at that package level as a result. Snippet elements earn their keep when code gets copied wholesale between projects without a clean package boundary around it, a pattern Nix's dependency model actively discourages. Generate File elements when a license auditor asks for them, or when a regulatory submission demands that level of detail (FDA guidance on medical device software is one example). Otherwise, the package-level dependency graph is the artifact that actually carries meaning. Nixpkgs, described as a collection of over 80,000 software packages with verifiable transitive SBOMs, is built and reasoned about at that same package level, telling you where the established practice actually sits.
How Nix derivations satisfy the SPDX Build Profile's provenance fields
The Build Profile in SPDX 3.0 exists to describe one specific instance of a build: who built it, with what tool, at what time, from which inputs. It's the part of the spec built to capture provenance that a bare package record can't.
Nix answers nearly every field in that profile with something that already exists in the derivation. The build tool identity isn't just a name and version string; the builder executable is itself a store path, so its identity is content-addressed the same way the output is. Build inputs are enumerated explicitly: inputSrcs lists direct store paths, inputDrvs lists derivation paths along with the specific outputs required from them, and both map directly onto the Build Profile's input elements without any translation layer. What was actually present during the build has an authoritative answer rather than a best guess, because Nix's build environment restricts what the build process can access to its declared inputs. Timing information comes from derivation evaluation and from build infrastructure like Hydra, supplementing the metadata sbomnix records in the document header.
There's a natural bridge here to SLSA, the supply-chain framework for build provenance: sbomnix includes a provenance tool that generates SLSA v1.0 compliant attestation files for any Nix flake or derivation, drawing on the same underlying data that fills in the SPDX Build Profile fields.
Most build systems treat provenance as something you attach after the fact, a separate attestation step bolted onto a build that already finished. Nix doesn't work that way. The derivation spec is the provenance record. It exists before the build runs, it's content-addressed right alongside the output it describes, and you cannot forge it without changing the hash and thereby announcing that you changed something.
Generating accurate SPDX output from Nix in practice
The main open-source tool for turning all of this into an actual SPDX document is sbomnix (from the tiiuae organization on a code hosting platform). It takes a Nix flake reference or a store path as input and emits either SPDX or CycloneDX output, and it records the nixpkgs-specific metadata fields described above directly into the document header rather than leaving them to be reconstructed by whoever reads the SBOM later.
None of this makes SPDX generation automatic or effortless. Someone still has to decide how deep to go on File and Snippet elements, still has to wire sbomnix or an equivalent tool into a build pipeline, and still has to check the output against whatever regulatory or contractual requirement is driving the SBOM. What Nix changes is the starting point. The facts an SPDX document asks for, component identity, dependency relationships, build provenance, aren't being inferred from logs or guessed at from package manager metadata. They're sitting in the derivation graph already, waiting to be read out.



