用 Ollama 在 pre-commit 阶段作为第二层验证,解决 regex 假阳性问题,同时确保密钥数据不离开本机。
我的正则 secret scanner 曾经阻止过一次 commit,原因是某个测试文件中包含字符串 sk_test_EXAMPLE_KEY_DO_NOT_USE。同一周,另一个项目的一位同事把真实的 Etherscan API key 写进了一个硬编码 URL 并提交,而 scanner 没有发现,因为它不符合任何已知的 key 格式。这两次失败恰好概括了基于正则的 secret scanning:不重要的地方警报不断,真正重要的地方却悄无声息。
标准解决方案是维护一个永远只增不减的 allowlist 文件,再加上一群已经形成肌肉记忆、随手就会输入 git commit --no-verify 的开发者。一旦大家习惯性绕过 hook,scanner 就只剩装饰作用了。
于是我尝试了另一种方式:保留 regex scanner,但加入一个小型本地 LLM,让它提供第二意见。regex 阶段负责判断哪些内容值得检查,模型负责判断它究竟是不是真正的 secret。只有被标记的文件才会交给模型,因此 hook 依然很快;而且模型是运行在我自己机器上的 Ollama,所以 staged diff 永远不会离开我的笔记本电脑。最后这一点对我来说没有商量余地——把可能包含 secret 的 diff 发送给云端 API,让它帮你检查其中有没有 secret,这简直是一个自己就能写完的笑话。
Stage 1 会对 staged changes 执行一次刻意偏谨慎的正则扫描:高熵字符串、已知的 key 前缀、PRIVATE KEY 区块以及可疑的变量名。Stage 2 会把每个被标记的 hunk 连同前后几行上下文发送给通过 Ollama 运行的 qwen2.5-coder,并附上一段分类 prompt。判定为 SECRET 就阻止 commit,判定为 FALSE_POSITIVE 就允许通过。绝大多数 commit 根本不会触发 Stage 1,因此完全没有额外延迟。
.git/hooks/pre-commit(或者通过你选择的 hook manager 接入):
#!/usr/bin/env bash
set -euo pipefail
MODEL="${SECRET_HOOK_MODEL:-qwen2.5-coder:7b}"
# Stage 1: cheap and paranoid. Wide patterns, we WANT false positives here.
PATTERNS=(
'AKIA[0-9A-Z]{16}' # AWS access key
'-----BEGIN( RSA| EC| OPENSSH)? PRIVATE KEY-----'
'(api[_-]?key|secret|token|passwd|password)["'"'"']?\s*[:=]\s*["'"'"'][^"'"'"']{16,}'
'0x[a-fA-F0-9]{64}' # possible EVM private key
'[A-Za-z0-9+/]{40,}={0,2}' # high-entropy base64-ish
)
flagged=()
while IFS= read -r file; do
[[ -f "$file" ]] || continue
for p in "${PATTERNS[@]}"; do
if git show ":$file" | grep -qE "$p"; then
flagged+=("$file")
break
fi
done
done < <(git diff --cached --name-only --diff-filter=ACM)
[[ ${#flagged[@]} -eq 0 ]] && exit 0 # fast path: nothing suspicious
# Stage 2: ask the local model about each flagged file's staged content.
block=0
for file in "${flagged[@]}"; do
verdict=$(git show ":$file" | ollama run "$MODEL" "$(cat <<'PROMPT'
You review a file staged for a git commit. Decide if it contains a REAL
credential that must not be committed.
REAL secrets: live API keys, private keys (including 0x-prefixed 64-hex
EVM keys), tokens, passwords, connection strings with embedded passwords.
NOT secrets: placeholders (YOUR_KEY_HERE, xxx, changeme), documented
example keys, test fixtures clearly labeled as fake, public addresses,
hashes of public data, template variables like ${API_KEY}, lockfile
integrity hashes.
First line of your answer must be exactly SECRET or FALSE_POSITIVE.
Second line: one short reason.
PROMPT
)")
if [[ "$verdict" == SECRET* ]]; then
echo "BLOCKED: $file"
echo "$verdict" | sed -n '2p' | sed 's/^/ reason: /'
block=1
fi
done
if [[ $block -eq 1 ]]; then
echo ""
echo "Commit blocked. If this is wrong, re-run with SECRET_HOOK_MODEL"
echo "set to a bigger model, or use --no-verify and accept the risk."
exit 1
fi
exit 0
这里面有两个细节,比看上去重要得多。
扫描 staged content,而不是 working tree。git show ":$file" 读取的是 index。如果扫描磁盘上的文件,你可能会因为尚未 staged 的临时内容而阻止 commit;同时也会漏掉另一种情况:secret 已经 staged,但在 working copy 中已被删除。
exit code contract 就是完整的接口。exit 0 表示允许 commit,exit 1 表示阻止 commit,而模型输出的自由文本本身永远不能决定任何事情。我只解析第一行,并要求它必须是两个 token 之一。小型本地模型偶尔会输出一整段含糊其词的内容,强制它在第一行给出 machine-readable 结果,才是让这类模型能够用于 pipeline 的关键。
注意,这段 prompt 用在说明什么“不是 secret”上的文字,比说明什么“是 secret”还要多。这是刻意为之。regex 阶段已经确保模型看到的所有内容都像是 secret,因此模型真正的任务,是识别 placeholder、fixture 和 template。采用这种方式描述任务之后,被误判并阻止的情况大幅减少。我在构建 spectr-ai 时发现了这个模式:相比开放式地询问“这危险吗?”,明确告诉小型模型应该排除哪些内容,它们的表现要好得多。
对于 Web3 开发者,0x 加 64 个十六进制字符的 pattern 值得单独说明。交易 hash、storage slot 和 private key 在正则眼里完全一样。模型则可以结合变量名和周围代码,区分 DEPLOYER_PRIVATE_KEY = 0x... 与 KNOWN_TX_HASH = 0x...。仅仅是这一项区分,就贡献了这个 hook 为我带来的大部分价值,因为 regex scanner 要么会把区块链代码库中的每一个 32-byte 十六进制值都标记出来——令人无法忍受;要么一个都不标记——毫无用处。
延迟方面,在我的机器上(WSL2、中端 GPU),模型完成预热后,使用 7b 模型检查一个被标记的文件大约需要两到四秒;如果 Ollama 还需要先加载模型,耗时会更长。干净的 commit 不会付出任何时间成本,因为 Stage 1 会直接 short-circuit。涉及 .env.example 或测试 fixture 的 commit 则需要多花几秒。对我来说这可以接受,但你未必这么认为。如果你的团队每小时要 commit 四十次,应该让模型阶段保持 async,或者只提供 advisory。
关于 1.5b 与 7b,我最初尝试的是 qwen2.5-coder:1.5b,因为它几乎能立刻响应。但它太热衷于迎合要求了:即使是明显虚假的 fixture,也经常被标记为 SECRET。长此以往,我迟早会开始绕过自己的 hook,这就完全违背了它的初衷。7b 在理解“这个文件位于 tests/fixtures/ 中,而且变量名是 FAKE_KEY”之类的上下文时,表现明显更好。这项任务很少需要运行模型,所以我愿意为更大的模型付出额外成本。
它仍然可能在两个方向上出错。一个本地 7b 模型并不是 security boundary。格式奇怪的真实 key 仍可能漏网,因此这个 hook 只是对平台端扫描能力(GitHub push protection 等)的补充,而不是替代。它真正修复的是人为环节:这个 hook 很少发出警报,所以当它真的报警时,我会停下来认真查看,而不是条件反射般地去输入 --no-verify。一个大家信任并愿意遵守的 scanner,胜过一个更严格、却被所有人绕过的 scanner。
关于确定性,LLM 对边界输入的判定可能会在不同运行之间发生变化。对于一个会阻止 commit 的 hook,我可以接受这一点,因为边界案例恰恰就是我希望人类重新检查的内容。
到目前为止,我已经运行这套方案几个月了。regex 阶段每周会触发几次,模型几乎每次都会推翻它的判断;而唯一一次模型判定为 SECRET 时,它确实是对的:那是一个真实的 RPC URL,其中嵌入了 API key,藏在我正准备重新启用的一个旧测试中。
你目前使用什么 secret-scanning 方案?说实话,你有多经常绕过它?
对于后续操作,你可以考虑屏蔽此人和/或举报滥用行为。