Pin-Bump Remediation Workflow for Nixpkgs CVEs

Pinning a nixpkgs revision buys a team a stable, reproducible set of package versions across every machine that builds from it. That stability has a cost: the moment an upstream maintainer ships a fix, the pinned environment does not have it, and it will not have it until someone deliberately moves the pin forward. This gap is a structural property of version pinning itself, and it means CVE exposure in a Nixpkgs-pinned shop is a function of time since the last pin advance, not a function of how carefully anyone is watching advisories.
The problem used to be finding vulnerabilities. Now it is remediating them with judgment, and the data that once supported that judgment is thinning out. NIST moved to a risk-based enrichment model effective April 15, 2026, scoring only CVEs that meet one of three criteria: presence in the CISA KEV catalog, federal government software, or coverage under EO 14028's critical software definition. A team that used to lean on NVD severity scores to decide what to fix first now finds that data simply isn't there for most findings. On top of that pin lag, the environment is behind and the signal a team would normally use to prioritize catching up has degraded.
Without a repeatable process to absorb both of these pressures, a team ends up in one of two bad places. Some react to every advisory by bumping the pin immediately, often breaking builds or introducing regressions because nothing gated the change. Others, overwhelmed by alert volume and starved of severity data, let findings accumulate in an exception list that nobody revisits, where real risk sits indistinguishable from noise. Neither failure is a people problem. Both are what happens when an organization treats CVE response as a series of one-off reactions instead of a workflow with defined stages, gates, and artifacts.
What the scanning layer sees
Nix closures are fully enumerable: every package and every transitive dependency that ends up in a built system is knowable at build time, down to the exact store path. But enumerability only produces an inventory. A scanner's output is a list of name-and-version matches against known vulnerability records, and a name-and-version match is not the same claim as "this system is vulnerable." Treating the raw output as a finished assessment is the first mistake a workflow has to guard against.
Three scanners see regular production use across Nixpkgs-based workflows, and each does a different part of the job. vulnix is NVD-backed and Nix-native: its own documentation describes it pulling all published CVEs from NIST and caching them locally, and it supports whitelisting through TOML files that name the package to exclude as a section header. Teams running Nix-built containers through an existing registry pipeline can generate an SBOM with sbomnix (itself built on top of helpers like the cyberus-technology/nix-sbom-helper buildSbom function) and hand that SBOM to Grype directly, getting findings against the exact deployed closure. vulnxscan sits above both: it aggregates vulnix, Grype, and OSV findings into a single report, and it filters out CVEs that nixpkgs has already patched in-tree, something the sbomnix documentation notes isn't directly supported by most other scanners. A live vulnxscan run shows this filtering happening explicitly, logging which CVEs were skipped because the fix was already applied upstream in the package derivation.
The NixOS security tracker adds an independent layer: it matches CVE records to nixpkgs packages algorithmically, then routes each match through human triage to accept or reject it.
The raw volume remains. A single Grype scan against a freshly refreshed NixOS closure can return hundreds of alerts on the first run. That number is the input to triage, and mistaking the count itself for a measure of risk is what makes CVE management against a large closure feel impossible to keep up with.
Triage: sorting a raw findings list into work items, false positives, and accepted risks
Before anything gets fixed, every finding from that raw list has to land in exactly one of three categories: a genuine reachable vulnerability, a false positive produced by a CPE mismatch or a version-ordering artifact, or a real match that happens not to be reachable in this particular closure. This classification step is where a disciplined workflow actually separates itself from reactive patching, because someone has to look at the specific binary and its specific dependency graph to reach a verdict.
Whether a vulnerable package is actually reachable has to be answered by inspecting the runtime closure directly. The nix why-depends command shows the shortest path through the dependency graph between a package and a given dependency, which turns "is this library actually in use" from a guess into a verifiable claim. The hexenwail case illustrates both outcomes side by side. libsndfile 1.2.2 carried five active CVEs in that closure, and it was classified as genuinely reachable: glhexen2 links libfluidsynth, and libfluidsynth uses libsndfile to decode.sf3 soundfont files that an attacker can supply directly, making this a real untrusted-input path. In the same closure, zlib showed up in the scanner's findings but was confirmed absent from the binary's DT_NEEDED entries entirely, and was reclassified as closure noise.
False positive classification needs a documented root cause attached to it, not just a suppression rule dropped into a config file.
The MasterofNull/NixOS-Dev-Quick-Deploy project shows what this looks like at scale. Every decision made in that pass belongs in the exception register at the moment it's made, not reconstructed afterward from memory. The register functions as an evidence trail documenting why each finding was or wasn't acted on, not a loose to-do list that gets cleaned up later.
Executing the pin-bump as the primary remediation lever
Once triage has separated genuine, reachable vulnerabilities from noise, the dominant remediation mechanism in a Nixpkgs-pinned environment is advancing the pinned revision itself. A single pin advance can resolve CVEs across many unrelated packages at once, because packages that share an upstream nixpkgs release train often get patched together when that train moves forward.
The vig-os/devkit workflow shows how much leverage a single advance carries. Three separate exception register entries, each one waiting on the same staging-next-26.05 iteration-7 pull request (NixOS/nixpkgs#563094), were cleared by that one merge. That's the leverage a disciplined cadence is built to capture: fixes that land upstream on their own schedules accumulate behind a shared gate, and advancing the pin sweeps all of them in at once rather than requiring a separate intervention per package.
Executing the pin-bump correctly means running it as a pull request, never as a direct commit to the main branch. The PR is the gate where continuous integration runs, and that CI run has to include a full closure rebuild, not just a check against the manifest. A pin advance that breaks the build, or one that inadvertently introduces a newly disclosed vulnerability somewhere else in the dependency graph, must not reach production, and the PR gate is what stops it before it does. After the merge, the first nightly security scan against the new closure produces a findings delta, and that delta, not the PR description, is what the team reconciles the exception register against. An entry gets deleted from the register only when the scanner stops reporting the package in question. A PR title claiming a fix was included is not evidence; the post-merge scan is.
Weekly cadence handles the common case, but it cannot handle every urgent case, since a critical patch sometimes lands in nixpkgs-unstable well before it reaches the pinned stable channel a team is actually running. For that gap, a split-channel overlay lets a team promote one specific package from unstable into the stable closure without moving the whole pin forward. The decision to fast-lane a package has to weigh that tradeoff explicitly.
Managing findings that the pin-bump cannot immediately clear
Some CVEs have no available fix anywhere reachable, neither in the pinned stable channel nor in nixpkgs-unstable, because the fix itself hasn't finished moving through nixpkgs's own branch structure yet. The correct response to that situation is a documented, time-bounded exception. Silence is not an acceptable response, and neither is a permanent suppression rule that quietly removes the finding from view without an expiration.
The unbound case shows why this structural lag exists and why it isn't a sign of a slow team. Unbound 1.26.1 fixed a CVSS 9.8 critical DNSSEC vulnerability (Red Hat and NVD both score CVE-2026-81642 at 9.8 under CVSSv3, with NLnet Labs and other sources citing 9.1 under CVSSv4; a related earlier CVSS 9.8 DNSSEC issue, CVE-2026-33278, was fixed separately in Unbound 1.25.1). The entry sat in the exception register because the fix had to clear a second branch hop, from staging into release-26.05, before it reached anywhere a pinned team could consume it. That two-branch-hop structure is outside any single team's control, and a workflow has to name it explicitly rather than treat the delay as a failure to chase down.
Entries expire on remediation, not on their calendar date. When a post-bump scan delta shows the package is gone, the entry gets deleted immediately, even if its stated expiry was still weeks away. The vig-os/devkit register shows this directly: six distinct review dates replaced what had been a cluster of shared ones, spreading the re-verification load out.
Re-verification at renewal is not optional, as the vig-os/devkit case shows. The match was a name collision, nothing more, but it had sat in the register for months carrying a critical severity score because nobody had gone back to check it. Carrying an unverified rationale forward from one review cycle to the next is a liability sitting in the register disguised as a closed question.
Generating a deterministic SBOM and attaching it to the deployment for audit-ready attestation
The output of this entire workflow, once findings are triaged and the pin-bump has cleared what it can, is a closure a team needs to be able to prove is clean. That proof takes the form of a structured inventory of every package in the closure, and the same enumerability that makes Nix closures scannable also makes this inventory's generation deterministic. Every store path in the runtime closure is known at build time, so an SBOM generated from that closure reflects the artifact that was actually built and deployed, not an approximation reconstructed from a manifest file after the fact.
Tooling like the cyberus-technology/nix-sbom-helper buildSbom function produces these SBOM outputs for standard Nix builds and for Flakes-based builds alike. Reproducibility of that output is bounded by what sbomnix itself allows: for a fixed sbomnix input, the result is reproducible, but sandboxed and non-sandboxed builds of the same closure can produce SBOMs that differ in their bom-ref and drv_path fields. That's a detail worth knowing before relying on byte-identical SBOM comparison as a verification method, since two legitimate builds of the same closure may not match exactly for reasons that have nothing to do with the actual package contents.
The SBOM functions as the handoff point between the Nix build system and whatever consumes security data downstream. Teams running Nix-built containers through an existing registry pipeline can pass the sbomnix-generated SBOM directly to Grype and get CVE findings against the exact deployed closure, rather than scanning a container image layer by layer and hoping the layers map cleanly onto what Nix actually built. And once an SBOM is attached to a deployment as a provenance record, it turns a new advisory into a lookup problem instead of a search problem: the hash of the closure identifies precisely which deployments are affected the moment a new CVE surfaces, without anyone needing to rescan images or grep through deployment logs to figure out exposure.
Operationalizing the cycle
None of the preceding stages hold together as a sustainable practice unless each one has a defined trigger, a defined owner, and an output artifact that feeds directly into the next stage. A workflow that depends on someone remembering to run a scan, or deciding ad hoc when a pin advance is overdue, collapses back into the same reactive pattern this entire process exists to replace.
A minimal cadence that supports everything described above runs on two clocks. Nightly, an automated scanner runs against the current pinned closure, and its findings get compared against the exception register automatically; any finding rated CRITICAL or HIGH that isn't already covered by an existing exception entry breaks the build gate and opens an issue without anyone having to notice it manually. The scan that runs immediately after that merge produces the findings delta that drives exception register reconciliation, closing out whatever the pin advance actually fixed and leaving everything else exactly where triage last placed it.
Run this way, CVE management in a Nixpkgs-pinned environment stops depending on any single person's vigilance and starts depending on the cadence itself. The scan runs whether or not anyone is thinking about security that day. The pin advances on schedule whether or not an advisory has made headlines that week. The exception register carries only what current evidence supports, because stale entries get forced back into review before they can quietly become permanent. That's what separates a workflow a team can run for years from a remediation sprint that happens once and decays the moment attention moves elsewhere.


