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,sbomnixHTTP 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
| Input | Required | Default | Description |
|---|---|---|---|
targets | yes | - | Newline-delimited flake outputs to scan. |
flakeref | no | . | Flake to scan. . means the checked-out workspace root; a subdirectory also works. |
input-name | no | nixpkgs | Re-lockable input to diff against. |
unstable-ref | no | "" | Optional third scan target, typically github:NixOS/nixpkgs/nixos-unstable. |
whitelist | no | "" | Path to a suppressions CSV in the caller repository. |
nixprs | no | false | Enable best-effort nixpkgs PR enrichment during report rendering. |
nixprs-exclude-packages | no | "" | Whitespace-delimited package names to skip during nixprs enrichment. Findings remain active. |
nixtracker | no | false | Enable best-effort Nixpkgs security tracker enrichment during report rendering. |
token | no | "" | Optional token for nixprs; when empty, the trusted report step falls back to github.token. |
cachix-caches | no | "" | Space-delimited Cachix cache names to add as read-only substituters. |
upload-report | no | true | Upload 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-days | no | 30 | Number of days to retain the uploaded report artifact. |
sarif-location | no | "" | Repository-relative file responsible for the scanned closure. Enables SARIF output for a single target. |
strict-scanner | no | false | Fail closed on degraded scanner data: require vulnxscan's CPE dictionary and grype's database update check on every scan. |
Outputs
| Output | Description |
|---|---|
findings-path | Path to the complete machine-readable findings JSON. |
report-path | Path to the rendered markdown report directory. |
report-artifact-name | Name of the report artifact when upload-report is true. |
report-artifact-url | URL for the uploaded report artifact when upload-report is true. |
sarif | Path 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: readis sufficient for the action. Uploading its SARIF output needssecurity-events: write; private and internal repositories also need GitHub Code Security enabled andactions: read. - Security model: the untrusted
scanphase runs withoutGH_TOKEN; optional GitHub-authenticated enrichment happens later in the trustedreportphase. - 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 namedflakevuln-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-namegives 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.jsonremains available for diagnosis. Setupload-report: falseto disable the action-managed artifact. - The primary findings path is the
findings-pathoutput. 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-locationexposessarifafter 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-dictionaryto 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
grypedatabase - the
vulnixdatabase sbomnix's shared HTTP cache for OSV, repology, and optionalnixprs/nixtrackerlookups- 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_SECONDSfor existing cache state, defaulting to the recovery timeoutVULNIX_PREFLIGHT_RECOVERY_TIMEOUT_SECONDSafter resetting invalid cache state, default900
Local usage
Prerequisites
- Nix with
nix-commandandflakesenabled - Linux
GH_TOKENin the environment if you want higher GitHub API rate limits for--nixprslookups; without it,flakevulnfalls 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.jsonreport/README.mdreport/*.mdfor 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:
Truesuppresses a matching finding.Falsekeeps the finding active and only records the accompanying comment.