Go 博客
Go 如何缓解供应链攻击
现代软件工程是协作式的,并基于对开源软件的重用。这使得目标容易受到供应链攻击,即通过损害软件项目的依赖来攻击项目本身。
尽管采取了任何流程或技术措施,每个依赖项都不可避免地是一种信任关系。然而,Go 的工具和设计有助于在各个阶段缓解风险。
所有构建都是“锁定”的
外部世界(例如依赖项发布新版本)的更改无法自动影响 Go 构建。
与大多数其他包管理器的文件不同,Go modules 没有独立的约束列表和锁定特定版本的 lock 文件。任何 Go 构建所依赖的每个依赖项的版本完全由主模块的 go.mod
文件决定。
从 Go 1.16 开始,默认强制执行这种确定性,并且构建命令(go build
、go test
、go install
、go run
等)如果 go.mod 不完整将会失败。唯一会更改 go.mod
(以及随之更改构建)的命令是 go get
和 go mod tidy
。这些命令预计不会自动或在 CI 中运行,因此对依赖树的更改必须是故意的,并且有机会通过代码审查。
这对安全性非常重要,因为当 CI 系统或新机器运行 go build
时,已签入的源代码是构建内容的最终且完整的真相来源。第三方无法影响它。
此外,使用 go get
添加依赖项时,其传递依赖项会按照依赖项的 go.mod
文件中指定的版本添加,而不是最新版本,这得益于最小版本选择。对于调用 go install example.com/cmd/devtoolx@latest
也是如此,这在某些生态系统中相当于绕过了 pinning。在 Go 中,会获取 example.com/cmd/devtoolx
的最新版本,但其所有依赖项都会由其 go.mod
文件设定。
如果一个模块被攻陷并发布了新的恶意版本,在用户明确更新该依赖项之前,没有人会受到影响,这提供了审查更改的机会以及生态系统检测到该事件的时间。
版本内容永不更改
确保第三方无法影响构建的另一个关键属性是模块版本的内容不可变。如果攻击者攻陷一个依赖项后可以重新上传现有版本,他们就可以自动攻陷所有依赖于该版本的项目。
这就是 go.sum
文件的作用。它包含构成构建的每个依赖项的加密哈希列表。同样,不完整的 go.sum
会导致错误,并且只有 go get
和 go mod tidy
会修改它,因此对其的任何更改都会伴随故意的依赖项更改。其他构建则保证拥有一整套校验和。
这是大多数 lock 文件的常见功能。Go 通过 Checksum Database(简称 sumdb)超越了这一点,sumdb 是一个全局的、仅追加的、可加密验证的 go.sum 条目列表。当 go get
需要向 go.sum
文件添加条目时,它会从 sumdb 获取该条目,并附带 sumdb 完整性的加密证明。这确保了不仅特定模块的每个构建都使用相同的依赖项内容,而且所有模块都使用相同的依赖项内容!
sumdb 使得被攻陷的依赖项甚至 Google 运营的 Go 基础设施无法用修改后的(例如后门)源代码针对特定依赖者。您被保证使用的是与所有其他使用例如 example.com/modulex
v1.9.2 版本的人完全相同的代码,并且该代码已经过审查。
最后,我最喜欢的 sumdb 功能是:它不需要模块作者进行任何密钥管理,并且与 Go modules 的去中心化特性无缝集成。
VCS 是真相来源
大多数项目通过版本控制系统(VCS)开发,然后在其他生态系统中上传到包仓库。这意味着存在两个可能被攻陷的账户:VCS 主机和包仓库,后者使用频率较低,更容易被忽视。这也意味着更容易在上传到仓库的版本中隐藏恶意代码,特别是如果源代码在上传过程中被常规修改,例如进行压缩。
在 Go 中,不存在包仓库账户。包的导入路径嵌入了 go mod download
直接从 VCS 获取其模块所需的信息,VCS 中的标签定义了版本。
我们确实有 Go Module Mirror,但这只是一个代理。模块作者无需注册账户,也无需将版本上传到代理。代理使用与 go
工具相同的逻辑(实际上,代理运行 go mod download
)来获取和缓存版本。由于 Checksum Database 保证给定模块版本只能有一个源代码树,因此使用代理的每个人都会看到与绕过代理直接从 VCS 获取相同的结果。(如果 VCS 中不再有该版本或其内容发生变化,直接获取将导致错误,而从代理获取可能仍然有效,从而提高了可用性并保护生态系统免受 “left-pad”问题的影响。)
在客户端运行 VCS 工具会暴露相当大的攻击面。这是 Go Module Mirror 提供帮助的另一个方面:代理上的 go
工具在一个强大的沙箱内运行,并配置为支持所有 VCS 工具,而默认情况下仅支持两种主要的 VCS 系统(git 和 Mercurial)。使用代理的任何人仍然可以获取使用默认关闭的 VCS 系统发布的代码,但在大多数安装中攻击者无法触及这些代码。
构建代码不执行代码
Go 工具链的一个明确的安全设计目标是,无论获取还是构建代码,即使代码不受信任且是恶意的,都不会执行该代码。这与大多数其他生态系统不同,许多其他生态系统对包获取时运行代码提供一流支持。这些“安装后”钩子过去曾被用作最便捷的方式,将受损的依赖项变成受损的开发人员机器,并通过模块作者传播蠕虫。
公平地说,如果你正在获取某些代码,通常很快就会执行它,无论是作为开发人员机器上测试的一部分还是生产环境中二进制文件的一部分,因此缺少安装后钩子只会减缓攻击者的速度。(构建内部没有安全边界:任何构成构建的包都可以定义 init
函数。)然而,这可以是一个有意义的风险缓解措施,因为你可能正在执行一个二进制文件或测试一个仅使用模块部分依赖项的包。例如,如果你在 macOS 上构建并执行 example.com/cmd/devtoolx
,那么一个仅限 Windows 的依赖项或 example.com/cmd/othertool
的依赖项没有任何方式来攻击你的机器。
在 Go 中,不为特定构建贡献代码的模块对该构建没有安全影响。
“少量复制胜于少量依赖”
Go 生态系统中最后也是也许最重要的软件供应链风险缓解措施是最不技术的:Go 有一种拒绝大型依赖树的文化,并且倾向于少量复制而不是添加新的依赖项。这可以追溯到 Go 的一个谚语:“少量复制胜于少量依赖”。高质量的可重用 Go 模块 proudly 佩戴着“零依赖”的标签。如果你需要一个库,你可能会发现它不会让你依赖于其他作者和所有者的数十个其他模块。
这也是得益于丰富的标准库和附加模块(如 golang.org/x/...
系列),它们提供了常用的高级构建块,例如 HTTP 栈、TLS 库、JSON 编码等。
总而言之,这意味着只需少量依赖项即可构建丰富、复杂的应用程序。无论工具多么优秀,它都无法消除重用代码所带来的风险,因此最强的缓解措施永远是保持较小的依赖树。