.env 被 git 追踪后,「全量轮转 + 改写历史」的应激反应为什么是错的
做安全审计时,翻 git log -- .env,发现 .env 从 8 周前就开始被追踪了,横跨 8 个 commit,里面躺着数据库密码、第三方平台密钥、内部服务 token。第一反应:完蛋了,必须轮转所有密钥 + 改写 git 历史 + 强推到 origin。
这套流程我本可以花一整天去执行。但实际上,30 分钟就收场了,而且安全态势没有任何下降。原因在于:「密钥在历史里」和「密钥在危险里」是两个正交的轴。
现象
git log -- .env 返回了若干条 commit,证明 .env 文件(含真实凭据)曾被追踪。仓库是私有 GitHub 仓库,只有 2 个协作者,从未公开过。
本能反应立即跳到「Plan A」:
- 轮转数据库密码(生产数据库,有停机风险)
- 重新签发第三方平台密钥(需要重新走平台认证流程)
- 重新生成内部服务 token 并同步给两个服务
git filter-repo改写 8 周历史- 强推到 origin(破坏所有本地克隆的引用)
估算下来:1~2 天。
根因
这套「应激反应」的问题在于,它把「密钥有没有被写进历史」和「密钥有没有被恶意方读到」混为一谈。真正需要问的问题是:这段历史能被谁读到?
git log 里有密钥 ≠ 攻击者已拿到密钥。决策轴是「谁能访问这段历史」,而不是「历史里有没有密钥」。
公有仓库:GitHub event firehose(githubarchive.org)会在你 push 后数分钟内抓走所有内容。即使立刻删除,已视为暴露。必须轮转。
私有仓库 + 小规模固定协作者:ACL 边界等同于秘密的保险柜边界。历史里的密钥只对白名单内的协作者可见,爆炸半径被访问控制锁住了。
把这两种情况当成同一个问题处理,要么把私有仓库的「安全补丁」搞成生产事故,要么把公有仓库的真实泄漏当成小事放过。
正确的决策树分四步:
- 仓库曾经公开过吗? 哪怕 1 分钟 → 假设已被抓走,立即轮转,历史改写是防御纵深但救不了已经泄出去的密钥。
- 有没有下游缓存? CI runner 持久缓存、org 内部 fork、离职协作者的本地克隆、第三方 backup 服务。
- 协作者列表是否动态变化? 如果经常有人进出仓库,轮转是值得的;如果是固定的信任边界,ACL 本身就是防线。
- 根据以上三问选择行动:只做「向前防止」(gitignore + untrack + .env.example),或者叠加轮转,或者两者都做。
正解
对于私有仓库、固定协作者、从未公开的场景,最小且充分的动作是「向前防止」:
# 1. 加入 .gitignore
echo '.env' >> .gitignore
echo '.env.local' >> .gitignore
echo '.env.*.local' >> .gitignore
# 2. 取消追踪,保留本地文件
git rm --cached .env
# 3. 生成模板供新开发者使用
cp .env .env.example # 清空真实值,保留 key 名 + 注释
# 4. 提交
git add .gitignore .env.example
git commit -m "security: add .gitignore, untrack .env, add .env.example"轮转的代价不是零——数据库密码轮转有停机窗口,第三方平台密钥轮换需要走认证流程,旧 token 失效会触发级联故障排查。「轮转一切以求安心」本身是有成本的操作,而不是免费的保险。
向前防止后,将「接受风险」写成正式记录,并列明触发轮转的条件(仓库变公开、协作者离职、org 凭证疑似泄露等)——这不是「忽视了」,而是「测量了,定价了,划定了触发条件」。
验证向前防止是否生效:
# .env 不再被追踪
git ls-files | grep -E '\.env(\.|$)' # 应返回空
# 新克隆不含真实 .env
git status # .env 出现在 untracked+ignored,不在 staged一句话外卖
「密钥进了 git 历史」和「密钥已被攻击者读到」是正交的两件事——决策轴是访问控制边界,不是历史是否干净。