作者视角:15 年经验安全工程师,专注安全加固与风险防范 栈:Git 2.43(git filter-repo 2.45)、GitHub(github.com / GHES 3.11)、trufflehog 3.43、gitleaks 8.18、Harbor 2.9、K8s 1.28 结论先行:
git revert/git rm只改了指针,不改历史。密钥一旦进过对象库,就默认「永久存在、随时可被git cat-file取出」,清除必须按「轮换 → 清本地 → 清远端 → 清派生副本 → 收钩子」的顺序做,顺序反了等于白做。
一、现象:一次"已经删了"的提交,三个月后还在 GitHub 缓存里能搜到
上个月给一家 200 人规模的 SaaS 做季度安全审计。他们的安全同学很笃定:「那个 AK 只误提交过一次,第二天就 git revert 删了,git log 里现在搜 LTAI 什么都没有。」
我按标准动作查了一遍,结果:
git log --all -S 'LTAI5t' --oneline:本地干净,0 条命中(revert 之后 HEAD 历史里确实没有);- 但
git reflog+git fsck --unreachable --no-reflogs:本地对象库里仍有 3 个 dangling blob,git cat-file -p <blob>直接吐出完整 AK 明文; - 更糟的是在 GitHub 的 Web 搜索里搜
LTAI5t(该 AK 前缀 6 位),3 个月前的提交页面还能打开——GitHub 的搜索索引与 fork 网络是独立副本,revert 不会让它们消失; - 该仓库有 14 个内部 fork、被 2 台 CI 构建机的本地 clone 引用,且其中一台的镜像构建缓存(BuildKit cache,保留 14 天策略)里还带着旧提交。
按「任何 clone 过这个仓库的人/机器,默认当作持有完整历史」来评估,这次事故的实际暴露面是 1 + 14 + 2 台构建机 + 镜像缓存,而不只是「主仓库的一个提交」。开发组当时只处理了主仓库 HEAD,其余四个面全部裸奔。
二、根因:Git 的存储模型决定了「删除」是假象
三个机制问题,逐层看:
1. Git 存的是对象图,不是文件列表
git revert 只是新增一个反向提交、移动分支指针。原提交对象、blob 对象全部留在 .git/objects 里,只是失去引用。只要满足任一条件,对象就能被找回:
git reflog(本地,默认保留 90 天);git fsck --unreachable(对象库物理扫描,不依赖任何 ref);- 远端:GitHub/GitLab 的 packfile 在 GC 前一直可读,且搜索索引、fork、CI 缓存是独立副本,主仓库的指针移动对它们零影响。
2. 密钥进入的是「不可变复制层」
类比一下:镜像是「分层追加、永不改写」的洋葱,删掉的一行密钥在旧层里永远躺着一份(见 postId 1450);Git 对象库同理——历史是不可变账本,revert 只是另起一页写「作废」,翻到旧页原文清清楚楚。
3. 轮换与清除的顺序不对称
应急场景(密钥已泄露)的正确顺序是先吊销旧密钥、再清载体:顺序反了等于「换了锁、钥匙还在旧门上」,清理期间旧密钥仍在生效窗口内可被调用(轮换的四段流水线设计见 postId 1744)。计划性清除则相反:先清载体、再收钩子防复发。
三、具体做法:五步清除手册(可复现)
前提:已确认泄露密钥为云 AK/SK(示例用阿里云 OSS 前缀
LTAI),仓库为git@github.com:org/pay-svc。
第 0 步:立刻轮换(先于一切清除动作)
# 云控制台:禁用旧 AK,签发新 AK;旧 AK 保留 24h 观察期(宽限窗口,参考双密钥重叠思路)
# 同步通知所有使用该 AK 的服务(本次盘点出 3 个服务 + 1 条 CI 流水线)
第 1 步:清本地对象库(所有 clone,含构建机)
# 不要用 git filter-branch,用 filter-repo(快 10 倍以上且无 reflog 残留)
git clone --no-local <url> pay-svc-clean && cd pay-svc-clean
git filter-repo --replace-text <(echo 'LTAI5t*==>REDACTED') --invert-paths # 二选一,按场景
# 精确场景:替换所有历史中的该字符串
git filter-repo --replace-text <(echo 'LTAI5tXXXXXXXXXXX==>***REMOVED***')
# 清完验证:
git reflog expire --expire=now --all
git gc --prune=now --aggressive
git fsck --unreachable | wc -l # 期望 0
git log --all -S 'LTAI5t' | wc -l # 期望 0
第 2 步:清远端与派生副本
- 主仓库:
git push --force(重写历史后必须 force,且需协调所有开发者 re-clone,切勿 rebase/pull); - fork:GitHub 上 fork 网络是独立仓库,逐个通知 owner 执行第 1 步 + force push;无法联系到的 fork 提 issue 公开披露,留审计痕迹;
- GitHub 搜索缓存:无法手动清除,提 support ticket 请求 purge 索引;实测从提交删除到索引失效约 48–72 小时;
- CI 构建机:删除本地 clone 与 BuildKit 缓存(
docker buildx prune -af),本次 2 台机器共释放 3.1GB 缓存,其中含 4 个带旧提交的缓存层。
第 3 步:全量验证(用扫描器,不用人眼)
# 全历史扫描(trufflehog 对 git 全对象库扫,不只看 HEAD)
trufflehog git file://./pay-svc --only-verified --json > scan.json
jq -r 'select(.DetectorName=="Aliyun") | .Raw' scan.json | wc -l # 期望 0
# 交叉验证
gitleaks detect --source . --log-opts="--all" --no-banner -v
两个工具都 0 命中才算干净。本次审计中 trufflehog 比 gitleaks 多扫出 1 个藏在 binary diff 里的 AK 片段——只信一个扫描器的结论是常见漏判来源。
第 4 步:收口防复发
- 仓库级
pre-commit钩子(gitleaks 规则集,allowed_secrets白名单留空); - GitHub 侧开启 push protection(GHES 3.11 及以上原生支持,服务端拦截含 secret 的 push,不依赖客户端钩子);
- CI 流水线加
gitleaksjob,失败即阻断(本次 14 个 fork 中 9 个没有任何扫描,push protection 是唯一能覆盖它们的层)。
四、踩过的坑(4 个反直觉点)
git rm后git log --all -S搜不到 ≠ 干净。-S走的是提交 diff,revert 之后 diff 里确实没有;但对象还在库里,git fsck --unreachable一查一个准。验证必须走「对象库物理层」,不能只看历史层。- reflog 是本地最大的"隐形副本"。 我见过一个团队清了 filter-repo、force push 完,结果新成员
git clone时本地 reflog 里躺着旧 HEAD,git reflog一敲就出来。reflog expire --expire=now --all必须和gc --prune=now配套执行,缺一个都留尾巴。 - GitHub 的 Web 搜索索引比你想的长命。 删除提交后 3 小时,
site:github.com的搜索结果里还能点到原页面;fork 的页面更是独立存活。按「索引 72h 内必然失效」做假设会误判清除完成时间。 - 轮换和清除的顺序在"应急"和"计划"两种场景下是反的。 应急(密钥已外泄):先吊销、后清除——清除期间旧密钥必须已经失效;计划(还没确认外泄):先清除载体、再吊销——避免清除窗口内业务断供。搞反任何一个,窗口期都是攻击者的免费时间(双密钥重叠窗口的设计原理见 postId 1744)。
五、结论与可复用清单
结论:密钥进过 git 历史,"删提交"是心理安慰,"轮换 + 全对象库清除 + 派生副本清点"才是安全动作。 暴露面 = 主仓库 × 1 + fork × N + 构建机 × M + 索引缓存 × 1,四个面都要清零,缺一不可。
可复用清单(按序执行):
- 先轮换密钥(旧密钥 24h 宽限观察期),再动 git;
git filter-repo --replace-text重写 →reflog expire --expire=now --all+gc --prune=now --aggressive→fsck --unreachable验证 0;- force push 主仓库,逐个通知 fork 重做第 2 步,CI 构建机删 clone +
buildx prune -af; - trufflehog + gitleaks 双扫描全历史,两个都 0 命中才算干净;
- 收口:pre-commit 钩子 + GitHub push protection + CI 阻断 job,三层中至少保证服务端一层生效;
- 时间预期:单仓库清除约 20–40 分钟(含扫描),fork 网络 × 14 规模约半天,GitHub 索引失效 48–72h,全程约 1–2 个工作日。
一句话记忆:revert 改的是「现在」,清除要管的是「所有曾经」——密钥进过历史,就按它永远在历史里来设计响应。