做好依赖管理的十一条准则

| 2022-04-11

一、序

2021 年元旦过后的第一周,我们对 1000 多行用 golang 代码进行 Mob CR,类似于「扎堆编程」,一共持续了 4 个多小时,大家都很投入。

其中的一个讨论点就是:「如何正确引入和使用外部依赖包」。

打开 go.sum 文件,你会发现,有很多依赖包,它们会存在多个不同的版本。而且,有一些版本号甚至是以「0.」开头,后面是一长串日期加数字的版本标识,这可能意味着,它们都是不稳定的版本,如下图所示。

最终通过代码重构以后,我们这段代码的依赖数量大幅度减少,从 95 行减少到 55 行。

这要求每个开发人员都需要克制:

  • 不要只图一时的方便快捷,就随便引入外部依赖
  • 如果真的需要使用这个依赖,先要在组织所用的依赖库管理系统中查找是否已经引入了类似功能的外部包

与本世纪初相比,当今软件的生产速度已经加快了很多,一个很重要的原因是有大量的开源类库可用,不需要重复造轮子。

但并不是所有的类库都是经过严格测试,安全和质量是有保证的。

相反,绝大多数开源类库的质量是存疑的。即便是久经验证的 Log4j ,在 2021 年 12 月也暴露出了漏洞

而大厂支持的开源项目,也同一样有可能存在安全疑点,比如这个 Report a security vulnerability in nacos to bypass authentication #4593

所以,对于一个商业组织来说,软件系统的依赖管理挑战要比二十年前严峻得多。

在这次 Mob CR 中,同事 @xshi 给我们分享了一个英文链接,就是下面这篇,今天也分享在这里,原文链接见文末。


二、引子

几十年来,关于软件复用,说得多做得少。

现在,情况却反过来了:开发者几乎每天都在以软件依赖( dependency )的方式复用别人开发的软件,却没人去验证这些依赖是否安全和可靠。

作者 Russ Cox 的个人背景如下:

  • 管理 Google 的内部代码系统(此系统把软件依赖当做第一等公民)(引:1
  • 开发 Golang 语言的依赖相关功能(引:2

软件依赖中的很多严重风险被我们忽略了。

简单、细粒度的软件复用转变得如此之快,快到我们来不及梳理出如何选择依赖、有效使用依赖的最佳实践,甚至无法确定何时应使用依赖,何时不应使用依赖。

我写本文的目的在于:提高对软件依赖中的风险的认识,并希望抛砖引玉,共寻良策。

三、什么是依赖( Dependency )?

在现今的软件开发世界里,依赖(Dependency)是指:「你的应用程序需要调用的外部代码」。

使用依赖,可避免这些重复劳动:设计、编写、测试、调试、维护。

在本文,我们称这样的代码单元为包( package ),有些系统可能称之为库( library )或模块(module),但意思都一样。

使用别人已编写好的依赖库早已有之。

任何呈个开发人员可能都经历过:手动下载依赖、安装依赖,譬如 C 语言的 PCREzlib,或 C++ 的 BoostQt,或 Java 的 JodaTimeJUnit。这些包都是高质量的、已被充分调试过的,并且需要一定专业能力才能开发出来。

在十多年以前,对于开发者来说,为得到这些功能,需手动下载、安装、更新。

这种琐事虽然很无聊,但总比从头开发容易得多。由于需要比较多的手动操作,所以这些包的 size 通常比较大,毕竟操作一次不容易,一次就下载全吧。

因为,如果所需功能并不复杂,工作量不大的话,开发者很可能自己就重新实现一下了。

依赖包管理器( Dependency Manager ,或叫包管理器 —— package manager )可自动下载、安装所需的依赖包。

依赖包管理器的出现,让依赖包的下载和安装都变得更容易。手工开销变低了,再小的包也可轻松地发布、复用。

举例来说,Node.js 的依赖包管理器 NPM 提供了超过 750,000 个包。其中有一个叫 escape-string-regexp 的包,它仅有一个函数,用于转义正则表达式。其全部代码如下所示:

var matchOperatorsRe = /[|\\{}()[\]^$+*?.]/g;

module.exports = function (str) {
    if (typeof str !== 'string') {
        throw new TypeError('Expected a string');
    }
    return str.replace(matchOperatorsRe, '\\$&');
};

在依赖包管理器出现以前,很难想象可以发布这样一个仅 8 行代码的函数库。因为那时的发布代价太高,而收益甚微。

但 NPM 将这些成本降到几乎为 0 ,让再微小的功能都可以打包复用。截至 2019 年 01 月 下旬,the escape-string-regexp 这个包已被大约 1000 个其他 NPM 包依赖,更别提那些开发者为自己写的没公开发布的包了。

现在几乎每一门编程语言都有自己的依赖包管理器,如下面每个包管理器都包含超过十万个包:

  • Maven(Java)
  • Nuget(.NET)
  • Packagist(PHP)
  • PyPI(Python)
  • RubyGems(Ruby)

这种细粒度、广泛的软件复用方式,是过去二十年软件开发中最重要的变化之一。 面对这些变化,如果不引起重视,后果会很严重。

四、会在哪儿出问题

在本文中,一个包( Package )是指:你从互联网下载下来的一份代码(或者二进制包),它是由你不认识的人设计、编写、测试、调试的。

一旦开始使用这些代码,你的程序可能会面临因依赖问题而导致的失败、缺陷,和安全漏洞。

事实上,你现在手上正写的程序就依赖于这些从互联网上的陌生人那里得到的代码(比如 github 上的仓库)。

这其实并不是很安全的做法,但是,为什么还有那么多人愿意这么做呢?那是因为:

  • 简单方便
  • 程序能快速跑起来
  • 其他人都在这么做
  • 更重要的是:这似乎是一种惯例并延续到现在

然而,我们忽略了一些重要的区别。

几十年以前,大部分开发者是信任他们所依赖的别人写的软件,譬如:操作系统、编译器。这些软件是从已知的来源购买的,通常还包含一些支持协议。尽管这些软件依然会有潜在的缺陷,或奇怪的问题(引:3),但至少我们知道该找谁,甚至可以寻求商业或法律支持。

现在,开源软件几乎可以零成本在互联网上分发,这颠覆了以前那种「软件需要花钱购买」的方式。

在软件复用的方式比较麻烦时,很少有项目会去发布可复用的代码包。尽管开源软件都有许可证,并隐含「适用的范围」,但通常都会被大家忽略。一个项目的名声如何,反而往往是人们决定是否使用它的重要因素。对软件的信任已从商业、法律变成了名声。许多常见的早期的包仍保持着很好的声誉,包括:

  • BLAS(1979)
  • Netlib(1987)
  • libjpeg(1991)
  • LAPACK(1992)
  • HP STL(1994)
  • zlib(1995)

依赖包管理器已将开源代码的复用粒度降低到 1 个函数几十行代码的规模,这真的是一项重大的技术成就。依赖包管理器提供了无数的包供使用,我们信任这些包,但其商业、法律、信誉的支持并没跟上步伐。对于开源软件,我们用得多,却对其潜在风险关注得不多。

采用不良的依赖的成本可认为是所有可能的不良结果的总和,也即每个不良结果的成本乘以其发生的概率(风险)。

在不同类型的项目上,使用这些外部依赖,产生的结果也不相同。

比如,在某个人的个人爱好类项目上,只要他自己开心就行,无非是浪费一些时间,缺陷的影响也不大,其不良后果的成本几乎为零。甚至发现并调试这种缺陷会给他自己带来一些乐趣。

然而,在商业生产环境中的软件需要长期运行,这类外部依赖引发的错误成本就可能很高,比如导致服务器停机、敏感数据泄露、客户利益受损,甚至公司也可能因此而倒闭。这种故障引发的高成本使得管理这类问题带来的风险变得尤为重要。

我们实际上已经积累了一些相关的经验。

五、如何检查依赖

在公司招聘员工时,我们通常不会聘请一个我们完全不了解的软件开发者,而是先要了解一些相关信息,比如笔试、面试、背景调查等等。

直接使用在互联网上找到的一个依赖包,就如同聘请了一个完全不了解的软件开发者。

我们还是有必要做一些基本的检查,例如看一看这些代码是否会产生问题。如果发现的是一些小问题,你可以通过自行修订,或者提出 Issue ,提交补丁(如果能找到源头作者或社区的话)来规避它们。

但是,如果是严重问题,就应不使用这个软件包,而应该再去寻找替代品,或者干脆自己动手开发一个。

请记住,作者在互联网上发布开源软件包,是希望它们有用,但并不保证其可用性或提供支持

在开源包的开发和使用过程中,你甚至需参与调试优化。正如 GNU 通用公共许可证所警告的那样,「程序的质量和性能的全部风险是并存的。若程序有缺陷,导致的维护和修复成本需自行负责。」(引:4

下面将列举评判一个包的注意事项。

1、设计方面

  • 外部依赖包的文档是否清晰?
  • 它的 API 设计是否清晰?

如果作者能非常清晰地讲明白包的 API、设计概念、文档,那通常这份代码还算不错。基于清晰、设计良好的 API 进行编程有助于更简单、更快的编程并且更少错误。

还有,一定要关注一下,作者是否在文档中是否明确说明了后续升级时的可能兼容性问题?(如 C++(引:5),Go(引:6)的兼容性文档)

2、代码质量

  • 软件的代码写得怎么样?若你还不能确定,就要去尝试读一读其中的一部分代码。
  • 代码作者对待代码是否小心、认真、一致?
  • 代码看起来是否值得你去调试一下?你可能还真的需要调试一下。

使用一些常规方法来检查代码质量。

例如,编译 C 或 C++ 程序时,可通过启用编译器警告选项(如 -Wall)来告诉你如何避免各种未定义的行为。

较新语言,如 Go、Rust、Swift 使用了 unsafe 关键字来标记违反类型系统的代码,所以你可能还需要知道代码里有多少不安全的代码。

还有更高级的语义工具如 Infer(引:7)、SpotBugs(引:8)也挺有用。

Linter(语法、风格检查器)在此时的作用不大,因为它们很多如括号的风格问题,但应重点关注代码的语义问题。

你应保持开放的心态,对于看不去不正确的做法,可以再探索一下。

例如,SQLite 库中有 1 个 200,000 行代码的 C 文件和 1 个 11,000 行代码的头文件。这么大的文件是很奇怪的,但当你细究时会发现,源码的开发代码实质是由多个文件组成的:一个传统的文件树中包含了上百个 C 文件、测试文件及相关脚本。 这个巨大的源文件其实是由多个小文件自动合并而成的,这样做是为了方便那些没使用依赖包管理器的用户使用。 并且这样合并后的代码可以运行得相对更快些,因为此时编译器可有更多的优化空间。

3、自动化测试

  • 代码是否包含自动化测试用例?
  • 测试用例是否能运行?
  • 运行后是否能全部通过?

自动化测试用例除了可保证代码的基本质量,其实还表明作者细心维护功能正确性的态度。

举例来说,SQLite 项目的测试集就包含超过 30,000 个单独的测试用例,以及解释测试策略的开发文档(引:9)。

如果代码中没有或很少自动化测试用例,那就要小心了:这说明以后自己修改相关代码时,没有很方便地方式发现潜在的问题。假如你自己写代码时会写自动化测试用例,那你也应要求你外包给别人的代码也写测试用例。

如果有测试代码的话,你可以尝试运行验证来发现一些有用信息:

  • 代码覆盖率
  • 竞争检测(引:10
  • 内存占用检测
  • 内存泄露检测

4、调试与维护

找到你想引用的包的问题跟踪列表(例如 Github 某个项目的issues list),看看里面:

  • 有多少未关闭的 bug?
  • 未关闭的 bug 已经存在多久了?
  • 是否很多 bug 都已经被修复了?
  • 最近是否有 bug 被修复?

如果你发现里面有很多未关闭的 bug,并且这些 bug 已经存在较长时间了,则说明这个包不怎么好。

反之,若发现被关闭的问题很少是关于 bug 的,并且都已经修复了,则说明这个包还不错。

关注包的提交记录可发现:

  • 这些代码已经持续活跃维护多久了?
  • 目前是否还活跃?已经持续活跃了较长时间的软件包更可能还会继续维护
  • 有多少人参与维护?有些包纯粹是个人业余时间开发着玩的,有些则是多个专职开发者投入了数千小时的。通常后者的包更可能及时修复 bug、持续改进和日常维护。

但这也不是绝对,有些代码确实算是「完成」了。譬如 NPM 的包 escape-string-regexp,很早就有了,但基本不需要再修改了。

5、被使用的次数

  • 是否有很多包依赖于此包?

依赖包管理器平台通常可提供使用情况的统计数据,或者你可以去搜索引擎搜索来判断是否很多人用此包。很多人用的话,至少说明这份代码能满足这些人的使用,并通常能快速发现新问题。

广泛的使用量,其实也为持续维护提供保障,因为若维护者不维护了,还会有其他有兴趣的人去接手维护。

举例来说,像 PCREBoostJUnit 这些库被极为广泛地使用着。在你碰到问题前,很可能别人已经碰到,且已经修复了。

6、安全性

  • 你是否会使用第三方包来处理不可靠的输入?
  • 如果是的话,它能否足以抵御恶意输入?
  • 它是否出现在国家漏洞数据库(NVD)列出的安全问题中?(引:11

举例来说,2006 年,Jeff Dean 和我在 Google 代码搜索系统工作时(引:12),用了 grep 而非开源的代码。当时流行的 PCRE 正则表达式库似乎是一个不错的选择。

但之前我们与 Google 的安全团队讨论时了解到 PCRE 有一系列的问题包括缓冲区溢出(特别是其解释器中)。

我们当时也在 NVD 中找到关于 PCRE 的相关问题。虽然如此,我们也没放弃使用 PCRE,但我们因此变得更谨慎的进行测试和问题隔离。

7、查看许可证

  • 是否已获得代码的许可,或者说它是否有许可证?
  • 许可证是否适用于你的项目或公司?

Github 上有很小一部分代码没有明确标明许可证。 然而,此时此刻允许,并不表示你的项目或公司能一直可以使用这些依赖。 譬如,Google 就不允许使用基于类似这几种许可证的代码: AGPL(容易有法律风险)、WTFPL(过于模糊)(引:13)。

8、检查一下依赖包的依赖

  • 你引用的代码它自己本身是否也依赖于其他包?

间接依赖中的问题与直接依赖中的问题都是一样的。依赖包管理器平台可以列出包的所有依赖情况,所以理想情况下,你应逐一进行检查。间接的依赖也可能会导致风险,一个包自身的依赖需要更多的检查工作。

很多开发者从来不检查代码的间接依赖,也不知道间接依赖了什么。

例如,2016 年 03 月,NPM 社区发现许多广泛使用的项目(如 Babel、Ember、React)都间接依赖了一个很小的库 letf-pad(1 个只有 8 行的函数)。而 left-pad 的作者从 NPM 上删除了这个包,这导致很多 Node.js 用户的项目构建失败(引:14),甚至连 left-pad 自己也不例外。

例如,NPM 上总共约 750,000 个包,其中 30% 直接或间接依赖于 escape-string-regexp。根据 Leslie Lamport(图灵奖得主) 对分布式系统的观测,依赖包管理器平台可以轻松让一个包失效,从而导致你的包莫名变得不能用,例如无法编译构建打包。

9、通过自己的测试来检查

如前所述,检查工作应包含运行该依赖包自带的测试代码。但是,即使包自带的自动化测试可以通过,你也应该在使用这个包之前,继续为你所使用的相应功能写自动化测试用例。

这些测试用例应是简短的、独立的程序,以便于日后容易理解你的 API 对应的测试。

如果你现在还没写测试用例,就立即写吧,回头是岸~

花些额外精力写测试代码是很值得的,因为即使将来依赖的包的版本升级了,你的功能也会有保障。若你发现一个 bug,并且你自己有可能修复它,那么你可以重新执行一次你项目的测试用例,以确定不会影响到其他功能。

运行基本的检测来发现可能存在的问题。

以 Google 代码搜索系统为例,根据经验我们知道 PCRE 有时需要很长时间执行某些特殊的正则表达式搜索。我们最初的计划是将搜索分为“简单”和“复杂”两种正则表达式,并跑在互相独立的线程池中。第一阶段的测试中,我们跑了一些对比 pcregrep 和一些其他 grep 实现的基准测试。而当我们在一个测试用例中发现 pcrgrep 比最快的 grep 实现慢了近 70 倍时,我们开始重新考虑是否还该继续用 PCRE 了。尽管最后我们还是放弃了 PCRE,但时至今天这个测试用例依然保存在我们的代码库中。

六、对外部依赖进行封装

不同包的情况,你可能会因为下面因素而考虑是否继续使用它:

  • 新的更新版本不再是向后兼容的了
  • 包出现了严重的问题
  • 有更好的包可以替代它

基于上述原因,你需要额外工作来方便迁移到新的依赖。

如果你项目代码中很多地方用到了这个依赖包,那当迁移到一个用于替代它的新依赖包时,你需要修改的地方会很多。更麻烦的是,若你提供给外面的 API 包含了依赖包的 API,那当你迁移到新的依赖时,则使用了你 API 的代码都得修改,而那些调用了你 API 的代码,你可能是无法控制的。

为了避免依赖蔓延而导致的替换成本,你应额外定义一个接口来简单封装依赖中的细节

需注意的是,你的封装应仅包含你项目用到的功能,而非该依赖的全部功能

理想情况下,这样可以让你仅需要修改封装器,就能适配不同的依赖包。

迁移到新的依赖后,记得也修改所以对应的测试用例,以后还可以继续换依赖。

在 Google 代码搜索系统中,我们开发了一个抽象的 Regexp 类,类中定义了代码搜索接口,支持任意正则表达式引擎。然后我们为 PCRE 写了一个轻量的封装器来实现该接口。这种间接的方式可以更简单的测试不同的库,并可以避免去了解 PCRE 的内部细节。反过来看,这也使得我们更容易在不同的库之间切换。

七、隔离依赖

在程序运行的时候,隔离依赖可避免因为这个依赖有 bug 而导致的崩溃。

例如,Google Chrome 浏览器允许用户添加依赖(即扩展)。2008 年 Chrome 发布,引入了一个很关键的功能:将每个扩展隔离在运行在独立系统进程的沙箱中(引:15)。因此,有 bug 的扩展并不能访问到浏览器的全部内存,并可通过系统调用停掉扩展进程(引:16)。在 Google 代码搜索系统中,我们一直将 PCRE 解释器运行在一个类似的沙箱中,直到弃用它。现在,可以选择基于轻量级管理程序的沙箱(如 gVisor)(引:17)。将依赖隔离起来可减少很多风险。

即使有示例,也有现成的选项,在运行时隔离代码依然比较困难,而且的确很少人做到了。

真正的隔离需要一个完全内存安全的语言,不需要转换为无类型的代码。这不仅对 C、C++ 等完全不安全的语言有挑战,也对提供受限制的不安全操作的语言提出挑战,如含 JNI 的 Java、和含有「不安全」功能的 Go、Rust、Swift。

即使在内存安全的语言中如 JavaScript,其代码通常也可访问远远超过它所需要的地方。

NPM 包 event-stream 为 JavaScript 事件提供流式 API,在其 2018 年 11 月的最新版本中发现了两个半月前添加了混淆过的恶意代码。这些恶意代码从名为 Copay 的移动 App 中收集了大量比特币。这些代码访问了与事件流完全无关的系统资源(引:18)。针对此类问题,限制依赖的访问权限是防御措施之一。

八、避开依赖

如果某个依赖包的风险太大,而且你也找不到隔离的办法,那最好的办法就是完全不用它。或至少不要使用有问题的那部分。

例如,随着我们对 PCRE 风险、成本的更深入了解,在我们的代码搜索系统中,我们的使用方式也不断发生了变化。这些变化以下面的顺序发生:

  1. 直接使用 PCRE
  2. 在沙箱中使用 PCRE 的解释器
  3. 写一个新的正则表达式解释器,但依然使用 PCRE 的执行引擎
  4. 写一个新的解释器,并连接到一个不同的,且更高效的开源执行引擎
  5. 我们重写执行引擎。

重写以后,我们就没有任何依赖了,我们后来将其开源并命名为 RE2(引:19

如果你只需依赖包中的一小部分功能,最简单的方法可能是直接复制你所需的那部分下来(当然,也要保留适当的版权和其他法律声明)。这时你需要自行修复 bug、维护等等,但好处是你可以与较大的风险隔离开来。Go 开发者社区有一句谚语:“小的复制比小的依赖更好”(引:20)。

九、依赖的更新策略

关于软件的传统观点是「如果没有问题,就不要动它」。因为,依赖包的升级可能会引入新的 bug ,而且升级也不像增加新功能那样带来多少好处,何苦要冒这个险呢?

但上述观点忽略了两种成本,它们分别是:

  1. 最终还是要升级,因此而带来的成本(如 Log4j 的漏洞)。在软件中,修改代码的难度并不是线性递增的。10 次小改动比 1 次大改动更少工作量,也更不容易出问题
  2. 很难发现之前新版本中已被修复过的 bug。尤其是在与安全相关的环境中,外面已知的漏洞都会被人利用,你每天都有可能被攻击者入侵。

例如,Equifax(一家跨国征信公司)的高管在 2017 年国会证词中的详述(引:21)。当年 3 月 7 日,Apache Struts 爆出一个新漏洞,并发布了修复版本。3 月 8 日,Equifax 收到 US-CERT 应更新 Apache Struts 的通知。3 月 9 日、3 月 15 日,Equifax 分别进行了代码和网络扫描,并未发现有涉及漏洞的外网服务器。5 月 13 日,攻击者发现了(Equifax 的安全团队未发现)依然有存在漏洞的服务器。攻击者利用 Apache Struts 漏洞入侵了 Equifax 的网络,并在接下来的两个月内盗取了约 1.48亿人的详细个人信息和财务信息。Equifax 公司最终在 7 月 29 日发现被入侵,并在 9 月 4 日进行了公开说明。到了同年 9 月,Equifax 的 CEO、CIO、CSO 已全部辞职,并且国会开始介入调查。

Equifax 的经验告诉我们,虽然依赖包管理器平台知道构建代码时所使用的版本,但你还是需要另外的工作来跟踪线上部署过程的信息。

对于 Go 语言,我们正尝试在每个二进制文件中自动包含版本清单,以便在部署过程中可找到所需升级的依赖项。Go 还可以在运行时提供这些信息,这样服务器就可以通过查询依赖库中的已知 bug ,并在需要升级时自动向监控服务发送报告。

及时升级依赖固然重要,但升级就意味着向项目添加新代码,这时,我们仍旧需要去评估新版本依赖中可能带来的风险。

至少,你应稍看一下版本间的代码差异,或看一下版本发布说明,来确定关键位置的升级代码。

如果实在有太多的差异代码,导致难以通过差异信息来发现问题,那么,你应把这个问题也作为一个升级风险来对待。

  • 你应该重新跑一下你项目的测试用例,以确保升级后能兼容上一版本。
  • 重新运行依赖包本身的测试也是必要的。
  • 如果依赖包本身也有自己的依赖,那么你的项目配置中可能会依赖某些间接依赖包的不同版本。
  • 运行依赖包本身的测试用例可快速发现你的项目配置是否有问题。

并且,版本升级不应完全自动化。在升级前,你应预先验证新版本是否能在你的环境运行。(引:22

**安全相关的关键升级窗口期特别短。**如果你的升级过程已经包括了运行之前所写的整合测试用例和合规测试用例,那你已经可以在上线前预先发现问题了。在这种情况下,你升级越快,风险越低。

在 Equifax 公司的入侵事件发生后,法院的安全团队发现证据表明攻击者(可能是不同的攻击者)在 Apache Struts 漏洞爆出后仅第 3 天(即 3 月 10 日)就已经入侵了 Equifax 的服务器,但他们当时只运行了一个 whoami 命令。

十、对依赖的监控

即使你已经做到上述所说的,工作仍旧没有完成。

你还应持续监控依赖的状况,以便评估是否继续使用这些依赖。

你应先确认你所使用的包的确是你想要的那个版本。

目前大部分依赖包管理器可以轻松做到(甚至自动地记录)某个版本代码的加密哈希值。然后,在其他电脑或测试环境中重新下载依赖包后,可验证哈希值是否一致。

这样可以保证你的代码构建是基于你曾检查过、测试过的、完全相同的依赖代码。从而避免类似 event-stream 那样的攻击(偷偷地在已发布的版本 3.3.5 中插入了恶意代码,因为没有做哈希校验)。若有哈希校验,则攻击者必须创建一个新的 3.3.6 版本,并等人们升级(且是没留意是否有修改的前提下)才能攻击成功。

另外,还需注意是否有新的间接依赖被加入进来了。版本升级也很容易在你升级目前的依赖时引入新的间接依赖。

event-stream 这个案例中,恶意代码被隐藏在一个不同的包中 flatmap-stream,而 event-stream 发布新版本时就将这个包作为新依赖引入进来了。

这些间接依赖所造成的影响也会体现在你项目构建后的包的尺寸上。

在 Google 的 Sawzall(引:23)(一门 JIT 日志处理语言)中,作者发现,在不同时候,其主解释器二进制文件不仅包含 Sawzall 的 JIT 信息,还包含从未使用过的 PostScript、Python、JavaScript 的解释器。每次细查发现,其实原因是 Sawzall 所依赖的某些库声明了一些从未使用的其他依赖,再加上 Google 的构建系统会自动处理新的依赖关系,从而导致上述结果。这类错误正是 Go 语言为什么会将未使用过的导入当成是编译时错误的原因。

十一、什么时机用来检查是否继续使用原有的依赖

当然是升级版本时。

定期重新检查依赖是否有变化也很重要。

  • 能否确定是否真的没有安全问题或其他 bug 需要修复了?
  • 该项目是否已不再维护了?也许已是时候开始准备去替换依赖了。

重新查看每个依赖项的安全相关历史记录也很重要。例如,Apache Struts 在 2016、2017、2018 均披露过不同的严重远程代码执行漏洞。所以即使你的服务器都已升级到其最新版本,但有着这样的安全历史记录,你应再三思考是否应继续使用它。

十二、总结

软件复用的时代已经来临,我们不能低估其带来的好处:软件复用为开发者带来了巨大的便利

尽管它的好处固然不可否认,但我们在这股转变的洪流中,还没来得及认真去思考随之而来的风险。

今时不同往日,我们拥有太多依赖,我们已经不能再像二三十年那样「过于信任依赖」了。

我在本文已经说明了,对依赖项进行严格的验证、测试会有很大的工作量,并且大部分团队做不到这一点。

我甚至怀疑是否真的有开发者做到了对每个新引入的依赖都做了上述工作。而我自己以前仅在我的部分依赖中做到了部分工作。大部分情况下的决策完全就是「先用着再看」,并且如果再往前一步就会增加很多工作量。

但 Copay 和 Equifax 公司被入侵的案例已经敲响了警钟:我们现在这种使用依赖的方式真的存在严重问题。我们不能对此掉以轻心

为此,我提出下面 3 条建议:

  • 意识到问题严重性。我希望本文至少能让你意识到这是一个值得着手去解决的问题。并且需要很多人一起花大力气才能解决。

  • 现在就该开始建立最佳实践。我们需要建立最佳实践来管理现在可用的依赖。这意味着从最初决定采用依赖阶段到上线阶段都要制定评估、减少、跟踪风险的流程。事实上,正如一些工程师专注与测试工作一样,我们可能还需要一些专门管理依赖的工程师

  • 开发面向未来的依赖管理技术

依赖包管理器已基本消除了下载和安装依赖的成本。未来的开发工作重点更应在于:降低评估和维护依赖的成本。 例如,依赖包的搜索站点应让开发者更容易共享他们发现的问题和修复方案等。构建工具应至少做到更容易跑包自身的测试用例。若能做的更好的话,构建工具和包管理系统应协同起来,允许包的开发者去在代码变更时,测试可能对其 API 的所有公共客户端造成的影响。编程语言也应提供更简单的机制来隔离有问题的包。

世界上有优质的软件这么多,为更安全可靠地复用这些软件,我们应携手前行。

参考
  1. Rachel Potvin and Josh Levenberg, “Why Google Stores Billions of Lines of Code in a Single Repository,” Communications of the ACM 59(7) (July 2016), pp. 78-87. https://doi.org/10.1145/2854146
  2. Russ Cox, “Go & Versioning,” February 2018. https://research.swtch.com/vgo
  3. Ken Thompson, “Reflections on Trusting Trust,” Communications of the ACM 27(8) (August 1984), pp. 761–763. https://doi.org/10.1145/358198.358210
  4. GNU Project, “GNU General Public License, version 1,” February 1989. https://www.gnu.org/licenses/old-licenses/gpl-1.0.html
  5. Titus Winters, “SD-8: Standard Library Compatibility,” C++ Standing Document, August 2018. https://isocpp.org/std/standing-documents/sd-8-standard-library-compatibility
  6. Go Project, “Go 1 and the Future of Go Programs,” September 2013. https://golang.org/doc/go1compat
  7. Facebook, “Infer: A tool to detect bugs in Java and C/C++/Objective-C code before it ships.” https://fbinfer.com/
  8. “SpotBugs: Find bugs in Java Programs.” https://spotbugs.github.io/
  9. D. Richard Hipp, “How SQLite is Tested.” https://www.sqlite.org/testing.html
  10. Alexander Potapenko, “Testing Chromium: ThreadSanitizer v2, a next-gen data race detector,” April 2014. https://blog.chromium.org/2014/04/testing-chromium-threadsanitizer-v2.html
  11. NIST, “National Vulnerability Database – Search and Statistics.” https://nvd.nist.gov/vuln/search
  12. Russ Cox, “Regular Expression Matching with a Trigram Index, or How Google Code Search Worked,” January 2012. https://swtch.com/~rsc/regexp/regexp4.html
  13. Google, “Google Open Source: Using Third-Party Licenses.” https://opensource.google.com/docs/thirdparty/licenses/#banned
  14. Nathan Willis, “A single Node of failure,” LWN, March 2016. https://lwn.net/Articles/681410/
  15. Charlie Reis, “Multi-process Architecture,” September 2008. https://blog.chromium.org/2008/09/multi-process-architecture.html
  16. Adam Langley, “Chromium’s seccomp Sandbox,” August 2009. https://www.imperialviolet.org/2009/08/26/seccomp.html
  17. Nicolas Lacasse, “Open-sourcing gVisor, a sandboxed container runtime,” May 2018. https://cloud.google.com/blog/products/gcp/open-sourcing-gvisor-a-sandboxed-container-runtime
  18. Adam Baldwin, “Details about the event-stream incident,” November 2018. https://blog.npmjs.org/post/180565383195/details-about-the-event-stream-incident
  19. Russ Cox, “RE2: a principled approach to regular expression matching,” March 2010. https://opensource.googleblog.com/2010/03/re2-principled-approach-to-regular.html
  20. Rob Pike, “Go Proverbs,” November 2015. https://go-proverbs.github.io/
  21. U.S. House of Representatives Committee on Oversight and Government Reform, “The Equifax Data Breach,” Majority Staff Report, 115th Congress, December 2018. https://oversight.house.gov/report/committee-releases-report-revealing-new-information-on-equifax-data-breach/
  22. Russ Cox, “The Principles of Versioning in Go,” GopherCon Singapore, May 2018. https://www.youtube.com/watch?v=F8nrpe0XWRg
  23. Rob Pike, Sean Dorward, Robert Griesemer, and Sean Quinlan, “Interpreting the Data: Parallel Analysis with Sawzall,” Scientific Programming Journal, vol. 13 (2005). https://doi.org/10.1155/2005/962135

英文原文:https://research.swtch.com/deps

作者:Russ Cox(Go 语言主要开发者之一,任职于 Google)