Claude Code 跑了 git add . && git commit -m "...",diff 里包含 .env.local——你的 Stripe live key 就在里面。或者它把上次调试遗留的真 OpenAI key 加进了测试 fixture。commit 还在本地——但只要推了,密钥就上了远端 git 史、GitHub 的搜索索引、以及任何在你发现前就 pull 过的 fork。
最快处置(现在就做):先到 provider 那里 rotate 泄漏的 key,然后再把它从 history 里清掉。只有 rotate 这一步真正堵住了暴露口,其它步骤只是减少能发现它的人。顺序很关键——先 rotate、再重写 history,绝不反过来。
接着用三道不依赖 agent 自觉的防线挡住下一次:settings.json 里一条 permissions.deny 规则硬性禁掉 git add . 和 git add -A、一个 pre-commit secret scanner、以及 GitHub push protection。Agent 没法可靠区分「密钥」和「测试数据」——所以正解是让宽 add 根本跑不了,而不是指望模型小心。
你属于哪种情况?
| 情况 | 暴露范围 | 第一步 |
|---|---|---|
| 本地 commit、还没推 | 仅在磁盘 | 若 key 曾进过被备份/被索引的文件就 rotate,然后 git reset(Step 2) |
| 已推到私有 repo | 远端 history + 协作者 | 立刻 rotate,再 git filter-repo + 强推(Step 2) |
| 已推到公开 repo | 落地那一刻即公开 | 立刻 rotate;假定 bot 已经抓走了。重写 history 是清理,不是止损 |
| 在 commit message 或分支名里 | git metadata,不在 diff | rotate;字符串在 reflog/refs 里,仍需重写 history |
不确定有没有推出去,跑 git log origin/main..HEAD——带密钥的 commit 出现在里面就是本地独有;不出现就说明已经到了远端。
常见原因
按命中率从高到低:
1. Agent 用了 git add . 或 git add -A
改完执行 catch-all add。.env.local、.env.production、secrets.json、SSH key——只要不在 .gitignore 里都被扫进来。这是迄今最常见的路径:模型想把「自己的成果」都 stage 进去,顺手就抓了最宽的命令。
如何判断:跑 git show --stat HEAD(或在客户端里看 commit 的文件列表)。任务范围外的文件出现,就是宽 add。
2. .gitignore 没覆盖那个路径
.env 已 ignore 但项目还读 config/secrets.json 或 .env.development.local——要么 gitignore 过期,要么从没盖到这条路径。
如何判断:git check-ignore -v <secret-path> 没返回——文件没被 ignore。
3. 测试 fixture / doc 里硬编了密钥
.test.ts 里写了真 key 来调试”一会儿”。Agent 看作合法测试数据 commit 了。
如何判断:密钥出现在和 config 无关的文件——测试、doc、script——来源是「调试遗留」。
4. .env.example 里写了真值不是占位
加新变量时 STRIPE_LIVE_KEY= 文档化到 .env.example,结果打了真 key 而不是 <your-key-here>。Agent commit .env.example(这个文件不在 gitignore)。
如何判断:.env.example 里有真值样的字符串而不是占位符。
5. token 进了 commit message 或分支名
分支叫 fix/sk-test-abc123-payment-bug(字面 key);或 commit message 里有 // debug: sk_live_xxx——diff 里没但 git metadata 漏了。
如何判断:在 history 里搜 sk_、ghp_、xoxb_ 这类前缀,包括 commit message。
6. 生成产物里嵌了密钥
构建输出、source map、编译 bundle 内联了 secret。Agent 还 commit 了 dist/(本来就不该 commit)。
如何判断:密钥在生成文件里。dist/、build/、out/、coverage/ 根本不该进 git。
最短修复路径
按紧迫度排序。已推的情况下 Step 1 和 2 必须 5 分钟内做完。
Step 1:立刻 rotate 密钥
假定已泄漏。任何 push 出去的密钥从落地那一刻起就当公开——公开 repo 的爬虫和 bot 常在几秒内就标记出新 key,GitHub 自己也会通知很多 provider(Stripe、OpenAI、AWS)好让它们自动吊销。立刻轮换:
Stripe → Developers → API keys → roll 受影响的 key
OpenAI → API keys → revoke,再新建
AWS → IAM → Security credentials → 停用 key,再新建
GitHub → Settings → Developer settings → Personal access tokens → revoke,再重建
更新 .env.local 和部署平台(Vercel、Netlify、Railway 等)的环境变量为新值,再确认生产用新 key 还能跑。旧 key 要 revoke 而不只是换掉——「留一天保险起见」就把这步的意义抵消了。
「我推之前就 catch 到了」也别省这一步。文件曾经明文落在磁盘上;只要机器被备份过、同步到云盘、或被任何工具索引过,这个值可能已经溜出去了。
Step 2:从 git 史里删(若已推)
未推的:
# 简单情况:还没推,密钥只在 HEAD
git reset HEAD~1
# 改/删文件里的密钥
git add <other-files-only>
git commit -m "..."
已推的需要重写 history。截至 2026 年 6 月,git filter-repo 是 Git 官方文档指向的工具(filter-branch 已弃用、极易把重写搞坏);如果只需要清掉某个文件或字符串,BFG Repo-Cleaner 是更快更简单的替代。
# 用 git-filter-repo(推荐,比 filter-branch 好)
pip install git-filter-repo
# 删整个文件的所有历史
git filter-repo --invert-paths --path .env.local
# 或在所有 blob 内容里替换特定字符串(替成 ***REMOVED***)
echo "sk_live_REAL_KEY_HERE==>REMOVED" > /tmp/secret-replacements.txt
git filter-repo --replace-text /tmp/secret-replacements.txt
# 强推(要和团队协调——history 被改写)
git push --force-with-lease origin main
收手之前确认密钥真的从每个 commit 都消失了:
git log -p -S 'sk_live_REAL_KEY_HERE' --all # 应该什么都不打印
git grep -n 'sk_live_REAL_KEY_HERE' $(git rev-list --all) # 应该什么都不打印
强推后每个协作者都得重 clone 或把改动 rebase 到重写后的 history 上。有 fork 或本地旧 clone 的人可能仍持有密钥——这正是 Step 1(rotate)不可省略的原因。
repo 公开的话,密钥在落地那一刻就已公开。rotate 才是真正堵住口子的那步;重写 history 只减少随手发现的概率,并不能撤销已发生的暴露。
Step 3:用 deny 规则硬性禁掉宽 git add
CLAUDE.md 里的指示只是模型可以忽略的提示。settings.json 里的 permissions.deny 规则由 Claude Code 自己强制执行——被 deny 的命令根本不会跑,而且 user 级 deny 不会被 project 级的 allow 覆盖。截至 2026 年 6 月,Claude Code 按 deny → ask → allow 的顺序评估权限,所以这是现成最强的一道防线。
加到 ~/.claude/settings.json(user 级,覆盖所有项目)或 .claude/settings.json(项目级):
{
"permissions": {
"deny": [
"Bash(git add .)",
"Bash(git add -A)",
"Bash(git add --all)",
"Bash(git add:*)"
]
}
}
注意匹配的是字面命令字符串,所以每种写法都要列上;最后那行 Bash(git add:*) 再兜住任何带参数的其它形式。如果这行对你的工作流太狠(它会挡掉所有带参数的 git add),删掉它、保留前三条显式写法即可。
再配一条 CLAUDE.md 政策,让模型也理解意图、伸手就抓对命令:
## Git 政策
永远不要用 `git add .`、`git add -A`、`git add --all`。
始终 stage 显式路径:`git add src/billing.ts src/billing.test.ts`
(或用 `git add -p` 逐 hunk 接受)。
每次 commit 前 `git status` 一遍,确认只有预期文件被 stage。
没有宽 add,密钥就没法被意外卷进来——这一层直接干掉来源 #1。
Step 4:装 pre-commit secret scanner
选一个:
# git-secrets(AWS 风格模式)
brew install git-secrets
git secrets --install
git secrets --register-aws
# detect-secrets(Python,可配置性强)
pip install detect-secrets
detect-secrets scan > .secrets.baseline
或走 pre-commit 框架,让全团队都跑:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
然后 pre-commit install。现在每个 commit(agent 的、你的)创建前都先扫一遍。想用 gitleaks 代替?同样的方式加它的 hook(repo: https://github.com/gitleaks/gitleaks,id: gitleaks)——同样的防护,开箱即带更广的 provider 模式。
Step 5:收紧 .gitignore + .env.example 检查
# .gitignore
.env
.env.local
.env.*.local
.env.production
secrets/
*.pem
*.key
*.p12
config/secrets.json
CI 加一条:.env.example 不能有真值:
# scripts/check-env-example.sh
if grep -E 'sk_(live|test)_[a-zA-Z0-9]{20,}|ghp_[a-zA-Z0-9]{36}' .env.example; then
echo ".env.example 里有像真密钥的字符串"
exit 1
fi
Step 6:开 GitHub push protection + CI 扫描
pre-commit hook 只对装了它的开发者生效。两道服务端防线则总会运行。
push protection 要第一个开。它在密钥到达远端之前就挡住 git push,并标出涉事文件、行号和密钥类型,让凭证根本进不了 history。截至 2026 年 6 月,它对公开 repo 免费(私有 repo 需 GitHub Secret Protection);GitHub 在 2026 年 3 月扩了一批新 provider 的覆盖,且对其中很多默认开启 push protection(Vercel、Supabase、Databricks、Shopify 等)。在 repo Settings → Code security → Secret scanning → Push protection 里开。
CI 扫描兜住漏网的——对已经在 history 里、或 push protection 不认识的格式特别有用:
# .github/workflows/secret-scan.yml
on: [push, pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
如何确认已修复
宣布事件关闭前,过一遍这份清单:
- key 已失效。 用旧 key 打一下 provider(比如一次
curl测试调用)——应该返回401/已吊销,而不是成功。 - history 里没了。
git log -p -S '<secret>' --all什么都不打印。 - 哪里都没 stage。
git status看不到.env*或密钥文件,git check-ignore -v .env.local确认它被 ignore。 - deny 规则生效。 让 Claude Code 跑
git add .——它应该以权限拒绝消息拒掉,而不是 stage 文件。 - scanner 会触发。 往文件里塞个假的
sk_live_TEST123...试着 commit——pre-commit hook 应该挡下。
预防建议
四道独立防线,从强到弱排——每一道兜住前一道漏的:
- deny 规则(
settings.json):硬性禁掉git add ./-A/--all,让 agent 根本跑不了;再配一条 CLAUDE.md 政策 - pre-commit scanner(detect-secrets / gitleaks / git-secrets):用
pre-commit装,全团队都跑 - GitHub push protection 开着,泄漏的 key 在到远端前就被挡
- CI 扫描(gitleaks-action)作为总会跑的兜底
外加把诱饵直接清掉的卫生习惯:
- 密钥只放
.env.local、gitignore;.env.example只放占位符 - 测试 / fixture / doc 里不放真 key——用明显的假值
sk_test_FAKE_xxx - 季度审计
git log -S '<prefix>'找旧泄漏密钥——还在 history 里的全 rotate - 任何已推泄漏的处置顺序:先 rotate、再重写 history——绝不反过来
FAQ
我推之前就 catch 到了——还非得 rotate 吗? 如果是真实的活密钥,要。这个值曾明文落在磁盘上;只要机器同步到云盘、被备份、或被任何后台工具索引过,它可能已经出去了。rotate 很便宜,赌自己安全不便宜。
repo 是私有的——推进去的密钥还算问题吗? 比公开的风险低,但仍当作已泄漏处理。每个 pull 过的协作者本地 history 里都有它,而私有 repo 可能被改成公开、被 fork、或日后 access token 自己泄漏。先 rotate,再重写 history。
我能不能不重写 history,直接在新 commit 里删掉文件?
不行。后续 commit 只是把文件从 HEAD 移除,但密钥永远留在更早那个 commit 的 blob 里——git log -p 和 GitHub 的 history 视图照样看得到。你必须重写 history(或者 rotate,让旧值变得一文不值)。
permissions.deny 规则会不会把正常 commit 也搞坏?
不会。它只挡宽的 git add . / -A 形式(加上 Bash(git add:*) 的话则挡所有带参形式)。stage 显式路径或用 git add -p 照常可用。某个项目确实需要宽 add 的话,把 deny 只放 user 级,再按项目覆盖。
GitHub push protection 要花钱吗? 对公开 repo,截至 2026 年 6 月免费。私有 repo 需要 GitHub Secret Protection。无论哪种,Step 4 的 pre-commit scanner 都能在本地给你等效覆盖、零成本。
为什么先 rotate 再重写 history,而不是反过来? 因为重写要花几分钟协调(强推、人人重 clone),而且永远够不着 fork 和已有 clone。这段窗口里活 key 仍可被利用。rotate 立刻让泄漏的值一文不值,所以每次都先做它。
相关阅读
标签: #Claude Code #排查 #排查 #安全 #密钥