泄漏扫描与 Git guardrails 的闭环 深度解析
这个子页面拆安全面:secret-scan 看凭据形状,leak-gate 看人员和环境泄漏,install-git-hooks 把它们挂到 Git 生命周期。它们不是形式化安全证明,但能把最常见的公开仓库事故挡在提交前。
导入链与契约
安全规则在 core 里写得很硬:core/20-principle-rules.md:54、core/20-principle-rules.md:60、core/20-principle-rules.md:64 规定 live secret 不进入 tracked file。脚本层用 scripts/secret-scan.sh:21、scripts/leak-gate.sh:97、scripts/install-git-hooks.sh:56 把这个规则落成扫描和 hook;这也呼应 core/20-principle-rules.md:65、core/20-principle-rules.md:66 的态度:发现 secret 要修复和清史,而不是用 allowlist 糊过去。
20# Generic high-signal secret patterns (shapes, not values). 21PATTERNS=( 22 'BEGIN [A-Z ]*PRIVATE KEY' # PEM private keys (RSA/OPENSSH/EC/bare/ENCRYPTED) 23 'AKIA[0-9A-Z]{16}' # AWS access key id 24 'ASIA[0-9A-Z]{16}' # AWS temp key id 25 'gh[pousr]_[A-Za-z0-9]{20,}' # GitHub tokens 26 'xox[baprs]-[A-Za-z0-9-]{10,}' # Slack tokens 27 'sk-[A-Za-z0-9]{20,}' # OpenAI-style keys 28 'sk-ant-[A-Za-z0-9_-]{20,}' # Anthropic-style keys 29 'AIza[0-9A-Za-z_-]{35}' # Google API key 30 '(password|passwd|pwd|secret|token|api[_-]?key)["'"'"' ]*[:=][[:space:]]*["'"'"'][^"'"'"' ]{8,}' # assigned secret literal 31) 32 33# Patterns go to a temp file so the whole scan is ONE grep pass (-f ORs every line of the file). 34# Matching per-line in a shell loop costs a subprocess per line per pattern — imperceptible on 35# Linux (~1ms spawns), minutes on Windows/Git Bash (~30ms). This script runs as a pre-commit 36# hook, and a hook slow enough to be disabled is a guardrail that isn't there (R8). 37PATTERN_FILE="$(mktemp)" 38trap 'rm -f "$PATTERN_FILE"' EXIT 39printf '%s\n' "${PATTERNS[@]}" > "$PATTERN_FILE"
核心实现,逐段拆
▸Secret scanner 的单 grep 策略
`secret-scan` 先把所有高信号 pattern 写进临时文件,再做一次 grep。scripts/secret-scan.sh:21、scripts/secret-scan.sh:37、scripts/secret-scan.sh:41、scripts/secret-scan.sh:65、scripts/secret-scan.sh:77 说明它为 pre-commit 性能做过取舍:一次扫描比逐行逐 pattern spawn 更适合 hooks。它不是单独存在的工具,而是在执行 core/20-principle-rules.md:54、core/20-principle-rules.md:56、core/20-principle-rules.md:57 的 no-secrets 规则。
41input_lines() { 42 if [ "${1:-}" = "--all" ]; then 43 # -n 64 batches files per shell instead of -I{} spawning one shell PER FILE. The trailing 44 # newline keeps a file that lacks one from welding its last line onto the next file's first. 45 git -C "$ROOT_DIR" ls-files -z \ 46 | xargs -0 -n 64 sh -c 'for f in "$@"; do cat -- "$f"; printf "\n"; done' _ 2>/dev/null 47 elif [ "${1:-}" = "-" ]; then 48 cat 49 elif [ $# -gt 0 ]; then 50 cat "$@" 51 else 52 # staged diff: only ADDED lines (strip the leading +). The `|| true` keeps an empty or 53 # deletion-only staged diff from tripping `set -o pipefail` (grep exits 1 on no match), 54 # which previously produced a false BLOCKED when there was nothing to scan. 55 { git -C "$ROOT_DIR" diff --cached --no-color -U0 2>/dev/null | grep -E '^\+' | grep -Ev '^\+\+\+' | sed 's/^+//'; } || true 56 fi 57} 58 59# Drop allowlisted lines. Skipped entirely when the allowlist is absent or empty, so an empty 60# file can't turn into "match nothing / invert / drop everything" on a stricter grep. 61allow_filter() { 62 if [ -s "$ALLOW" ]; then grep -Evf "$ALLOW"; else cat; fi 63} 64 65scan() { 66 local hits line 67 # Blank lines out, allowlisted lines out, then every pattern matched in a single pass. 68 # `|| true` absorbs grep's exit-1-on-no-match, which pipefail would otherwise treat as failure. 69 hits="$(grep -Ev '^[[:space:]]*$' | allow_filter | grep -E -f "$PATTERN_FILE" || true)" 70 [ -n "$hits" ] || return 0 71 while IFS= read -r line; do 72 echo " ✗ possible secret: $(echo "$line" | cut -c1-100)" 73 done <<<"$hits" 74 return 1 75} 76 77if input_lines "$@" | scan; then 78 echo "secret-scan: clean." 79 exit 0 80else 81 echo 82 echo "secret-scan: BLOCKED — a line looks like a live secret (Rule 8)." 83 echo "Move it to env/secret-manager, or (only for a genuine test fixture) add a regex to .harness/secret-scan.allow." 84 exit 1 85fi
▸Leak gate 为什么单独存在
`leak-gate` 补的是 secret shape 之外的环境泄漏。scripts/leak-gate.sh:31、scripts/leak-gate.sh:63、scripts/leak-gate.sh:73、scripts/leak-gate.sh:104、scripts/leak-gate.sh:110、scripts/leak-gate.sh:116 显示它显式处理姓名、IP、邮箱和 home path,同时拒绝把内部 hostname denylist 写进公开仓库。它还承担 core/20-principle-rules.md:68、core/20-principle-rules.md:72 的 research durability 约束:公开 surface 应该可分享,私有材料不能混进发布物。
73# check <label> <extended-regex> <exempt-regex|""> <hint> 74# Findings print as file:line:match, so <exempt-regex> can anchor on the match with $. 75check() { 76 local label="$1" ere="$2" exempt="$3" hint="$4" hits line 77 # -I skips binaries; -o prints just the match so exemptions test the match, not the whole 78 # line (a line holding both a safe and an unsafe value must still fail). 79 # `|| true` absorbs grep's exit-1-on-no-match, which pipefail would treat as a hard error. 80 hits="$(git grep -noIiE -- "$ere" -- . "${SCOPE[@]+"${SCOPE[@]}"}" 2>/dev/null || true)" 81 [ -n "$hits" ] || return 0 82 if [ -n "$exempt" ]; then 83 hits="$(printf '%s\n' "$hits" | grep -Evi "$exempt" || true)" 84 [ -n "$hits" ] || return 0 85 fi 86 hits="$(printf '%s\n' "$hits" | allow_filter || true)" 87 [ -n "$hits" ] || return 0 88 echo " ✗ $label — $hint" 89 while IFS= read -r line; do 90 echo " $line" 91 done <<<"$hits" 92 fail=1 93} 94 95# 1. The operator's identity. The evidenced failure: --operator-name "<author>" sat in the 96# --help of both setup scripts, so every open-source user read a stranger's name. 97check "operator identity" \ 98 "\\b(${TERMS})\\b" \ 99 "" \ 100 "a specific person's name — use the repo's placeholder convention (\"Your Name\", \"You\", \"you/...\")" 101 102# 2. Routable IPv4. Loopback/private/link-local/broadcast and the RFC 5737 documentation 103# ranges are legitimate in docs and examples, so only a real, routable address fails. 104check "public IP address" \ 105 '\b([0-9]{1,3}\.){3}[0-9]{1,3}\b' \ 106 ':(0\.0\.0\.0|127\.[0-9.]+|10\.[0-9.]+|192\.168\.[0-9.]+|169\.254\.[0-9.]+|172\.(1[6-9]|2[0-9]|3[01])\.[0-9.]+|255\.255\.[0-9.]+|192\.0\.2\.[0-9]+|198\.51\.100\.[0-9]+|203\.0\.113\.[0-9]+)$' \ 107 "a routable IP pins this to one environment — use a private/documentation range (192.0.2.x)" 108 109# 3. Real email addresses. example.com/.org/.net are the reserved documentation domains. 110check "email address" \ 111 '\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b' \ 112 ':(noreply@|[A-Za-z0-9._%+-]+@(example\.(com|org|net)|host|localhost|domain\.com))' \ 113 "a real address — use user@example.com" 114 115# 4. Absolute home directories. These pin a path to one machine and one username. 116check "absolute home path" \ 117 '([A-Za-z]:\\Users\\[A-Za-z0-9._-]+|/home/[A-Za-z0-9._-]+|/Users/[A-Za-z0-9._-]+)' \ 118 ':([A-Za-z]:\\Users\\(you|your-name|username|user|name)|/(home|Users)/(you|your-name|username|user|me|name))$' \ 119 "a machine-specific path — use \$HOME or ~/" 120 121echo 122if [ "$fail" -eq 0 ]; then 123 echo "leak-gate: clean — the shipped surface names no operator and no environment." 124 exit 0 125fi 126echo "leak-gate: BLOCKED — the shipped surface leaks something specific to one operator/environment." 127echo "Replace it with the placeholder convention this repo already uses, or (only for a genuine" 128echo "false positive) add a regex to .harness/leak-gate.allow." 129exit 1
关键流程
▸Hook 写入如何避免覆盖用户脚本
hook writer 的关键在 scripts/install-git-hooks.sh:35、scripts/install-git-hooks.sh:45、scripts/install-git-hooks.sh:56、scripts/install-git-hooks.sh:64、scripts/install-git-hooks.sh:67:它备份 foreign hook,只写带 marker 的 managed hook,并按 flag 控制 pre-commit、commit-msg 和 pre-push。这个选择也符合 core/20-principle-rules.md:8、core/20-principle-rules.md:31、core/20-principle-rules.md:78:先理解已有东西、保持原子改动、控制工具表面积。
边界 / 失败模式
scripts/secret-scan.sh:30的 assigned-secret 正则高信号但不可能覆盖所有语言和配置格式。scripts/leak-gate.sh:36解释了为什么不能把客户名清单提交进公开扫描器。scripts/install-git-hooks.sh:13允许 `--no-verify` 单次绕过,所以团队仍需要 code review 和 CI。