用于提交和部署代码的 CI 机器人的最小权限原则
拥有广泛写入权限和无限制部署权限的自动化机器人,会因一次错误的运行而导致整个系统发生故障。以下是如何缩小其影响范围的方法。
一个提交代码、推送分支和触发部署的自动化机器人,是一个拥有生产环境访问权限且无人为干预的机器账户。简单的设置赋予了它一个长期有效的令牌、对整个仓库的写入权限以及部署任何东西的权限。这种方式第一次尝试就能成功,因此被广泛采用。这也意味着,一次意外失误、一次提示注入或一次令牌泄露,就能在无人察觉的情况下重写任何文件并发布任何服务。这篇文章旨在探讨如何缩小这种爆炸半径:为每个机器人赋予独立的身份、短期有效的凭证、一个范围狭窄的路径白名单以及经过签名的输出,这样一次错误的运行只会损坏一个角落,而不是波及全部。
为什么机器人的爆炸半径比人的大
一个拥有广泛访问权限的人仍然受到人类速度的限制。他们打开几个文件,思考,做出更改,然后由同事审查 pull request。错误往往很小,而且速度足够慢,可以被发现。
机器人则没有任何此类制动机制。它在毫秒级内行动,在它能访问的每个代码仓库中重复相同的操作,并且不会停下来思考某个更改是否看起来有问题。如果逻辑有缺陷或帐户被接管,那么当初为了方便设置而授予的广泛权限,现在会让攻击者以机器速度在令牌的全部范围内移动。方便的默认设置和最坏情况下的事件,只是在顺利和不顺时看待同一权限集的不同视角。
因此,设计目标不是“信任机器人”。机器人无法像人一样被信任,因为其背后没有判断力,只有规则和被输入的任何内容。目标是限制机器人能做的事情,这样即使一次完全被攻陷的运行,其影响范围也仍然很小。将每个自动化帐户都视为一种有限能力,而不是一个受信任的行动者。
为每个机器人提供独立的身份
第一个错误是在多个任务之间共享一个高权限账户。测试运行器、发布任务、文档发布器和三个自动化脚本都使用同一个机器身份进行认证,这会构成一个单点完全故障。这些任务中的任何一个被攻破,都会交出该共享账户所能做的一切权限的并集。
将它们拆分开来。每个不同的任务都应获得其独立的身份,且该身份只拥有该任务所需的权限。发布文档的身份不应该能够部署支付服务。运行测试的身份应该只拥有读取权限,仅此而已,因为测试不需要向生产环境写入任何内容。
独立的身份也使审计日志更具可读性。当一个账户处理所有事情时,一条“the ci account pushed a commit”的日志记录几乎告诉不了你任何信息。当文档机器人(docs bot)有自己的名称时,一条显示 docs bot 在 docs 目录之外进行写入的条目就是一个可以触发警报的明显异常。基于身份的作用域划分能将你的访问日志转变为一个有效的入侵信号。
将权限范围限定到确切的路径和资源
最小权限意味着授予的权限与工作任务相匹配,而不是与平台相匹配。一个更新更新日志的机器人需要对一个文件的写入权限,而不是对整个目录树的写入权限。一个部署一项服务的机器人需要对该服务的部署权限,而不是对项目中每一项服务的部署权限。
两项限制在这里起到了主要作用。限制机器人可以操作的资源,并限制它可以写入的路径。一个部署身份应该绑定到特定的服务或命名空间,这样即使是有效的令牌也无法触及任何其他东西。一个提交机器人应该被限制在一个目录内,这可以通过拒绝其范围之外更改的分支保护规则来实现,或者通过在差异偏离时使运行失败的检查来实现。
# Per-bot permission model. Each identity gets only what its job requires.
identities:
changelog-bot:
repo_write: true
path_allowlist:
- "CHANGELOG.md"
- "docs/releases/**"
deploy: none
service-deploy-bot:
repo_write: false # deploys, does not commit
deploy:
target: "web-frontend" # this one service, nothing else
environments: ["staging", "production"]
test-bot:
repo_write: false # read-only; tests never write prod
deploy: none
将模型写出来的意义在于,你可以更精确地做出“授予其写入权限”这一决定。几乎每个机器人所需要的权限都远少于完整的授权,而一旦设置好,范围更小的授权在运行时不会产生任何成本。
广泛访问与最小权限的并排比较
这两种设置在平常看起来很相似。但在糟糕的日子里,它们则完全不同。下表展示了在每种模型下同一个自动化机器人的情况。
| 属性 | 广泛访问 | 最小权限 |
|---|---|---|
| 身份 | 所有作业共享一个账户 | 每个作业一个身份 |
| 仓库写入 | 整个树 | 仅限列入白名单的路径 |
| 部署范围 | 任何服务,任何环境 | 一个指定的服务和环境 |
| 凭证生命周期 | 长期令牌,无过期时间 | 每次运行生成,几分钟后过期 |
| 泄露的令牌会暴露什么 | 无限期的完全写入和部署权限 | 一个路径或一个服务,直到令牌过期 |
| 一次错误运行的影响范围 | 任何文件,任何服务 | 仅限该机器人的指定范围 |
| 审计信号 | “ci 账户执行了某项操作” | 具名执行者,越权操作警报 |
| 轮换负担 | 手动,容易忘记 | 无,凭证是临时的 |
最重要的几行是关于泄露和错误运行的行。在广泛访问模型下,一个被泄露的令牌或一次有 bug 的运行可以触及所有东西。在最小权限模型下,同样的故障被限制在单个路径或单个服务内,并且凭证的短暂生命周期意味着一旦运行结束,被盗的令牌就几乎毫无价值。
使用短期凭据消除长期有效的密钥
存储在密钥中的长期有效令牌是一个持续存在的风险。它不会过期,可能会通过日志或被攻破的依赖项泄漏,而且一旦泄漏你通常不会知道,因为被盗的令牌产生的调用看起来与真实的机器人完全相同。轮换是一项繁琐的手动任务,因此令牌会不断累积,其生命周期会超过需要它们的任务。
更好的模式是为每次运行新生成凭据,并在几分钟后过期。许多自动化平台可以为一次运行提供一个签名的身份断言,而目标系统可以配置为信任来自特定调用者的该断言,并将其交换为短期访问令牌。没有存储持久的密钥,所以没有什么可以泄漏、轮换,或在几个月后在保险库中被遗忘地发现。信任被锁定到确切的身份,并且通常是确切的分支,因此来自其他任何地方的断言在获得实际权限之前就会在交换阶段失败。
当一个平台无法为每次运行生成凭据,而你不得不使用存储的令牌时,请将其设置为具有短期轮换周期的、有范围限制的令牌,并将范围保持得像上述最小权限模型一样狭窄。一个范围严格限定的短期密钥是一个可靠的后备方案。而一个范围宽泛、永不过期的密钥则是应该在设计中予以淘汰的。
将内容写入与部署分离,避免一次泄露引发连锁反应
一个微妙的陷阱是,为了方便而将所有权限捆绑到一个凭证中。如果同一个令牌既可以提交代码又可以触发部署,那么泄露它就等于交出了整个链条:攻击者可以一步到位地写入恶意更改并发布它,中间没有任何第二道屏障。
按功能分离令牌。用于写入内容的凭证不应能进行部署,而用于部署的凭证也不应能写入内容。现在,写入端的泄露可以污染文件,但无法自行将其发布上线;而部署端的泄露可以重新部署现有的工件,但无法引入新的恶意代码。攻击者需要同时获得两者才能完成端到端的入侵,这比只需要一个的门槛要高得多。
# Two credentials, two jobs, no overlap.
content-writer:
can_commit: true
can_deploy: false # writing alone cannot reach production
deployer:
can_commit: false # deploying alone cannot introduce new code
can_deploy: true
target: "web-frontend"
这与保持读写分离是同样的道理,只是应用在了更高一个层级上。将职责分散到不同的凭证中意味着,没有任何单个被盗的密钥能完成从源代码变更一直到线上流量的完整运行。
签署提交和工件,以便验证来源
限定范围可以限制机器人能做什么。签署则可以让你证明机器人实际做了什么。当机器人签署其提交和它构建的工件时,每一份输出都带有一个可验证的来源标记,任何未签名或由错误密钥签名的东西都明显是外来的。
这很重要,因为狭窄的权限集仍然给混乱或被劫持的机器人留下了在其职责范围内产生输出的空间。签名将该输出变成了你可以检查的东西。部署步骤可以拒绝交付未经预期构建身份签名的工件,因此,将未签名二进制文件混入流水线的攻击者会在关口被拒绝,而不是被交付。签名的提交历史意味着声称来自发布机器人的伪造提交会验证失败,因为攻击者没有该机器人的签名密钥。
当机器人的输出会流入稍后运行的某个东西时,签署就值得设置。将其与上述的短期凭据相结合,保持签名密钥本身的最小化和独立性,来源验证就会成为一个自动检查,而不是一个手动信任的决定。
权衡取舍,以及何时应保持简单
这一切都不是没有代价的。坦率地说,其成本在于设置的复杂性和持续的结构维护。一个带有宽泛令牌的共享账户只需点击几下即可完成。而最小权限版本则需要多个身份、为每个提交机器人设置路径允许列表、为每个部署机器人进行资源绑定、为短期凭证建立联合信任、分离内容和部署令牌,以及带有自身验证步骤的签名密钥。这都是实实在在的工作,特别是每次运行的凭证信任,很容易在放宽限制的方向上出现不易察觉的错误,导致它看起来能正常工作,但实际上信任了超出预期的调用者。要留出时间来精确编写这些信任条件,并测试来自错误通道的身份确实会被拒绝。
这样做的好处是,成本只需一次性付出,之后基本消失,而它所消除的风险却是持续且反复出现的。没有需要轮换的令牌,没有范围会悄悄扩大的共享账户,一次被攻破的运行会被限制在单一路径或单一服务中,而不是影响整个系统。
当然,仍需做出判断。一个没有真实数据、生命周期只有几天的用后即弃的沙盒,并不需要全套配置,因为当整个项目下周就要被删除时,其爆炸半径已经很小了。一个针对模拟器运行的本地脚本完全不需要生产凭证。当一个机器人需要长期访问重要资源时——例如人们依赖的仓库、面向真实用户的部署路径、在生产环境中运行的制品——这套完整模型就物有所值了。对于任何在线上系统上自动提交或部署的自动化而言,宽泛的授权今天能为你节省几分钟,但只要它存在一天,就会带来一个系统范围的隐患。而最小权限原则需要一次性花费一个更长的下午,但此后能将每一次意外运行的影响都控制在很小的范围内。