APIs, integration & security — in depth
FeaturesLong read

Tracing a CVE Through the Nix Dependency Graph

Nix's hashed dependency graph turns CVE triage into a query instead of a hunt.

Senior Writer · · 11 min read
Cover illustration for “Tracing a CVE Through the Nix Dependency Graph”
Features · September 9, 2026 · 11 min read · 2,439 words

A CVE gets disclosed, and inside a Nix-based stack, finding out what's affected is a query, not an investigation. That's the whole thesis. Nix models every package as a node keyed by a cryptographic hash of its inputs, which means the dependency graph itself becomes something you can search, diff, and prove things about, instead of something you have to re-discover on every host, every time.

Compare that to what happens with apt, dnf, pip, npm, or cargo. Each of these resolves versions based on the environment it's running in, the mirror it hit, the state of its cache, and the moment it happened to run. Two machines that supposedly built "the same" service can end up with quietly different dependency trees, and there's no queryable record anywhere that says what's actually installed across the fleet. You find out by going and looking, one deployment at a time.

The transitive dependency problem makes this worse, not better. The package with the vulnerability is frequently something nobody on the team ever typed into a terminal. CVE-2025-10894, the malicious Nx npm package compromise, carried a CVSS score of 9.6 (critical), and it's a clean illustration of the pattern: the compromise rides in through the graph, removed from anything a developer actually chose to install directly. Lockfiles like Cargo.lock, package-lock.json, or uv.lock help contain resolver drift within a single ecosystem, but none of them describe the full runtime picture: base image, native libraries, CA certificates, environment variables, the system-level packages sitting underneath the language runtime. So when a CVE drops, the actual work is manual: check this service, then that image, then this host, then that runtime, with no structural shortcut anywhere in the process. And that model is about to get a lot more strained. AI-assisted vulnerability discovery, like Google's Big Sleep finding a zero-day in SQLite, Microsoft Copilot surfacing bootloader vulnerabilities, and DARPA's AIxCC initiative, all point the same direction: more CVEs, faster, and a manual per-environment inspection model that doesn't scale to meet them.

How Nix models the dependency graph as a cryptographically addressable structure

Nix starts from one rule: a package is a pure function of its inputs. Same inputs, same outputs, every time, on every machine. That single constraint is what makes everything downstream of it possible.

Every package build in Nix is described by a derivation, a complete and deterministic recipe that names every input, every build step, every dependency, with nothing left implicit. The full transitive set of everything that feeds into a given environment, every package and build input pulled in along the way, is called its closure. If two environments resolve to the same closure, meaning the same Nix store path, they are not "probably" the same for CVE purposes. They're provably the same. That's a different category of claim than anything a traditional package manager can make.

The mechanism behind that claim is content-addressing: every path in the Nix store is named using a cryptographic hash of its inputs. That's what turns store paths into things you can compare and query, rather than named things that quietly drift underneath a version label that hasn't changed. Builds also run sandboxed, so provenance is baked into the build itself rather than bolted on afterward as documentation someone has to remember to write.

Flakes, introduced as an experimental feature and now the standard way most teams work in 2025, add flake.lock: a file that pins the exact commit hash of every external input, locking down the entire input graph, not just the top-level package versions. And the scale here isn't a rounding error. As of January 2025, Nixpkgs holds more than 122,000 packages, making it the largest up-to-date package repository by count, anywhere. This isn't a boutique tool for hobbyists chasing reproducibility for its own sake. It's a graph big enough that the structural properties actually matter operationally.

What that structure changes, concretely, is the shape of the triage problem. It turns into a deduplication exercise. If five hundred deployments share one closure, the expensive part, the actual vulnerability analysis, runs once per unique dependency set, not five hundred times.

What tracing a CVE through the graph actually looks like, step by step

Start with a CVE disclosed against a named package and a named version. From there, the process is genuinely just a handful of steps, and each one is a lookup rather than a search.

First, identify affected closures: query which lockfiles, store paths, or SBOMs actually contain the vulnerable derivation. This runs against an indexed set of resolved records already sitting in a database somewhere. Nobody's SSHing into boxes to check installed package lists.

Second, map those closures back to the environments that reference them, whether that's a dev shell, a CI job, or a production deployment. Because the mapping is deterministic, this step doesn't involve guesswork about what's "probably" running where.

Third, remediate: edit the environment definition to pull in the patched version.

Fourth, validate, and this is the part worth sitting with. Validation here means comparing the new closure against the old vulnerable one and confirming the bad store path is gone. That's a graph diff, not a re-scan of a live system. You're not asking a scanner to go hunt for the vulnerability again and hope it doesn't miss it this time.

Fifth, promote: push the new environment reference (a commit hash, a new generation) atomically, and every consumer of that reference inherits the fix at once.

Call it lower-entropy if you want a term for it: there's no ambiguity about whether a patch landed. The store path either contains the vulnerable derivation, or it doesn't. Compare that to the conventional workflow, where every environment has to be inspected on its own because nothing proves two environments are actually identical. They might look the same. Nix is the only one of the two that can prove it.

The tooling that makes graph queries and CVE matching operational

The graph model is the foundation, but it needs tooling on top to actually connect to CVE data, and a handful of projects do that work.

The Nixpkgs Security Tracker (tracker.security.nixos.org) is the official service for managing vulnerability data across Nixpkgs and NixOS, and it got funding through the Sovereign Tech Fund's "Contribute Back Challenge" in 2023. It serves three groups: the NixOS security team, who review and link incoming CVEs; Nixpkgs maintainers, who get notified when their packages carry vulnerabilities; and everyday Nixpkgs users, who can subscribe to notifications on packages they actually care about. Entries move through five stages: Untriaged (auto-matched by the system), Dismissed (a human decided it doesn't actually affect Nixpkgs), Accepted (queued for publication), Draft issues (bundled for the next GitHub issue), and Published, which triggers maintainer notification and mitigation coordination. What this tracker really solves is record linkage: matching an entry in a CVE database to the actual Nixpkgs derivation it applies to, which sounds simple and isn't.

vulnix (github.com/nix-community/vulnix) is a CLI tool that checks a Nix store for packages reachable from live paths against NVD entries, and it can walk a derivation's entire transitive closure, with JSON output for anyone who wants to pipe results into something else. Its own documentation shows sample output flagging things like binutils-2.31.1 and libssh2-1.9.0, each with CVE IDs, CVSSv3 scores, and store paths attached. The tool is upfront about its weak point too: matching Nix package names to NVD product names uses a fairly blunt heuristic, direct match first, then lowercase and underscore variants, and the documentation itself says this mapping "is too simplistic and needs to be improved in future versions." That means false positives and missed matches are a real, acknowledged risk, not a hypothetical one. vulnix also supports a whitelist, so teams can suppress known false positives or accepted risks by package, version, CVE ID, and expiry date. And if a patch filename happens to contain a CVE identifier, vulnix will auto-suppress that CVE from its reports.

FlakeBOM generates CycloneDX SBOMs straight from any Nix flake, with vendored-dependency detection included. Flox, a developer environment platform built on Nix with hash-pinned SBOMs and CVE remediation baked in, is another point in this space, which is where single-manifest reproducibility meets supply-chain accountability. It's built explicitly with EU Cyber Resilience Act compliance in mind, and it's built explicitly with EU Cyber Resilience Act compliance in mind.

Safeguard (safeguard.sh) ingests Nix-generated CycloneDX SBOMs and cross-references components against OSV, GHSA, and NVD. Its policy gates can block a flake update outright if it drags in a vulnerable transitive dependency. For teams running private binary caches, it provides additional checks around the integrity of substituted artifacts.

Manifest Platform offers automated vulnerability mapping across Nix packages, giving security teams a way to track known vulnerabilities across Nix-based environments without building the matching logic themselves.

None of these tools have fully closed the name-matching gap, and it's worth saying plainly: that gap is the central unsolved problem in this whole layer. Nix store paths follow Nix's own internal naming conventions, and those don't map cleanly onto NVD's CPE identifiers. Every tool here has to bridge that gap somehow, and none of them do it perfectly.

How SBOM generation falls out of the graph rather than being added on top of it

In most build systems, an SBOM is something you generate after the fact: a snapshot, bolted onto a pipeline, that needs its own tooling and its own discipline to keep from going stale the moment something changes upstream.

Nix doesn't work that way, because the dependency graph already is the build graph. Every input gets declared and hashed before a build ever runs, so an SBOM isn't a separate artifact somebody has to remember to generate and keep in sync. It falls out of the graph traversal that already happened. A command like nix-store -q --requisites $(nix-build ./default.nix) lists the full closure directly, and tools such as FlakeBOM turn that into standard CycloneDX output. FlakeBOM does the same thing straight from a flake, vendored-dependency detection included.

There's a broader case for why this matters beyond convenience, too. SBOM mandates exist to help mitigate supply-chain risk, and the structural completeness of a graph-derived SBOM positions it as a basis for proactive trust assessments, not just after-the-fact record-keeping. Nix's graph structure lines up with that argument almost exactly, because the SBOM it produces isn't a manifest describing what was supposed to be installed. It's a record of what actually built.

That distinction is where the real payoff sits for financial services, healthcare, and other security-sensitive environments: the ability to prove, with cryptographic backing, exactly what software ran in production, not what a changelog says should have been running.

Where the graph model's guarantees have limits, attack vectors that operate before or around the hash

None of this is a claim that Nix is unbreakable, and it's worth being direct about where the guarantees stop. The graph's proofs apply after inputs get pinned. Everything interesting, attack-wise, happens before or around that pinning moment.

Flake input poisoning is the clearest example. If a flake references an input by branch name or tag instead of an immutable commit hash, a compromised upstream maintainer, or a simple account takeover, can inject malicious code before sandboxing ever kicks in. This is especially dangerous in CI/CD pipelines that run nix flake update on autopilot, and any setup using a floating reference like github:NixOS/nixpkgs/master is exposed at evaluation time. Teams that skip committing flake.lock to version control leave every single build open to resolver-time compromise, no exception.

There's also CVE-2024-27297, a time-of-check/time-of-use bug that let attackers tamper with fixed-output derivation outputs after the hash verification had already run, which defeats the entire point of the hash check.

Then there's the 2025 batch of Lix CVEs, five issues in total: CVE-2025-46415, CVE-2025-46416, CVE-2025-52991, CVE-2025-52992, and CVE-2025-52993. The attack chain here is genuinely clever. Linux abstract UNIX domain sockets aren't bound to the filesystem, and researchers found that if a build user can predict a temporary directory path, colluding malicious derivations can use that to smuggle file descriptors out of a sandbox that was supposed to contain them. The root cause traced back to build directories getting staged in a shared, world-writable /tmp, combined with a codebase that leaned on path-based APIs instead of file descriptors, which is exactly the kind of time-of-check/time-of-use gap that CVE-2024-27297 also exploited. Lix's fix moved builds into /nix/var/nix/builds under exclusive daemon control, switched to file-descriptor-based APIs like openat to close the race condition, and added validation on incoming file descriptors during builds. Separately, CVE-2026-64846 affected Nix versions prior to 2.35.0, involving a sandbox escape via a symlink, patched in 2.35.0.

The honest summary is this: Nix makes tracing deterministic after the pin. The trust boundary is the pin itself, and floating references, compromised upstream maintainers, and daemon-level bugs are exactly the surface still left exposed.

What this structural property changes about how CVE response actually works for teams

Before this model, CVE response scales with deployment count, because there's no proof that any two environments are the same, so every one of them needs its own individual scan. That's the state most teams are still working in.

After it, environments sharing a closure are provably identical for triage purposes, and the expensive analysis runs once per unique dependency set rather than once per box. That's the whole shift, and it's a structural one, not a tooling one.

It shows up in a few concrete places. In CI/CD, the same environment definition runs locally, in CI, and in production, so a fix applied at the environment-definition level is a fix applied everywhere that definition gets referenced, no per-environment re-validation required. In onboarding, a new engineer or a security auditor running nix develop gets exactly the environment the lockfile describes, full stop, no "has this machine actually been patched" guesswork. In platform engineering, a central team can maintain a base environment, audit it, and cut a new generation, and every downstream consumer inherits the fix by pulling the updated reference rather than following a patch checklist by hand. And across architectures, the same closure reproduces identically on macOS and Linux, so a CVE audit done on one platform holds for the other instead of needing to happen twice.

None of this holds automatically, though, and that's the closing point worth being blunt about. The guarantees only stand if teams commit flake.lock, use pinned inputs instead of floating branch references, and treat environment definitions as version-controlled artifacts, not throwaway config. The tooling creates the conditions for CVE tracing to actually be tractable. Whether those conditions hold up is a discipline problem, and that part's still on the team, not the graph.

Sources

  1. Flox | Achieving CVE Remediation in an Era of Escalating Vulnerabilities
  2. Fixes for five Lix CVEs
  3. GitHub - nix-community/vulnix: Vulnerability (CVE) scanner for Nix/NixOS [maintainer=@henrirosten]
  4. Nixpkgs security tracker
  5. zeropath.com
  6. guix.gnu.org
  7. github.com
  8. github.com