Go 博客

Go 如何缓解供应链攻击

Filippo Valsorda
2022 年 3 月 31 日

现代软件工程是协作性的,并且基于重用开源软件。这将目标暴露于供应链攻击,在供应链攻击中,软件项目会通过破坏其依赖项来受到攻击。

尽管有任何流程或技术措施,但每个依赖项不可避免地都是信任关系。但是,Go 工具和设计有助于在各个阶段降低风险。

所有构建都是“锁定”的

外界发生的变化,例如发布新的依赖项版本,不会自动影响 Go 构建。

与大多数其他包管理器文件不同,Go 模块没有单独的约束列表和锁定文件来固定特定版本。贡献到任何 Go 构建的每个依赖项的版本完全由主模块的 go.mod 文件 确定。

从 Go 1.16 开始,这种确定性默认情况下得到执行,并且构建命令 (go buildgo testgo installgo run 等) 如果 go.mod 不完整,将失败。唯一会更改 go.mod(因此也会更改构建)的命令是 go getgo mod tidy。这些命令预计不会在 CI 中自动运行,因此对依赖项树的更改必须有意进行,并且有机会进行代码审查。

这对安全非常重要,因为当 CI 系统或新机器运行 go build 时,签入的源代码是构建内容的最终且完整的真相来源。第三方无法影响这一点。

此外,当使用 go get 添加依赖项时,其传递依赖项将以依赖项的 go.mod 文件中指定的版本添加,而不是以其最新版本添加,这得益于 最小版本选择。对于 go install example.com/cmd/devtoolx@latest 的调用也是如此,某些生态系统中相当于它的调用绕过了固定。在 Go 中,将获取 example.com/cmd/devtoolx 的最新版本,但随后其所有依赖项将由其 go.mod 文件设置。

如果某个模块受到攻击,并发布了新的恶意版本,则在明确更新该依赖项之前,没有人会受到影响,这提供了审查更改的机会,并为生态系统检测事件赢得时间。

版本内容永远不会更改

确保第三方无法影响构建的另一个关键属性是,模块版本的內容是不可变的。如果攻击者破坏了某个依赖项,并能够重新上传现有版本,那么他们就可以自动破坏所有依赖于该依赖项的项目。

这就是 go.sum 文件 的用途。它包含构建中所有依赖项的加密哈希列表。同样,不完整的 go.sum 会导致错误,并且只有 go getgo mod tidy 会修改它,因此对它的任何更改都将伴随着有意的依赖项更改。其他构建保证具有完整的一组校验和。

这是大多数锁定文件的一个常见功能。Go 通过 校验和数据库 (sumdb 简称) 超越了这一点,校验和数据库是一个全局的仅追加加密可验证的 go.sum 条目列表。当 go get 需要向 go.sum 文件添加条目时,它会从 sumdb 中获取它,以及 sumdb 完整性的加密证明。这确保了不仅某个模块的每次构建都使用相同的依赖项内容,而且所有模块都使用相同的依赖项内容!

sumdb 使得受损的依赖项,甚至 Google 运营的 Go 基础架构都无法使用修改后的 (例如带后门的) 源代码来针对特定依赖项。您保证使用的是与其他所有使用例如 example.com/modulex 的 v1.9.2 版本并对其进行过审查的人相同的代码。

最后,我最喜欢的 sumdb 特性:它不需要模块作者进行任何密钥管理,并且与 Go 模块的去中心化特性无缝协作。

VCS 是真相来源

大多数项目都是通过某种版本控制系统 (VCS) 开发的,然后上传到其他生态系统的包存储库中。这意味着有两个帐户可能会受到攻击,VCS 主机和包存储库,其中后者使用频率较低,更容易被忽视。这也意味着更容易将恶意代码隐藏在上传到存储库的版本中,尤其是在源代码作为上传的一部分被定期修改的情况下,例如为了将其最小化。

在 Go 中,没有像包存储库帐户这样的东西。包的导入路径包含 go mod download 需要的信息,以便直接从 VCS 获取其模块,其中标签定义版本。

我们确实有 Go 模块镜像,但这只是一个代理。模块作者不会注册帐户,也不会将版本上传到代理。代理使用与 go 工具相同的逻辑 (实际上,代理运行 go mod download) 来获取和缓存版本。由于校验和数据库保证对于给定的模块版本只能存在一个源树,因此使用代理的每个人都会看到与绕过代理并直接从 VCS 获取的每个人相同的結果。(如果版本在 VCS 中不再可用,或者其内容已更改,那么直接获取会导致错误,而从代理获取可能仍然有效,从而提高可用性并保护生态系统免受 “left-pad” 问题 的影响。)

在客户端运行 VCS 工具会暴露相当大的攻击面。这是 Go 模块镜像提供的另一个帮助:代理上的 go 工具运行在强大的沙箱中,并且配置为支持所有 VCS 工具,而 默认情况下只支持两个主要的 VCS 系统 (git 和 Mercurial)。使用代理的任何人都可以获取使用默认情况下未启用的 VCS 系统发布的代码,但攻击者在大多数安装中都无法访问该代码。

构建代码不会执行代码

Go 工具链的一个明确的安全设计目标是,获取或构建代码都不会让该代码执行,即使该代码不受信任且具有恶意性。这与大多数其他生态系统不同,其中许多生态系统对在包获取时运行代码提供一流的支持。这些“安装后”钩子过去曾被用作将受损的依赖项转换为受损的开发人员机器以及 蠕虫传播 通过模块作者的最方便方式。

公平地说,如果您正在获取一些代码,那么通常是为了在之后不久执行它,要么作为开发人员机器上的测试的一部分,要么作为生产环境中二进制文件的一部分,因此缺乏安装后钩子只会减缓攻击者的速度。(构建中没有安全边界:任何为构建做出贡献的包都可以定义一个 init 函数。)但是,它可能是一个有意义的风险缓解措施,因为您可能正在执行一个二进制文件或测试一个仅使用模块依赖项子集的包。例如,如果您在 macOS 上构建并执行 example.com/cmd/devtoolx,那么 Windows 专用依赖项或 example.com/cmd/othertool 的依赖项将无法破坏您的机器。

在 Go 中,不为特定构建提供代码的模块对该构建没有安全影响。

“少量复制比少量依赖更好”

Go 生态系统中最终也是最重要的软件供应链风险缓解措施是最不技术性的:Go 有一种文化,拒绝大型依赖项树,并且更喜欢复制一小部分内容,而不是添加新的依赖项。它一直追溯到 Go 的格言之一:“少量复制比少量依赖更好”。高质量的可重用 Go 模块自豪地戴着“零依赖项”的标签。如果您发现自己需要一个库,您很可能会发现它不会让您对其他作者和所有者的数十个其他模块产生依赖。

这得益于丰富的标准库和附加模块 (golang.org/x/...),这些模块提供了常用的高级构建块,例如 HTTP 堆栈、TLS 库、JSON 编码等。

总而言之,这意味着可以使用很少的依赖项来构建丰富而复杂的应用程序。无论工具有多好,它都无法消除重用代码所带来的风险,因此最强大的缓解措施始终是较小的依赖项树。

下一篇文章:熟悉工作区
上一篇文章:泛型的介绍
博客索引