代码实战 · 2026 年 11 月 11 日

用 Git Hook 自动拦截密钥泄露

AK、token、私钥被提交进仓库,往往是在推送之后才被发现。用一段 pre-commit 脚本扫描暂存区新增行,把泄露挡在本地,并给出误报处理和泄露后的补救顺序。

测试环境的 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 里全是长随机串,扫它纯属自找麻烦。

扫描器#

python
#!/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")
python
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 是新文件的起始行,从这个数开始给每个 + 行递增,报错才能精确到行。没有这一步,提示只能写「某个文件里有个密钥」,等于让人自己找。

输出与退出码#

python
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 只做转发:

shell
#!/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 跑同一份脚本兜底。
  • 泄露后的顺序是:轮换密钥 → 清理历史 → 通知相关方,别跳步。

完

#Git#安全#Python
回到顶部 ↑