#!/usr/bin/env bash # Pre-commit hook: block staging of text files containing Swedish personnummer patterns. # Installed via: git config core.hooksPath .git-hooks # # WHAT IT SCANS: text files with these extensions staged for commit: # .py .txt .md .json .csv .yaml .yml .toml .sh # # WHAT IT DOES NOT SCAN: image files (PNG, JPG, etc.). # Image redaction is a MANUAL VISUAL gate — verify PNG/JPG files by eye before committing. # # Pattern covers: # 10-digit: YYMMDD-XXXX (e.g., 901215-1234) # 12-digit: YYYYMMDD-XXXX (e.g., 19901215-1234) # Plus-separator (100+ years): YYMMDD+XXXX # Samordningsnummer: same formats; day-part 61-91 is not distinguished (any match blocked) set -euo pipefail # Patterns (regex alternation): # [0-9]{6}[+-][0-9]{4} — 10-digit form with hyphen or plus # [0-9]{8}-[0-9]{4} — 12-digit full-year form with hyphen PATTERN='[0-9]{6}[+-][0-9]{4}|[0-9]{8}-[0-9]{4}' # Get staged text files (added, copied, modified, renamed — not deleted) staged_files=$(git diff --cached --name-only --diff-filter=ACMR 2>/dev/null | \ grep -E '\.(py|txt|md|json|csv|yaml|yml|toml|sh)$' || true) if [ -z "$staged_files" ]; then exit 0 fi # Determine scan backend: prefer grep -P (PCRE); fall back to python3 if unavailable. # macOS BSD grep may not support -P; the fallback ensures portability. # # The probe MUST actually exercise a digit-dash-digit match, or it silently # always-fails and never selects grep. We build the probe digits at runtime via # printf so this hook file never contains a literal personnummer (which would # otherwise self-trip the hook when the hook itself is committed). _grep_supports_P() { local six="123456" four="7890" printf '%s-%s\n' "$six" "$four" | grep -qP '[0-9]{6}-[0-9]{4}' 2>/dev/null } # Scan convention (MUST match _scan_file_python): exit 0 = CLEAN (no PII), # exit non-zero = PII FOUND. `grep -qP` exits 0 when it FINDS a match, so it is # negated here to align the two backends — otherwise the loop guard below would # be inverted for the grep path (the CR-01 bug). _scan_file_grep() { local f="$1" ! grep -qP "$PATTERN" "$f" 2>/dev/null } _scan_file_python() { local f="$1" python3 - "$f" <<'PYEOF' import re, sys pattern = re.compile(r'[0-9]{6}[+-][0-9]{4}|[0-9]{8}-[0-9]{4}') with open(sys.argv[1], errors='replace') as fh: sys.exit(0 if not pattern.search(fh.read()) else 1) PYEOF } _get_matches_grep() { local f="$1" grep -nP "$PATTERN" "$f" 2>/dev/null | head -3 || true } _get_matches_python() { local f="$1" python3 - "$f" <<'PYEOF' import re, sys pattern = re.compile(r'[0-9]{6}[+-][0-9]{4}|[0-9]{8}-[0-9]{4}') with open(sys.argv[1], errors='replace') as fh: for i, line in enumerate(fh, 1): if pattern.search(line): print(f"{i}:{line.rstrip()}") PYEOF } # Select backend once if _grep_supports_P; then SCAN_CMD=_scan_file_grep MATCH_CMD=_get_matches_grep else SCAN_CMD=_scan_file_python MATCH_CMD=_get_matches_python fi found=0 while IFS= read -r file; do if [ -f "$file" ] && ! $SCAN_CMD "$file"; then echo "ERROR: Possible personnummer found in: $file" $MATCH_CMD "$file" found=1 fi done <<< "$staged_files" if [ "$found" -eq 1 ]; then echo "" echo "COMMIT BLOCKED: Remove or redact personnummer patterns before committing." echo "Pattern matched: YYMMDD-XXXX, YYYYMMDD-XXXX, or YYMMDD+XXXX" echo "" echo "NOTE: Image files (PNG/JPG) are NOT scanned — verify PNG/JPG redaction visually." echo " Real letters must live in data/letters/private/ (gitignored), never in public/." exit 1 fi exit 0