测试环境的 AK 被写进了 config.py,提交、推送、合并,三天后才在代码评审里被发现。删掉那一行没有用——它还在历史里,远端缓存里,以及所有克隆过仓库的人的硬盘上。
唯一可靠的结论是:只要密钥进过仓库,就当作已泄露。所以真正有价值的动作发生在提交之前,而不是事后清理。
为什么放在 pre-commit#
Git 有好几个 hook 可用,位置决定了成本和效果:
| Hook | 触发时机 | 优点 | 局限 |
|---|---|---|---|
pre-commit |
本地提交前 | 最快拦截,开发者立刻知道 | 可被 --no-verify 绕过 |
pre-push |
推送前 | 覆盖一次提交里的多个 commit | 仍在本机,可绕过 |
pre-receive |
服务端接收前 | 无法绕过 | 需要仓库管理权限 |
| CI 检查 | 合并请求时 | 覆盖所有来源 | 密钥已进远端历史 |
本地 hook 的价值是反馈快:写完代码一提交就被拦住,比等 CI 跑三分钟友好得多。但它只是第一道网,CI 里应该跑同一个脚本兜底——两者共用一份规则,避免「本地过了、CI 又报」的割裂。
推行时最怕两件事:一是误报太多,二是不知道被拦了之后该做什么。前者靠规则收紧解决,后者靠提示语解决——报错信息里必须直接写「这行是测试数据就加 # allowlist-secret,否则删掉并轮换密钥」,而不是只打印「检测到敏感信息」。
规则:宁可少报,不要乱报#
正则写宽了,每天几十条误报,团队会集体加 --no-verify,等于没做。写窄了会漏,所以按「高置信度前缀」优先:
| 规则 | 匹配特征 | 误报率 |
|---|---|---|
| AWS Access Key | AKIA + 16 位大写字母数字 |
极低 |
| GitHub Token | ghp_、gho_、ghs_ 等前缀 |
极低 |
| Slack Token | xoxb-、xoxp- 等前缀 |
极低 |
| 私钥文件头 | -----BEGIN ... PRIVATE KEY----- |
极低 |
| 通用赋值 | api_key = "..." 且值长度 ≥ 16 |
中等,需白名单 |
通用规则是误报的主要来源。缓解办法有三层:要求值被引号包住且长度够、跳过文档和锁文件、允许行尾写 # allowlist-secret 显式放行。
误报最典型的三种来源:README 里的示例值(api_key = "sk-example-1234567890")、测试夹具里的假 token、以及打包产物里的哈希。前两种可以用白名单词兜住,第三种只能靠扩展名跳过——压缩过的 JS 和 sourcemap 里全是长随机串,扫它纯属自找麻烦。
扫描器#
#!/usr/bin/env python3
"""扫描暂存区新增行里的疑似密钥;命中时退出码为 1。"""
import re
import subprocess
import sys
RULES = [
("aws-access-key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
("github-token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36}\b")),
("slack-token", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}")),
("private-key", re.compile(
r"-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----")),
("generic-secret", re.compile(
r"""(?ix)\b(?:api[_-]?key|secret|token|passwd|password|access[_-]?key)\b
['"]?\s*[:=]\s*['"]([^'"\s]{16,})['"]""")),
]
ALLOW = re.compile(r"allowlist-secret")
PLACEHOLDER = re.compile(r"(?i:example|changeme|placeholder|dummy|your[_-]?key)")
SKIP_EXT = (".md", ".lock", ".min.js", ".map", ".snap", ".svg", ".ipynb")
def staged_diff():
return subprocess.run(
["git", "diff", "--cached", "--unified=0",
"--diff-filter=ACM", "--no-color"],
capture_output=True, text=True, check=True).stdout
def scan(diff_text):
findings, path, lineno = [], None, 0
for raw in diff_text.splitlines():
if raw.startswith("+++ b/"):
path, lineno = raw[6:], 0
continue
if raw.startswith("@@"):
m = re.search(r"\+(\d+)", raw)
lineno = (int(m.group(1)) if m else 1) - 1 # 首条新增行的行号
continue
if not raw.startswith("+") or raw.startswith("+++"):
continue
lineno += 1
content = raw[1:]
if not path or path.endswith(SKIP_EXT) or ALLOW.search(content):
continue
for name, rx in RULES:
m = rx.search(content)
if not m or PLACEHOLDER.search(m.group(0)):
continue # 占位符不算泄露,例如文档里的 example key
findings.append((path, lineno, name, content.strip()[:120]))
break # 一行只报一次,避免重复刷屏
return findings
--unified=0 让 diff 只输出变更行,扫描成本与提交大小成正比,而不是和仓库大小成正比。--diff-filter=ACM 排除删除和重命名,只看新增、复制、修改。
只扫新增行是刻意的:历史里已有的问题不应该在每次提交时反复报,那会让 hook 从「守门」变成「噪声源」。存量密钥应该单独跑一次全量扫描,列清单、排期处理,和提交时的增量检查分开。
二进制文件也不用担心。git diff 对它只输出一行 Binary files ... differ,不会带出内容,正则自然匹配不到。
解析 hunk 头是为了给出准确行号。@@ -12,3 +12,5 @@ 里的 +12 是新文件的起始行,从这个数开始给每个 + 行递增,报错才能精确到行。没有这一步,提示只能写「某个文件里有个密钥」,等于让人自己找。
输出与退出码#
def main():
try:
findings = scan(staged_diff())
except subprocess.CalledProcessError as e:
print(f"git diff 执行失败,跳过扫描:{e}", file=sys.stderr)
return 0 # 不让工具故障阻断提交
if not findings:
return 0
print("检测到疑似密钥,已阻止提交:", file=sys.stderr)
for path, lineno, name, snippet in findings:
print(f" {path}:{lineno} [{name}] {snippet}", file=sys.stderr)
print("确认是测试数据请在该行加 # allowlist-secret;否则删除并轮换密钥。",
file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
判断脚本坏了还是抓到密钥,靠退出码:0 放行,1 拦截。git diff 本身失败时返回 0 是刻意的——hook 不应该因为环境异常把所有人卡住,这类错误交给 CI 报。
接上 Hook#
把脚本放进仓库的 scripts/ 目录,hook 只做转发:
#!/bin/sh
# .githooks/pre-commit —— 用 git config core.hooksPath .githooks 启用
exec "${PYTHON:-python3}" "$(git rev-parse --show-toplevel)/scripts/secret_scan.py"
.git/hooks 不进版本库,所以用 core.hooksPath 指向仓库内的目录。启用只需两行:chmod +x .githooks/pre-commit scripts/secret_scan.py,然后 git config core.hooksPath .githooks。团队里可以再加一条 make setup 把这步写进去,避免有人克隆后忘了配。
git rev-parse --show-toplevel 保证在子目录里提交也能找到脚本。PYTHON 变量是给 Windows 用户留的口子——Git for Windows 里通常只有 python,没有 python3。
验证一下:新建一个文件写入 AKIAIOSFODNN7EXAMPLE,git add 后提交,应该看到拦截信息,git log 里没有新 commit。把这一行改成 AKIA... # allowlist-secret 再提交,应当通过。
绕过、漏网与善后#
--no-verify:任何本地 hook 都能被绕过。CI 里对git diff origin/main...HEAD跑同一个脚本,能覆盖这种情况。- 新克隆的仓库:
core.hooksPath是本地配置,不会被克隆。要么写进仓库的初始化脚本,要么直接依赖 CI。 - 已经泄露:先轮换密钥(在服务端吊销),再清理历史(
git filter-repo或 BFG),最后通知用过这份代码的人。顺序不能反——先清历史再轮换,等于给攻击者留了时间窗。 - 历史清理要谨慎:重写历史会让所有协作者的分支失效,操作前备份仓库,先在镜像上演练一遍。
还有一个常被提议的方案:用信息熵检测「像不像随机字符串」。它在理论上能抓到没有固定前缀的密钥,实际用起来误报率极高——UUID、哈希、base64 编码的图片路径都会命中。除非有专人维护阈值,否则不要加,它会把团队推向 --no-verify。
不要试图用 .gitignore 防密钥。它只影响未跟踪文件,一旦 git add -f 或者文件早已被跟踪,照样提交。也不要把密钥写进环境变量文件再提交,那只是换了个文件名。
结论#
- 密钥进过仓库就视为泄露,拦截要发生在提交前,而不是推送后。
- 用高置信度前缀规则做主力,通用赋值规则配白名单,避免误报逼出
--no-verify。 git diff --cached -U0只扫增量,解析 hunk 头拿到准确行号。- 本地 hook 用
core.hooksPath共享,CI 跑同一份脚本兜底。 - 泄露后的顺序是:轮换密钥 → 清理历史 → 通知相关方,别跳步。
完