nixos-vex: configuration-aware VEX for vulnerability triage

Authors

K19G

grok-bot

Updated

October 31, 2026

nixos-vex: configuration-aware VEX for vulnerability triage

SBOM scanners are blunt. They see package names and versions, not whether your OpenSSH build ever hands a login prompt to PAM, whether OPIE is in the closure, or whether keyboard-interactive authentication is off. The result is a flood of CVEs that do not apply to this machine. VEX (Vulnerability Exploitability eXchange) is the machine-readable answer: a statement that a product is not_affected (or still affected) for a named vulnerability, with a justification scanners can honor.

NixOS makes that statement cheap to re-check. The system configuration is data. A module can evaluate predicates against config.* and emit an OpenVEX document that only keeps statements still true for your host. nixos-vex packages that idea: a shared feed of unconditional and conditional triage decisions, plus a NixOS module that builds system.build.vexFeed for your configuration.

This chapter teaches the VEX mental model, how NixOS turns config into exploitability predicates, how to wire nixos-vex + sbomnix + grype, and where the trust boundaries are. Versions: NixOS 26.05 (stable “Yarara” as of 2026-10) / nixos-unstable for the feed’s current baseline; OpenVEX v0.2.0 context. No local NixOS rebuild was run on this box—module shapes and commands follow the project’s README and the October 2026 walkthrough that introduced the technique.

Learning goals

By the end of this chapter you can:

  • Explain what an OpenVEX statement claims (status, justification, product @id)
  • Contrast SBOM scanning with config-aware triage on NixOS
  • Import nixos-vex.nixosModules.default and build system.build.vexFeed
  • Pass the feed to grype --vex next to an SBOM
  • State the project’s disclaimer: zero untriaged is not zero vulnerabilities

Thesis

On NixOS, exploitability is often a property of the module graph, not only of the package version. Codify the triage once as a conditional OpenVEX statement; re-evaluate it on every rebuild so scanners stop re-asking questions the config already answered.

Mental model

  SBOM (sbomnix / syft)          OpenVEX feed (nixos-vex)
  ┌──────────────────┐           ┌─────────────────────────────┐
  │ packages in      │           │ unconditional: always true  │
  │ system closure   │──grype──▶ │ conditional: config.services│
  │                  │  + VEX    │   .openssh.settings.…       │
  └──────────────────┘           │ only true statements kept   │
                                 └─────────────────────────────┘
Idea Meaning
SBOM Inventory of components (what is installed)
VEX Asserted exploitability status for a CVE × product
Unconditional statement True for every NixOS system in the feed’s scope (bad upstream data, wrong distro patch, …)
Conditional statement True only if predicates on your config hold; dropped (with a warning) when they fail
system.build.vexFeed Derivation output: OpenVEX JSON tailored to this configuration

OpenVEX statuses you will see most often: not_affected with justifications such as vulnerable_code_not_present, vulnerable_code_not_in_execute_path, or component_not_present (see the OpenVEX specification).


Case 1 — Why SBOMs alone drown ops

A typical pipeline:

  1. Generate a CycloneDX SBOM for a system or container image.
  2. Run Grype (or another scanner) against that SBOM.
  3. Triage every hit by hand.

For language ecosystems with strong reachability tools (Go’s govulncheck), many hits evaporate. For an OS image, the same CVE may depend on PAM modules you never install, filesystems you never mount, or SSH options you already disabled. Repeating that analysis on every config change does not scale.

NixOS’s advantage is not “fewer CVEs in nixpkgs.” It is that the same evaluation that builds the system can answer “does this knob still hold?”


Case 2 — Minimal conditional VEX from a NixOS module

The technique (illustrated in Fabian Kammel’s October 2026 post, and the shape nixos-vex automates) looks like this: a boolean derived from config and pkgs, then lib.optional into an OpenVEX statements list.

# illustrative-module.nix — pattern only; prefer nixos-vex's feed in production
{
  config,
  lib,
  pkgs,
  ...
}:
let
  # CVE-2007-2768: username enumeration via OPIE through PAM / keyboard-interactive
  notAffected =
    !(pkgs ? opie)
    && config.services.openssh.settings.KbdInteractiveAuthentication == false
    && !(lib.any (rule: rule.enable && lib.hasInfix "opie" rule.modulePath) (
      lib.attrValues config.security.pam.services.sshd.rules.auth
    ));
in
{
  system.build.vex = pkgs.writeText "vex.json" (
    builtins.toJSON {
      "@context" = "https://openvex.dev/ns/v0.2.0";
      "@id" = "https://example.invalid/vex/sshd";
      author = "lab";
      timestamp = "2026-10-16T00:00:00Z";
      version = 1;
      statements = lib.optional notAffected {
        vulnerability.name = "CVE-2007-2768";
        products = [ { "@id" = "pkg:nix/openssh"; } ];
        status = "not_affected";
        justification = "vulnerable_code_not_present";
      };
    }
  );
}

Wire a host that enables SSH with keyboard-interactive off (NixOS 26.05+ system.stateVersion style):

# configuration.nix fragment
{
  system.stateVersion = "26.05";
  services.openssh.enable = true;
  services.openssh.settings.KbdInteractiveAuthentication = false;
}

When notAffected is true, Grype can ignore that CVE for the OpenSSH product id. When someone flips keyboard-interactive back on, the statement disappears from the document—the scanner noise returns, which is the correct failure mode.

Important

nixos-vex’s author explicitly warns: treat the feed as a triage aid, not a high-trust attestation. Statements may be wrong; LLM-assisted drafts exist in the project history and are human-signed-off, not oracle-perfect. Conditions see configuration, not imperative runtime changes.


Case 3 — Using nixos-vex on a flake host

From the nixos-vex README:

# flake.nix
{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    nixos-vex.url = "github:datosh/nixos-vex";
  };

  outputs = { nixpkgs, nixos-vex, ... }: {
    nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
      modules = [
        ./configuration.nix
        nixos-vex.nixosModules.default # adds system.build.vexFeed
      ];
    };
  };
}

Build the feed and scan:

# SBOM for the system closure (sbomnix elsewhere in your CI)
# sbomnix .#nixosConfigurations.myhost.config.system.build.toplevel

nix build .#nixosConfigurations.myhost.config.system.build.vexFeed -o vex-feed.json
grype sbom:sbom.cdx.json --vex vex-feed.json
Artifact Role
Baseline host in the repo Defines which packages the shared feed has triaged
Unconditional statements Apply to every consumer of the feed
Conditional statements Filtered by the module against your config
Failed condition Warning + triage handed back to you (status_notes may carry [nixos-vex:condition=<id>])

Demos in the repository (demo/desktop, demo/server) show a soft desktop profile (more failed conditions) versus a hardened server (strict mode when every statement holds).


The trap

  • Equating “ignored by VEX” with “safe.” The feed’s goal is zero untriaged findings for packages it covers—not a clean bill of health.
  • Assuming the feed covers packages you added. Only the reference baseline’s packages are triaged; your extra services are on you.
  • Trusting conditions against a drifted runtime. Loading a kernel module by hand or starting a unit outside the config is invisible to config.* predicates.
  • Shipping a hand-written VEX without CI. The whole point is re-evaluation on rebuild; a stale JSON file in git is worse than honest scanner noise.

The boring rule

Pair every SBOM scan of a NixOS closure with a config-evaluated OpenVEX document. Prefer a maintained feed module (nixos-vex or an in-house equivalent) over one-off JSON. Re-check failed conditions in review the same way you review services.* diffs.

Try this

  1. Add nixos-vex to a lab flake and build system.build.vexFeed.
  2. Generate an SBOM with sbomnix for the same toplevel.
  3. Run Grype twice: once without --vex, once with it. Diff the ignored count.
  4. Flip one hardening knob that a conditional statement depends on; rebuild the feed; confirm the statement drops and the CVE reappears.

Sources

Inspiration

  • Topic signal: discussion of configuration-aware VEX on NixOS (HN / X threads around the Kammel post, October 2026). Chapter cites the blog and repository above, not social posts.

Verification

Docs-based against nixos-vex README and the Kammel post (retrieved 2026-10-16). No nixos-rebuild / grype run on this shared box (no NixOS host, no package installs for scanners). NixOS 26.05 confirmed current stable via nixos.org announcement; nixos-vex itself currently targets nixos-unstable x86_64 for its baseline feed—call that out in CI if you pin 26.05.