有人提交了一个 .env 文件,或者为了快速验证什么,把一个线上 API 密钥粘进了配置模块。评审者发现了,密钥在下一次提交中被删掉,大家就此翻篇。但密钥还在那里。Git 保存每个文件的每一个版本,任何能克隆仓库的人都能读到添加它的那次提交。
硬编码凭据对应 CWE-798。本文讲的是当一个密钥已经进入历史之后该怎么办:找出每一个密钥、在做别的事之前先轮换、判断重写历史是否值得,以及建立能拦住下一次泄露的防线。
为什么删掉那一行还不够
一次移除密钥的提交,只是新增了一个不含它的快照。上一个包含密钥的快照,仍然被该分支的历史引用着。git log -p 能看到它,git checkout 到旧提交能把它取回来,而且现有的每一个克隆和 fork 都已经有了副本。如果原始提交曾被推送过,合并前把分支压缩成一个提交也没用:远端可能仍然保留着它们,而任何抓取过它们的人本地都有。
在公开仓库上,就当密钥已经被人看到了。自动化抓取程序会盯着公开推送中的凭据特征,从推送到第一次被滥用之间的窗口可能非常短。在私有仓库上,暴露面更小但不为零:每一位拥有读权限的员工、外包、CI 系统和集成都拿到了它。
第一步:先轮换
轮换是唯一真正消除风险的步骤。重写历史只是让密钥对未来的克隆不可见;它对已经存在的副本毫无作用。所以在动仓库之前,先吊销并替换凭据。
规划轮换时要避免造成故障。许多服务商允许两个密钥同时有效:先创建新密钥,在所有用到旧密钥的地方部署,确认流量已经切过去,再吊销旧密钥。如果服务商只允许一个密钥,就接受短暂中断;几分钟的停机,好过为了协调一次完美切换而让一个已知泄露的凭据继续有效。
- 在服务商控制台生成新密钥,并通过你常规的配置路径部署它。
- 吊销旧密钥。不要只是停用它;一个不再使用但仍然有效的密钥依然是有效密钥。
- 查看服务商的审计日志,确认自提交之日起是否有用旧密钥产生的活动:API 调用、新用户、权限变更、异常账单。
- 如果泄露的是数据库密码或签名密钥,想清楚它可能打开了什么。JWT 签名密钥泄露意味着任何令牌都可能被伪造,因此要让现有会话全部失效。
- 记录下泄露了什么、持续了多久、你做了哪些改动。客户或审计人员问起时你会用得上。
第二步:找出所有泄露的内容
有一个被提交的密钥,往往就还有别的。请搜索完整历史,而不只是当前工作树,并包含所有分支和标签。
# 在历史任意位置新增或删除某个字符串的提交
git log -p -S "sk_live_" --all
# 在所有提交的差异中做正则搜索
git log -p -G "AKIA[0-9A-Z]{16}" --all
# 曾经在可疑路径上存在过的文件
git log --all --oneline -- .env config/production.json
# 专用扫描器会检查数百种已知的凭据格式
gitleaks git -v .
trufflehog git file://. --only-verifiedgitleaks 和 trufflehog 是为此打造的开源扫描器。它们了解常见服务商密钥的格式,trufflehog 还能检查找到的凭据是否仍然有效。较老的 gitleaks 版本使用 gitleaks detect --source . 而不是 git 子命令。先对完整历史跑一次扫描器,然后让它持续检查新提交。预料之中会有一些误报,比如测试夹具和文档里的示例密钥。逐一复核之后,把确认的误报记入扫描器的允许列表或基线文件,这样下一次运行就只会显示新的问题。
别忘了仓库周边的地方:回显过环境变量的 CI 日志、issue 和拉取请求评论、wiki 页面、由该仓库构建的 Docker 镜像层,以及调试时分享出去的 gist 或粘贴内容。
第三步:判断是否要重写历史
一旦密钥完成轮换,它就没用了,所以重写历史属于清理而非止损。在仓库是公开的、密钥本身还透露了别的信息(内部主机名、客户名称),或者合规要求如此时,重写仍然值得做。它有实打实的代价:每位协作者都必须重新克隆,进行中的拉取请求会受影响,提交哈希会改变。任何按哈希引用提交的东西,比如发布说明、部署记录、issue 链接或其他仓库中锁定的依赖,都会指向该分支上不再存在的提交。请提前公告这次重写,挑一个安静的时段,并先合并或关闭进行中的拉取请求。
git filter-repo 是 Git 项目为此推荐的工具,用以取代更老的 git filter-branch。它在一个全新克隆上工作,并出于安全考虑移除 origin 远端,因此推送前需要把它加回来。
# 从一个全新克隆开始
git clone [email protected]:acme/api.git api-clean
cd api-clean
# 方案 A:从每一次提交中移除某个文件
git filter-repo --invert-paths --path config/production.env
# 方案 B:在所有出现的位置替换密钥字符串
# replacements.txt 每行一条规则,例如:
# PASTE_THE_OLD_KEY_HERE==>REDACTED
git filter-repo --replace-text ../replacements.txt
# filter-repo 会移除 origin;把它加回来并强制推送
git remote add origin [email protected]:acme/api.git
git push --force --all
git push --force --tags重写做不到的事
- 它触及不到已有的克隆、fork、CI 缓存或备份。任何在重写之前抓取过的人都保留着旧提交。
- 在 GitHub 上,为拉取请求创建的引用是只读的,可能让旧提交仍然可达。移除缓存视图和这些引用需要联系 GitHub 支持。
- 从旧克隆拉取或合并的协作者,可能把旧历史直接推回来。请让所有人重新克隆,并在此期间保护该分支。
- 它不会把密钥从它被复制到的其他任何地方移除:日志、工单、聊天记录或构建产物。
这就是顺序很重要的原因。先轮换,再清理。一段被重写过、但密钥仍然有效的历史,只会带来虚假的安全感。推送之后,请在一个全新克隆上运行扫描器,确认密钥确实已从每个分支和标签中消失。
第四步:预防下一次
目标是让提交密钥变难、让发现密钥变快。没有哪一项控制能同时做到这两点,所以要分层叠加。
让密钥远离代码路径
在运行时从环境变量或密钥管理器读取凭据,并在启动时校验所需的凭据是否存在。提交一个带占位值的 .env.example,并从第一次提交起就把 .env 放进 .gitignore。生产环境可使用 AWS Secrets Manager、Google Secret Manager、HashiCorp Vault 之类的密钥管理器,或你所在平台的加密环境变量设置,它们能提供访问控制、审计日志和更简便的轮换。
在提交之前拦下密钥
pre-commit 钩子能在开发者的机器上就拦住这个失误,让它根本到不了远端。pre-commit 框架让这件事只需几行配置,每位贡献者都能安装。
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: vX.Y.Z # 固定到最新的发布标签
hooks:
- id: gitleaks
# 每个克隆安装一次
# pip install pre-commit
# pre-commit install钩子是本地的,可以被跳过,所以要在 CI 里对每次推送运行同样的扫描器作为兜底。在 GitHub 上,如果你的方案支持,请启用密钥扫描和推送保护;推送保护会在服务端拒绝包含已识别服务商令牌的推送。
减小漏掉的泄露造成的损害
- 把每个密钥的权限限制到最小,并在服务商允许时限制到特定 IP 或来源。
- 优先使用短期凭据,例如从 CI 到云服务商的 OIDC 联合身份,而不是长期有效的静态密钥。
- 各环境使用各自独立的密钥,这样泄露的测试密钥碰不到生产环境。
- 为每个关键密钥准备一份轮换手册,让压力之下的轮换变成例行操作而非临场发挥。
代码评审的位置
扫描器擅长匹配已知格式;它们在上下文方面较弱,比如由两个常量拼出的密码,或随配置兜底逻辑一起发布的默认凭据。对代码做一遍评审能发现这些。CodeAuditAgent 在审计公开仓库的默认分支或粘贴的代码片段时,会把硬编码凭据标记为 CWE-798 并附上原文引用的证据。它读取的是当前代码,而不是提交历史,所以针对历史提交仍需保留一个历史扫描器。
简短版本:被提交过的密钥就是泄露的密钥。今天就轮换它,如果值得承受那些干扰就清理历史,并让下一次泄露在 pre-commit 钩子上失败,而不是出现在一个公开页面上。