| 失效的假设 | “我把含密钥的文件删了并提交了,密钥就没了” |
| 所属组 | W7 · 配置与暴露 |
| 收尾主题 | 这一篇的修复思路(假设已泄露、立即轮换)呼应第 7 篇 W2 开篇的"假设库已泄露" |
| 复现环境 | Python 3 标准库 + git,无外部依赖 |
一、一个几乎每个团队都犯过的错
你在翻一个提交记录,看到一行:
API_KEY=sk-live-7f3a9c21
心里咯噔一下——这是三周前你自己提交的。
你 git rm 掉那个文件,写了句 remove secret,提交,push。工作区干干净净,GitHub 上也看不到它了。你松了口气。
这口气松早了。
打个比方
你把写着密码的那张纸撕了,扔进碎纸机。桌面上确实干净了。
可这张纸在被你撕掉之前,已经被复印了几十份——每个 clone 过这个仓库的人手上都有一份完整的副本,包括每一次修改的历史。你销毁的只是你桌上那一份。
而且更糟:碎纸这个动作本身被记了下来(那条 remove secret 提交),等于在目录上写了一行"第 47 页曾经有份机密文件,已撕毁"。你给想找它的人指了路。
用真实的 git 仓库看一遍:
import subprocess, tempfile, os
def git(d, *a): return subprocess.run(["git","-C",d,*a],
capture_output=True, text=True).stdout
d = tempfile.mkdtemp()
git(d, "init", "-q"); git(d, "config", "user.email", "[email protected]"); git(d, "config", "user.name", "t")
# 提交一个含密钥的文件
open(os.path.join(d,"config.env"),"w").write("API_KEY=SK-LIVE-supersecret\n")
git(d, "add", "config.env"); git(d, "commit", "-q", "-m", "add config")
# "意识到了",删掉文件再提交
git(d, "rm", "-q", "config.env"); git(d, "commit", "-q", "-m", "remove secret")
# 工作区已经没有这个文件了
print("工作区还有 config.env 吗:", os.path.exists(os.path.join(d,"config.env")))
# 但历史里照样捞得到
history = git(d, "log", "-p", "--all")
print("密钥仍在 git 历史中:", "SK-LIVE-supersecret" in history)
print("=> 删文件只是加了个'删除'提交,旧提交原封不动,clone 就带着它")
工作区还有 config.env 吗: False
密钥仍在 git 历史中: True
=> 删文件只是加了个'删除'提交,旧提交原封不动,clone 就带着它
工作区里文件确实没了(False),可密钥还在历史里(True)。原因是 git 的本质:它记录的是每一次变更,而不是"当前状态"。 你"删除文件"这个动作,只是又加了一个"删除"提交;之前那个"添加了密钥"的提交原封不动地留在历史里。任何人 clone 这个仓库,或者翻一下 git log,都能把密钥捞出来。
⭐ 失效的假设:“我看不到了” = “它没了”。 在 git 里,删除是一次新的提交,不是抹除。这和第 16 篇 Heartbleed"读不留痕"是镜像的两面——那里是"泄露了却查不到",这里是"删除了却还在"。
二、为什么"清理历史"是个陷阱
发现删文件没用后,第二反应通常是:“那我把历史也改了”——用工具重写 git 历史,把那个提交里的密钥抹掉。
这条路能做,但不该作为你的主要修复,原因有三:
① 你抢不过时间 从密钥被 push 上去,到你重写历史,中间的每一秒
它都可能已经被 clone、被 fork、被 CI 缓存、被扫描机器人抓走
—— 公开仓库上,密钥被自动化扫描发现常常只要几分钟
② 你清不干净 fork、镜像、别人的本地 clone、CI 日志、备份,你都改不到
③ 重写历史代价大 改写公共历史会打乱所有协作者,且容易出错
⚠️ 核心认知:一旦一个密钥被提交并推送,就必须假设它已经泄露了。 你不知道谁在这段时间抓走了它,也没法保证抹干净。纠结于"把它从历史里擦掉",是在解一个错误的问题。
三、正确的修复:轮换
⚠️ 这一篇最难接受的不是技术,是接下来这个结论:你没法把它删干净,所以别在"删干净"上花时间。
大多数人发现密钥泄露后,会先花半天研究怎么重写 git 历史。而那半天里,泄露的密钥一直有效。顺序反了。
⭐ 回到碎纸机那个比方:复印件已经发出去了,你追不回来。但你可以换锁。
换了锁之后,那些复印件上印的还是原来那把钥匙的样子——它们一夜之间变成了废纸,而你完全不需要知道它们散落在谁手上。
对的问题不是"怎么让别人看不到这个密钥",而是"怎么让这个密钥失效"。
唯一可靠的修复:立即【轮换】——作废这个密钥,签发一个新的。
旧密钥一旦作废,它是否还躺在 git 历史里就【无所谓了】:
历史里那串字符指向的东西,已经不再有效。
⭐ 这一步把问题从"追赶泄露"(打不赢)变成了"让泄露物失效"(你完全可控)。这正是第 7 篇 W2 开篇那句"假设库已泄露"的同一个思维——不赌"没人拿到",而是设计"就算拿到了也没用"。 整个 Web 安全清单,在收尾这一篇又回到了它的起点。
轮换之后,再考虑要不要顺便清理历史(为了整洁、为了合规),但那是次要的、锦上添花的事,不是修复本身。
四、更好的做法:让密钥根本进不去
修复是补救,预防才是正题。让密钥从一开始就进不了版本库:
① 密钥不入库 用密钥管理服务 / 环境变量 / 专门的 secrets 存储,
代码里只引用"从哪取",不含密钥本身
② .gitignore + 提交前扫描 把 .env、密钥文件加入忽略;用 pre-commit 钩子/扫描器
在提交那一刻拦下疑似密钥(防患于提交前)
③ 短期凭据 能用 OIDC/临时凭据就别用长期密钥(呼应第 19 篇 CI)
④ 服务端扫描历史 定期扫仓库历史里的密钥,发现即轮换
⚠️ 还记得第 20 篇的"目录列举暴露 .git/“吗?两篇在这里合流:如果你的 Web 服务器把 .git/ 目录暴露在公网,攻击者能直接下载整个仓库历史——你以为只在内部的代码库,连同里面所有历史密钥,一起公开了。 配置错误(W7-1)+ 密钥入库(W7-2),叠起来就是一次完整的泄露。
五、它出现过的地方
展开在事件分析里:
2021.04 Codecov bash uploader 被改,持续外传 CI 环境里的凭据
六、防御
① 提交并推送过 = 已泄露 => 立即轮换 这是修复;作废旧密钥后历史里那串字符就无所谓了
② 密钥根本不入库 secrets 存储 / 环境变量 / 短期凭据
③ 提交前扫描拦截 pre-commit 钩子 + 服务端历史扫描,双层
④ 别暴露 .git(接第 20 篇) Web 根目录下的 .git 等于公开整个历史
⑤ 清理历史是次要 可做,但永远在"已轮换"之后,不替代轮换
判断标准:假设你所有提交过的密钥此刻都在攻击者手里——你的系统还安全吗? 答"安全”,因为你都轮换了;答"不安全",就有该轮换而没轮换的。
七、这些防御的边界
① 轮换要求密钥"可轮换"。 有些密钥轮换起来很痛(硬编码进大量客户端、第三方深度集成、需要协调多方)。轮换难的密钥,恰恰最该从一开始就严防入库——预防的价值和轮换的成本成正比。
② 提交前扫描有漏报。 密钥格式千变万化,扫描器认得出标准格式的(AWS key 等),认不出自定义格式的。它是纵深,不是保证;仍要靠"密钥不入库"的架构从根上避免。
③ 轮换后要确认旧密钥真的失效。 作废操作本身可能没生效、可能有缓存、可能还有别的副本在用。轮换不是"签发新的",是"确认旧的不再工作"。
关于时效:git 的"删除是新提交"这一本质不会变,轮换永远是正解。会变的是密钥管理的工具生态——secrets 管理服务、OIDC 短期凭据、自动扫描与轮换的集成度在快速提升。落地用当前生态的最新能力,本篇不钉具体产品。
八、本篇小结(兼全清单收尾)
密钥进了 git,删文件再提交【没用】——git 记录的是每次变更,不是当前状态。
删除只是又一个提交,旧提交原封不动,clone/log 都能捞出密钥。
⭐ 失效的假设:"我看不到了" = "它没了"
⭐ 一旦提交并推送,就【假设已泄露】:清理历史打不赢时间、清不干净
正解是【轮换】—— 作废旧密钥,历史里那串字符就无所谓了
把问题从"追赶泄露"(打不赢) 变成"让泄露物失效"(你可控)
—— 和第 7 篇 W2 开篇"假设库已泄露"同一个思维,清单首尾呼应
预防:密钥不入库(secrets 存储/环境变量/短期凭据) + 提交前扫描 + 别暴露 .git
判断:假设所有提交过的密钥都在攻击者手里,你的系统还安全吗?
思考题
- §一 用真实 git 证明"删文件 ≠ 删历史"。为什么 git 的"记录变更而非状态"这一设计,使得"删除"永远不是"抹除"?
- §二 说"清理历史是解一个错误的问题"。正确的问题是什么?把"让别人看不到"和"让它失效"对比,为什么后者你完全可控、前者你打不赢?
- 轮换后"历史里那串字符就无所谓了"。这句话的前提是什么?如果轮换没真正生效(§七③),这个前提还成立吗?
- §四 把 W7-1(.git 暴露)和 W7-2(密钥入库)合流成一次完整泄露。请把这条链完整描述一遍:从一个配置错误到攻击者拿到可用密钥。
- 这一篇的"假设已泄露、立即轮换"和第 7 篇"假设库已泄露、设计泄露后的韧性"是同一个思维。把它推广:安全设计里"假设最坏已发生"这个前提,还能用在哪些场景?
- §七① 说"轮换难的密钥最该严防入库"。为什么预防的价值和轮换成本成正比?举一个"轮换极痛"因而必须从不入库的密钥例子。
- 回顾整份 W1–W7 清单:从注入到配置,“默认拒绝"“验证在信任之前"“假设最坏已发生"这几条原则反复出现。挑其中一条,说明它在至少三个不同章节里分别是怎么体现的。
相关:第 20 篇:配置与暴露 · 第 7 篇:密码存储 · 第 19 篇:构建与 CI · Web 安全机制篇 · 事件分析