做好依赖管理的十五条准则(下)

“ 做好这十五条,才能确保软件供应链的健康!”
在上一篇文章「做好依赖管理的十五条准则(上)」中,我们讨论了:
什么是依赖管理?
为什么当今商业软件开发的依赖管理越来越重要?
依赖管理在哪里可能出现问题?
使用依赖的注意事项有哪些注意事项?
接下来,我们将主要讨论依赖管理的准则,一共十五条。它分为两方面:
引用前的检查准则(共 9 项)
引用后的使用准则(共 6 项)
篇幅较长,请准备好~
1
引用前的检查准则
(共 9 项)
1、对设计的考查
外部依赖包的文档是否清晰?
它的 API 设计是否清晰?
如果作者能非常清晰地讲明白包的 API、设计概念、文档,那通常这份代码还算不错。基于清晰、设计良好的 API 进行编程有助于更简单、更快的编程并且更少错误。
还有,一定要关注一下,作者是否在文档中是否明确说明了后续升级时的可能兼容性问题?(如 C++,Go 的兼容性文档)
2、对代码质量的考查
软件的代码写得怎么样?若你还不能确定,就要去尝试读一读其中的一部分代码。
代码作者对待代码是否小心、认真、一致?
代码看起来是否值得你去调试一下?你可能还真的需要调试一下。
使用一些常规方法来检查代码质量。
例如,编译 C 或 C++ 程序时,可通过启用编译器警告选项(如 -Wall)来告诉你如何避免各种未定义的行为。
比较新的语言,如 Go、Rust、Swift 使用了 unsafe 关键字来标记违反类型系统的代码,所以你可能还需要知道代码里有多少不安全的代码。
还有更高级的语义工具如 Infer、SpotBugs 也挺有用。
Linter(语法、风格检查器)在此时的作用不大,因为它们很多如括号的风格问题,但应重点关注代码的语义问题。
你应保持开放的心态,对于看上去不太正确的做法,可以再探索一下。
例如,SQLite 库中有 1 个 200,000 行代码的 C 文件和 1 个 11,000 行代码的头文件。
这么大的文件是很奇怪的,但当你细究时会发现,源码的开发代码实质是由多个文件组成的:一个传统的文件树中包含了上百个 C 文件、测试文件及相关脚本。
这个巨大的源文件其实是由多个小文件自动合并而成的,这样做是为了方便那些没使用依赖包管理器的用户使用。
而且,这样合并后的代码可以运行得相对更快些,因为此时编译器可有更多的优化空间。
3、自动化测试
代码是否包含自动化测试用例?
测试用例是否能运行?
运行后是否能全部通过?
自动化测试用例除了可保证代码的基本质量,其实还表明作者细心维护功能正确性的态度。
举例来说,SQLite 项目的测试集就包含超过 30,000 个单独的测试用例,以及解释测试策略的开发文档。
如果代码中没有或很少自动化测试用例,那就要小心了:这说明以后自己修改相关代码时,没有很方便地方式发现潜在的问题。假如你自己写代码时会写自动化测试用例,那你也应要求你外包给别人的代码也写测试用例。
如果有测试代码的话,你可以尝试运行验证来发现一些有用信息:
代码覆盖率
竞争检测
内存占用检测
内存泄露检测
4、调试与维护
找到你想引用的包的问题跟踪列表(例如 Github 某个项目的issues list),看看里面:
有多少未关闭的 bug?
未关闭的 bug 已经存在多久了?
是否很多 bug 都已经被修复了?
最近是否有 bug 被修复?
如果你发现里面有很多未关闭的 bug,并且这些 bug 已经存在较长时间了,则说明这个包不怎么好。
反之,若发现被关闭的问题很少是关于 bug 的,并且都已经修复了,则说明这个包还不错。
关注包的提交记录可知道:
这些代码已经持续活跃维护多久了?
目前是否还活跃?已经持续活跃了较长时间的软件包更可能还会继续维护
有多少人参与维护?有些包纯粹是个人业余时间开发着玩的,有些则是多个专职开发者投入了数千小时的。通常后者的包更可能及时修复 bug、持续改进和日常维护。
但这也不是绝对,有些代码确实算是「完成」了。
譬如 NPM 的包 escape-string-regexp,很早就有了,但基本不需要再修改了。
5、被使用的次数
是否有很多包都依赖于此包?
依赖包管理器平台通常可提供使用情况的统计数据,或者你可以去搜索引擎搜索来判断是否很多人用此包。很多人用的话,至少说明这份代码能满足这些人的使用,并通常能快速发现新问题。
广泛的使用量,其实也为持续维护提供保障,因为若维护者不维护了,还会有其他有兴趣的人去接手维护。
举例来说,像 PCRE、Boost、JUnit 这些库被极为广泛地使用着。在你碰到问题前,很可能别人已经碰到,且已经修复了。
6、安全性
你是否会使用第三方包来处理不可靠的输入?
如果是的话,它能否足以抵御恶意输入?
它是否出现在国家漏洞数据库(NVD)列出的安全问题中?
举例来说,2006 年 Google 代码搜索系统,用了
grep而非开源的代码。当时流行的PCRE正则表达式库似乎是一个不错的选择。但 Google 的安全团队说,
PCRE有一系列的问题包括缓冲区溢出(特别是其解释器中)。在 NVD 中也能找到关于
PCRE的相关问题。但是,代码搜索系统并没放弃使用
PCRE,而是更谨慎的进行测试和问题隔离。
7、查看许可证
是否已获得代码的许可,或者说它是否有许可证?
许可证是否适用于你的项目或公司?
Github 上,有一部分代码库没有明确标明许可证。
然而,此时此刻允许,并不表示你的项目或公司能一直可以使用这些依赖。
譬如,Google 就不允许使用基于类似这几种许可证的代码: AGPL(容易有法律风险)、WTFPL(过于模糊)。
8、检查一下依赖包的依赖
你引用的代码它自己本身是否也依赖于其他包?
间接依赖中的问题与直接依赖中的问题都是一样的。依赖包管理器平台可以列出包的所有依赖情况,所以理想情况下,你应逐一进行检查。间接的依赖也可能会导致风险,一个包自身的依赖需要更多的检查工作。
很多开发者从来不检查代码的间接依赖,也不知道间接依赖了什么。
例如,2016 年 03 月,NPM 社区发现许多广泛使用的项目(如 Babel、Ember、React)都间接依赖了一个很小的库
letf-pad(1 个只有 8 行的函数)。而left-pad的作者从 NPM 上删除了这个包,这导致很多 Node.js 用户的项目构建失败,甚至连left-pad自己也不例外。例如,NPM 上总共约 750,000 个包,其中 30% 直接或间接依赖于
escape-string-regexp。根据 Leslie Lamport(图灵奖得主) 对分布式系统的观测,依赖包管理器平台可以轻松让一个包失效,从而导致你的包莫名变得不能用,例如无法编译构建打包。
9、通过自己的测试来检查
如前所述,检查工作应包含运行该依赖包自带的测试代码。但是,即使包自带的自动化测试可以通过,你也应该在使用这个包之前,继续为你所使用的相应功能写自动化测试用例。
这些测试用例应是简短的、独立的程序,以便于日后容易理解你的 API 对应的测试。
如果你现在还没写测试用例,就立即写吧,回头是岸~
花些额外精力写测试代码是很值得的,因为即使将来依赖的包的版本升级了,你的功能也会有保障。若你发现一个 bug,并且你自己有可能修复它,那么你可以重新执行一次你项目的测试用例,以确定不会影响到其他功能。
运行基本的检测来发现可能存在的问题。
以 Google 代码搜索系统为例,根据经验,
PCRE有时需要很长时间执行某些特殊的正则表达式搜索。最初的计划是将搜索分为「简单」和「复杂」两种正则表达式,并跑在互相独立的线程池中。
第一阶段的测试中,谷歌跑了一些对比pcregrep和一些其他grep实现的基准测试。当在一个测试用例中发现pcrgrep比最快的grep实现慢了近 70 倍时,谷歌开始重新考虑是否还该继续用PCRE了。尽管最后谷歌还是放弃了
PCRE,但时至今天这个测试用例依然保存在我们的代码库中。
1
引用后的使用准则
(共 6 项)
1、对外部依赖进行封装
不同包的情况,你可能会因为下面因素而考虑是否继续使用它:
新的更新版本不再是向后兼容的了
包出现了严重的问题
有更好的包可以替代它
基于上述原因,你需要额外工作来方便迁移到新的依赖。
如果你项目代码中很多地方用到了这个依赖包,那当迁移到一个用于替代它的新依赖包时,你需要修改的地方会很多。
更麻烦的是,若你提供给外面的 API 包含了依赖包的 API,那当你迁移到新的依赖时,则使用了你 API 的代码都得修改,而那些调用了你 API 的代码,你可能是无法控制的。
为了避免依赖蔓延而导致的替换成本,你应该
额外定义一个接口来简单封装依赖中的细节。
你的封装应仅包含你项目用到的功能,而非该依赖的全部功能。
理想情况下,这样可以让你仅需要修改封装器,就能适配不同的依赖包。
迁移到新的依赖后,记得也修改所以对应的测试用例,以后还可以继续换依赖。
在 Google 代码搜索系统中,开发了一个抽象的
Regexp类,类中定义了代码搜索接口,支持任意正则表达式引擎。然后为
PCRE写了一个轻量的封装器来实现该接口。这种间接的方式可以更简单的测试不同的库,并可以避免去了解 PCRE 的内部细节。当然,这也使得我们更容易在不同的库之间切换。
2、对依赖进行隔离
在程序运行的时候,隔离依赖可避免因为这个依赖有 bug 而导致的崩溃。
例如,Google Chrome 浏览器允许用户添加依赖(即扩展)。
2008 年 Chrome 发布,引入了一个很关键的功能:将每个扩展插件隔离在运行在独立系统进程的沙箱中。因此,有 bug 的扩展插件并不能访问到浏览器的全部内存,并可通过系统调用停掉扩展进程。
在 Google 代码搜索系统中,一直将
PCRE解释器运行在一个类似的沙箱中,直到弃用它。现在,可以选择基于轻量级管理程序的沙箱(如gVisor)。将依赖隔离起来可减少很多风险。
即使有示例,也有现成的选项,在运行时隔离代码依然比较困难,而且的确很少人做到了。
真正的隔离需要一个完全内存安全的语言,不需要转换为无类型的代码。这不仅对 C、C++ 等完全不安全的语言有挑战,也对提供受限制的不安全操作的语言提出挑战,如含 JNI 的 Java、和含有「不安全」功能的 Go、Rust、Swift。
即使在内存安全的语言中如 JavaScript,其代码通常也可访问远远超过它所需要的地方。
NPM 包
event-stream为 JavaScript 事件提供流式 API,在其 2018 年 11 月的最新版本中发现了两个半月前添加了混淆过的恶意代码。这些恶意代码从名为Copay的移动 App 中收集了大量比特币。这些代码访问了与事件流完全无关的系统资源。针对此类问题,限制依赖的访问权限是防御措施之一。
3、如果无法隔离,最好避开
如果某个依赖包的风险太大,而且你也找不到隔离的办法,那最好的办法就是完全不用它。或至少不要使用有问题的那部分。
例如,随着谷歌对 PCRE 风险、成本的更深入了解,在其代码搜索系统中,使用方式也不断发生了变化。这些变化以下面的顺序发生:
直接使用
PCRE在沙箱中使用
PCRE的解释器写一个新的正则表达式解释器,但依然使用
PCRE的执行引擎写一个新的解释器,并连接到一个不同的,且更高效的开源执行引擎
我们重写执行引擎。
在重写以后,谷歌对它就没有任何依赖了,我们后来将其开源并命名为 RE2
如果你只需依赖包中的一小部分功能,最简单的方法可能是直接复制你所需的那部分下来(当然,也要保留适当的版权和其他法律声明)。这时你需要自行修复 bug、维护等等,但好处是你可以与较大的风险隔离开来。
Go 开发者社区有一句谚语:“小的复制比小的依赖更好”。
4、依赖的更新策略
关于软件的传统观点是「如果没有问题,就不要动它」。因为,依赖包的升级可能会引入新的 bug ,而且升级也不像增加新功能那样带来多少好处,何苦要冒这个险呢?
但上述观点忽略了两种成本,它们分别是:
升级的成本。最终还是要升级,因此而带来的成本(如 Log4j 的漏洞)。在软件中,修改代码的难度并不是线性递增的。10 次小改动比 1 次大改动更少工作量,也更不容易出问题。
问题跟踪的成本。很难发现之前新版本中已被修复过的 bug。尤其是在与安全相关的环境中,外面已知的漏洞都会被人利用,你每天都有可能被攻击者入侵。
例如,Equifax(一家跨国征信公司)的高管在 2017 年国会证词中的陈述。
当年 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 ,并在需要升级时自动向监控服务发送报告。
及时升级依赖固然重要,但升级就意味着向项目添加新代码,这时,我们仍旧需要去评估新版本依赖中可能带来的风险。
至少,你应稍看一下版本间的代码差异,或看一下版本发布说明,来确定关键位置的升级代码。
如果实在有太多的差异代码,导致难以通过差异信息来发现问题,那么,你应把这个问题也作为一个升级风险来对待。
你应该重新跑一下你项目的测试用例,以确保升级后能兼容上一版本。
重新运行依赖包本身的测试也是必要的。
如果依赖包本身也有自己的依赖,那么你的项目配置中可能会依赖某些间接依赖包的不同版本。
运行依赖包本身的测试用例可快速发现你的项目配置是否有问题。
并且,版本升级不应完全自动化。
在升级前,你应预先验证新版本是否能在你的环境运行。
安全相关的关键升级窗口期特别短。
如果你的升级过程包括运行以前所写的整合测试用例与合规测试用例,那你已有能力在上线前预先发现问题了。在这种情况下,你升级越快,风险越低。
在 Equifax 公司的入侵事件发生后,法院的安全团队发现证据表明攻击者(可能是不同的攻击者)在 Apache Struts 漏洞爆出后仅第 3 天(即 3 月 10 日)就已经入侵了 Equifax 的服务器,但他们当时只运行了一个
whoami命令。
5、对依赖的监控
即使你已经做到上述所说的,工作仍旧没有完成。
你还应持续监控依赖的状况,以便评估是否继续使用这些依赖。
你应先确认你所使用的包的确是你想要的那个版本。
目前大部分依赖包管理器可以轻松做到(甚至自动地记录)某个版本代码的加密哈希值。在其他电脑或测试环境中重新下载依赖包后,可验证哈希值是否一致。
这样可以保证你的代码构建是基于你曾检查过、测试过的、完全相同的依赖代码以从而避免类似 event-stream 那样的攻击(偷偷地在已发布的版本 3.3.5 中插入了恶意代码,因为没有做哈希校验)。若有哈希校验,则攻击者必须创建一个新的 3.3.6 版本,并等人们升级(且是没留意是否有修改的前提下)才能攻击成功。
另外,还需注意是否有新的间接依赖被加入进来了。版本升级也很容易在你升级目前的依赖时引入新的间接依赖。
在 event-stream 这个案例中,恶意代码被隐藏在一个不同的包中 flatmap-stream,而 event-stream 发布新版本时就将这个包作为新依赖引入进来了。
这些间接依赖所造成的影响也会体现在你项目构建后的包的尺寸上。
在 Google 的 Sawzall(一门 JIT 日志处理语言)中,作者发现:
在不同时候,其主解释器二进制文件不仅包含 Sawzall 的 JIT 信息,还包含从未使用过的 PostScript、Python、JavaScript 的解释器。
每次细查发现,其原因是 Sawzall 所依赖的某些库声明了一些从未使用的其他依赖,再加上 Google 的构建系统会自动处理新的依赖关系,从而导致上述结果。
这类错误正是 Go 语言为什么会将未使用过的导入当成是编译时错误的原因。
6、什么时机用来检查是否继续使用原有的依赖
当然是升级版本时。
定期重新检查依赖是否有变化也很重要。
能否确定是否真的没有安全问题或其他 bug 需要修复了?
该项目是否已不再维护了?也许已是时候开始准备去替换依赖了。
重新查看每个依赖项的安全相关历史记录也很重要。
例如,Apache Struts 在 2016、2017、2018 均披露过不同的严重远程代码执行漏洞。
所以,即使你的服务器都已升级到其最新版本,但有着这样的安全历史记录,你应再三思考是否应继续使用它。
3
3 条小建议
软件复用的时代已经来临,
我们不能低估其带来的好处:软件复用为开发者带来了巨大的便利。
尽管它的好处固然不可否认,但我们在这股转变的洪流中,还没来得及认真去思考随之而来的风险。
今时不同往日,我们拥有太多依赖,已经不能再像二三十年前那样「过于信任依赖」了。
说实在的,对依赖项进行严格的验证、测试会有很大的工作量,并且大部分团队做不到这一点。
我甚至怀疑是否真的有开发者做到了对每个新引入的依赖都做了上述工作。
大部分情况下的决策完全就是「先用着再看」,
而且,再往前一步就会增加很多工作量。
但 Copay 和 Equifax 公司被入侵的案例已经敲响了警钟:
我们现在这种使用依赖的方式真的存在严重问题。我们不能对此掉以轻心。
为此,我提出下面 3 条建议:
意识到问题严重性。我希望本文至少能让你意识到这是一个值得着手去解决的问题。并且需要很多人一起花大力气才能解决。
现在就该开始建立最佳实践。我们需要建立最佳实践来管理现在可用的依赖。这意味着从最初决定采用依赖阶段到上线阶段都要制定评估、减少、跟踪风险的流程。事实上,正如一些工程师专注与测试工作一样,我们可能还需要一些专门管理依赖的工程师。
开发面向未来的依赖管理技术。
依赖包管理器已基本消除了下载和安装依赖的成本。
未来的开发工作重点更应在于:降低评估和维护依赖的成本。
例如,
依赖包的搜索站点应让开发者更容易共享他们发现的问题和修复方案等。
构建工具应至少做到更容易跑包自身的测试用例。
若能做的更好的话,构建工具和包管理系统应协同起来,允许包的开发者去在代码变更时,测试可能对其 API 的所有公共客户端造成的影响。
编程语言也应提供更简单的机制来隔离有问题的包。
世界上优质的软件这么多,为更安全可靠地复用这些软件,我们应携手前行。
参考阅读
做好依赖管理的十五条准则(上)
本文来自互联网用户投稿,文章观点仅代表作者本人,不代表本站立场,不承担相关法律责任。如若转载,请注明出处。 如若内容造成侵权/违法违规/事实不符,请点击【内容举报】进行投诉反馈!
