机密会留在日志和镜像层里
一句话总结
流水线中机密泄露的位置几乎是固定的:构建日志、镜像层与配置历史、仓库提交历史、产物这四处。而且,对已经泄露的机密,应对措施不是删除,而是撤销并重新签发。
为什么需要它
处理机密时的失误,多半不是出于恶意,而是出于图方便。为了调试把环境变量整个打印出来,用构建参数传令牌最省事,带着配置文件里的值就提交了。每一件都是某个人为了省几分钟做的事,结果却是能读到这个值的人的范围整体扩大了。
我们把这四个位置逐一看一遍。
- 构建日志。 日志通常在组织内部被广泛阅读、长期保存并可被搜索。只需一行打印这个值的命令就够了。
- 镜像层与配置历史。 镜像里装的不只是文件系统,还有它是如何构建出来的。构建中途写成文件,之后的步骤再删掉,前面的层里依然留着。
- 仓库提交历史。 已经提交的值,即使在下一个提交中删掉,历史里依然存在。在此期间被复制走的副本、fork、缓存里也有。
- 产物。 构建生成的配置文件、测试报告、转储文件中会混入这个值。产物往往比日志保存得更久。
掩码不是对策,而是最后一道防线
CI 平台会把日志中已知的机密值遮盖起来。这有帮助,但不能把它当作对策。 理由很简单:掩码只能遮盖与已知字符串完全相同的东西。值只要有一点变形,就会原样显示出来。
- 用 base64 编码后打印,是另一个字符串,不会被遮盖。GitHub 文档也明确写道,base64 只是把二进制转成文本,不能替代加密。
- 被夹在 JSON 或 URL 里并被转义之后,就成了另一个字符串。
- 多行的值如果被拆成一行一行输出,每一行都与登记的值不同。
- 由机密派生出的值(签名、哈希、令牌交换的结果),一开始就没有登记过。
所以顺序是这样的:先让它不被打印出来,掩码只作为减少失误的最后一张网。 GitHub 建议,即使是自身不属于机密的敏感值,也用 ::add-mask:: 登记,而这同样带着同样的局限。另外,由 fork 仓库触发的工作流,除 GITHUB_TOKEN 之外不会传递任何机密。这是比掩码强得多的保护,恰好体现了划分边界优于遮盖这一原则。
构建参数与运行时机密
Docker 官方文档中有一句话直接给出了结论:“构建参数和环境变量不适合用来向构建传递机密,因为它们会留在最终镜像中。”写入后再删掉,也会留在镜像所包含的构建记录里。
取而代之的,是构建时的机密挂载。
# 값은 그 RUN 명령이 도는 동안에만 존재하고, 레이어에 남지 않는다
RUN --mount=type=secret,id=npmtoken \
NPM_TOKEN="$(cat /run/secrets/npmtoken)" npm ci
默认的挂载位置是 /run/secrets/<id>(占位符为机密的 id),该命令结束后它就消失,并且不会保留在镜像层中。
运行时机密则是另一回事。 运行时需要的值,由运行环境而不是镜像来提供。用环境变量传递很方便,但它会连同进程列表、子进程、错误报告、转储一起被带出去。用文件传递通常更好,这时要确定两件事:权限(收窄到只有该进程能读取,并连目录权限一起检查)和生命周期(什么时候消失,轮换之后是否会重新读取)。
短生命周期在结构上更优
长生命周期的凭据有一个问题:“不知道什么时候泄露的”。几个月前通过日志泄露出去的令牌,现在依然有效。短生命周期的凭据给泄露出去的值的用处设定了时效。 它虽然无法阻止泄露,但把受害窗口缩短到了几分钟。
出于同样的理由,权限范围也要给得窄。流水线只需要读取,就只给读取。这不是阻止泄露的措施,而是决定泄露时损失大小的措施。
泄露之后的应对是撤销和重新签发
这里是最常出错的地方。当值进入了提交,人们往往想通过修改历史把它抹掉。修改历史对已经被复制的副本、fork、缓存、日志、备份都毫无影响。 关键在于,无法知道在此期间有没有人读取过这个值。
所以顺序始终一样:先撤销该凭据,再签发新的值,更换使用它的地方。之后才谈得上清理历史和防止再发。 从历史中删除不是第一步,通常还是最不重要的一步。
在现场相遇的样子
- 为了调查失败而把环境变量整个打印出来的调试步骤还留着。这是已经做好了制造事故的准备。需要的话,只打印键名,不打印值。
- 拆开镜像一看,通过构建参数传入的令牌原样出现。这个值已经是撤销的对象。
- 以文件形式传递机密,却把权限开得很宽,同一个 Pod 里的其他进程也能读取。
- 机密被误提交时,复盘里写着“已清理历史,所以没问题”。如果没有撤销,就有问题。
参考
- Docker 构建机密:https://docs.docker.com/build/building/secrets/
- Dockerfile 参考:https://docs.docker.com/reference/dockerfile/
- 在 GitHub Actions 中使用机密:https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions
- GitHub Actions 工作流命令:https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions
- GitLab CI/CD 变量:https://docs.gitlab.com/ci/variables/
下一项实验要做什么
用 shell、git、skopeo 逐一重现并堵住机密泄露的四个位置。先加入一个打印环境变量的步骤,确认值会留在日志里,再接上一个模拟掩码的过滤器,然后构造反例:转成 base64 再打印,让它直接穿过那个过滤器。 接着用 skopeo inspect --raw 打开 /opt/images 中的 oci-archive,亲眼确认镜像里装的不只是文件系统,还有配置和历史。提交历史这一边,则通过提交一个值、删除之后再从旧提交中原样取出来的方式处理。最后构建检查机密文件权限和生命周期的脚本,以及一份以撤销开头、写明泄露应对流程的检查清单。