有一天你在 GitHub 上看提交记录,突然发现自己把 .env 一起推上去了。
里面有数据库密码、对象存储密钥,或者某个按量计费的 API Key。
很多人的第一反应是:删掉文件,再补一个提交。
稍微熟悉 Git 的人会再多做一步:把那次 commit 从历史里抹掉,强推一次。
然后松一口气。
问题在于,密钥一旦进入一个别人可能读到的位置,它就应该被当作已经泄露。 后面再把字符串擦掉,只是在清理现场。

图:Microsoft Learn,Azure Key Vault authentication flow。本文封面同样使用该图,微软文档 CDN 在中国大陆通常比 GitHub Raw 一类图床稳定。
先说最重要的:先让旧密钥失效
假设泄露的是一个云平台 Access Key。
你此时最优先的动作通常不是研究 git filter-repo 参数,而是去云平台控制台把旧密钥禁用、撤销或者轮换。
原因很简单:Git 历史清理需要时间,攻击者不需要等你。
GitHub 官方在“Removing sensitive data from a repository”文档里把这一点写得很直接:如果泄露的是密码、Token 或其他凭证,第一步应该先撤销或轮换。历史重写甚至可能不是必需动作,因为只要旧凭证已经彻底失效,它的直接访问价值就没有了。
所以我更建议把处置顺序固定下来:
- 确认泄露的凭证是什么。
- 立即撤销或禁用旧凭证。
- 生成新凭证,并更新真正需要它的服务。
- 检查旧凭证有没有被使用过。
- 再清 Git、日志、镜像和其他副本。
这五步的顺序不要反。
删除最新提交,解决不了 Git 历史
Git 很容易让新人误会,因为你在当前文件树里删掉了一个文件,页面上也看不到它了,但旧 commit 仍然存在。
例如你先提交:
commit A: add .env
commit B: remove .env
在 B 的文件列表里,.env 的确没了。
可只要 A 仍然能访问,里面的内容就仍然存在。
更麻烦的是,一个公开仓库可能已经被别人 clone、fork,GitHub 的 Pull Request、缓存视图和其他引用也可能保存着相关对象。GitHub 官方甚至专门提醒,重写历史以后,旧 clone 如果处理不当还可能把敏感内容重新推回来。
这也是为什么“我已经 git rm 了”不能作为事故关闭条件。
GitHub 的 Push Protection 值得开
比事故后清理更便宜的办法,是尽量让密钥根本进不了仓库。
GitHub 的 Secret Scanning / Push Protection 会识别大量常见的 Token 和私钥格式,在提交阶段直接拦截。

图:GitHub Push Protection 拦截界面截图,图片由 G-gen Tech Blog 收录。GitHub 对公开仓库会自动运行 Secret Scanning;组织私有仓库的具体能力与套餐有关。
它当然拦不住所有秘密。
内部系统的自定义 Token、自己生成的随机字符串、某些连接串,都可能没有明显特征。所以 .gitignore、提交前扫描、CI 扫描仍然有价值。
一个很基础的 .gitignore 至少应该考虑:
.env
.env.*
!.env.example
*.pem
*.key
credentials.json
secrets/
.env.example 里只写字段名:
DATABASE_URL=
REDIS_PASSWORD=
API_TOKEN=
不要为了“方便新人”塞一套还能连测试库的真实账号进去。
CI 日志经常是第二个泄露现场
有时候仓库本身没问题,CI 把密钥吐出来了。
典型场景包括:
set -x
curl -H "Authorization: Bearer $API_TOKEN" ...
或者调试时一句:
echo "$DATABASE_URL"
再或者程序启动失败,把完整环境变量打印进日志。
CI 平台一般会对已登记的 Secret 做掩码,但不要把掩码当成绝对保险。编码、切片、拼接、二次处理后的值都有机会绕过简单遮盖规则。
OWASP 的 Secrets Management Cheat Sheet 给出的原则很朴素:密钥应该可撤销、尽量短寿命、限制可见范围,并且不要把明文 Secret 写进日志。
如果发生泄露,我会把 CI 日志也放进排查范围:
代码仓库
↓
Pull Request / Actions 日志
↓
构建缓存
↓
制品仓库
↓
Docker 镜像
↓
部署日志
↓
APM / Sentry / 日志平台
只盯 GitHub 首页,很容易漏掉后面一串副本。
Dockerfile 里删掉密钥,也可能已经晚了
这个坑尤其常见。
例如:
FROM alpine
COPY .env /app/.env
RUN ./build.sh
RUN rm /app/.env
很多人看到最后一行 rm 会觉得安全了。
Docker 镜像是分层构建的。前面的层如果已经把 Secret 写进去,后面再删文件,并不会自动让旧层里的内容消失。
更糟糕的是,有人直接这样传构建凭证:
ARG API_TOKEN
ENV API_TOKEN=$API_TOKEN
Docker 官方目前明确把 ARG 和 ENV 视为不适合传递构建 Secret 的方式,因为敏感值可能持久化到最终镜像或其元数据中。
需要在构建阶段访问私有仓库、对象存储或内部 API 时,应优先使用 BuildKit Secret Mount:
RUN --mount=type=secret,id=api_token \
TOKEN="$(cat /run/secrets/api_token)" && \
./fetch-private-dependency.sh "$TOKEN"
构建:
docker buildx build \
--secret id=api_token,env=API_TOKEN \
-t example/app:1.0 .
Docker Compose 运行时也支持 secrets,权限可以按服务单独授予,比把所有密码一股脑塞进环境变量更容易控制暴露面。
如果你已经怀疑密钥进入过镜像,别只重新 build 一个新 tag。旧镜像、Registry、CI cache、开发机缓存都值得检查。
“存在环境变量里”也不等于完成密钥管理
环境变量比把密码硬编码进源码好很多,但它仍然只是传递方式。
真正麻烦的是生命周期。
谁生成的?
谁能读取?
多久过期?
怎么轮换?
轮换会不会把线上服务打挂?
旧值失效后,怎么确认没有遗留实例还在使用?
一个成熟一些的方案会让 Secret 进入专门的密钥系统,例如 Vault、云厂商 Secret Manager、Azure Key Vault 等,由应用凭身份去取。

图:Microsoft Learn。界面默认隐藏 Secret Value;真正关键的控制仍然是身份、授权、网络边界、审计和轮换。
这里还有一个经常被忽略的方向:能不用长期静态密钥,就尽量别用。
云环境里,如果服务支持 Managed Identity、Workload Identity、OIDC 一类短期身份凭证,通常比在 CI 里长期保存一个几年不换的 Access Key 更省心。
长期密钥最大的问题是,它泄露以后可能很久都没人知道。
如果今天真的泄露了,我会怎么处理
下面这份清单可以直接拿去做事故响应。
1. 记录时间点
先记下:
- Secret 第一次进入可访问位置的时间;
- 被发现的时间;
- 最后一次确认仍然有效的时间;
- 完成撤销的时间。
后面查日志时,这个窗口很重要。
2. 立即撤销旧凭证
不要等历史清理完成。
数据库账号无法直接吊销 Token 的,就先改密码;云 API Key 先 Disable;证书和签名密钥按对应机制处理。
3. 创建新凭证并缩小权限
事故其实是一次很好的权限审计机会。
如果一个“发邮件用的 Token”同时能删用户、读数据库和创建管理员,那问题比这次 Git 泄露大得多。
新密钥只给当前服务真正需要的权限。
4. 查访问日志和账单
至少检查:
- 异常 IP;
- 异常地域;
- 非工作时段调用;
- API 调用量突然增加;
- 新建资源;
- 权限修改;
- 大量读取、下载或导出;
- 云资源费用异常。
找不到异常不能证明没人用过,但能帮助判断事故影响。
5. 清理 Git 历史
GitHub 当前推荐 git-filter-repo 处理敏感数据历史,例如删除一个文件:
git filter-repo \
--sensitive-data-removal \
--invert-paths \
--path path/to/.env
如果只是某段文本泄露,可以使用 replace-text 方式处理。
历史重写会改变 commit hash,也会影响协作者和 Pull Request,所以团队仓库要先协调。
6. 清理其他副本
别忘了:
- Fork;
- 其他人的 Clone;
- CI 日志;
- Artifact;
- Docker Registry;
- 构建缓存;
- 对象存储;
- Issue / PR 评论;
- Wiki;
- 聊天记录;
- 错误上报平台。
有些地方无法彻底删除。这也是前面为什么把“撤销旧密钥”放在第一位。
7. 补预防措施
事故结束后至少做三件事:
阻止提交 → Secret Scanning / pre-commit
减少长期密钥 → OIDC / Managed Identity / 短期 Token
限制后果 → 最小权限 + 审计 + 到期时间
不然下一次只会换一个文件名重新发生。
密钥管理真正难的是“换得动”
很多团队知道 Key 不该写在源码里,也知道最好三个月轮换一次。
实际到了生产环境,没人敢换。
因为没人知道某个密码到底被多少服务引用:
API
├── production
├── staging
├── cron job
├── old worker
├── CI
└── 某台已经没人记得的服务器
这种密钥就算藏得再严,也已经变成了一颗运维炸弹。
所以我越来越在意一件事:Secret 能不能被快速轮换。
一个凭证如果泄露后需要停机两小时、改十台机器、找三个人确认,说明它平时的管理方式就有问题。
安全设计应该允许你在凌晨三点发现异常以后,几分钟内把旧凭证作废,同时让新凭证平稳接管。
这比“我们的 .env 从来不提交 Git”要可靠得多。
最后
密钥泄露这种事故很容易给人一种错觉:那串字符删掉了,事情就结束了。
实际上,那串字符只是一张通行证。
真正需要确认的是:
这张通行证到过哪里,谁可能见过,它能打开哪些门,以及现在还能不能继续开门。
如果你最近正在做服务器和发布链路加固,可以继续看本站这几篇:
- 你扫描的是代码,攻击者盯的是发布链路:软件供应链安全到底在防什么
- SSH 改个端口就算加固?一台 Linux 服务器上线前我会检查这些东西
- 用 Caddy + Docker Compose 给自托管服务自动上 HTTPS
参考资料
- GitHub Docs — Removing sensitive data from a repository https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/removing-sensitive-data-from-a-repository
- GitHub Docs — Secret scanning / Push protection https://docs.github.com/en/code-security/how-tos/secure-your-secrets/detect-secret-leaks/enable-secret-scanning
- Docker Docs — Build secrets https://docs.docker.com/build/building/secrets/
- Docker Docs — Manage secrets securely in Docker Compose https://docs.docker.com/compose/how-tos/use-secrets/
- OWASP Cheat Sheet Series — Secrets Management https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
- Microsoft Learn — Azure Key Vault https://learn.microsoft.com/en-us/azure/key-vault/general/overview
密钥泄露以后,删掉 GitHub 提交为什么还不够
https://wangling.hauchet.cn/archives/secret-leak-incident-response-git-history-rotation
评论