密钥泄露应急响应
一份应对任意 git 托管软件项目中密钥泄露的运行手册:轮换凭据,隔离或清除每一份副本,并用机械化的检查证明事件已经关闭。它适用于 API key、令牌、数据库密码、私钥、服务凭据以及整份 env 文件;与语言、框架、git 托管平台无关。
命令示例假定 POSIX shell——Windows 上请从 git-bash 或 WSL 里执行;git 命令本身在各平台上行为一致。
各阶段这样排序,是为了让每一步都让下一步更安全。在单个阶段内,顺序比速度更重要;跳过前面通常会造成返工,或让真正的风险原地不动。
目录
- 何时使用
- 手册地图
- 操作原则
- 反模式与常见问题(Anti-patterns & FAQ)
- Phase 0 —— 分诊:泄露了什么、扩散到哪
- Phase 1 —— 先轮换,再碰历史
- Phase 2 —— 构建期注入的密钥要重建,而不是改配置
- Phase 3 —— git 历史:改写还是冻结(这是决策,不是条件反射)
- Phase 4 —— 残留清扫
- Phase 5 —— 验证关闭与事件记录
- 错误处理约定
- 失败模式与恢复
- 完整走查(端到端样例)
何时使用
自动触发:用户描述与下表场景相关时直接启用,不需要点名本技能。
| 用户会这么说 | 实际场景 |
|---|---|
| 「完了,我把 .env 推到 GitHub 了」 | 密钥进 git → Phase 1 轮换、Phase 3 历史处置 |
| 「前端 bundle 里能搜到 API key」 | 构建期内联 → Phase 2 |
| 「CI 日志里把 token 打出来了」 | 日志扩散 → Phase 4 残留清扫 |
| 「要不要用 filter-repo 把历史洗掉」 | 历史处置决策 → Phase 3 |
| 「轮换完了,怎么证明这事过去了」 | 零命中验证与关闭 → Phase 5 |
手动调用:对话里说「用密钥泄露应急响应处理」,或直接用 @ 引用本技能(平台的完整 slug 可能带作者后缀,不必写全名)。
不适合:没有确认泄露的一般安全评审或渗透测试——先走安全评估流程,本手册按「已泄露」处置。;占位符含义与取值来源见 references/error-handling.md 第六节。
手册地图
| 阶段 | 目标 | 进入下一阶段的闸门 |
|---|---|---|
| 0 分诊 | 盘点泄露了什么、扩散到哪 | 每一项都已归类为「设计上公开」或「必须保密」 |
| 1 轮换 | 替换每一个泄露值、作废旧值 | 提供方明确以拒绝证明旧值已失效 |
| 2 重建 | 清除固化进已部署产物里的值 | 在构建产物里搜旧值结果为零 |
| 3 历史 | 决定改写还是冻结,执行,验证 | 选定路径已在每个 ref 与每个镜像仓库上执行 |
| 4 清扫 | 清除其它各处的残留副本 | 每处要么已清除,要么记为已接受风险 |
| 5 关闭 | 证明关闭并写下记录 | 四项零出现检查通过;记录已写 |
操作原则
- 轮换优先于清理。 轮换过的密钥在所有地方同时作废。只从历史里删掉、但仍有效的密钥,依然是一张活凭据。绝不要用一次历史改写去换掉一次轮换。
- 用拒绝来验证,不靠假设。「我改过了」不算关闭。旧值必须被证明已死(提供方拒绝它)、并且不存在(搜索结果为零)。
- 假定副本已经存在。 任何到过公开或半公开面的东西,都已被爬取、缓存、fork、截图或备份。按「至少有一样副本不在你控制之内」来规划。
- 边做边写下一切。 轮换日期、范围与未结项都写进事件记录。只活在某人记忆里的部分轮换,会变成下一次事件。
本手册的交付物:一份分诊后的清单、已轮换的凭据、一个历史处置决定(改写、冻结或两者并用)、一次残留清扫,以及一份关闭记录。
反模式与常见问题(Anti-patterns & FAQ)
反模式(顺序做错,等于没做)
- 先改 git 历史,后轮换密钥:历史改写的窗口期里密钥仍然有效。正确顺序是 Phase 1 轮换 → Phase 2 重建产物 → Phase 3 再决定历史处置。
- 只删文件、不轮换:从工作区删掉
.env、force-push 覆盖提交,都影响不到已经拿到密钥的人。删除只解决「以后不再泄露」,轮换才解决「已经泄露」。 - 忽略构建产物与镜像层:前端 bundle 内联的 key、镜像历史层里 COPY 进去的配置,改源码不会消失——必须重建并重新打镜像(Phase 2)。
- 忘记 CI 日志、构建缓存、截图与工单附件:这些是最常见的二次扩散途径;Phase 4 的残扫范围要覆盖 CI artifact、PR/工单附件、聊天截图与备份。
- 没有零命中验证就宣布结束:Phase 5 的可复现检查(全仓库、全镜像、全历史)与时间戳留档,才是「关闭事件」的依据。
- 把轮换当一次性动作:没有登记未完成轮换(Phase 1.3),几周后没人记得哪把密钥还没撤销。
常见问题(FAQ)
Q:密钥只出现在私有仓库里,还要轮换吗?
要。私有 ≠ 安全:协作者、fork、CI 缓存与备份都可能拿到过;先按「已泄露」处理,再按实际暴露面决定后续处置强度。
Q:必须改写 git 历史吗?
不必。判断标准在 Phase 3:仓库已公开或已分发时,改写历史清不干净下游副本,冻结归档 + 轮换 + 监控更划算;只有内部分发可控时才值得 git filter-repo 全量改写。
Q:轮换后旧密钥能留一段时间做灰度吗?
只在有明确兼容窗口时才允许,并且要登记到期时间与撤销动作(Phase 1.3 的未完成轮换清单);没有撤销计划的「两把都能用」就是长期风险。
Q:没有权限改 CI 流水线或仓库历史怎么办?
先把分诊结果与建议动作写进事件记录并升级给有权限的人;分诊与轮换仍要你自己做完,不要因为拿不到权限就停在原地。
Phase 0 —— 分诊:泄露了什么、扩散到哪
动手改任何东西之前,先建一份清单。对每个泄露项回答:它是什么、出现在哪、何时首次暴露、谁可能看到过。
0.1 收集泄露清单
逐个走查所有扩散面。泄露很少停留在最初被发现的地方:
- git 历史:不只是最新一次 commit。密钥提交过一次,就存在于包含它的每一个 commit 里,也存在于每一个做过的 clone 里。commit message 也要查——密钥同样会漏进 message。
- 前端构建产物:部署到 CDN 或托管平台的 JS bundle、静态 chunk 与 source map。
- 容器镜像:镜像层会把构建时存在的一切固化进去,包括 build-arg 的值和被拷贝的文件。已推送的层在 registry 里持久存在,即使 tag 被覆盖也还在。
- CI/CD 日志:构建与部署日志会回显环境变量、失败命令与堆栈。凡有仓库或 CI 访问权的人都能读日志,而且日志常常被无限期保留。
- 聊天、工单与截图:聊天串、issue tracker 与 wiki 里粘贴的配置、调试输出与截图。
- 备份与归档:数据库 dump、卷归档、项目目录副本、旧工作站。
0.2 对每一项归类
- 设计上公开:本就打算随客户端代码发布的值——可公开的 key 与客户端标识符,其提供方本来就预期它们可见。暴露是正常的。轮换是可选项;应当去查提供方对这类 key 实际提供哪些限制(origin/referrer 白名单、scope、服务端滥用防护)并配上。限制能力因提供方而异——到提供方文档里确认,不要假设一定有域名白名单。
- 必须保密:任何能换到访问权的东西——私钥、密码、提供方 API key、会话令牌、连接串。这些驱动本手册的其余部分。
拿不准时,按「必须保密」归类。多轮换一次只花几分钟;少轮换一次要付整个事件。
分诊产出:一张表,列为 条目 / 暴露在哪 / 首次暴露时间 / 归类 / 扩散面。后续每个阶段都引用这张表。
0.3 控制仍在扩散的东西
控制在被捕获之前才有用;它永远替代不了轮换。
- 如果密钥几分钟前刚推到有访问控制的远端,删分支也许能抢在人眼之前。把这样换来的时间当额外奖励,不是修复——轮换照做。
- 在公开远端上,假定几分钟内就被捕获。别演「删了就没事」这出戏,直接进 Phase 1。
- 尽可能撤销分享链接,删除仍可编辑的截图与聊天消息。
- 提供方支持的话,立刻轮换已暴露的会话令牌:它们一撤销就立即失效,常常是整个事件里最快的得分点。
Phase 1 —— 先轮换,再碰历史
在做任何关于 git 历史的决定之前先轮换。理由是:只清历史不轮换,所有现存副本——fork、clone、缓存、截图——手里仍是一张有效凭据。只轮换不清理,历史里只留下一具难看的尸体。前者让事件变得无害,后者不能。
1.1 生成并保存替换值
- 新值必须是高熵随机:生成器产出的一长串随机 hex 或 base64,或提供方自己的重新生成按钮。绝不要旧值的变体,绝不要人类能记住的东西。
- 新值只存进被 gitignore 的 env 文件或密钥管理服务。如果落进了文件,在它第一次 commit 之前先确认该文件已被忽略(
git check-ignore <path>)。
1.2 部署、撤销、验证——按这个顺序
- 部署新值到每一个消费方:应用 env、CI 变量、容器 env,以及开发机配置。如果密钥是构建期注入的,先重建(Phase 2)——已部署的产物才是消费方。
- 在提供方撤销旧值:控制台、管理 API,或移除配置。真正杀死密钥的是提供方侧的撤销;从自己仓库里删掉并不算。
- 验证旧值被拒绝:用旧值真实发起一次带认证的调用,确认返回授权失败(401/403,或该提供方等价的未授权响应)。证据带时间戳留存。
- 验证新值可用(在生产上),免得事件演变成一次故障。
1.3 显式跟踪未完成的轮换
有些凭据没法立刻轮换:与第三方共享、嵌在已出货的客户端设备里,或归另一个团队所有。每一条都作为未结项写进事件记录,带上负责人与目标日期。「轮换了一部分」却永远不完成,是本阶段最常见的失败模式——悄悄留着的未结项比从未开始的更糟,因为所有人都以为它做完了。
1.4 按依赖顺序轮换
对一个被多个系统消费的凭据,从 Phase 0 的表里梳理出消费方,按刻意安排的顺序更新:先预发环境做彩排,再更新生产消费方,最后是开发机。这能避免经典的次生故障——撤销之后还有消费方指着旧值。
如果提供方根本不提供撤销——有些自建或遗留系统就是不能——就上临时措施,例如网关拒绝规则、IP 白名单或网络边界,并把该值记为未结项,直到能做真正的轮换。临时措施限制的是谁能用这个泄露值;只有轮换才能让它退役。
Phase 2 —— 构建期注入的密钥要重建,而不是改配置
前端框架的环境变量(NEXT_PUBLIC_、VITE_、REACT_APP_* 及同类)会在构建期以字面量字符串替换进 bundle。有三个后果常被忽略:
- 事后改 env 文件,对已经部署的东西毫无影响。旧值会留在每一个正在被服务的 bundle 里,直到重建上线。
- 任何打开浏览器 devtools 的人都能读到这个值。把密钥打进客户端代码,等同于公开发布。
- 所以轮换要求:构建环境里放新值 → 重建 bundle 或镜像 → 重新部署 → 失效 CDN 缓存。跳过重建的轮换,在用户侧等于没轮换。
- source map 也算构建产物:它们让压缩后的 bundle 重新变得可全文搜索,所以 bundle 里有的东西,已发布的 map 里也有。不要发布生产 source map;已经发出去的也要纳入产物搜索范围。
- 重新部署后,旧 bundle 仍留在被缓存的 URL 上。失效 CDN 与代理缓存,然后确认先前部署的 bundle 不再从任何地方被服务。
验证重建:在已发布的构建产物里搜旧值;计数必须为零:
grep -rc "<old-key>" dist/ || echo "clean: 0 occurrences"
# 注意:工作区零命中 ≠ 对象库里没有——历史提交仍在 .git/objects,需另跑
# git rev-list --all | xargs -r git grep -F "<old-key>"
# 两者都为零,才能写「零命中」结论;值含特殊字符时先转义或用 -F。
防御性做法:让机器凭据完全不进浏览器
持久的修法是架构性的:浏览器永远不该携带机器对机器的凭据。
- 把凭据挪到反向代理或网关层:配置
proxy_set_header(nginx)或你网关的等价配置,在服务端给代理请求附上 API key。 - 从每个前端调用点移除硬编码的 header 或默认值——这往往涉及几十个文件——然后重建前端。
- 代理从 env 文件或密钥存储读自己的 key,绝不从前端构建里读。
- 结果:即使整份前端 bundle 泄露也暴露不了什么,因为 bundle 里根本没有可暴露的东西。
这种模式适用于守护机器 API 的凭据。用户级认证(登录会话、按用户的令牌)是另一个问题,代理注入解决不了。
Phase 3 —— git 历史:改写还是冻结(这是决策,不是条件反射)
> 完整决策因素与两条路径(git filter-repo / 冻结镜像仓库)见 references/history-disposition.md —— 结论:历史处置是决策不是条件反射——仓库已分发时改写清不干净下游副本,冻结归档 + 轮换 + 监控更划算。
Phase 4 —— 残留清扫
轮换与历史工作清掉的是主要副本。还要清扫那些被遗忘、旧值仍存活的地方:
- 镜像 registry:旧镜像层仍可按 digest 拉取;删除或覆盖受影响的 tag,并在 registry 支持时清除其缓存。
- fork 与托管侧缓存:git 托管平台上的 fork、缓存的 commit 视图、归档的 PR diff。
- CI 缓存、产物与日志:一个被打印出来的环境变量会永远活在日志里;平台允许的地方,清除日志与产物。
- 队友的本地 clone:陈旧分支、stash 与编辑器历史;请每位协作者重新 clone 并确认。
- 备份归档:数据库 dump、卷归档、改写前的 bundle、项目目录副本。新值验证通过后,删除含旧值的那些。
- 部署平台的构建日志,以及任何摄入过 CI 输出的日志聚合系统。
- 事件讨论期间产生的聊天与工单附件。
- 公开网页存档与搜索缓存:仓库页面的快照。这些通常清不掉——记为已接受风险,靠「该值已死」兜底。
对每一处:能清就清。清不掉的地方——第三方存档、已打印的日志、别人的 fork——在事件记录里记为已接受的残留风险,并写上「该值已死」这一缓解事实。
Phase 5 —— 验证关闭与事件记录
关闭是机械的,不是叙事的。旧值必须在你能控制的四个地方达到零出现:
- 当前源码树 —— 递归搜索旧值 → 0
- 完整 git 历史 —— 跨所有 ref、内容与 commit message 做 pickaxe 搜索 → 0。具体命令:
bash
git log --all -S'<old-secret>' --oneline # commits that introduced or removed the string
git rev-list --all | xargs -r git grep -F '<old-secret>' # -F 按字面量匹配;值里有 $ . * 等先转义或用 -F
# through xargs batches past ARG_MAX on large
# repos (-r = do nothing on empty input)
3. 构建产物 —— 已发布的 bundle、镜像、source map → 0
4. 仍在你手里的备份与归档 → 0,或已删除
再加行为检查:提供方以授权失败拒绝旧值,并接受新值。
然后写事件记录,写进仓库的 ops 文档或 changelog。用下面这样的结构,让下一个读者能审计它:
# Secret leak incident — <date>
## Scope
- <credential-name>: rotated <date>, method <provider-console|admin-api|config>;
old value verified rejected <date>; new value verified live <date>
## History disposition
- Decision: <rewrite|freeze|hybrid> — because <exposure, rotation status, collaborator count>
- Mirrors: <each affected remote and its disposition>
## Verification (all dated)
- Source tree search: 0 occurrences
- Full history search (all refs, content and messages): 0 occurrences
- Build artifacts: 0 occurrences
- Backups: purged or deleted (list each)
## Open items
- <credential>: rotation pending, owner <name>, target <date>
## Residual risks
- <location>: cannot purge because <reason>; accepted because the value is dead
用同样的字段填它——日期与范围、动作与理由、验证证据、未结项、残留风险——按你的命名习惯调整。
错误处理约定
本技能把出错分成四类,处置与重试策略不同,不要混着来:
| 类别 | 典型情况 | 处置 | 可重试 |
|---|---|---|---|
| 输入错误 | 参数缺失、路径或格式不对 | 直接拒绝,指出缺哪个字段 | 否 |
| 环境错误 | 权限、网络、依赖服务不可用 | 给检查命令,停下等修复 | 修复后 |
| 证据不足 | 日志、指标、状态缺失 | 先补证据,不做判断 | 补证据后 |
| 结论冲突 | 多个判据指向不同根因 | 列冲突点,先安全后性能 | 否 |
统一输出格式(每次给结论都按这四段写,便于他人复核):
判据:<看到的现象与具体取值>
结论:<属于哪一类 / 最可能的根因>
动作:<最小修改,或需要补的证据>
验证:<怎么确认修好了>
本技能常见错误码:
| 代码 | 现象 | 处置 |
|---|---|---|
E-ENV-01 |
轮换后旧 key 仍能调用 | 找出仍在用的副本逐个撤销(Phase 1.2) |
E-ENV-02 |
git 历史里还能搜到 | 按 Phase 3 改写历史或冻结归档 |
E-ENV-03 |
镜像层里仍有密钥 | 重建镜像并重新推送(Phase 2) |
E-ENV-04 |
扫描 0 命中但不敢关闭 | 扩范围重扫,留命令与时间戳 |
E-ENV-05 |
新凭据部署后不生效 | 重启或重建服务并做一次真实调用验证(Phase 1.2) |
输入校验清单、重试与退出语义见 references/error-handling.md。
失败模式与恢复
| 失败现象 | 常见原因 | 检查方法 | 恢复动作 | 可否重试 |
|---|---|---|---|---|
| 轮换后旧 key 仍能调用 | 还有副本在用(缓存 / 另一环境) | curl -H "Authorization: Bearer <旧值>" <api>,断言返回 401 |
找出仍在用的副本逐个撤销(Phase 1.2) | 不重试(必须清零) |
| git 历史里还能搜到 | 只在工作区删了文件 | git log -S'<前缀>' --all -p、git rev-list --all 后 grep(-S 只命中内容变更,不覆盖提交信息;提交信息要另用 git log --all --grep='<前缀>' 查) |
按 Phase 3 改写历史或冻结归档 | 不适用 |
| 镜像层里仍有密钥 | 构建期 COPY 进去的 | docker history --no-trunc <image> 查层内容 |
重建镜像并重新推送(Phase 2) | 可重试(重建) |
| 扫描 0 命中但不敢关闭 | 扫描范围不全 | 核对范围是否含 CI 产物、工单附件、备份(Phase 4) | 扩范围重扫,留命令与时间戳 | 可重试 |
| 新凭据部署后不生效 | 部署未刷新 / 读的是旧环境 | docker exec <c> env 看实际注入值 |
重启或重建服务并做一次真实调用验证(Phase 1.2) | 可重试 |
失败时优先中止:历史改写(Phase 3)一旦开始就不可逆,动它之前必须完成轮换并冻结备份;拿不准就先冻结归档,别改写。
完整走查(端到端样例)
走查一:前端打包产物里出现密钥(构建期注入 → 必须重建)
场景:前端打包产物里出现 sk- 开头的 key,需轮换并重建(Phase 2)。
输入:用户原话「客户说在我们前端的 js 里搜到一个 sk- 开头的 key,是不是要全换掉」。
curl -s https://<site>/assets/index-*.js | grep -o 'sk-[A-Za-z0-9]\{8,\}' | head
grep -rn 'VITE_API_KEY\|sk-' --include=*.ts --include=*.tsx . | head
docker history --no-trunc <image> | grep -i build
中间输出(示例):
sk-live-4f9c…(示例)
./src/config.ts:7: apiKey: import.meta.env.VITE_API_KEY
./.env.production:2:VITE_API_KEY=sk-live-4f9c…(示例)
<missing> 0B RUN /bin/sh -c npm run build # 构建期把 VITE_* 内联进产物
定位与修复:密钥走的是 VITE_ 前缀,构建期被内联进 JS,改 .env 不生效 →
- Phase 1:先轮换该 key(不等改代码);
- Phase 2:改由服务端反向代理注入,去掉
VITE_前缀,重建镜像; - 旧镜像与旧 CDN 产物下架。
修复后复测:
$ curl -s https://<site>/assets/index-*.js | grep -c 'sk-live'
0
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'Authorization: Bearer sk-live-4f9c…(示例)' https://<api>/v1/models
401
结论:验证 Phase 2「构建期注入的密钥要重建而不是改配置」,以及 Phase 5 的零命中验证。
走查二:仓库历史里仍躺着一把旧 key(改写还是冻结)
输入:用户原话「审计说我们 git 历史里还有一把旧 key,要不要用 filter-repo 洗掉」。
git log -S 'sk-live' --oneline --all
git remote -v
中间输出(示例):
a1b2c3d rotate keys
f9e8d7c initial import
origin https://github.com/example/app.git (fetch)
origin https://github.com/example/app.git (push)
定位:命中 2 个提交,仓库已推到公开远端——改写历史清不掉已克隆到下游的副本、fork 与镜像归档,收益低而代价高。按 Phase 3 的决策因素,这里应走「冻结归档」而不是全量改写;同时旧 key 已在 Phase 1 轮换过(否则先别碰历史)。
修复:旧仓库设为只读归档并新建干净仓库承接开发;事件记录写明「改写 / 冻结」的处置理由与时间。
复测:
git -C <new-repo> log -S 'sk-live' --oneline --all
curl -s -o /dev/null -w '%{http_code}\n' -H 'Authorization: Bearer sk-live-<旧值>' https://app.example.com/v1/models
(新仓库无输出)
401
结论:验证 Phase 3 的判据——历史处置是决策不是条件反射;先轮换,再按「仓库是否已分发」在改写与冻结之间选。
分工:走查一覆盖 Phase 2「构建期注入要重建产物」,走查二覆盖 Phase 3「历史改写还是冻结」,两者都在轮换之后、清扫之前。
附录 —— 预防模式
- CI 里的密钥扫描(gitleaks、trufflehog,或托管平台自带的扫描),在 push 与 PR 时都跑,并为命中写明处置方式。扫描把未来的泄露从「几个月后才发现」变成「几分钟内抓住」。
- env 文件卫生:从第一次 commit 起就把
.env写进.gitignore;提交一个只含占位名的.env.example。卫生也管历史——今天引入的模式修不了旧 commit。 - 服务端注入架构:网关通过代理 header 注入附带机器凭据;前端 bundle 里一个都没有。新的前端功能一开始就按这个形状做,不要等出事后返工。
- 生成式、单一用途的密钥:高熵、按环境、按消费方。共享值会把轮换变成一次协调工程,而不是按一下按钮。
- 最小权限与短生命周期:把每个凭据限定到最窄的权限集,提供方支持时优先用短生命周期或自动轮换的凭据。泄露一把只读范围的 key 和泄露一把 admin key 是完全不同的事件——范围决定你在处理哪一种。
- 常备凭据清单:维护一张活的映射表,记录每个凭据住在哪——仓库、CI、镜像、设备、第三方。Phase 0 的清单应当变成这张常备清单,也是下一次事件之前你要演练对照的东西。
延伸阅读
references/error-handling.md · references/history-disposition.md