Managing CVE Exceptions and Accepted Risk in Nix Environments
Distinguish false positives from accepted risk to audit CVE exceptions properly.

A team running vulnix against a recent stable NixOS system can expect to see dozens of CVE findings in a single pass, spanning packages that range from a command-line developer tool to a language runtime's bootstrap build to a handful of obscure supporting libraries. Others are build-time artifacts that most engineers on the team have never heard of and would struggle to explain why they're even present on a server. That gap between the volume of the report and the team's working knowledge of its own system is the starting condition for everything this piece addresses.
Part of the volume is structural. The mapping vulnix uses to go from a Nix package name to an NVD product entry is coarse by design: it attempts a direct match, then falls back to variations involving lowercase conversion and underscores substituted for hyphens. That approach catches real matches, but it also catches collisions, cases where a CVE genuinely applies to some other piece of software that happens to share a name with a package in nixpkgs. This isn't a bug in vulnix so much as a structural consequence of trying to map a sprawling, decentralized package ecosystem onto NVD's product taxonomy, so a nontrivial share of any given scan's findings turn out to be findings about the wrong software.
The rest of the volume comes from what makes Nix valuable in the first place: a CVE can exist for a package without being reachable in the environment currently deployed, and that gap can be wide and legitimate, a direct consequence of reproducibility rather than a sign that anything has gone wrong.
Faced with that mixture, teams tend toward one of two failure modes. Neither failure mode is sustainable, and the way out of both is a disciplined process for separating findings into the categories they actually belong to.
The two fundamentally different decisions behind every CVE finding
Every CVE finding that isn't resolved by an immediate patch eventually resolves into one of two decisions, and the entire discipline of exception management depends on keeping those two decisions distinct. The finding is either invalid in this environment, a false positive or a package that doesn't apply here, or it is real and the team is choosing to accept the risk it represents for a documented reason. Conflating these two categories is the single most common root cause behind exception-management programs that fail.
What unites these cases is that the underlying system isn't actually exposed to the vulnerability the CVE describes.
The second category, accepted risk, covers situations where the vulnerability is real and reachable, but the team has decided, for a specific and documented reason, not to remediate it immediately.
These two categories demand different metadata, different expiry logic, and different audit trails, and a whitelist that doesn't distinguish between them produces a flat, undifferentiated list of suppressions. A security engineer looking at that list a year later would have no way to tell which entries represent "this was never a real risk" and which represent "this was a real risk someone chose to live with." Those are two very different statements about the organization's actual security posture, and an exception record that erases the distinction between them isn't honest about what risk is actually being carried.
What an auditable exception record must contain
A CVE exception that can't be audited is a silent suppression, indistinguishable from negligence the moment a suppressed finding turns out to have been exploitable all along.
Vulnix's whitelist format gives teams a concrete, minimal structure for avoiding that trap. It's expressed as TOML, organized under section headers scoped to a package or a specific package version, and each entry can carry a handful of fields that together constitute an auditable record rather than a bare suppression. A comment field carries the justification in free text.
Mapped onto the two-category model from the previous section, a complete exception record needs: the specific CVE identifier or identifiers being excepted, never a package-level blanket; the affected package and version, tied to the actual Nix store path or derivation rather than just a package name, so the scope of the exception is exact rather than approximate; the exception type, false positive or not-applicable versus accepted risk; a justification explaining the reasoning, including, for platform-specific non-applicability, the specific condition involved, such as macOS-only or build-time-only; and an issue or ticket URL pointing at whatever upstream or internal thread the team is using to track resolution.
The until date deserves particular attention because it's doing more work than it looks like it's doing. Once that date passes, the rule stops suppressing the finding, and the next scan surfaces it again. That's the mechanism that keeps an accepted risk from quietly becoming a forgotten risk: the team is forced back to the finding rather than allowed to let the exception run indefinitely on autopilot. A false positive with a stable, structural justification, such as a permanent name collision with an unrelated product, can reasonably be marked permanent, but that should come with an explicit comment explaining why the determination is expected to hold indefinitely, not through an absent or indefinitely distant expiry date.
The vulnix TOML schema is a minimum, not a complete standard.
Categorizing and triaging a new finding before writing an exception
Writing an exception record before actually understanding the finding behind it produces records that are wrong about as often as they're right, marking a false positive as accepted risk or the reverse, and an exception built on a guess doesn't survive an audit. Triage has to come before the decision, and that triage follows a sequence of concrete, repeatable checks.
The first step is confirming the package is in the runtime closure. Running nix why-depends traces the dependency path from the top-level environment down to the flagged derivation. A bootstrap compiler or test-only dependency that can't be reached from the runtime closure, something like go-1.21.0-linux-amd64-bootstrap, is a strong candidate for a not-applicable exception on exactly that basis.
The second step is checking whether nixpkgs has already patched the issue even though the version string hasn't changed to reflect it. Where that's uncertain, the derivation itself needs direct inspection rather than trust in the automated detection.
The third step is verifying platform and configuration scope, and CVE-2026-39860 is a clean illustration of why this step matters. A team running macOS-only CI infrastructure can mark this finding not-applicable, with a justification that cites that exact condition rather than a vague note that the finding "doesn't seem relevant."
The fourth step is checking upstream nixpkgs and the NixOS security advisory channel for a fix already in motion. It's a candidate for a pin update, and treating it as anything else just delays work that's already been done upstream.
Only after working through those four steps should a team assign the exception type and set the expiry. Accepted-risk exceptions need a deadline tied either to when an upstream fix is expected to land or to a scheduled date for re-review, so the exception has a built-in forcing function rather than an open-ended shelf life.
Encoding exceptions in a versioned, machine-readable file that travels with the environment
An exception decision recorded in a spreadsheet, a Slack thread, or a ticket comment starts drifting out of sync with the actual environment almost immediately. The record has to be version-controlled alongside the environment definition it governs, so that any given commit of the environment and the exceptions accepted against it can be inspected together, as a single coherent state, rather than reconstructed by cross-referencing a build artifact against a conversation that happened weeks earlier.
That placement means a reviewer looking at a pull request that bumps a dependency can see, in the same diff, whether that bump also introduces or resolves an exception.
Vulnix can load whitelists from local file paths or from HTTP URLs, which opens up a useful two-layer model. The two layers combine at scan time, so neither has to duplicate the other.
The flake.lock file already functions as a provenance record in its own right: it pins every input to a specific git revision and narHash, and Nix will refuse to build if the content it fetches doesn't match what's pinned. An exception whitelist committed alongside that lock file inherits the same audit properties. Every change to it is a commit, every commit has an author and a timestamp, and the diff between any two versions shows what changed and when.
For teams running declarative, reproducible environments, where the environment manifest itself is the unit that gets promoted from one stage to the next rather than a built artifact handed off separately, this co-location matters because it closes a gap that otherwise has to be reconstructed after the fact. Any deployment can be traced back to an exact environment state that includes both its dependency graph and the complete set of exceptions accepted against it at that point in time. CVE-to-decision traceability becomes a property of the commit history rather than something an auditor has to go reconstruct from memory and old tickets.
Running the exception gate in CI to prevent silent drift
A whitelist sitting in a repository is a document until something enforces it. Left unenforced, the environment can change in ways that quietly invalidate an existing exception: a new CVE surfaces against an already-whitelisted package, a version bump changes what's actually installed, or an until date passes unnoticed, and none of it is visible until the next manual audit happens to catch it.
The pattern that holds up in practice is a vulnix gate run nightly or on every pull request, scanning the pinned Nix closure and checking its findings against the committed whitelist. Any finding not covered by a current, non-expired entry fails the gate. That single design choice changes the nature of the problem: an un-excepted finding becomes a build-breaking event that blocks a merge, not a line item quietly accumulating in a dashboard nobody reads.
The gate needs to fail closed. Without that discipline, routine maintenance becomes a vector for silently introducing new risk, defeating the point of pinning dependencies.
Expired exceptions behave exactly as they're supposed to under this model. When an until date passes, the corresponding vulnix rule stops suppressing its finding, and the finding reappears as a gate failure on the next scan. The team is forced to re-evaluate the finding rather than quietly extending the deadline out of habit, which is precisely the behavior an auditor would want to see: a decision that gets revisited on schedule rather than one that calcifies into permanent silence.
The gate also does useful work validating the whitelist itself. An exception entry that references a CVE no longer detected, because the package has since been updated or removed from the closure entirely, is stale metadata rather than a live control. Pruning entries that haven't triggered across several scan cycles keeps the whitelist an honest record of what risk is actually being accepted right now, rather than an accumulating archive of decisions made about environments that no longer exist.
Keeping exception decisions synchronized when the environment or the CVE landscape changes
The hardest part of this discipline isn't writing the first exception record but keeping it true as the environment changes, as dependencies get bumped and transitive graphs shift underneath a project, and as the CVE landscape changes, as new vulnerabilities get disclosed against packages that were already whitelisted for entirely different reasons.
A whitelist entry scoped precisely to specific CVE identifiers, rather than to a whole package, already protects against part of this drift. If a new CVE is disclosed against a package that already carries an exception for a different, older CVE, the existing rule doesn't suppress the new finding. It simply doesn't apply, and the CI gate catches the new finding as uncovered, forcing a fresh triage pass rather than letting the new vulnerability ride along silently under an old justification.
The other half of the synchronization problem is the expiry mechanism already built into the until field, paired with the CI gate that enforces it. A finding marked accepted-risk with a deadline tied to an expected upstream fix doesn't need a human to remember to check back in on it. The scan re-surfaces the finding the moment the deadline passes, turning re-review from a task someone has to remember into a task the system produces automatically. The same logic applies when an upstream fix actually lands, as it did for CVE-2026-39860 with fixed versions identified and a corresponding pull request merged into the Nix repository: a team tracking that exception against its expected resolution date will see the finding reappear in the gate, prompting the pin bump that resolves it for good rather than an indefinite extension of an exception that no longer needs to exist.
What makes this sustainable over the life of a project is that none of it depends on anyone's memory. The whitelist, the gate, and the expiry dates together form a loop that keeps surfacing exactly the findings that need a human decision, at exactly the moment that decision needs to be made again, without burying the team in noise and without letting an old decision outlive the reasoning that justified it.


