你执行 git push origin main 后,才发现这个 commit 里 config.py 写着 STRIPE_SECRET_KEY = "sk_live_abc123..."。或者收到 GitHub 的邮件:Secret scanning detected a Stripe API Key in commit abc1234。公开仓库在 push 后几秒内就会被爬虫扫到,所以请直接假设这把 key 已经泄露。
最快的修复顺序——务必按这个顺序,不要颠倒:
- 先在密钥提供方吊销并重新签发(rotate)泄露的凭证。 这一步最优先。只要旧 key 还有效,清历史就是白费功夫。
- 把仓库临时设为私有,边清理边挡住新的爬虫。
- 用
git filter-repo --sensitive-data-removal从所有历史里清除 secret,再git push --force --mirror origin。 - 用 TruffleHog 审计,并开启 push protection,杜绝再次发生。
最常见的错误就是只删文件、不吊销 key。爬虫早就拿到了明文,只有吊销才能让它失效。
先判断你属于哪种情况
| 情况 | 有效的旧 key 还危险吗? | 最低限度操作 |
|---|---|---|
| 几秒前刚 push,仓库公开 | 危险,按已被抓取处理 | 立即吊销,再清历史 |
| 收到 GitHub secret-scanning 告警 | 危险——GitHub 会通知提供方,但不会替你吊销 | 立即吊销,并在提供方后台确认 |
| 仓库原本公开,现已转私有 | 危险——clone 过的人本地仍有这把 key | 立即吊销,再清历史 |
| 仓库一直私有,仅内部使用 | 风险较低,但有读权限的人都拿得到 | 有疑虑就吊销,再清历史 |
| secret 是占位符 / 假 fixture | 不危险 | 无需吊销,把假值改成明显是假的即可消除告警 |
截至 2026 年 6 月,GitHub secret scanning 对公开仓库免费且始终开启。对于其合作伙伴计划内的凭证类型(Stripe、AWS、GitHub PAT、OpenAI、Slack、Supabase、Vercel 等 100 多种),GitHub 会把匹配结果转发给签发该凭证的提供方,由对方决定是否吊销或标记。GitHub 本身永远不会替你 rotate 凭证,所以第 1 步绝对不能跳过。
常见原因
按命中率从高到低排列。
1. 直接把硬编码凭证提交到配置文件里
为了本地测试,把真实 API key 写进 config.py、.env 或 application.properties,没检查内容就提交了。
怎么判断:git show HEAD -- config.py | grep -i "key\|secret\|password\|token" 会在 diff 里直接显示这条凭证。
2. .env 文件没有加入 .gitignore
为本地开发创建了 .env,但仓库没有 .gitignore,或者 .gitignore 里没列 .env,于是 git add . 把它一并加了进去。注意:事后再加 .gitignore 不会自动取消追踪已被 Git 追踪的文件。
怎么判断:本以为已忽略,git ls-files | grep "\.env" 却还能列出该文件;grep "\.env" .gitignore 没有任何输出。
3. 测试 fixture 或种子数据里的真实 secret
开发者为了让某个测试通过,把占位符替换成了真实 key,然后把 fixture 提交了上去。
怎么判断:git grep -n "sk_live\|ghp_\|AKIA\|AIza\|SG\.\|xoxb-" -- "*.json" "*.yaml" "tests/" 会在测试目录里找出真实凭证的格式。
4. Notebook 的 output cell 里带了凭证
Jupyter notebook(.ipynb)把 output cell 以 JSON 形式存在文件里。运行时打印出来的 secret 会被序列化进提交的 notebook。
怎么判断:git show HEAD -- notebook.ipynb | grep -iE "sk_live|ghp_|AKIA|AIza" 能在序列化的 output 里找到这个值。
5. secret 被编进了二进制 / 编译产物
提交进仓库的编译后二进制文件里把 secret 烤了进去。
怎么判断:git show HEAD --stat | grep -E "\.so|\.exe|\.dll|\.wasm" 列出已提交的二进制,再对每个跑 strings binary_file | grep sk_live。
最短修复路径
Step 1:立即吊销(revoke)泄露的凭证 — 这一步最优先
直接去提供方创建一把新凭证,再停用旧的:
Stripe: dashboard.stripe.com > Developers > API keys > Roll key
GitHub PAT: Settings > Developer Settings > Personal access tokens > Delete(再重新生成)
AWS: IAM > Users > Security credentials > Create access key,然后 Deactivate 旧 key
OpenAI: platform.openai.com > API keys > Revoke(再 Create new secret key)
Twilio/SendGrid 等: 对应的吊销页面
数据库: 立即修改密码
旧 key 现在已失效。在它无法被使用的状态下继续清理。
Step 2:把仓库设为私有(临时措施,不是修复)
GitHub: Settings > General > Danger Zone > Change repository visibility > Make private
这能在你重写历史期间挡住新爬虫。清理完后想公开再公开。
Step 3:如果是几秒前刚 push 且无人 clone,可以 amend
仅当出问题的 commit 就是分支顶端、且基本是你一个人的仓库时才安全:
# 先从文件里删掉 secret,再执行:
git add config.py
git commit --amend --no-edit
git push --force-with-lease origin main
只要有人可能 clone 或 fork 过,就跳过这步直接做 Step 4——amend 只重写最新一个 commit,secret 仍留在更早的 commit 或别人的副本里。
Step 4:用 git-filter-repo 从所有历史中清除 secret
git filter-repo 是 GitHub 官方推荐的工具(已取代缓慢且废弃的 git filter-branch)。先安装:
brew install git-filter-repo # macOS
pip install git-filter-repo # 跨平台
然后要么在每个 commit 里把 secret 字符串替换掉,要么整文件删除。--sensitive-data-removal 是 GitHub 文档针对本场景给出的调用方式:
# 方案 A —— 在每个 commit 里把明文 secret 替换成占位符。
# 新建 expressions.txt,每行一条规则:
# sk_live_abc123...==>***REMOVED***
git filter-repo --sensitive-data-removal --replace-text expressions.txt
# 方案 B —— 把出问题的整个文件从所有历史中删除
git filter-repo --sensitive-data-removal --invert-paths --path config.py
如果仓库有未提交改动、或默认情况下不是一份全新 clone,git filter-repo 会拒绝运行——确认接受重写后加 --force。它还会故意移除你的 origin remote,所以下一步要重新添加。
Step 5:把重写后的历史 force push 到每个分支和 tag
git remote add origin <url> # filter-repo 把 remote 删掉了
git push --force --mirror origin # GitHub 推荐的一次性写法:所有分支 + tag
如果 --mirror 对你的 remote 太激进(它会删掉本地没有的远端 ref),就改成显式推送:
git push --force-with-lease --all
git push --force-with-lease --tags
Step 6:清理本地对象,然后审计并锁死
# 让 reflog 过期并在本地回收旧对象
git reflog expire --expire=now --all
git gc --prune=now --aggressive
# 审计工作区和完整历史里是否还有其他有效 secret。
# 扫历史用 git 子命令(本地路径必须加 file:// 前缀):
npx trufflehog filesystem . --only-verified
npx trufflehog git file://"$(pwd)" --only-verified
--only-verified 只保留 TruffleHog 确认仍然有效的 secret,免得你去追那些已经吊销的旧 key。
Step 7:缓存视图与 fork(最容易被忘的部分)
- 缓存的 commit 视图:重写历史后,GitHub 可能还会在一段时间内按 SHA 在某个 URL 上提供旧 commit。去 support.github.com 提交请求,说明 secret 被推送、历史已重写,请求清除缓存视图。按 GitHub 政策,只有当风险无法靠吊销凭证消除时 Support 才会删除数据——这正是 Step 1 必须最先做的原因。
- fork:fork 是独立仓库。你的 force-push 碰不到它,也无法触及你不拥有的 fork。在 fork 拥有者重新 clone 或删除之前,泄露的 commit 在那份 fork 里依旧可达。真正能消除这一影响的只有吊销凭证。
如何确认已经修好
git log --all --oneline -S "sk_live_abc123"(或按文件:git log --all --oneline -- config.py)没有任何输出——字符串和/或文件已从每个分支和 tag 消失。- 在 GitHub 上按 SHA 打开那个旧 commit,应当返回
404(给几分钟时间;若一直还在就请求清缓存)。 npx trufflehog git file://"$(pwd)" --only-verified报告 0 个有效 secret。- 在提供方后台,旧 key 显示已吊销/已删除,应用与 CI 已改用新 key。
- 在 GitHub Security 标签页,secret-scanning 告警已 Close as Revoked。
预防建议
- 在首次提交前就把
.env加入.gitignore:echo ".env" >> .gitignore && git add .gitignore。若已被追踪,用git rm --cached .env再提交。 - 仓库里只提交含占位值的
.env.example,并写明开发者各自本地复制成.env。 - 开启 GitHub push protection:Settings > Security > Advanced Security > Secret protection。截至 2026 年 6 月,它对所有仓库(含公开仓库)免费,会在包含已知格式 secret 的 push 到达远端之前拦截。(公开仓库的检测始终开启;仓库级别的拦截需管理员手动启用。)
- 安装提交前扫描器——
detect-secrets或gitleaks——在 secret 被提交之前就拦下来。 - 测试 fixture 用明显是假的值(如
sk_test_placeholder),不要用看起来像真的 key。 - 在运行时从 CI/CD secrets、专用 secret 管理工具(AWS Secrets Manager、Doppler、HashiCorp Vault)或 Kubernetes Secrets 注入凭证——绝不从提交进仓库的文件里读取。
- 定期 rotate 凭证,这样旧历史里万一泄露的 key 也早已失效。
常见问答 (FAQ)
Q: 我已经删掉文件并重新提交了,现在安全了吗?
A: 不安全。删文件只是新增了一个「删除文件」的 commit,原来那个 commit 里仍有明文,git show <old-sha> 能完整看到。必须用 git filter-repo 重写历史(Step 4)才能真正清除——而且你仍然必须吊销这把 key。
Q: GitHub secret scanning 说它「已上报」这个 secret,是帮我吊销了吗? A: 没有。对合作伙伴计划内的凭证类型,GitHub 只是通知签发该凭证的提供方,由对方决定是否吊销。GitHub 永远不会替你 rotate 凭证。请到提供方后台确认,并自己签发一把新 key。
Q: 仓库现在已转私有,安全了吗? A: 更安全,但不是安全。公开期间 clone 或 fork 过的人手里仍有这把 key,暴露的那几秒里爬虫也可能抓走了。无论当前可见性如何,泄露的 secret 都按已泄露处理。
Q: 能不能改用 BFG Repo-Cleaner?
A: 可以。在 replacements.txt 里写 old_secret==>***REMOVED***,运行 bfg --replace-text replacements.txt,再执行 git reflog expire --expire=now --all && git gc --prune=now --aggressive。BFG 处理大仓库很快,但 git filter-repo 覆盖的边界情况更多,也是 GitHub 推荐的工具。
Q: 我们有 50 个 fork,force-push 会更新它们吗? A: 不会。fork 是独立仓库,你的 push 无法重写它们。请逐个让 fork 拥有者重新 clone,或向 GitHub Support 申请删除 fork。无论如何,吊销凭证才是真正的修复,能让泄露的 key 失效。
Q: 我重写历史后,协作者的分支会不会把 secret 又带回来? A: 有可能。基于受污染历史拉了分支的同事,必须把工作 rebase 到清理后的历史上,而不是 merge——一个 merge commit 就可能把旧对象拖回来。force-push 之后让所有人重新 clone 或 rebase。
相关阅读
- Claude Code commit 了 secret
- 历史里的大文件让 push 失败
- Clone 之后 git hooks 不执行
- 凭证 helper 锁住,pull / push 全失败
- Force push 覆盖了队友的 commit
- 找回 Git 历史里的旧版本文件
标签: #git #version-control #排查