TT Lab
开始
学习 学习路径 课程

CI/CD 流水线

机密会留在日志和镜像层里

在 TT Lab 中继续学习

一句话总结

流水线中机密泄露的位置几乎是固定的:构建日志、镜像层与配置历史、仓库提交历史、产物这四处。而且,对已经泄露的机密,应对措施不是删除,而是撤销并重新签发。

为什么需要它

处理机密时的失误,多半不是出于恶意,而是出于图方便。为了调试把环境变量整个打印出来,用构建参数传令牌最省事,带着配置文件里的值就提交了。每一件都是某个人为了省几分钟做的事,结果却是能读到这个值的人的范围整体扩大了。

我们把这四个位置逐一看一遍。

掩码不是对策,而是最后一道防线

CI 平台会把日志中已知的机密值遮盖起来。这有帮助,但不能把它当作对策。 理由很简单:掩码只能遮盖与已知字符串完全相同的东西。值只要有一点变形,就会原样显示出来。

所以顺序是这样的:先让它不被打印出来,掩码只作为减少失误的最后一张网。 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、缓存、日志、备份都毫无影响。 关键在于,无法知道在此期间有没有人读取过这个值。

所以顺序始终一样:先撤销该凭据,再签发新的值,更换使用它的地方。之后才谈得上清理历史和防止再发。 从历史中删除不是第一步,通常还是最不重要的一步。

在现场相遇的样子

参考

下一项实验要做什么

用 shell、git、skopeo 逐一重现并堵住机密泄露的四个位置。先加入一个打印环境变量的步骤,确认值会留在日志里,再接上一个模拟掩码的过滤器,然后构造反例:转成 base64 再打印,让它直接穿过那个过滤器。 接着用 skopeo inspect --raw 打开 /opt/images 中的 oci-archive,亲眼确认镜像里装的不只是文件系统,还有配置和历史。提交历史这一边,则通过提交一个值、删除之后再从旧提交中原样取出来的方式处理。最后构建检查机密文件权限和生命周期的脚本,以及一份以撤销开头、写明泄露应对流程的检查清单。