流水线出事故的地方
一句话总结
流水线是仓库中持有最强权限、执行任意代码的地方,所以事故大多不是“因为配置错了”,而是“因为有人能够操控那次执行”而发生的。
为什么需要它
CI 作业是手握部署凭据、注册表令牌、云密钥在运行的。而这个作业要执行什么,由仓库中的文件决定。也就是说,能往仓库里放文件的人,实际上可以用那些凭据做任何事。如果很晚才意识到这一点,就会在“构建服务器在内网,所以没关系”的说法中度过好几年。
供应链方面的事件反复证明了这一点。在 tj-actions/changed-files 事件中,攻击者并没有攻破仓库。他们只是把一个被广泛使用的 Action 的 tag 移到了恶意提交上,无数引用该 tag 的流水线就原样执行了恶意代码。tag 是人可以移动的标签,而流水线相信了这个标签。
工作原理
防御要设在四个地方。
第一,机密值。CI 变量要放在项目设置中,而不是仓库里,并标记为受保护变量,使其只传递给在受保护分支和受保护 tag 上运行的作业。掩码会在日志中遮住值,但它更像是礼貌而不是防御——如果把值用 base64 编码后输出,就能通过掩码。真正的防御,是让那个值一开始就不进入那个作业。
第二,来自 fork 的合并请求。由于别人写的流水线配置要在我们的 Runner 上运行,所以默认不提供机密值。缓存也会跨越信任边界。如果 fork 把恶意依赖植入缓存,之后的构建就会原样使用它,这就构成了缓存投毒。
第三,引入别人配置的 include。引入其他仓库的模板时,如果把 ref 设为分支名,每当该分支改变,我们流水线的内容也会改变。必须用提交 SHA 固定,昨天和今天才会一致。作业使用的容器镜像,也出于同样的原因,要用摘要或不可变 tag 固定。
第四,可回退的部署。生产部署作业要设为手动审批(when: manual 和 allow_failure: false),并指定 environment,把什么发布到了哪里留成记录。并且,如果把部署所用的镜像 tag 设为提交 SHA,出事故时,“当时发布的是什么”,只靠注册表和 Git 日志,5 分钟内就能得出答案。如果生产上只有 latest,就没有人能回答这个问题,也没有可回退的对象。
实际工作中要守住的最低线
现实中,很难一次把所有事都做到。对刚开始着手的团队,最好给出顺序。先把部署凭据移到受保护变量,然后给生产部署加上手动审批,然后固定镜像和 include 的引用,最后调整 fork 合并请求的流水线策略。前两项一天就能完成,并能防住大部分事故。
还有一点。当流水线运行时间开始超过 15 分钟,人们就不再等待结果,而是去做别的事。这样就会很晚才发现失败,而晚发现的失败,已经是在堆积了多个提交之后,难以缩小原因。安全与速度看起来是各不相干的主题,但没有人看的流水线,作为门禁也是死的,所以说到底是同一件事。
下一项实验要做什么
通过运行来确认变量进入位置的优先级和环境范围,看到没有掩码的日志中机密是如何留下来的,然后创建泄漏检查器和引用固定检查器。接着是用 environment、手动审批、提交 SHA 实现可回退的部署,最后创建在提交之前和流水线的第一个作业中拦截配置错误的门禁。实验环境中没有 GitLab 服务器,所以受保护变量、掩码、CI_JOB_TOKEN 不会重现,并在各个实验中写明了这一局限。实验之后的测验中,会区分受保护变量与掩码的差别、固定 include 与镜像引用的原因,以及手动审批要成为审批门禁所需的条件。