Integrating Nix CVE Scanning Into CI Release Gates
Freshness checks on lock files catch CVEs faster than package-by-package scanning.

In Nix, every build input is hashed into the output store path, so two builds with identical inputs always land on the same path and any tampered input produces a detectably different hash, with tampering anywhere breaking the Merkle chain upward. Two builds fed identical inputs land on the identical store path every time.
That property has a direct consequence for how security patching works. Nix has no separate per-package alerting layer sitting on top of stable releases: when a CVE fix lands, it gets backported into the current stable branch. A team's entire CVE exposure reduces to one measurable number: how old is the flake.lock.
Compare that to a language-ecosystem dependency graph, npm, Maven, pip, where a scanner has to walk a transitive tree that can run into the thousands of packages and flag which ones happen to carry a known vulnerability today. In Nix the question isn't which of many thousands of packages has a CVE right now. How old the lock is is the question.
That reframing sets the terms for everything that follows. A CI release gate for a Nix project is a freshness check, not primarily a package-matching exercise. It's a freshness check, backed by a scan of what's actually running, and the rest of this piece works out what that means in practice.
What a stale lock costs: the 15-month gap case
The abstract risk of lock staleness turned concrete in a documented incident: a flake.nix pinned to nixos-24.11, a channel that reached end of life in June 2025, went unrefreshed, and every NixOS host in that homelab ran roughly 15 months without package security updates, as recorded in cwage/homelab issue #298, opened September 16, 2026. Fifteen months is not a brief lapse. It spans well over a year of backported CVE fixes that never reached a single running host.
The gap wasn't caused by negligence in the ordinary sense. Dependabot had already opened a pull request to bump the channel reference, but nothing in the pipeline proved that PR was safe to merge, and nothing flagged that the fix sat undeployed. The absence of a gate meant the exposure stayed invisible until someone went looking for it.
Fixing it required migrating the channel to nixos-26.05 in PR #299, deploying a staleness nag via ntfy that fires past a threshold of lock age or within a threshold of EOL, and wiring Renovate to open a PR on Monday mornings that refreshes flake.lock, all described in the same issue thread. None of those three pieces is exotic engineering. All three exist because the first version of the system had none of them.
The lesson generalizes cleanly within the Nix model, without needing to be stretched into a broader lecture on patch management. Lock staleness is the primary mechanism by which known CVEs accumulate on a Nix-managed fleet, and no scanner, however well configured, can compensate for a lock nobody ever refreshes. A CI gate downstream of this case has one non-negotiable job: it has to prevent exactly this kind of silent, multi-month drift from happening again.
Scanning the runtime closure instead of the source tree
Given that the lock's age is the central signal, the next design decision is what the gate actually scans once it runs. The right target is the live runtime closure built from the flakeref itself, and that choice sets whether the gate gets full coverage or a reduced, partial view of what's actually deployed, as opposed to scanning a pre-generated SBOM or the source tree.
The AnNIXion project's pull request #111, merged September 3, 2026, documents how the shortcut fails. Passing vulnxscan --sbom silently drops vulnix from the scan, because vulnix can't run without live store paths to inspect, and vulnix turns out to be the only one of the three scan engines capable of surfacing certain findings, including a CVSS 9.8 finding in xrdp. Nothing in the SBOM path throws an error. It just quietly scans less. That gap is reduced coverage by design rather than by accident.
That's the reason the CI job is willing to pay the cost of a full closure build before it scans anything. The closure gets built first, then scanned against, on a schedule set for Mondays at 06:17 UTC with an option to trigger on demand. Two smaller engineering details keep that job honest rather than noisy. The commit gate filters out diffs that are only timestamp changes, so the CVE history recorded in git tracks real movement in findings rather than clock ticks, and a missing render directory exits with code 2 rather than 1, so a broken scan run can't be misread as a clean one. The ci.yml configuration also adds paths-ignore: [docs/security/**], so a routine weekly bot commit doesn't accidentally trigger an unrelated three-hour ISO build.
The nix-bitcoin scan, as of September 28, 2026, covers a real node running bitcoind, lnd, electrs, btcpayserver, nbxplorer, and backups, and it surfaced 47 affected store paths carrying non-whitelisted CVEs, including CVSS 9.8 findings in curl, glibc, openssl, perl, and unbound. They're the foundation the application runs on, and a source-tree scan simply never looks there.
The three-tier blocking policy that makes a gate usable rather than ignored
Scanning the right surface only matters if the findings lead somewhere. A gate configured to block on every single finding gets abandoned within a few release cycles; a gate that only ever warns gets ignored just as fast. The design that survives contact with a real release schedule separates findings into three tiers: what blocks a release, what gets tracked without blocking, and what gets suppressed as noise.
The block tier should be narrow and defensible. It belongs to known-exploited entries, where there's no debate about real-world impact, and to anything scoring 9.0 or above, the exact severity band the nix-bitcoin scan showed accumulating in foundational packages like curl, glibc, and openssl. This tier isn't a matter of internal taste. OSPS Baseline Level 3 explicitly expects software composition analysis findings above a defined policy threshold to block a release, which gives the block tier a governance anchor outside the team's own preferences.
The warn tier catches what needs watching without stopping a release today: findings in the 7–9 range, lower-band exploit probability, nothing with a public proof-of-concept yet. The AnNIXion design records these in committed git history, so the CVE timeline stays auditable over time, and a finding that eventually escalates from warn to block appears in the diff.
The suppress tier exists to stop the gate from drowning in findings that carry no decision value: dependencies confirmed to be dev- or test-only and absent from the runtime closure, and packages a vendor has issued a VEX not_affected statement for.
This gap is structural rather than a configuration mistake: NVD lag means a high-severity CVE can be published with a working public proof-of-concept the same day, yet carry no CVSS score for days, weeks, or indefinitely, thanks to the NVD backlog. A block tier keyed purely to CVSS ≥ 9.0 won't trigger on a finding that has no score yet, no matter how dangerous it already is. The workable mitigation is a policy rule that treats any score-absent CVE with a public proof-of-concept as critical regardless of what the CVSS field says.
Why the Trivy supply-chain compromise reframes scanner trust
On March 19, 2026, threat actor TeamPCP compromised Aqua Security's Trivy vulnerability scanner for the second time in roughly three weeks, after a first compromise around March 1, 2026, injecting a credential stealer into the official trivy-action and setup-trivy GitHub Actions used by more than 10,000 CI/CD workflows worldwide. The scanner itself became the attack surface: on self-hosted runners it swept SSH keys, cloud provider credentials, Kubernetes tokens, and database credential paths, and on GitHub-hosted runners it scraped CI/CD secrets straight out of memory.
That is why the CI job pays for a full closure build before scanning: the closure is built, then scanned against it; the job is scheduled Mondays at 06:17 UTC and can be triggered on demand. A tool trusted to inspect every build for weaknesses needs its own provenance checked just as rigorously.
Nix's content-addressed model gives a structural answer to that problem rather than a procedural one. Pinning the scanner's version in flake.lock, with a verified narHash attached, means the scanner binary itself can't be swapped out silently, because the same Merkle-chain guarantee that protects application dependencies protects the tool used to inspect them. That's the moment where Nix's hermetic build model, usually pitched as a convenience for developers, turns out to carry real security weight: a scanner whose inputs are pinned and content-addressed can't be replaced by a compromised upstream release without visibly breaking the hash chain.
Declarative environment manifests that pin every piece of tooling, scanner included, at a verified hash, and that support version control and atomic rollback, close off the entire class of attack that hit Trivy. In this model the scanner is simply another package living inside the closure, bound by the same provenance guarantees as everything else in it.
Lock freshness as an operational discipline: automation patterns that keep the gate meaningful
A gate built correctly still decays into uselessness if nobody refreshes the lock and nobody runs the scan. Automated lock maintenance, not a developer remembering to check a dashboard, is what keeps the gate honest over months and years.
The cwage/homelab setup shows what a minimal version of that discipline looks like in practice, running as an actual system. Renovate runs self-hosted via modules/renovate.nix, firing every Monday at 06:00, opening a single pull request that refreshes flake.lock alongside compose image pins, Dockerfiles, and Actions pins in the same pass. A staleness nag fires once lock age crosses a configured threshold, or once the pinned channel nears its own end-of-life window, and it's deployed across all hosts so the signal reaches whoever needs it regardless of who last opened the repository. A CI build job defined in .github/workflows/checks.yml builds every NixOS host's full system closure on any pull request touching flake, host, module, or Nix paths. The gate fires directly on the Renovate PR before it can be merged.
What the homelab setup deliberately leaves out carries as much weight as what it includes. The issue thread rules out system.autoUpgrade, because it produces divergent locks across hosts and risks unattended reboots on infrastructure hosts like DNS servers, exactly the kind of surprise the whole system is built to avoid. It also rules out deploy-rs or colmena, judging that four hosts don't justify the added complexity when a plain make target does the job without introducing new failure modes.
The AnNIXion project's weekly schedule and commit-gate logic work the same loop from a different angle: a lock refresh generates a pull request automatically, CI builds the full closure and runs the scan against it, the gate blocks any merge where blocking-tier findings are present, and the committed security pages accumulate into an auditable CVE timeline over time. Declarative environment tooling that versions the full stack, lock file, scanner, and build inputs together, and supports named sharing and on-demand pulls, means a new developer or a freshly provisioned CI runner gets an identical environment without manual setup. That portability is what lets the freshness discipline survive staff turnover, rather than depending on whoever happened to configure it first.
The gaps that remain in the Nix CVE gate
None of this closes every question a release gate needs answered. Nix's reproducibility guarantee settles one of three supply-chain security questions outright, confirming that a given binary really does come from the code it claims to come from, but it says nothing on its own about whether that code was trustworthy to begin with, or whether the binary can be trusted independent of the build process that produced it.
Visibility into vulnerabilities has its own limit, separate from reproducibility. A perfectly reproducible build of a vulnerable package is still a vulnerable package, so the gate only ever catches what the scanner's database already knows, not what hasn't been disclosed yet. NVD lag, potentially a day or more without a CVSS score after a CVE is published, means a critical finding with a same-day public proof-of-concept can pass through a score-threshold gate on the day of disclosure, and the policy mitigation is to treat unscored CVEs with a public PoC as critical, which requires human judgment or an additional data feed like EPSS or CISA KEV.
Nix itself is not exempt from this discipline. The Debian security tracker lists open CVEs against the nix package across multiple releases. The tool managing the environment is subject to the same scanning scrutiny as everything it manages, and a team running an older Nix build, bookworm's 2.8.0 for instance, inherits vulnerabilities already fixed in later releases.
Who patches the patch when a CVE lands in nixpkgs itself before the community backport catches up? The commercially available answer among the sources reviewed here is a curated nixpkgs distribution with critical CVEs patched under a contractual service-level agreement, Determinate Secure Packages being the named example. That kind of guarantee is something community nixpkgs, run on volunteer maintenance timelines, does not promise.
The remaining gap is provenance reporting itself, separate from scanning. A closure scan tells a team what's vulnerable right now; it doesn't produce a portable record of what was in the build and why certain findings were accepted. FlakeBOM, also from Determinate, generates SBOMs directly from flakes, folds in Vulnerability Exploitability eXchange data for CVEs that have been manually mitigated, and merges custom VEX metadata in a single pass. That makes the SBOM an output of the same build process the gate already scans, rather than a separate report assembled after the fact, closing the gap between what the gate checks and what a compliance reviewer eventually needs to see.
Sources
- NixOS update cadence: lock refresh, CI build, deploy target, staleness alerts · Issue #298 · cwage/homelab
- [cve-scan] known CVEs in the shipped closure · Issue #1 · ostermayer/nix-bitcoin
- Information on source package nix
- feat(ci): run the CVE scan weekly and publish only when it has news by Pyth3rEx · Pull Request #111 · Pyth3rEx/AnNIXion
- Fix or replace the CVE scans and gate releases on them · Issue #3173 · finos/architecture-as-code
- Trivy vulnerability scanner backdoored with credential stealer in supply chain attack
- CVE-2026-33634 and the Trivy supply chain compromise — how mutable tags turned a security scanner into a credential stealer
- Checking and dealing with CVEs - Help - NixOS Discourse


