Claude Code 误把密钥 commit 进 git:先 rotate、再清史、后封堵

Claude Code 把你的 `.env` 或 API key 卷进了 commit。先 rotate 密钥(永远第一步),再清 git 史,最后用 deny 规则、scanner、push protection 彻底封堵。

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,不在 diffrotate;字符串在 reflog/refs 里,仍需重写 history

不确定有没有推出去,跑 git log origin/main..HEAD——带密钥的 commit 出现在里面就是本地独有;不出现就说明已经到了远端。

常见原因

按命中率从高到低:

1. Agent 用了 git add .git add -A

改完执行 catch-all add。.env.local.env.productionsecrets.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/gitleaksid: 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 }}

如何确认已修复

宣布事件关闭前,过一遍这份清单:

  1. key 已失效。 用旧 key 打一下 provider(比如一次 curl 测试调用)——应该返回 401/已吊销,而不是成功。
  2. history 里没了。 git log -p -S '<secret>' --all 什么都不打印。
  3. 哪里都没 stage。 git status 看不到 .env* 或密钥文件,git check-ignore -v .env.local 确认它被 ignore。
  4. deny 规则生效。 让 Claude Code 跑 git add .——它应该以权限拒绝消息拒掉,而不是 stage 文件。
  5. 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 #排查 #排查 #安全 #密钥