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:
parent
ead1b97500
commit
a865c34876
63
skills/nix-security-scan/README.md
Normal file
63
skills/nix-security-scan/README.md
Normal 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).
|
||||
163
skills/nix-security-scan/SKILL.md
Normal file
163
skills/nix-security-scan/SKILL.md
Normal 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/)
|
||||
202
skills/nix-security-scan/scripts/scan-flakes.sh
Executable file
202
skills/nix-security-scan/scripts/scan-flakes.sh
Executable 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
|
||||
65
skills/nix-security-scan/scripts/scan-one.sh
Executable file
65
skills/nix-security-scan/scripts/scan-one.sh
Executable 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
|
||||
Loading…
Reference in a new issue