Go 博客
Go 如何缓解供应链攻击
现代软件工程是协作式的,并且基于重用开源软件。这使得目标容易受到供应链攻击,在这种攻击中,软件项目通过破坏其依赖项来被攻击。
尽管有任何流程或技术措施,每个依赖项都不可避免地是一种信任关系。然而,Go 的工具和设计有助于在各个阶段降低风险。
所有构建都是“锁定”的
外部世界的任何变化——例如发布了依赖项的新版本——都无法自动影响 Go 构建。
与其他大多数包管理器文件不同,Go 模块没有单独的约束列表和一个固定特定版本的锁定文件。任何 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
时也会发生同样的情况,在某些生态系统中,这些命令的等效命令会绕过锁定。在 Go 中,将获取 example.com/cmd/devtoolx
的最新版本,然后所有依赖项都将由其 go.mod
文件设置。
如果一个模块被破坏并发布了一个新的恶意版本,没有人会受到影响,直到他们显式更新该依赖项,这提供了审查更改的机会,并为生态系统检测事件争取了时间。
版本内容永不改变
确保第三方无法影响构建的另一个关键属性是模块版本的內容是不可变的。如果破坏依赖项的攻击者可以重新上传现有版本,他们就可以自动破坏所有依赖于它的项目。
这就是 go.sum
文件 的作用。它包含对贡献给构建的每个依赖项的加密哈希列表。同样,不完整的 go.sum
会导致错误,并且只有 go get
和 go mod tidy
会修改它,因此对它的任何更改都将伴随故意的依赖项更改。保证其他构建具有完整的校验和集。
这是大多数锁定文件的常见功能。Go 通过校验和数据库(简称为 sumdb)超越了这一点,它是一个全局的、仅追加的、经过加密验证的 go.sum 条目列表。当 go get
需要向 go.sum
文件添加条目时,它会从 sumdb 获取该条目以及 sumdb 完整性的加密证明。这确保了不仅特定模块的每个构建都使用相同的依赖项内容,而且每个模块都使用相同的依赖项内容!
sumdb 使得被破坏的依赖项甚至 Google 运行的 Go 基础架构都无法针对特定依赖项使用修改后的(例如,带有后门的)源代码。您可以确定自己使用的是与所有使用例如 v1.9.2 版本的 example.com/modulex
的人使用并审查过的代码完全相同。
最后,sumdb 的我最喜欢的功能是:它不需要模块作者进行任何密钥管理,并且它与 Go 模块的去中心化特性无缝协作。
VCS 是真相来源
大多数项目都通过某种版本控制系统(VCS)进行开发,然后在其他生态系统中上传到包存储库。这意味着有两个帐户可能被破坏,VCS 主机和包存储库,后者使用频率较低,并且更有可能被忽略。这也意味着更容易在上传到存储库的版本中隐藏恶意代码,特别是如果源代码在上传过程中被常规修改,例如为了最小化它。
在 Go 中,不存在包存储库帐户之类的东西。包的导入路径嵌入了 go mod download
需要直接从 VCS 获取其模块 的信息,其中标签定义了版本。
我们确实有 Go Module Mirror,但它只是一个代理。模块作者不注册帐户,也不向代理上传版本。代理使用与 go
工具相同的逻辑(实际上,代理运行 go mod download
)来获取和缓存版本。由于校验和数据库保证了给定模块版本只能有一个源树,因此使用代理的每个人都将看到与绕过代理直接从 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 模块自豪地佩戴。如果你发现自己需要一个库,你很可能会发现它不会让你承担数十个其他作者和所有者的模块的依赖。
这得益于丰富的标准库和附加模块(golang.org/x/...
),它们提供了常用的高级构建块,例如 HTTP 堆栈、TLS 库、JSON 编码等。
总而言之,这意味着可以仅使用少数几个依赖项来构建丰富、复杂的应用程序。无论工具多么好,它都无法消除重用代码所涉及的风险,因此最强大的缓解措施将始终是小型依赖项树。