在密钥被提交到 Git 历史之前将其拦截
提交到 git 的秘密会永久存在,即使删除后也是如此。本文介绍了一种分层防御机制,它可以在提交前拦截秘密,并说明了在秘密泄露时如何进行轮换。
一旦 API 密钥、令牌、密码或私有证书进入了提交 (commit),你就遇到了一个删除操作无法解决的问题。Git 历史在设计上就是持久的。一个被强制移除的密钥仍然存在于每个克隆 (clone)、每个分叉 (fork)、每个 CI 缓存中,以及在你注意到之前获取了该分支的每个编辑器中。这个值必须被视为已作废,这意味着需要轮换,而不是清理。所以,真正的防御措施不是在事后检测泄露的密钥,而是在密钥进入历史记录之前就阻止提交。这篇文章将展示如何分层构建这种阻止机制,为什么单层防御永远不够,以及当密钥无论如何还是泄露了时该怎么做。
为什么说删除已提交的密钥为时已晚
已提交的密钥就是已公开的密钥。从推送(push)到你注意到的那一刻,任何有读取权限的人都可以拉取(pull)这个值,自动化的复刻(fork)可以镜像它,扫描器也可以索引它,这些扫描器会同时爬取公共和私有主机。重写历史记录可以从你的分支(branch)顶端移除这个值,但无法触及那些已经离开你机器的副本。
清理失败还有第二个原因。重写历史记录会改变你编辑的那个提交(commit)之后所有提交的哈希值,这会迫使每个协作者重置其本地分支,并破坏所有开放的拉取请求(pull request)。你付出了实际的运维成本,但密钥仍然是泄露状态,因为你无法证明在暴露窗口期内没有人复制过它。
正确的心智模型很简单。一旦密钥进入共享历史记录,它的价值就消亡了。唯一安全的应对措施是在源头轮换密钥并使旧密钥失效。其他所有事情,包括重写历史记录,都只是在这个值已经变得毫无价值之后所做的清理工作。这就是为什么这项工作应该放在上游,在提交(commit)的边界上,在那里密钥仍然可以被完全排除在历史记录之外。
分层防御,一览
没有哪一项检查能捕获所有问题。本地钩子速度快,但可以被跳过。服务器扫描是强制执行的,但在推送后运行。文件规则可以防止整类的错误,但无法读取意图。每一层都弥补了其他层留下的空白,因此需要将它们一起运行。
| 层 | 运行位置 | 捕获内容 | 绕过风险 |
|---|---|---|---|
| 提交前钩子 | 开发者机器,提交前 | 暂存的密钥,在它们进入历史记录之前 | 高,因为是本地的,可以被跳过 |
| CI 扫描 | 服务器,推送后 | 钩子遗漏的或被跳过的钩子放过的任何内容 | 低,集中强制执行 |
| 忽略和白名单规则 | 两者皆有 | 绝不应被暂存的密钥文件,以及误报 | 不适用 |
| 自动化守卫 | 机器人和脚本 | 暂存了密钥的机器提交 | 低,在自动化内部运行 |
| 轮换 | 泄露后 | 已泄露值的损害 | 不适用 |
将表格从上到下看作一个漏斗。提交前钩子以低成本在早期阻止了常见情况。CI 扫描是钩子下方的安全网,用于应对开发者运行 git commit --no-verify 或根本没有安装钩子的情况。文件规则缩小了两种扫描器需要分析的范围,自动化守卫覆盖了无人审核的提交,而轮换是最后一层,是你希望永远不会运行的一层。
使用 pre-commit 钩子阻止提交
阻止机密信息泄露成本最低的地方,是在开发者自己的机器上,于提交对象存在之前。pre-commit 钩子会针对暂存的更改运行,如果发现疑似凭证的内容,它就会以非零状态退出,而提交也永远不会发生。因为它只检查暂存的内容,所以速度足够快,可以在每次提交时运行,而不会让任何人注意到延迟。
三种信号可以捕获大多数真实的泄露。首先是文件名:像 .env、.pem、.key、.p12 和 .pfx 这样的文件几乎绝不应该出现在代码仓库中。其次是已知的凭证格式:许多提供商颁发的密钥都具有固定的前缀和长度,而私钥则带有一个可识别的标头行。第三是高熵:赋给名为 token 或 password 的变量的一长串看似随机的字符是一个强烈的迹象,即使它不匹配任何已知格式。
#!/usr/bin/env bash
# .git/hooks/pre-commit (or the equivalent managed by a hook framework)
set -euo pipefail
# Patterns for well-known credential shapes and private key headers.
SECRET_PATTERNS='(-----BEGIN [A-Z ]*PRIVATE KEY-----)|(secret_[A-Za-z0-9]{24,})|([A-Za-z0-9+/]{40,}={0,2})'
# Inspect only what is staged, and only added or changed lines.
staged=$(git diff --cached --name-only --diff-filter=ACM)
fail=0
for file in $staged; do
# 1. Reject credential-bearing filenames outright.
case "$file" in
*.pem|*.key|*.p12|*.pfx|.env|.env.*)
echo "blocked: $file looks like a credential file"
fail=1
continue
;;
esac
# 2. Scan the staged content of the file for secret-shaped values.
if git show ":$file" | grep -nEq "$SECRET_PATTERNS"; then
echo "blocked: $file contains a value matching a secret pattern"
fail=1
fi
done
if [ "$fail" -ne 0 ]; then
echo "commit rejected. remove the secret, or add an allowlist entry if this is a false positive."
exit 1
fi
有两个细节比表面看起来更重要。这个钩子读取 git show ":$file",这是暂存区版本的内容,而不是工作副本。这可以防止一种竞态条件:开发者取消暂存某个修复,但工作文件看起来仍然是干净的。并且它使用 --diff-filter=ACM 进行过滤,这样删除操作就不会触发扫描,因为移除文件绝不会是将密钥引入历史记录的方式。
熵是模式集的弱点,因为一个合法的二进制文件的 base64 数据块或一个长哈希值,看起来可能与密钥完全一样。上面的正则表达式使用了一个保守的长度下限来减少噪音,接下来的几节将介绍在实践中使熵检测可用的两样东西:一个用于捕获其遗漏项的 CI 防护网,以及一个用于处理其错误标记项的允许列表。
CI 中的第二道关卡
本地钩子有一个致命的弱点。它存在于开发者的机器上,这意味着它可以被跳过、卸载,或者在一次新的克隆中根本没有被设置。git commit --no-verify 用一个标志就能绕过它。任何一个标志就能禁用的防御措施都不是一种控制,而是一种建议。因此,同样的扫描必须在任何人都无法选择退出的地方再次运行:在 CI 中,在每次推送时。
CI 扫描所做的不仅仅是重复钩子的工作。它会扫描推送的完整差异,包括在从未安装过钩子的机器上进行的提交,并且它可以在首次运行时扫描历史深度,以捕获在该控制措施存在之前提交的秘密。因为它在服务器上运行,所以其结果是权威的。失败的扫描会阻止合并,没有任何本地标志可以强行通过。
# A minimal CI job that fails the pipeline on a detected secret.
scan-secrets:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history, so the scan sees every commit in the push
- name: scan for secrets
run: |
# Scan the range that this push introduced, not just the tip.
base="${BEFORE_SHA:-HEAD~1}"
if git diff "$base"..HEAD | grep -nE "$SECRET_PATTERNS"; then
echo "secret detected in pushed commits. rotate the value and clean history."
exit 1
fi
分工是关键所在。hook 是一条捷径,它能防止几乎所有密钥进入开发者本地的 history,而修复此类问题只需编辑文件即可,成本极低。CI 扫描是一条强制路径,它假设 hook 已被跳过,并拒绝将包含密钥的 push 变为 merge。前者方便快捷,后者具有权威性,而两者你都需要。
当 CI 扫描触发时,存在一种重要的不对称性。到它运行时,密钥早已被 push,这意味着它可能已经泄露。因此,CI 捕获到的情况并非一次完美的补救。它是一个信号,提示需要启动轮换程序,将该值视为已泄露,然后清理 history。
减少误报的文件和路径规则
当需要猜测的内容更少时,扫描器的效果会更好。两条文件级规则完成了大部分工作。第一条是严格的 .gitignore,它从一开始就将整类机密文件排除在暂存区之外。如果 .env、密钥文件和本地凭据缓存被忽略,开发者就无法意外地 git add 它们,扫描器也永远不必推断其内容。
# Never track local secret material.
.env
.env.*
!.env.example
*.pem
*.key
*.p12
*.pfx
credentials.json
!.env.example 这一行值得特别说明。你需要一个提交到代码库的模板,用占位符值来展示存在哪些变量,这样扫描器的允许列表就可以精确地将该文件排除在外。它记录了配置的结构,但本身不包含任何真实的值。
第二条规则是为扫描器本身设置一个明确的允许列表,因为误报是不可避免的。测试装置会故意包含假的密钥。文档中会展示示例令牌。一个被引入的依赖项会附带一个示例证书。如果没有办法将这些标记为已知安全,那么它们中的每一个都会阻止合法的提交,开发者就会转而使用 --no-verify,这会使整个系统失效。允许列表应该范围窄且可审查,固定到特定的路径或特定的行,绝不能是“在所有地方都忽略此模式”这种一刀切的规则。
# Scanner allowlist: narrow, path-scoped, reviewed in pull requests.
allowlist:
- path: testdata/fake_keys.txt # deliberately fake fixtures
- path: docs/configuration.md # example token in prose
reason: "documented placeholder, not a live credential"
这里的准则是,每个白名单条目都是一个小的、明确的、带有原因的例外,并像任何其他变更一样接受审查。一个模式级别的静默是你挖了之后就会忘记的坑。一个带有明确说明原因的路径级别例外是可审计的,并且审查拉取请求的人可以确切地看到被豁免的内容及其原因。
防御自动化提交
最危险的提交是那些无人查看的提交。一个打开依赖更新拉取请求的机器人,一个重新生成配置文件并提交结果的脚本,一个写入构建清单的发布作业:任何这些都可能在无人参与的情况下暂存一个密钥,而没有人会在差异中注意到危险信号。自动化运行速度快,且不进行任何审查,因此需要将阻止机制直接内置到其自身路径中。
规则是,自动化提交路径运行与人工相同的扫描,并将命中视为硬性停止,而不是一个需要记录的警告。如果机器人的扫描在它将要提交的内容中发现了密钥,那么该提交绝不能发生。机器人会大声地报告作业失败,以便人工进行调查,而不是悄悄地提交密钥然后继续。这与人工钩子采用的“关闭失败”姿态相同,应用于风险更高的地方,因为没有审查者作为后盾。
这也是熵检查发挥其价值的地方。一个编写代码的人很少会意外地粘贴一个让他们自己都感到惊讶的、真实的40字符随机字符串。另一方面,模板化配置文件的自动化程序可以从环境变量中提取一个实时值,并将其直接写入一个受追踪的文件。通用的高熵检查恰好能捕获这类错误,即值的格式不匹配任何已知的提供商格式,但从其形态和变量名来看,它无疑是一个密钥。
当密钥确实泄露时,首先轮替
密钥迟早会泄露,而你的响应顺序决定了它会造成多大的损害。本能的反应是立即清理历史记录,让密钥值消失。这种本能是错误的,因为密钥值已经泄露出去了,而重写历史记录对于已经离开你机器的副本毫无作用。第一步永远是轮替。
- 在做任何其他事情之前,先在源头轮替或撤销暴露的密钥值。在服务提供商处生成一个替代品,并使旧的失效。密钥一旦公开,其价值就已消亡,所以要先干掉它。
- 确认旧的密钥值不再有效。尝试用它进行身份验证,并核实调用被拒绝。在你亲眼看到它失败之前,你并没有真正地撤销它。
- 通过你常规的密钥存储,将新的密钥值部署到服务需要它的任何地方,这样系统就能依靠替代品继续运行。
- 只有现在才能清理历史记录。重写携带了密钥的提交并强制推送(force-push),要明白克隆、分叉和缓存中仍然保留着旧的密钥值。这一步是为了保持整洁,而不是为了遏制。
- 检查访问日志,看在泄露和撤销之间旧密钥值是否被使用过。如果该凭证授予了对真实数据的访问权限,就要假设它可能已被使用,并进行相应的调查。
顺序就是全部的教训。轮替可以遏制事件。清理历史记录只是事后整理。一个先重写历史记录再进行轮替的团队,会把最宝贵的应急时间花在毫无作用的步骤上,而那个有效的、已泄露的凭证却能继续为任何已经复制了它的人工作。
误报的权衡,以及为何“失败时关闭”策略更优
每个密钥扫描器都面临着同样的矛盾。收紧模式,你会漏掉真实的密钥。放宽模式,你又会因误报而阻止合法的提交。没有任何设置可以同时消除这两种情况,所以你必须选择你更倾向于哪种错误,而这个选择并非对称。
误报是一种烦恼。开发者看到一个被阻止的提交,检查被标记的行,确认它是一个测试装置或文档中的占位符,然后添加一个精确的允许列表条目。成本是几分钟时间和一次经过审查的例外。而漏报则是一次泄露。一个真实的凭证溜了过去,进入了共享历史记录,现在你就得在压力之下轮换密钥并检查访问日志。这两种后果完全不在一个量级,这就是为什么扫描器应该采用失败时关闭(fail-closed)的策略:当有疑问时,就阻止,然后让人员来确认例外情况。
只有当处理误报的路径成本真的很低时,这个选择才行得通,这也是为什么允许列表如此重要。如果清除误报的过程很痛苦,开发者就会用 --no-verify 绕过扫描器,而被绕过的控制措施什么也保护不了。所以,你要保持模式足够激进以捕获真实的密钥,并投入精力使例外处理变得快速、精确且可审查。在检测时采用失败时关闭策略,在处理例外时保持低成本,你就能得到一个开发者愿意持续使用而不是与之对抗的系统。
将这些层次结合起来,整体轮廓就很清晰了。pre-commit 钩子在开发者的机器上防止密钥进入历史记录。CI 扫描强制执行同样的规则,并且没有任何标志可以跳过它。文件和允许列表规则减少了猜测工作,并保持了误报成本在较低水平。自动化防护措施覆盖了那些无人审查的提交。而密钥轮换则随时准备着,以防万一有东西通过了扫描,因为整个设计背后的坦诚假设是,最终总会有东西漏网,而一个已提交的密钥在它落地的瞬间就已经失效了。