Flakevuln

September 16, 2026 · View on GitHub

Flake-agnostic vulnerability scanning for Nix flakes, packaged as both a reusable GitHub Action and a local CLI.

flakevuln scans each configured output at its committed dependency pins, re-locks a selected input to compare against its current upstream, and can add an explicit unstable comparison. It renders the results as a GitHub Actions Step Summary, a detailed Markdown report, and machine-readable JSON.

For a high-level explanation of the problem it solves, its outputs, and how it fits into an end-to-end vulnerability-management process, see the flakevuln overview.

Features

  • Scans one or more flake outputs from a checked-out repository or a remote flakeref.
  • Compares the committed lock state against a re-locked baseline for a chosen input, defaulting to nixpkgs.
  • Optionally adds a third scan against an explicit unstable input such as github:NixOS/nixpkgs/nixos-unstable.
  • Writes machine-readable findings plus markdown reports.
  • Optionally produces SARIF for GitHub Code Scanning.
  • Explains each finding with per-derivation patch evidence from vulnxscan.
  • Reuses the same engine locally and in GitHub Actions.
  • Persists grype, vulnix, sbomnix HTTP cache data, and prior-run baseline findings across workflow runs.

GitHub Action

The composite action installs Nix, builds flakevuln, runs an untrusted scan phase, then renders the report in a trusted phase.

Minimal workflow

name: flakevuln

on:
  pull_request:
  workflow_dispatch:

permissions:
  contents: read

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
      - uses: tiiuae/flakevuln@<commit-sha>
        with:
          targets: |
            packages.x86_64-linux.default

Prefer pinning the action to a full commit SHA in production workflows.

Example with optional inputs

name: flakevuln

on:
  workflow_dispatch:
  schedule:
    # Pick your own minute instead of copying this one. See the note below.
    - cron: "17 3 * * 1"

permissions:
  contents: read

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
      - uses: tiiuae/flakevuln@<commit-sha>
        with:
          targets: |
            packages.x86_64-linux.default
            devShells.x86_64-linux.default
          unstable-ref: github:NixOS/nixpkgs/nixos-unstable
          whitelist: .github/flakevuln/manual_analysis.csv
          nixprs: true
          nixprs-exclude-packages: linux
          nixtracker: true
          cachix-caches: nix-community my-org

Choose your own cron minute rather than reusing the one above. A scan fans out to shared community infrastructure, including repology.org and the Nixpkgs security tracker, and neither is a large service. Copies of the same schedule across many repositories concentrate that load into a single minute, so staggering costs you nothing and helps the services everyone depends on.

None of the optional inputs above are free, and they cost in different ways. nixprs and nixtracker add rate-limited enrichment lookups to the report phase, which can dominate the total run time, and the shared HTTP cache holding their responses is deliberately short-lived so scheduled scans re-query and report current data. unstable-ref instead adds a third full scan of every target, so its cost tracks closure size rather than finding count. Enable each because you want what it produces, not because this example shows it.

Example: scan a flake in a subdirectory

name: flakevuln

on:
  pull_request:

permissions:
  contents: read

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
      - uses: tiiuae/flakevuln@<commit-sha>
        with:
          flakeref: ./services/api
          targets: |
            packages.x86_64-linux.default

For an in-repository example that uses the checked-out action source directly, see example-scan.yml.

Example: upload SARIF to GitHub Code Scanning

Set sarif-location when scanning one flake target. The action exposes native vulnxscan SARIF for the current pin while retaining the normal lock-updated and optional unstable comparisons in the Flakevuln report.

- id: flakevuln
  uses: tiiuae/flakevuln@<commit-sha>
  with:
    targets: packages.x86_64-linux.default
    sarif-location: flake.nix
    upload-report: false
- name: Upload SARIF
  if: ${{ always() && steps.flakevuln.outputs.sarif != '' }}
  uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
  with:
    sarif_file: ${{ steps.flakevuln.outputs.sarif }}
    category: vulnxscan

The job needs security-events: write to upload SARIF. Private and internal repositories also need GitHub Code Security enabled and actions: read.

Inputs

InputRequiredDefaultDescription
targetsyes-Newline-delimited flake outputs to scan.
flakerefno.Flake to scan. . means the checked-out workspace root; a subdirectory also works.
input-namenonixpkgsRe-lockable input to diff against.
unstable-refno""Optional third scan target, typically github:NixOS/nixpkgs/nixos-unstable.
whitelistno""Path to a suppressions CSV in the caller repository.
nixprsnofalseEnable best-effort nixpkgs PR enrichment during report rendering.
nixprs-exclude-packagesno""Whitespace-delimited package names to skip during nixprs enrichment. Findings remain active.
nixtrackernofalseEnable best-effort Nixpkgs security tracker enrichment during report rendering.
tokenno""Optional token for nixprs; when empty, the trusted report step falls back to github.token.
cachix-cachesno""Space-delimited Cachix cache names to add as read-only substituters.
upload-reportnotrueUpload the findings and rendered report as an artifact. With false the Step Summary is still trimmed, so publish report-path yourself or the omitted tables are unreachable.
report-retention-daysno30Number of days to retain the uploaded report artifact.
sarif-locationno""Repository-relative file responsible for the scanned closure. Enables SARIF output for a single target.
strict-scannernofalseFail closed on degraded scanner data: require vulnxscan's CPE dictionary and grype's database update check on every scan.

Outputs

OutputDescription
findings-pathPath to the complete machine-readable findings JSON.
report-pathPath to the rendered markdown report directory.
report-artifact-nameName of the report artifact when upload-report is true.
report-artifact-urlURL for the uploaded report artifact when upload-report is true.
sarifPath to the current SARIF file when sarif-location is set.

Behavior

  • Supported runners: Linux runners. The action is designed around cachix/install-nix-action, so macOS may work, but this repository currently validates releases on Linux.
  • Required workflow permissions: contents: read is sufficient for the action. Uploading its SARIF output needs security-events: write; private and internal repositories also need GitHub Code Security enabled and actions: read.
  • Security model: the untrusted scan phase runs without GH_TOKEN; optional GitHub-authenticated enrichment happens later in the trusted report phase.
  • Report publication: the action uploads a report artifact containing findings.json, report/README.md, and each per-target markdown report. The landing page carries the run notes saying which enrichment ran and which comparisons were skipped. The artifact is named flakevuln-report-<UTC timestamp>-<nonce>: the timestamp sorts once downloaded, and the nonce keeps the name unique across a workflow run, as artifact names must be. report-artifact-name gives the exact name to callers that need it.
  • Step Summary: each target is a heading carrying its active finding count, folded when a scan covers more than one target. A target whose scan failed reads (scan failed) rather than a count, since zero would report it clean. The summary omits the no-longer-active, whitelisted, and patch-evidence tables, which are the bulk of a large report and the least useful part to read inline, along with the cross-references into them. Each target says what was omitted, and links to the run artifacts when one was uploaded. The published report and a local run's output keep everything.
  • Oversized reports: should the trimmed summary still exceed GitHub's 1MiB per-step limit, a compact index replaces it, with per-target counts of new, no longer active, and active findings so the page says whether anything changed without a download. A count renders as - when it is not knowable rather than zero: the scan failed, the comparison did not run, or there is no previous run.
  • Artifact upload still runs after a successful scan if the trusted report step fails, so findings.json remains available for diagnosis. Set upload-report: false to disable the action-managed artifact.
  • The primary findings path is the findings-path output. For compatibility, the action also writes a copy to ${{ runner.temp }}/flakevuln/findings.json, but callers should migrate to the output path for multi-invocation jobs.
  • SARIF output: setting sarif-location exposes sarif after a nonempty current-pin file and consistent companion outputs are generated. Scanner errors and missing or empty SARIF fail the scan; rejected companion output discards SARIF. It is written before the lock-updated and unstable scans. Code Scanning counts each affected package version, while the report collapses versions by vulnerability and package, so the Security tab will normally show more alerts than the report's Currently Active table. The report's patch-evidence suppression can increase that difference.
  • Repology outages: a scan with valid fallback triage rows remains successful. Findings and any requested SARIF are retained, Repology-provided version and classification fields stay blank, and the report begins with a degraded-mode warning. Malformed, missing, or evidence-inconsistent triage output still fails the affected scan state.
  • Baseline diffing: the action persists a prior findings set keyed by flakeref, targets, and input-name, then reports what changed since the last successful run for that same scope.
  • Strict scanner policy: strict-scanner: true (CLI: --strict-scanner) passes --require-cpe-dictionary to vulnxscan and makes grype database update checks fatal when attempted (GRYPE_DB_AUTO_UPDATE=true, GRYPE_DB_REQUIRE_UPDATE_CHECK=true). Grype rate-limits update checks (default: one per 2h), so scans after the action's grype DB update step skip the check and reuse its result; a scan whose own check runs and fails — or a cold CPE cache without network access — fails instead of producing partial findings. It is off by default; enable it when a green run must never rest on degraded scanner data.

Caches and report output

The action restores the newest matching cache entry from earlier runs and saves fresh state for the next run. It persists:

  • the grype database
  • the vulnix database
  • sbomnix's shared HTTP cache for OSV, repology, and optional nixprs / nixtracker lookups
  • the previous-run findings baseline used for "since last run" sections

With nixtracker enabled, the first uncached tracker lookup may walk the tracker's full paginated issue list and take several minutes. The shared sbomnix HTTP cache keeps those responses for 6 hours and reuses them across CVE chunks.

After cache restore and before the target scan, the action runs a best-effort vulnix preflight against a small nixpkgs#hello derivation when existing cache state is present. A cold cache skips the preflight and is initialized by the target scan, avoiding a duplicate database download. The preflight requires parseable JSON so a bad restored DB is noticed early. If existing vulnix cache files fail for a non-timeout reason, the action removes only ~/.cache/vulnix/Data.fs* and ~/.cache/vulnix/lock, then retries once. The original files are staged with same-filesystem hard links and restored if the clean-cache retry fails. A later invocation also restores a staged cache left behind by abrupt termination. Setup failures, timeouts, and retry failures warn and continue so the target scan can still refresh and save a usable cache.

Advanced callers can tune the preflight with environment variables:

  • VULNIX_PREFLIGHT_TIMEOUT_SECONDS for existing cache state, defaulting to the recovery timeout
  • VULNIX_PREFLIGHT_RECOVERY_TIMEOUT_SECONDS after resetting invalid cache state, default 900

Local usage

Prerequisites

  • Nix with nix-command and flakes enabled
  • Linux
  • GH_TOKEN in the environment if you want higher GitHub API rate limits for --nixprs lookups; without it, flakevuln falls back to anonymous best-effort queries

Quick start

Run against the current checkout:

nix run .#flakevuln -- local packages.x86_64-linux.default

By default, local writes outputs under .flakevuln/:

  • findings.json
  • report/README.md
  • report/*.md for per-target detail pages

Scan a remote flake by embedding the target in the flakeref:

nix run .#flakevuln -- local \
  -f 'github:nix-community/poetry2nix#packages.x86_64-linux.default'

When --flakeref already includes a #target fragment, you can omit the positional target arguments.

Scan a flake from a local subdirectory:

nix run .#flakevuln -- local \
  -f ./services/api \
  packages.x86_64-linux.default

Scan multiple outputs from the current checkout:

nix run .#flakevuln -- local \
  packages.x86_64-linux.default \
  devShells.x86_64-linux.default

Write outputs somewhere else:

nix run .#flakevuln -- local \
  -o out/flakevuln \
  packages.x86_64-linux.default

Common local options

Run the optional unstable comparison:

nix run .#flakevuln -- local \
  --unstable-ref github:NixOS/nixpkgs/nixos-unstable \
  packages.x86_64-linux.default

Use a suppressions CSV:

nix run .#flakevuln -- local \
  --whitelist .github/flakevuln/manual_analysis.csv \
  packages.x86_64-linux.default

Enable best-effort nixprs enrichment:

nix run .#flakevuln -- local \
  --nixprs \
  packages.x86_64-linux.default

Skip selected packages during nixprs enrichment:

nix run .#flakevuln -- local \
  --nixprs \
  --nixprs-exclude-package linux \
  packages.x86_64-linux.default

Enable best-effort Nixpkgs security tracker enrichment:

nix run .#flakevuln -- local \
  --nixtracker \
  packages.x86_64-linux.default

If the flake input you want to re-lock is not named nixpkgs, set it explicitly:

nix run .#flakevuln -- local \
  --input-name my-nixpkgs \
  packages.x86_64-linux.default

Split-phase CLI usage

Use the low-level subcommands when you want to materialize findings first and render reports later:

nix run .#flakevuln -- scan \
  --flakeref . \
  --target packages.x86_64-linux.default \
  --findings findings.json \
  --sarif vulns.sarif \
  --sarif-location flake.nix

nix run .#flakevuln -- report \
  --findings findings.json \
  --outdir report \
  --nixprs

Patch evidence

Reports explain why a finding survived filtering, as an exception rather than as a column on every row. Every target report carries a collapsed Patched and Partially Patched Findings section listing the derivations behind a finding and any patches whose file names mention the vulnerability ID. It holds only the findings the active tables cannot explain by themselves: those hidden because every derivation is patch-matched, and those whose derivations disagree, whose patch metadata could not be read, or whose derivation could not be identified at all. The Step Summary leaves that section out and names the omission instead, so the evidence lives in the complete report.

Findings whose derivations all carry a matching vulnerability-ID patch are hidden from the active tables and listed only in that section. The active section note reports how many were hidden, so the report itself records the omission even after the findings artifact has expired. Findings whose patch evidence needs review stay in the active tables with a (*) in the comment column: either the matched derivations disagree, or their patch evidence could not be established. The note beside the table says how many and where their per-derivation detail is; reports carry no links within themselves, since a link resolves only where the rendered markdown is viewed and the Step Summary does not render every section it would point at.

A matching patch file name is evidence that a fix was applied, not proof, and an absent one is not proof of exposure: a patch named fix-build.patch can carry the same fix without naming the vulnerability.

findings.json keeps the full evidence under schema_version: 2. See doc/component-evidence.md for the schema, the scan-outcome rules, and the trust boundary.

Whitelist format

The whitelist action input and the --whitelist CLI flag both point to a CSV that you keep in your own repository. This repository's dogfood scan keeps its triage in .github/flakevuln/manual_analysis.csv.

Prefer explicit True and False values in the whitelist column:

  • True suppresses a matching finding.
  • False keeps the finding active and only records the accompanying comment.