有一天你在 GitHub 上看提交记录,突然发现自己把 .env 一起推上去了。

里面有数据库密码、对象存储密钥,或者某个按量计费的 API Key。

很多人的第一反应是:删掉文件,再补一个提交。

稍微熟悉 Git 的人会再多做一步:把那次 commit 从历史里抹掉,强推一次。

然后松一口气。

问题在于,密钥一旦进入一个别人可能读到的位置,它就应该被当作已经泄露。 后面再把字符串擦掉,只是在清理现场。

Azure Key Vault 的认证与密钥访问流程

图:Microsoft Learn,Azure Key Vault authentication flow。本文封面同样使用该图,微软文档 CDN 在中国大陆通常比 GitHub Raw 一类图床稳定。

先说最重要的:先让旧密钥失效

假设泄露的是一个云平台 Access Key。

你此时最优先的动作通常不是研究 git filter-repo 参数,而是去云平台控制台把旧密钥禁用、撤销或者轮换。

原因很简单:Git 历史清理需要时间,攻击者不需要等你。

GitHub 官方在“Removing sensitive data from a repository”文档里把这一点写得很直接:如果泄露的是密码、Token 或其他凭证,第一步应该先撤销或轮换。历史重写甚至可能不是必需动作,因为只要旧凭证已经彻底失效,它的直接访问价值就没有了。

所以我更建议把处置顺序固定下来:

  1. 确认泄露的凭证是什么。
  2. 立即撤销或禁用旧凭证。
  3. 生成新凭证,并更新真正需要它的服务。
  4. 检查旧凭证有没有被使用过。
  5. 再清 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 检测到 Google Cloud 私钥

图: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 官方目前明确把 ARGENV 视为不适合传递构建 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 等,由应用凭身份去取。

Azure Key Vault 中默认隐藏的 Secret 值

图: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”要可靠得多。

最后

密钥泄露这种事故很容易给人一种错觉:那串字符删掉了,事情就结束了。

实际上,那串字符只是一张通行证。

真正需要确认的是:

这张通行证到过哪里,谁可能见过,它能打开哪些门,以及现在还能不能继续开门。

如果你最近正在做服务器和发布链路加固,可以继续看本站这几篇:

参考资料

  • 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