diff --git a/skills/nix-security-scan/README.md b/skills/nix-security-scan/README.md new file mode 100644 index 0000000..b4d4e6f --- /dev/null +++ b/skills/nix-security-scan/README.md @@ -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). diff --git a/skills/nix-security-scan/SKILL.md b/skills/nix-security-scan/SKILL.md new file mode 100644 index 0000000..0860891 --- /dev/null +++ b/skills/nix-security-scan/SKILL.md @@ -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/`: + +- `.sbom.cdx.json` - CycloneDX SBOM +- `.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/) diff --git a/skills/nix-security-scan/scripts/scan-flakes.sh b/skills/nix-security-scan/scripts/scan-flakes.sh new file mode 100755 index 0000000..cb67a92 --- /dev/null +++ b/skills/nix-security-scan/scripts/scan-flakes.sh @@ -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 diff --git a/skills/nix-security-scan/scripts/scan-one.sh b/skills/nix-security-scan/scripts/scan-one.sh new file mode 100755 index 0000000..3bfa0bc --- /dev/null +++ b/skills/nix-security-scan/scripts/scan-one.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +# scan-one: Scan a single flake for vulnerabilities +# Usage: scan-one.sh [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