feat(nix-security-scan): add CVE scanning skill for Nix flakes

- scan-one.sh: scan single flake with sbomnix + grype
- scan-flakes.sh: batch scan all flakes in directory
- SKILL.md: full documentation with fix strategies
- README.md: quick start guide

Pipeline: sbomnix (SBOM) → grype (CVE scan) → markdown report

Ref: skills-rg9m
This commit is contained in:
dan 2026-01-26 10:08:04 -08:00
parent ead1b97500
commit a865c34876
4 changed files with 493 additions and 0 deletions

View file

@ -0,0 +1,63 @@
# nix-security-scan
Scan Nix flakes for known CVEs using SBOM generation and vulnerability scanning.
## Installation
The skill requires these tools (all in nixpkgs):
```bash
nix-shell -p sbomnix grype jq
```
Or add to your system packages.
## Usage
```bash
# Scan current project
./scripts/scan-one.sh .
# Scan a specific flake
./scripts/scan-one.sh ~/proj/dotfiles
# For NixOS, specify the full system path
./scripts/scan-one.sh "~/proj/dotfiles#nixosConfigurations.hostname.config.system.build.toplevel"
# Scan all projects in a directory
./scripts/scan-flakes.sh
```
## How it works
1. **Generate SBOM** - Uses `sbomnix` to create a CycloneDX bill of materials from Nix store paths
2. **Scan for CVEs** - Uses `grype` to match packages against vulnerability databases
3. **Report** - Generates summary with critical/high vulnerabilities
## Example output
```
=== Results ===
Total vulnerabilities: 174
Critical: 12
High: 68
Medium: 83
Low: 11
=== Critical/High Details ===
Critical CVE-2024-5197 libvpx 1.12.0
Critical CVE-2025-48174 libavif 0.9.3
High CVE-2025-66476 vim 9.1.1918
...
```
## Fixing issues
Most vulnerabilities can be fixed by updating nixpkgs:
```bash
nix flake update nixpkgs
sudo nixos-rebuild switch --flake .
```
See SKILL.md for advanced options (overlays, patches).

View file

@ -0,0 +1,163 @@
---
name: nix-security-scan
description: Scan Nix flakes for known CVEs using SBOM generation and vulnerability scanning
---
# Nix Security Scan
Scan Nix flakes for known security vulnerabilities using sbomnix + grype.
## When to Use
- Daily automated security monitoring
- Before deploying a NixOS configuration
- After updating flake.lock to verify fixes
- When auditing a project's dependencies
## Prerequisites
- `sbomnix` - SBOM generator for Nix
- `grype` - Vulnerability scanner
- `jq` - JSON processing
All available via `nix-shell -p sbomnix grype jq` or in nixpkgs.
## Quick Start
### Scan a single project
```bash
# Scan current directory's flake
./scripts/scan-one.sh .
# Scan a specific flake
./scripts/scan-one.sh ~/proj/dotfiles
# For NixOS configs, specify the full path
./scripts/scan-one.sh ~/proj/dotfiles#nixosConfigurations.hostname.config.system.build.toplevel
```
### Scan all projects
```bash
# Scan all flakes in ~/proj
./scripts/scan-flakes.sh
# Customize
PROJECTS_DIR=~/work NOTIFY=false ./scripts/scan-flakes.sh
```
## Understanding Results
### What gets scanned
- **Runtime dependencies** - packages that end up in the final closure
- **Not buildtime** - compilers, build tools are excluded by default
### Severity levels
| Severity | Action |
|----------|--------|
| Critical | Address immediately |
| High | Address soon |
| Medium | Monitor, address when convenient |
| Low | Informational |
### Common false positives
- **shellcheck CVE-2021-28794** - VSCode extension vulnerability, not the CLI
- **Ancient mujs CVEs** - Often disputed or not applicable
- **Version mismatches** - Scanner may not recognize Nix-patched versions
## Fixing Vulnerabilities
### Option 1: Update nixpkgs (easiest)
```bash
cd ~/proj/your-flake
nix flake update nixpkgs
# Then rebuild to apply
```
### Option 2: Override specific package
```nix
# In your flake.nix or overlay
{
nixpkgs.overlays = [
(final: prev: {
libfoo = prev.libfoo.overrideAttrs (old: {
version = "1.2.3";
src = prev.fetchurl { ... };
});
})
];
}
```
### Option 3: Apply patch
```nix
{
nixpkgs.overlays = [
(final: prev: {
libfoo = prev.libfoo.overrideAttrs (old: {
patches = (old.patches or []) ++ [
(prev.fetchpatch {
url = "https://github.com/.../commit/abc123.patch";
hash = "sha256-...";
})
];
});
})
];
}
```
## Automation
### systemd timer (recommended)
Add to your NixOS or home-manager config:
```nix
systemd.user.services.nix-security-scan = {
description = "Daily Nix security scan";
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.writeShellScript "nix-security-scan" ''
export PATH="${pkgs.sbomnix}/bin:${pkgs.grype}/bin:${pkgs.jq}/bin:$PATH"
${./scripts/scan-flakes.sh}
''}";
};
};
systemd.user.timers.nix-security-scan = {
wantedBy = ["timers.target"];
timerConfig = {
OnCalendar = "daily";
Persistent = true;
};
};
```
## Output
Reports are written to `~/.local/share/nix-security-scan/`:
- `<project>.sbom.cdx.json` - CycloneDX SBOM
- `<project>.vulns.json` - Grype vulnerability scan results
- `report-YYYY-MM-DD.md` - Human-readable summary
## Limitations
1. **Scans built derivations** - Must rebuild after flake update to see fixes
2. **Name-based matching** - Some false positives from package name collisions
3. **No auto-fix** - Manual intervention required for patches/overrides
4. **Slow on large closures** - NixOS systems have thousands of components
## See Also
- [sbomnix documentation](https://github.com/tiiuae/sbomnix)
- [grype documentation](https://github.com/anchore/grype)
- [Nix security tracker](https://nixpkgs.tracker.nix.systems/)

View file

@ -0,0 +1,202 @@
#!/usr/bin/env bash
set -euo pipefail
# nix-security-scan: Daily scanner for CVEs in Nix flakes
# Scans all flakes in PROJECTS_DIR, reports vulnerabilities
PROJECTS_DIR="${PROJECTS_DIR:-$HOME/proj}"
CACHE_DIR="${CACHE_DIR:-$HOME/.local/share/nix-security-scan}"
SEVERITY_FILTER="${SEVERITY_FILTER:-Critical,High}"
NOTIFY="${NOTIFY:-true}"
VERBOSE="${VERBOSE:-false}"
HOSTNAME="${HOSTNAME:-$(hostname)}"
# Ensure cache directory exists
mkdir -p "$CACHE_DIR"
# Logging
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
debug() {
[[ "$VERBOSE" == "true" ]] && echo "[DEBUG] $*" >&2 || true
}
# Check dependencies
for cmd in sbomnix grype jq nix; do
if ! command -v "$cmd" &>/dev/null; then
echo "Error: $cmd required but not found" >&2
exit 1
fi
done
# Determine what to scan in a flake
# Returns the flake reference to use
get_flake_target() {
local project="$1"
local project_name="$2"
# Check if it's a NixOS config (has nixosConfigurations)
if nix flake show "$project" 2>/dev/null | grep -q "nixosConfigurations"; then
# Try current hostname first, then any available
if nix eval "${project}#nixosConfigurations.${HOSTNAME}" &>/dev/null; then
echo "${project}#nixosConfigurations.${HOSTNAME}.config.system.build.toplevel"
return
fi
# Get first available nixosConfiguration
local first_config
first_config=$(nix flake show "$project" --json 2>/dev/null | jq -r '.nixosConfigurations | keys[0] // empty')
if [[ -n "$first_config" ]]; then
echo "${project}#nixosConfigurations.${first_config}.config.system.build.toplevel"
return
fi
fi
# Check for packages.default or packages.x86_64-linux.default
if nix eval "${project}#packages.x86_64-linux.default" &>/dev/null 2>&1; then
echo "${project}#packages.x86_64-linux.default"
return
fi
# Fall back to default output
echo "$project"
}
# Track totals
total_projects=0
total_vulns=0
affected_projects=()
skipped_projects=()
log "Starting security scan of flakes in $PROJECTS_DIR"
# Scan each project with a flake.lock
for project in "$PROJECTS_DIR"/*/; do
project_name=$(basename "$project")
# Skip if no flake.lock
[[ -f "$project/flake.lock" ]] || continue
((total_projects++)) || true
log "Scanning: $project_name"
# Determine what to scan
flake_target=$(get_flake_target "$project" "$project_name" 2>/dev/null) || {
log " Skipping: couldn't determine flake target"
skipped_projects+=("$project_name")
continue
}
debug "Flake target: $flake_target"
# Generate SBOM
sbom_file="$CACHE_DIR/${project_name}.sbom.cdx.json"
pushd "$CACHE_DIR" > /dev/null
if ! sbomnix "$flake_target" --cdx "${project_name}.sbom.cdx.json" 2>&1 | grep -v "^INFO"; then
log " Warning: Failed to generate SBOM"
skipped_projects+=("$project_name")
popd > /dev/null
continue
fi
popd > /dev/null
# Check SBOM was created
if [[ ! -f "$sbom_file" ]]; then
log " Warning: SBOM file not created"
skipped_projects+=("$project_name")
continue
fi
# Scan for vulnerabilities
vuln_file="$CACHE_DIR/${project_name}.vulns.json"
if ! grype "sbom:$sbom_file" -o json 2>/dev/null > "$vuln_file"; then
log " Warning: Failed to scan"
skipped_projects+=("$project_name")
continue
fi
# Count by severity
critical=$(jq '[.matches[] | select(.vulnerability.severity == "Critical")] | length' "$vuln_file")
high=$(jq '[.matches[] | select(.vulnerability.severity == "High")] | length' "$vuln_file")
medium=$(jq '[.matches[] | select(.vulnerability.severity == "Medium")] | length' "$vuln_file")
if [[ $critical -gt 0 || $high -gt 0 ]]; then
log " ⚠️ $critical critical, $high high, $medium medium"
affected_projects+=("$project_name:$critical:$high")
((total_vulns += critical + high)) || true
else
log " ✓ clean (or medium/low only)"
fi
done
# Summary
log ""
log "=== Summary ==="
log "Projects scanned: $total_projects"
log "Projects skipped: ${#skipped_projects[@]}"
log "Critical/High vulnerabilities: $total_vulns"
log "Affected projects: ${#affected_projects[@]}"
# Desktop notification if vulnerabilities found
if [[ "$NOTIFY" == "true" && ${#affected_projects[@]} -gt 0 ]]; then
if command -v notify-send &>/dev/null; then
notify-send -u normal "Nix Security Scan" \
"${#affected_projects[@]} projects have vulnerabilities ($total_vulns critical/high)"
fi
fi
# Generate report
report_file="$CACHE_DIR/report-$(date '+%Y-%m-%d').md"
{
echo "# Nix Security Scan Report"
echo ""
echo "**Date:** $(date '+%Y-%m-%d %H:%M')"
echo "**Projects scanned:** $total_projects"
echo "**Projects skipped:** ${#skipped_projects[@]}"
echo "**Affected projects:** ${#affected_projects[@]}"
echo ""
if [[ ${#affected_projects[@]} -gt 0 ]]; then
echo "## Affected Projects"
echo ""
echo "| Project | Critical | High |"
echo "|---------|----------|------|"
for entry in "${affected_projects[@]}"; do
IFS=':' read -r name crit hi <<< "$entry"
echo "| $name | $crit | $hi |"
done
echo ""
echo "## Details"
echo ""
for entry in "${affected_projects[@]}"; do
IFS=':' read -r name _ _ <<< "$entry"
echo "### $name"
echo ""
echo '```'
jq -r '.matches[] | select(.vulnerability.severity == "Critical" or .vulnerability.severity == "High") | "\(.vulnerability.severity): \(.vulnerability.id) - \(.artifact.name) \(.artifact.version)"' \
"$CACHE_DIR/${name}.vulns.json" 2>/dev/null | sort -u | head -20
echo '```'
echo ""
done
else
echo "✅ No critical or high vulnerabilities found."
fi
if [[ ${#skipped_projects[@]} -gt 0 ]]; then
echo "## Skipped Projects"
echo ""
for name in "${skipped_projects[@]}"; do
echo "- $name"
done
fi
} > "$report_file"
log "Report written to: $report_file"
# Exit with status based on findings
if [[ ${#affected_projects[@]} -gt 0 ]]; then
exit 1
else
exit 0
fi

View file

@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
# scan-one: Scan a single flake for vulnerabilities
# Usage: scan-one.sh <flake-path-or-ref> [output-dir]
FLAKE_REF="${1:-.}"
OUTPUT_DIR="${2:-$HOME/.local/share/nix-security-scan}"
mkdir -p "$OUTPUT_DIR"
# Derive a name from the flake ref
if [[ "$FLAKE_REF" == "." ]]; then
NAME=$(basename "$PWD")
else
NAME=$(basename "$FLAKE_REF")
fi
echo "Scanning: $FLAKE_REF"
echo "Output: $OUTPUT_DIR"
echo ""
# Generate SBOM
echo "==> Generating SBOM..."
cd "$OUTPUT_DIR"
sbomnix "$FLAKE_REF" --cdx "${NAME}.sbom.cdx.json" 2>&1 | grep -E "^(INFO|ERROR)" || true
if [[ ! -f "${NAME}.sbom.cdx.json" ]]; then
echo "Error: SBOM generation failed" >&2
exit 1
fi
COMPONENTS=$(jq '.components | length' "${NAME}.sbom.cdx.json")
echo " Components: $COMPONENTS"
echo ""
# Scan for vulnerabilities
echo "==> Scanning for vulnerabilities..."
grype "sbom:${NAME}.sbom.cdx.json" -o json 2>/dev/null > "${NAME}.vulns.json"
# Summary
TOTAL=$(jq '.matches | length' "${NAME}.vulns.json")
CRITICAL=$(jq '[.matches[] | select(.vulnerability.severity == "Critical")] | length' "${NAME}.vulns.json")
HIGH=$(jq '[.matches[] | select(.vulnerability.severity == "High")] | length' "${NAME}.vulns.json")
MEDIUM=$(jq '[.matches[] | select(.vulnerability.severity == "Medium")] | length' "${NAME}.vulns.json")
LOW=$(jq '[.matches[] | select(.vulnerability.severity == "Low")] | length' "${NAME}.vulns.json")
echo ""
echo "=== Results ==="
echo "Total vulnerabilities: $TOTAL"
echo " Critical: $CRITICAL"
echo " High: $HIGH"
echo " Medium: $MEDIUM"
echo " Low: $LOW"
echo ""
if [[ $CRITICAL -gt 0 || $HIGH -gt 0 ]]; then
echo "=== Critical/High Details ==="
jq -r '.matches[] | select(.vulnerability.severity == "Critical" or .vulnerability.severity == "High") | "\(.vulnerability.severity)\t\(.vulnerability.id)\t\(.artifact.name) \(.artifact.version)"' \
"${NAME}.vulns.json" | sort -u | column -t
exit 1
fi
echo "✅ No critical or high vulnerabilities found."
exit 0