一次典型的翻车现场是这样的:你让编码智能体帮忙接一个第三方 API,它为了"让代码能跑起来",把 key 直接写进了 src/config.ts,顺手还补了一个能跑的示例。你 review 的时候只看了业务逻辑,git commit -a && git push,几秒钟后 key 就进了远端仓库。如果这个仓库是公开的,或者有外部协作者,事情就从"写错一行代码"变成了"凭据泄露事故"。
这篇能做出什么
照着走完,你会得到一个完整的闭环结果:
- 一份覆盖全部提交历史(不只是当前工作区)的泄露清单,知道哪把 key、在哪个 commit、哪个文件里;
- 一次有序的吊销与轮换,让泄露出去的那串字符变成废字符串;
- 一个改写过的 git 历史,密钥不再出现在任何分支、任何 tag 的任意一次提交里;
- 一道提交前拦截,让同类事情下次在
git commit阶段就被挡住; - 一份可以贴进团队文档的复盘结论。
顺序很重要:先吊销,再清理历史。反过来做,等于花两小时擦脚印,却把钥匙留在原地。
前置条件清单
动手前确认这几件事:
- 你对目标仓库有 admin 权限(改分支保护、强推、改仓库安全设置都需要);
- 你本地有一份完整克隆(
git clone而不是--depth 1),历史扫描必须有全量对象; - 准备好下面这些工具,用包管理器装即可,版本以各自官方文档当前版本为准:
gitleaks(扫描与提交前拦截)git-filter-repo(改写历史,Python 包,pip install git-filter-repo)- 可选:
trufflehog(带在线验证,能区分真 key 和假 key) - 通知到所有协作者:接下来会强推,大家需要重新克隆;
- 如果 key 属于云厂商,提前在控制台找到吊销/禁用入口,别等到清理到一半才发现找不到按钮。
第 1 步:确认泄露面,先把范围摸清
不要一上来就改历史。先搞清楚"漏了什么、漏到哪"。三个地方都要看:
远端平台告警。 GitHub 仓库的 Settings → Code security 页面能开启 secret scanning 和 push protection(可用范围以 GitHub 官方文档为准)。如果已经收到告警邮件,邮件里会写明仓库、commit、密钥类型,这是最权威的线索。
提交历史里的可疑文件。 先粗筛一遍:
```bash
看哪些提交动过常见的敏感文件名
git log --all --oneline --full-history -- .env .env.local '*.pem' '*.p12' 'config/secrets.*'
搜提交信息里有没有人贴过 key
git log --all --oneline --grep='key\|token\|secret' -i
```
编码智能体留下的痕迹。 这类事故通常有几个固定特征:示例文件(example.*、demo.*、test_*.py)、临时脚本(tmp/、scripts/)、注释掉的调试代码、.vscode/ 或 .idea/ 里的运行配置、以及 .claude/、CLAUDE.md 这类被一起提交的智能体配置文件。把这些路径单独列出来,后面扫描时重点看。
第 2 步:本地全历史扫描
用 gitleaks 把整个仓库的历史扫一遍,输出 JSON 便于逐条处理。不同版本的子命令命名略有差异,旧版用 detect,新版提供 git / dir 子命令,以官方文档当前版本为准:
```bash
gitleaks detect \
--source . \
--log-opts="--all" \
--report-format json \
--report-path leaks.json \
--redact
```
几个参数值得解释:
--log-opts="--all"是关键,不加它只扫当前分支,其它分支和 tag 里的泄露会被漏掉;--redact让报告里的密钥打码,避免这份 JSON 本身又变成新的泄露源;- 报告里的
Commit、File、RuleID、Secret四个字段足够定位问题。
想进一步降噪,可以用 trufflehog 做在线验证,它只报"能真实调通"的凭据:
```bash
trufflehog git file://. --only-verified
```
两边的结果对不上很正常:gitleaks 基于规则匹配,会报出已失效的旧 key;trufflehog 只验证当前有效的。按并集处理,失效的也当作泄露对待。
扫完先做分类,把结果拆成三堆:
1. 确认有效、且在用的 key——最高优先级;
2. 已失效但仍留在历史里的 key——中优先级;
3. 误报(测试用的假 key、文档里的占位符)——记录下来,后面加白名单。
第 3 步:判定影响面
同一把 key,泄露在公开仓库和私有仓库,处置节奏不一样。按下表快速判断:
| 情况 | 处置力度 |
|---|---|
| 公开仓库 + 有效生产凭据 | 立刻吊销,当作已被使用处理,查审计日志 |
| 公开仓库 + 只读/沙箱凭据 | 尽快吊销,检查权限边界 |
| 私有仓库 + 有效凭据 | 吊销 + 清理历史,检查协作者列表 |
| 任何情况 + 云厂商主账号凭据 | 立即吊销,走安全事件流程 |
特别提醒:key 是"泄露即失效"的,从 push 成功那一刻起就要假设它已经外泄。GitHub 上被公开的仓库会被爬虫在分钟级抓取,这一点在业内有大量公开案例。
同时去云厂商控制台看该凭据最近的调用记录:来源 IP、调用时间、调用的接口。如果出现你没见过的 IP 或异常接口,说明它已经被用过了,处置要升级。
第 4 步:先吊销、再轮换
这是整篇里唯一不能跳的一步。
吊销(Revoke):在云厂商控制台把旧凭据直接禁用或删除。注意有些平台叫"禁用",有些叫"删除访问密钥",语义都是让这串字符串立刻失效。
轮换(Rotate):签发新凭据,写进安全的地方——本地 .env(已在 .gitignore 里)、CI 的 Secrets、密钥管理服务。不要写进仓库里的任何文件,包括示例文件。
顺手做一次权限收敛:新 key 只给这个项目需要的权限,别再用主账号密钥。能用短期凭据(STS、OIDC 联合身份)就别用长期 AK。
轮换完成后,回到第 2 步的报告里,把已吊销的条目标注清楚,方便后面写复盘。
第 5 步:改写历史,把密钥从 git 里挖掉
现在才轮到清理。先做一份镜像备份,改坏了能回滚:
```bash
git clone --mirror https://github.com/your-org/your-repo.git repo-backup.git
```
准备替换文件 replacements.txt,每行一条"原字符串==>替换值":
```text
YOUR_LEAKED_KEY_HERE==>*REMOVED*
ANOTHER_LEAKED_TOKEN==>*REMOVED*
```
然后执行改写:
```bash
git filter-repo --replace-text replacements.txt --force
```
git-filter-repo 出于安全考虑,默认拒绝在非新鲜克隆上运行,所以 --force 在这里是必要的。如果某个敏感文件整个都不该存在于历史里(比如误提交的 .env),用路径删除更彻底:
```bash
git filter-repo --path .env --path config/secrets.yaml --invert-paths --force
```
改写完成后,必须做验证,别信"跑完了应该就没了":
```bash
全历史搜一遍,应该没有任何输出
git log --all -p -S 'YOUR_LEAKED_KEY_HERE' --oneline
文件级确认
git log --all --oneline --full-history -- .env
```
第二条命令如果没有输出,说明该文件已经从所有历史中被移除。再用 gitleaks 复扫一次,确认报告清空。
对于特别大的仓库,filter-repo 可能跑很久,可以先用 --partial 在浅克隆上试跑验证替换规则是否正确,再对全量仓库执行。
第 6 步:强推与协作同步
改写历史会让所有 commit SHA 变化,普通 push 一定被拒。需要:
```bash
git push --force --all
git push --force --tags
```
如果开了分支保护,先在仓库设置里临时放开,推完立刻恢复。
推完之后还有几件事容易漏:
- 删除所有 fork。fork 里保留着旧历史,清理主仓库不影响它们;
- 检查 Pull Request。已合并的 PR 页面可能还缓存着旧 diff;
- 检查 Actions 运行日志和 Release 附件。日志里打印过 key 的情况非常常见;
- 通知协作者重新克隆,不要在他们本地
git pull,否则旧提交会被推回来; - 如果仓库曾经是公开的,考虑联系平台支持清理缓存视图。这一步不一定能彻底解决,所以第 4 步的吊销永远是主防线。
第 7 步:把闸门装回去
清理是止血,拦截才是防复发。三层配置:
第一层:.gitignore。 把智能体最容易误提交的路径一次性挡住:
```gitignore
.env
.env.*
!.env.example
*.pem
*.key
.claude/settings.local.json
.vscode/settings.json
.idea/
```
第二层:pre-commit 钩子。 用 pre-commit 框架管理,配置写进仓库,团队共享:
```yaml
.pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: <以官方仓库当前 release tag 为准>
hooks:
- id: gitleaks
```
安装并启用:
```bash
pre-commit install
pre-commit run --all-files # 首次全量跑一遍
```
如果不想引入 pre-commit 框架,用 core.hooksPath 把钩子目录纳入版本管理也行:
```bash
mkdir -p .githooks
cat > .githooks/pre-commit <<'EOF'
#!/bin/sh
gitleaks protect --staged --redact --verbose
EOF
chmod +x .githooks/pre-commit
git config core.hooksPath .githooks
```
注意 gitleaks protect 在新版里可能已被 git --staged 取代,按官方文档当前版本写。
第三层:CI 兜底。 在流水线里加一个扫描 job,即使有人用 --no-verify 绕过本地钩子,也会在合并前被拦住。同时把仓库的 push protection 打开,让平台在 push 阶段就拒绝。
给编码智能体加一条硬规则。 在 CLAUDE.md 或类似的智能体指令文件里明确写:
```markdown
- 任何情况下不要把密钥、token、连接串写入仓库内的文件。
- 需要凭据时,从环境变量读取,并在示例中使用占位符。
- 新增配置文件时,同步更新 .gitignore 和 .env.example。
```
再配合"最小权限"的运行环境:给智能体用的凭据是只读的、短期的、限定仓库的,这样即使它犯错,损失也有上限。
常见坑与排错
扫描结果为空,但明明有 key。 九成是漏了 --log-opts="--all",只扫了当前分支。也可能是工作在浅克隆上,历史对象根本不存在,重新完整克隆再扫。
filter-repo 报 "Refusing to destructively overwrite repo history"。 这是保护机制,确认备份已做后加 --force。或者干脆重新 clone 一份干净仓库再跑。
强推被拒绝。 分支保护规则在拦,去设置里临时放开;也可能是协作者有未合并的提交,先协调。
清理完历史,扫描还是能扫到。 检查三处:其他分支有没有推上去、tag 有没有推、fork 有没有删。用 git log --all -S 'KEY' 定位是哪个 ref 还在。
pre-commit 从来没触发过。 确认执行过 pre-commit install,它会写 .git/hooks/pre-commit。如果是 core.hooksPath 方案,确认路径指向正确且脚本有可执行权限。
误报太多,钩子天天拦正常提交。 gitleaks 支持在仓库根目录放 .gitleaksignore,按 fingerprint 忽略特定条目;也可以在配置里给测试文件加 allowlist。原则是只忽略具体条目,不忽略整个规则。
同事本地 git pull 之后历史又乱了。 改写历史后必须让所有人重新 clone,这一点要在通知里写清楚,否则旧提交会被合并回来。
下一步建议
如果你刚走完这一遍,可以顺着往三个方向加固:
一是把这个流程脚本化,写成一个 scripts/secret-audit.sh,新人入职或季度检查时一键跑完扫描和报告生成;二是把密钥管理从 .env 迁到集中式方案,本地用 direnv 或命令行工具注入,CI 用平台 Secrets,生产用云厂商的托管密钥服务,让"仓库里出现明文 key"从物理上变得不可能;三是把智能体使用规范写进团队文档,明确它可以读哪些文件、可以用什么凭据、提交前必须自检什么,把这次事故变成一条可复用的规则。
泄露事故真正昂贵的部分从来不是清理历史的两小时,而是"以为清理完就没事了"的那份侥幸。先吊销,再清理,最后装闸门——这个顺序记住,剩下的都是操作细节。
