清理 Git 仓库里的泄露密钥

密钥一旦进了 Git 历史,就不是删掉当前文件一行那么简单了。我们从吊销密钥开始,用镜像仓库和 git-filter-repo 重写完整历史,再验证、清理本地对象并强制推回远端,顺手把原脚本里的几个坑也填上。

密钥被人提交进 Git 仓库,哪怕只是停留了几分钟,也应该按已经泄露来处理。

删掉当前分支里的那一行,心理上会舒服一点,安全上却远远不够,大写滑稽,小写尴尬。

因为 Git 不是一张会自动覆盖的白纸,旧提交、标签、分支和本地对象都可能还握着那份内容。

先把密钥作废

改历史不是止血

我们先不急着执行清理命令。

第一件事是去密钥所属的平台吊销、禁用或轮换它。

比如云服务访问密钥、数据库密码、部署令牌、Webhook 密钥,都应该先换成新的值。

已经进入 Git 的密钥,就不能再当作秘密保存。

即使我们马上把提交重写了,也无法证明没人提前 clone、下载、截图或从缓存里拿走它。

历史清理解决的是“仓库里还留着什么”,轮换解决的是“旧凭证还能不能继续开门”。

这两个动作不能混成一个。

还要记录影响范围:密钥属于哪个系统、权限有多大、从哪个提交和文件泄露、哪些 CI 或服务器使用过它。

Git 历史清理不会自动删除 Docker 镜像、构建日志、制品和部署平台里的环境变量,这些地方要分别处理。

准备完整镜像仓库

清理历史要在隔离副本里做,别直接拿平时开发目录开刀。

我们先拿到远端的完整裸仓库:

# 镜像克隆会带上分支、标签等远端引用,不是只拿当前分支
git clone --mirror https://git.mebugs.com/mebugs.git

# 裸仓库目录通常以 .git 结尾,里面没有普通工作区
cd mebugs.git

# 先确认远端和引用都在,避免对错仓库动手
git remote -v
git show-ref
​

上面的 mebugs.git 没有普通工作区文件,这是正常现象。

如果仓库很大,目录要放在磁盘空间充足的位置,并提前确认没有其他人继续向远端推送。

git-filter-repo 通常会为了避免误操作移除 origin,所以真正推送前要重新检查远端。

如果远端已经存在,不要重复执行 git remote add origin,否则会看到:

error: remote origin already exists.
​

应该根据实际情况使用:

# 已经存在 origin 就修改地址
git remote set-url origin https://git.mebugs.com/mebugs.git

# origin 确实不存在时才新增
git remote add origin https://git.mebugs.com/mebugs.git
​

这里建议先确认 URL,再开始改历史。

把一份干净仓库推到错误项目上,属于另一种很有创造力的事故。

用过滤器重写历史

准备替换规则

我们创建一个 expressions.txt 文件,只放待匹配的敏感内容和替换结果。

真实密钥不要写进文章、聊天记录、提交信息或脚本示例里,下面只用占位符表示思路:

# 按字面量匹配一段已确认泄露的占位示例
leaked-token-placeholder==>***REMOVED***

# 用正则匹配一类配置值,等号右边是统一替换文本
regex:API_KEY=[A-Za-z0-9_\-]+==>API_KEY=***REMOVED***

# 按字面量替换一个配置文件中的敏感行片段
password-placeholder==>***REMOVED***
​

规则文件通常是一行一条规则,左边是匹配表达式,右边是替换文本,中间用 ==> 分隔。按字面量匹配时直接写内容,需要匹配一类格式时使用 regex: 前缀。

规则不要一上来写得过于宽泛,先用测试副本验证,再逐步补齐,比一条正则把半个配置文件吞掉稳妥得多。

执行历史替换:

# 用规则文件重写所有相关历史,不只处理当前分支
git filter-repo --replace-text ../expressions.txt
​

如果规则文件不在上一级目录,就换成实际路径。过滤器会生成新的提交对象,哪怕文件内容看起来没变,提交哈希也可能因为父提交变化而全部变化。

整体关系可以按下面这条线理解:

泄露密钥
   │
   ├─→ 吊销旧密钥并轮换
   ├─→ clone --mirror 获取完整引用
   ├─→ expressions.txt 定义匹配和替换
   ├─→ git-filter-repo 重写历史
   ├─→ 多维度搜索残留
   ├─→ 清理 reflog 和本地对象
   └─→ force --mirror 推回远端
​

这几个节点不能只做最后一步。

直接强推没有清理历史,等于把旧问题原封不动地重新发布一遍。

验证每个藏身处

清理完不能靠“命令没报错”判断成功。Git 仓库里可能藏着多个分支、标签、文件重命名前的旧路径,以及提交信息里的敏感字符串。

我们至少从关键字、路径、分支和标签四个角度查一遍。

# -S 查找指定字符串数量发生变化的提交
git log --all --full-history -S 'leaked-token-placeholder' --oneline

# 检查替换后的占位符是否出现
git log --all --full-history -S '***REMOVED***' -p

# 检查泄露文件的历史路径
git log --all --full-history -- 'config/mebugs.yaml'

# 列出所有本地分支和标签
git for-each-ref --format='%(refname)' refs/heads refs/tags
​

git log -S 很适合查某个字符串什么时候进入或离开文件,但不是万能扫描器。

复杂格式还要结合路径、提交内容和人工抽查。

镜像仓库里可能有不在日常开发界面展示的引用。

不要只打开默认分支网页看一眼,那只能说明默认分支当前长什么样。

清理本地残留对象

过滤器改的是引用和提交历史,本地仓库还可能暂时保留旧提交对象。

我们把 reflog 和不可达对象也处理掉:

# 让 reflog 不再保留旧引用的时间线
git reflog expire --expire=now --all

# 清理不可达对象,让旧提交不再留在这个本地镜像里
git gc --prune=now --aggressive
​

这两条命令主要处理当前本地副本,不等于远端服务器、其他电脑、fork 或平台缓存也被清掉了。

所以“处理孤儿分支”不能简单理解成只跑 reflog 和 gc。

真正让敏感提交从仓库历史里消失,靠的是过滤器重写并把新历史推到远端;孤儿或残留引用是否存在,还要靠引用枚举和关键字验证确认。

如果只是普通仓库维护,我不建议随手使用 --aggressive。

这是敏感数据清理场景,耗时更长,但我们需要尽量处理当前本地镜像的残留。

强推并通知所有人

确认验证结果后,再把重写后的完整镜像推回远端:

# 确认 origin 指向正确项目后,整体覆盖远端引用
git push --force --mirror origin
​

这里的 origin 是远程名,--mirror 会同步所有引用,包含删除远端已经不存在于本地镜像的引用。

远端平台可能启用了分支保护,强推会被拒绝。

需要管理员临时放开相关限制,推送完成后立刻恢复。

影响范围

强推完成后,提交哈希会变化,受影响的不只是当前电脑:

  • 其他人的旧克隆仍会保留旧提交。
  • fork 可能继续引用旧历史。
  • Pull Request 页面可能存在旧 diff、缓存快照或讨论内容。
  • Git LFS 对象可能需要按平台能力单独清理。
  • CI 缓存、构建日志、发布制品和备份也要分别检查。

平台上的敏感数据清理通常还需要联系托管方支持团队,不要假设 force push 会瞬间抹掉所有副本。

协作者重新同步

不要让同事直接在旧克隆目录里执行普通 git pull,那可能把旧历史再次合并回来。

更稳妥的方式是删除旧目录后重新 clone;如果本地改动很多,先备份未提交改动,再按团队方案重新变基到新历史。

远端完成历史重写
   │
   ├─→ 暂停旧目录继续推送
   ├─→ 备份未提交改动和必要分支
   ├─→ 删除或隔离旧克隆副本
   ├─→ 重新 clone 新历史
   └─→ 将业务改动重新变基或手工迁移
​

重新 clone 是最省脑子的做法。

看到“远端已经干净”后顺手一把 git pull,事故可能又从旧历史里复活。

收尾

一次完整处理可以压缩成几条检查线:

  1. 先吊销并轮换泄露密钥,再处理 Git 历史。
  2. 用 git clone --mirror 保存完整远端引用,并留好原始备份。
  3. 用 git-filter-repo --replace-text 按规则重写所有历史。
  4. 按关键字、文件路径、分支和标签检查残留。
  5. 执行 reflog 过期和垃圾回收,清掉当前本地镜像的旧引用与悬空对象。
  6. 确认远端名和分支保护后,用 git push --force --mirror origin 发布新历史。
  7. 处理 fork、PR、缓存、LFS、制品和协作者旧克隆。

最容易犯的错,是把“文件现在看不到了”当成“历史已经干净了”。

Git 的提交像一摞复印件,撕掉最上面那张,不代表下面没有同样内容。

我们既要让旧密钥失效,也要让仓库历史不再继续传播它。

记录安全事件时只写类型、位置和处理结果,不要把真实密钥再抄一遍。

我个人会把规则文件、验证结果和远端推送时间保存到受控的内部记录里,然后把临时镜像和原始副本按安全团队要求销毁或隔离。

这样做起来麻烦一点,却比下个月又从某台旧电脑里翻出一把“已经删掉”的钥匙靠谱。

参考链接:

更多推荐

章节目录