软件复用导致的软件依赖问题 - research!rsc


几十年来,对软件重用的讨论远比实际的软件重用更常见。今天,情况正好相反:开发人员每天都以软件依赖的形式重复使用其他人编写的软件,而且情况大多未经审查。
我自己的背景包括使用Google的内部源代码系统十年,该系统将软件依赖关系视为一流的概念,1 并且还开发了对Go编程语言中依赖关系的支持。2
软件依赖性带来了经常被忽视的严重风险。转向简单,细粒度的软件重用发生得如此之快,以至于我们还没有理解有效选择和使用依赖关系的最佳实践,甚至无法确定何时适当和何时适用。我写这篇文章的目的是提高对风险的认识,并鼓励对解决方案进行更多研究。

什么是依赖?
在今天的软件开发世界中,依赖项是您希望从程序中调用的附加代码。添加依赖项可避免重复已完成的工作:设计,编写,测试,调试和维护特定的代码单元。在本文中,我们将该代码单元称为 ; 一些系统使用诸如库或模块之类的术语而不是包。
承担外部编写的依赖是一个古老的做法:大多数程序员在他们的职业生涯中必须经历手动下载和安装所需库的步骤,如C的PCRE或zlib,或C ++的Boost或Qt,或Java的JodaTime或JUnit。这些软件包包含高质量的调试代码,需要大量的专业知识才能开发。对于需要其中一个软件包提供的功能的程序,手动下载,安装和更新软件包的繁琐工作比从头开始重新开发该功能的工作更容易。但是,重用本身的高成本意味着重用依赖的第三方软件包往往很大:通常只需要一个小库包将更容易重新实现。
一个依赖管理器 (有时称为包管理器)可以自动依赖包的下载和安装。由于依赖关系管理器使单个程序包更易于下载和安装,因此较低的固定成本使得较小的程序包可以经济地发布和重用。
例如,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,'\\ $&'); 
};

在依赖管理器之前,发布一个八行代码库是不可想象的:太多的开销,太少的好处。但NPM已将开销大约推至零,结果是我们可以打包和重用几乎无关紧要的一些功能。在2019年1月下旬,该escape-string-regexp软件包明确依赖于近千个其他NPM软件包,更不用说开发人员为自己使用而不共享的所有软件包。

现在依赖于每种编程语言的依赖管理器。Maven Central(Java),Nuget(.NET),Packagist(PHP),PyPI(Python)和RubyGems(Ruby)每个托管超过100,000个包。这种细粒度,广泛的软件重用的到来是过去二十年中软件开发中最重要的变化之一。如果我们不小心,它将导致严重的问题。

怎么可能出错?
包是从Internet下载的代码,添加一个包作为依赖包将开发代码设计,编写,测试,调试和维护的工作外包给互联网上的其他人,这是你经常不认识的人。通过使用该代码,您将自己的程序暴露给依赖项中的所有失败和缺陷。你的程序执行现在字面上取决于 从这个陌生人在互联网上下载的代码。以这种方式呈现,听起来非常不安全。为什么有人会这样做?
我们之所以这样做是因为它很容易,因为它似乎有效,因为其他人也都这样做,而且最重要的是,因为它似乎是古老惯例的自然延续。但是我们忽略了一些重要的差异。
几十年前,大多数开发人员已经信任其他人编写他们所依赖的软件,例如操作系统和编译器。该软件是从已知来源购买的,通常带有某种支持协议。仍然存在潜在的漏洞或彻头彻尾的恶作剧3 ,但至少我们知道我们正在和谁打交道,并且通常有商业或法律资源。
通过互联网免费分发的开源软件现象取代了许多早期的软件购买方式,这些项目建立了众所周知的声誉,这些声誉往往是人们决定使用因素中的重要因素,信任我们软件资源的商业和法律支持被声誉支持所取代。许多常见的早期包库仍然享有良好的声誉:考虑BLAS(1979年出版),Netlib(1987),libjpeg(1991),LAPACK(1992),HP STL(1994)和zlib(1995)。
依赖管理器已经缩减了这个开源代码重用模型:现在,开发人员可以按照几十行的各个函数的粒度共享代码。这是一项重大的技术成就。有无数可用的软件包,编写代码可能涉及如此大量的软件包,但信任代码的商业,法律和声誉支持机制尚未延续。我们相信更多的代码而没有理由这么做。
采用不良依赖的成本可以被视为所有可能的不良结果的总和,即每个不良结果的成本乘以其发生的概率(风险)。

使用依赖关系的上下文决定了不良结果的成本。一方面是一个个人爱好项目,其中大多数不良后果的成本几乎为零:你只是玩得开心,除了浪费一些时间之外,bug没有真正的影响,甚至调试它们也很有趣。所以风险概率几乎无关紧要:它被乘以零;另一方面,生产软件必须保持多年。在这里,依赖关系中的错误成本可能非常高:服务器可能会关闭,敏感数据可能会被泄露,客户可能会受到损害,公司可能会失败。高故障成本使得估计并降低严重故障风险变得更加重要。
无论预期成本如何,具有较大依赖性的经验都表明了一些估计和降低添加软件依赖性风险的方法。可能需要更好的工具来帮助降低这些方法的成本,就像依赖管理者迄今为止一直专注于降低下载和安装成本一样。

检查依赖性
你不会雇用一个你从未听说过的软件开发人员。您将首先了解有关应聘者的更多信息:检查,进行面试,运行背景检查等。在您依赖于在互联网上找到的包裹之前,首先同样谨慎的要了解一下它。

基本检查可以让您了解尝试使用此代码时遇到问题的可能性。如果检查发现可能存在小问题,您可以采取措施准备或避免它们。如果检查发现主要问题,最好不要使用这个包:也许你会找到一个更合适的,或者你需要自己开发一个。请记住,开源软件包是由作者发布的,希望它们有用,但不保证可用性或支持。在生产中断的过程中,您将成为调试它的人。正如最初的GNU通用公共许可证所警告的那样,“程序的质量和性能的全部风险都与您同在。如果程序证明有缺陷,您将承担所有必要的维修,修理或更正的费用。“ 


本节的其余部分概述了检查包装并决定是否依赖它时的一些注意事项。

设计
包文件是否清晰?API是否有清晰的设计?如果作者能够很好地解释软件包的API及其设计,那么用户在文档中会增加他们在源代码中很好地向计算机解释实现的可能性。为清晰,设计良好的API编写代码也更容易,更快速,并且希望减少错误。作者是否记录了他们对客户端代码的期望,以便将来的升级兼容?(示例包括C ++ 5和Go 6兼容性文档。)

代码质量
代码编写得很好吗?阅读其中一些内容。作者是否认真,尽职尽责,一致?它看起来像你想要调试的代码吗?
开发自己的系统方法来检查代码质量。例如,像编译C或C ++程序一样简单,启用了重要的编译器警告(例如-Wall),可以让您了解开发人员如何认真地避免各种未定义的行为。Go,Rust和Swift等最新语言使用unsafe关键字来标记违反类型系统的代码; 看看有多少不安全的代码。更高级的语义工具如Infer 7或SpotBugs 8也很有用。Linters的帮助不大:你应该忽略关于大括号样式等主题的死记硬背建议,而不是关注语义问题。

对您可能不熟悉的开发实践持开放态度。例如,SQLite库作为单个200,000行C源文件和单个11,000行标题发布,即“合并”。这些文件的大小应该引发一个初始的红旗,但更密切的调查会发现实际开发源代码,一个包含一百多个C源文件,测试和支持脚本的传统文件树。事实证明,单文件分发是从原始源自动构建的,对最终用户来说更容易,特别是那些没有依赖管理器的用户。(编译后的代码也运行得更快,因为编译器可以看到更多的优化机会。)

测试
代码是否有测试?你能跑吗?他们通过了吗?测试确定代码的基本功能是正确的,并且它们表明开发人员认真对待它是否正确。例如,SQLite开发树有一个非常全面的测试套件,包含超过30,000个单独的测试用例以及解释测试策略的开发人员文档。9 另一方面,如果测试很少或没有测试,或者测试失败,这是一个严重的危险信号:未来对软件包的更改可能会引入很容易被捕获的回归。如果你坚持用自己编写的代码进行测试(你这样做,对吗?),你应该坚持用你外包给别人的代码进行测试。
假设存在的测试,运行和通过,你可以通过像代码覆盖分析,竞争检测,运行时间仪表运行它们收集更多的信息,10 内存分配检查和内存泄漏检测。

调试
找到包的问题跟踪器。有很多开放的bug报告吗?他们开了多久了?有很多修复错误吗?最近有没有修复过的bug?如果你看到许多关于什么看起来像真正的错误的公开问题,特别是如果它们已经打开了很长时间,那不是一个好兆头。另一方面,如果很少发现错误并立即修复,那就太好了。

维护
查看包的提交历史记录。代码被维护了多长时间?现在是否积极维护?已经积极维护了较长时间的软件包更有可能继续维护。有多少人在包装上工作?许多软件包是开发人员在业余时间创建和共享的个人项目。其他人是一群付费开发人员数千小时工作的结果。一般来说,后一种软件包更有可能及时修复错误,稳定改进和一般维护。
另一方面,一些代码确实是“完成的”。例如,escape-string-regexp前面显示的NPM 可能永远不需要再次修改。

用法
许多其他软件包是否依赖于此代码?依赖关系管理器通常可以提供有关使用情况的统计信息,或者您可以使用Web搜索来估计其他人编写有关使用该软件包的频率。更多用户应该至少意味着代码运行良好的更多人,以及更快地检测新错误。广泛使用也是对持续维护问题的对冲:如果广泛使用的软件包丢失其维护者,感兴趣的用户可能会向前迈进。
例如,像PCRE或Boost或JUnit这样的库被广泛使用。这使得它更有可能 - 虽然肯定不能保证 - 你可能遇到的错误已经被修复,因为其他人首先遇到它们。

安全
您是否会使用包处理不受信任的输入?如果是这样,它是否能够抵御恶意输入?是否有国家漏洞数据库(NVD)中列出的安全问题历史记录?

许可
代码是否获得了适当许可?它有许可证吗?许可证是否适用于您的项目或公司?GitHub上的一小部分项目没有明确的许可证。您的项目或公司可能会对允许的依赖许可证施加进一步的限制。例如,Google不允许使用根据AGPL类许可证(过于繁琐)获得许可的代码以及类似WTFPL的许可证(过于模糊)

依赖
代码是否具有自己的依赖关系?间接依赖中的缺陷对于您的程序来说就像直接依赖中的缺陷一样糟糕。依赖关系管理器可以列出给定包的所有传递依赖关系,并且理想情况下应按照本节中的描述检查它们中的每一个。具有许多依赖关系的包会产生额外的检查工作,因为这些相同的依赖关系会产生需要评估的额外风险。
许多开发人员从未查看过代码的传递依赖关系的完整列表,也不知道他们依赖的是什么。例如,在2016年3月,NPM用户社区发现许多流行项目 - 包括Babel,Ember和React-all都间接依赖于一个名为的小包left-pad,由一个8行功能体组成。当left-pad从NPM 中删除该包的作者无意中打破了大多数Node.js用户的构建时,他们发现了这一点。14 而left-pad在这方面几乎没有例外。例如,NPM上发布的750,000个软件包中有30%至少是间接依赖的escape-string-regexp。根据Leslie Lamport对分布式系统的观察,依赖管理器可以轻松地创建一种情况,在该情况下,您甚至不知道存在的包的失败会导致您自己的代码无法使用。

测试依赖性
检查过程应包括运行包自己的测试。如果程序包通过检查并且您决定使项目依赖于它,则下一步应该是编写针对应用程序所需功能的新测试。这些测试通常以编写简短的独立程序开始,以确保您能够理解程序包的API,并且它可以执行您认为的功能。(如果您不能或不能,请立即返回!)然后需要额外的努力将这些程序转换为可以针对较新版本的程序包运行的自动化测试。如果您发现了一个错误并且有可能的修复,那么您将希望能够轻松地重新运行这些项目特定的测试,以确保该修复不会破坏其他任何内容。

抽象依赖
根据包的不同,您可能会在以后重新考虑。也许更新将使包朝着新的方向发展。也许会发现严重的安全问题。也许会有更好的选择。出于所有这些原因,值得努力使您可以轻松地将项目迁移到新的依赖项。
如果将从项目源代码中的许多位置使用该包,则迁移到新的依赖项将需要更改所有这些不同的源位置。更糟糕的是,如果程序包将在您自己的项目的API中公开,那么迁移到新的依赖项将需要在调用API的所有代码中进行更改,而这些代码可能无法控制。为了避免这些成本,定义您自己的接口以及使用依赖项实现该接口的瘦包装器是有意义的。请注意,包装器应仅包含项目所需的依赖项,而不是依赖项提供的所有内容。理想情况下,这允许您稍后通过仅更改包装器来替换不同的,同样适当的依赖项。

隔离依赖关系
在运行时隔离依赖项以限制其中的错误可能造成的损害也可能是适当的。防御之一是更好地限制依赖关系可以访问的内容。

避免依赖
如果一个依赖似乎风险太大而你找不到隔离它的方法,最好的答案可能是完全避免它,或者至少避免你认为最有问题的部分。
如果您只需要一小部分依赖关系,那么复制您需要的内容可能是最简单的(当然,保留适当的版权和其他法律声明)。您负责修复错误,维护等等,但您也完全与较大的风险隔离开来。Go开发者社区有一句谚语:“一点点复制比一点依赖更好。

升级依赖项
长期以来,关于软件的传统观点是“如果没有破坏,就不要修复它。”升级带来了引入新漏洞的机会;  没有相应的奖励 - 就像你需要的新功能 - 为什么要承担风险?
该分析忽略了两个成本。首先是最终升级的成本。在软件中,进行代码更改的难度并不是线性扩展的:进行十次小改动比做一次等效的大改变更少工作,更容易纠正。第二个是以艰难的方式发现已经修复的错误的成本。特别是在安全环境中,已知的漏洞被积极利用,每天等待的是攻击者闯入。
及时升级很重要,但升级意味着向项目添加新代码,这应该意味着更新您对基于新版本使用依赖项的风险的评估。至少,您需要浏览显示从当前版本到升级版本所做更改的差异,或者至少阅读发行说明,以确定升级代码中最可能关注的区域。如果许多代码正在发生变化,那么难以消化差异,这也是您可以纳入风险评估更新的信息。
您还需要重新运行您编写的特定于项目的测试,以确保升级的软件包至少与早期版本一样适合项目。重新运行包自己的测试也是有意义的。如果软件包有自己的依赖项,那么项目的配置完全有可能使用这些依赖项的不同版本(旧版或新版),而不是软件包的作者使用的版本。运行软件包自己的测试可以快速识别特定于您的配置的问题。
同样,升级不应该是完全自动的。在部署升级版本之前,您需要验证升级版本是否适合您的环境。
如果您的升级过程包括重新运行您已经为依赖项编写的集成和资格测试,那么您可能会在新问题到达生产之前识别它们,那么,在大多数情况下,延迟升级比快速升级更具风险。

观察您的依赖项
即使在所有这些工作之后,你还没有完成你的依赖。重要的是继续监控它们,甚至可能重新评估您使用它们的决定。
首先,确保您继续使用您认为的特定包版本。现在,大多数依赖关系管理器可以轻松甚至自动地记录给定包版本的预期源代码的加密哈希值,然后在另一台计算机或测试环境中重新下载包时检查该哈希值。这可确保您的构建使用您检查和测试的相同依赖源代码。这些类型的检查阻止了event-stream前面描述的攻击者在已发布的版本3.3.5中静默插入恶意代码。相反,攻击者必须创建一个新版本3.3.6,并等待人们升级(不仔细查看更改)。
同样重要的是要注意新的间接依赖关系:升级可以轻松引入新的软件包,现在项目的成功依赖于这些软件包。他们也值得你的关注。在这种情况下event-stream,恶意代码隐藏在另一个包中,flatmap-stream新event-stream版本将其添加为新的依赖项。
升级是重新审视使​​用正在发生变化的依赖关系的决定的自然时间。同样重要的是要定期访问任何依赖关系发生变化。没有安全问题或其他错误需要修复似乎是否合理?该项目是否已被放弃?也许是时候开始计划取代这种依赖。
重新检查每个依赖项的安全历史记录也很重要。例如,Apache Struts在2016年,2017年和2018年披露了不同的主要远程代码执行漏洞。即使您拥有运行它并立即更新它们的所有服务器的列表,该跟踪记录可能会让您重新考虑使用它。


结论
软件重用终于来了,我并不是要低估它的好处:它为软件开发人员带来了巨大的积极转变。即便如此,我们还是在没有完全考虑潜在后果的情况下接受了这种转变。在我们拥有比以往更多的依赖关系的同时,信任依赖关系的旧理由变得越来越无效。
我在本文中概述的对特定依赖项的批判性检查是一项重要的工作,并且仍然是例外而不是规则。但是我怀疑是否有任何开发人员真正为每一个可能的新依赖项做出这样的努力。我只为我自己的依赖项的子集做了一个子集。大多数时候,整个决定都是“让我们看看会发生什么。”很多时候,除此之外的任何事情似乎都是太多的努力。
但是Copay和Equifax攻击是我们今天消耗软件依赖的方式的明显警告。我们不应该忽视这些警告。我提出了三条广泛的建议。

  1. 认识到这个问题。 我希望这篇文章让你相信这里有一个值得解决的问题。我们需要很多人集中精力解决它。
  2. 建立今天的最佳实践。 我们需要使用现有的可用内容来建立管理依赖关系的最佳实践。这意味着制定评估,减少和跟踪风险的流程,从最初的采用决策到生产使用。事实上,正如一些工程师专注于测试一样,我们可能需要专门管理依赖关系的工程师。
  3. 为明天开发更好的依赖技术。 依赖管理器基本上消除了下载和安装依赖项的成本。未来的开发工作应侧重于降低使用依赖项所需的评估和维护成本。例如,包发现站点可能会找到更多方法来允许开发人员共享他们的发现。构建工具至少应该能够轻松运行包自己的测试。更积极的是,构建工具和包管理系统也可以一起工作,以允许包作者针对其API的所有公共客户端测试新的更改。语言还应提供简单的方法来隔离可疑包。

那里有很多好的软件。让我们一起来了解如何安全地重复使用它。