对于系统架构来说,外部系统依赖往往是系统质量属性的最大风险,软件本身也是如此。软件依赖性具有经常被忽视的严重风险。我们可能还不了解有效选择和使用依赖项的最佳实践,甚至不知道何时选择依赖项。这篇文章的目的是提高对风险的认识并尝试更多的解决方案。在软件开发中,依赖关系是程序员想要调用的附加代码。添加依赖项可以避免重复工作,例如设计、测试、调试和维护特定代码单元。这个代码单元称为包,或库,或模块等,在本文中将互换使用。使用软件依赖项很常见,我们都经历了手动安装所需库的步骤,例如用于C的PCRE或zlib;用于C++的Boost或Qt;或JUnitforJava等。这些软件库包含高质量和经过调试的代码,需要大量专业知识才能开发。为需要这些包提供的功能的程序手动下载、安装和更新包比从头开发这些功能要容易得多。依赖管理器,也称为包管理器,可以自动下载和安装依赖包。由于依赖管理器使单个包更容易下载和安装,因此成本更低,从而使发布和重用较小的包更经济。例如,Node.js的依赖管理器NPM提供了对超过几十万个包的访问。现在基本上每种编程语言都有依赖管理器:Maven(Java)、Composer(PHP)和pip(Python)都有超过100,000个包。这种细粒度、广泛的软件重用的出现是多年来软件开发中最重要的转变之一。但是,如果我们不更加小心,可能会导致严重的问题。1、依赖的进化包或库是从网上下载的代码。将一个包作为依赖添加到自己的程序中,这会暴露依赖中的所有失败和缺陷,因为它完全依赖于这些下载的代码。这种方法听起来很不安全。人们为什么这样做?因为它很简单,似乎可以工作,并且是引用内部依赖项的自然延续。过去,大多数开发人员信任他们所依赖的软件,例如操作系统和编译器。该软件是从已知来源购买的,虽然存在潜在的错误,但至少开发人员知道他们在与谁打交道,并且通常有可用的商业或法律资源。在Internet上免费分发的开源软件已经取代了许多早期购买的软件。一些项目已经建立了众所周知的声誉,例如早期的软件包libjpeg(1991)、HPSTL(1994)和zlib(1995)。声誉常常成为人们决定使用哪些依赖项的重要因素。信任软件来源和法律支持的业务被声誉支持所取代,这可能是共识的力量。依赖管理器进一步减少了开源代码重用模型的大小。开发人员现在可以在由几十行代码组成的单个函数的粒度上共享代码,这是一项重大的技术成就。有无数的包可用,编写代码可能涉及大量包,但信任代码的商业、法律和声誉支持机制并没有延续下去。开发人员毫无理由地信任更多代码。然而,采用不良依赖的成本可以看作是每个不良结果的成本乘以其发生的可能性的总和。使用依赖关系的场景决定了不良结果的成本。如果只是个人爱好的话,这些坏结果的成本大部分几乎为零,因为只是找乐子而已,出现风险的概率几乎为零。然而,如果它是一个已经维护了多年的生产软件,依赖关系中的错误成本可能会非常高:服务器可能会崩溃,敏感数据可能会泄露,客户可能会受到伤害,公司可能会受到伤害。甚至倒闭。失败的高成本使得评估和减轻关键风险变得更加重要。无论预期成本如何,都需要某种方法来估计和降低添加软件依赖项的风险。可能需要更好的工具来提供帮助,因为依赖管理器一直致力于降低下载和安装的成本。2.依赖检查在使用代码依赖时,基本的检查可以让我们知道我们遇到问题的可能性有多大。如果在检查过程中发现可能存在的小问题,可以采取措施进行预防或避免。如果在检查中发现大问题,最好不要使用这个包,也许你可以找到更合适的,也许你需要自己开发一个。开源包由其作者发布,希望它们有用,但可用性或支持的保证较少。系统挂了,包要调试,整个项目的质量和性能风险都在我们身上。因此,我们在检查依赖关系时需要考虑一些因素。2.1设计文件是否清晰?API是否有清晰的设计?如果作者能够在文档中很好地解释依赖包的API及其设计,那么他们在源代码中正确实现它的机会就会增加。使用清晰、设计良好的API编写代码也更容易、更快、更不容易出错。作者是否记录了他们对客户端代码使升级兼容??的期望?(例如C++23的兼容性文档。)2.2代码质量代码写得好吗?阅读一些代码。作者是否显得谨慎和一致?我们看到的代码是否与我们要调试的代码匹配?需要有一种系统的方法来检查代码质量。例如,简单地编译启用重要编译器警告(例如-Wall)的c或c++程序可以让开发人员了解他们在避免各种未定义行为方面有多糟糕,看看有多少不安全代码。忽略关于死记硬背的建议,转而关注语义问题。对不熟悉的开发实践持开放态度。例如,SQLite库提供了一个200,000行的c源文件和一个11,000行的名为amalgamation的头文件。这些文件的大小最初令人担忧,但深入挖掘后发现,实际的开发源代码由超过100个c源文件、测试和支持脚本的文件树组成。事实证明,单文件分发是从原始数据源自动构建的,使最终用户更容易,尤其是那些没有依赖管理器的用户。此外,编译后的代码运行得更快,因为编译器看到了更多的优化机会。2.3测试代码是否有测试?它们可以运行吗?测试确认代码的基本功能是正确的,并表明开发人员认真地保持代码的正确性。例如,SQLite开发树有一个非常全面的测试套件,包含超过30,000个单独的测试用例,以及解释测试策略的文档。未来的程序修改可能会引入易于发现的回归测试。假设测试运行通过,运行时工具可以收集额外的信息,例如代码覆盖分析、竞争检测、内存分配检查和内存泄漏检测。2.4调试找到包中的issues列表,里面有没有openbugreport?使用多久了?是不是还有很多bug没有修复?最近是否修复了任何错误?长期以来,这都不是一个好兆头。另一方面,如果关闭的问题表明缺陷很少并及时修复,那就太好了。2.5维护查看包的提交历史,代码被主动维护了多长时间?它还在积极维护吗?主动维护时间较长的包更有可能继续维护。有多少人对这个包做出了承诺?许多包是在业余时间创建和共享的个人项目,有些是一群付费开发人员数千小时工作的结果。一般来说,后一种类型的包更容易快速修复错误、稳步改进和定期维护。2.6用法是否有很多其他软件依赖于此代码库?依赖项管理器通常可以提供有关使用情况的统计信息,或者可以使用搜索来评估其他人使用该包的频率。更多的用户至少意味着有很多人可以很好地使用代码,并且更快地发现新的错误。广泛使用也避免了持续维护的问题,因为感兴趣的用户可能会做出更多贡献。2.7安全取决于包能否处理不受信任的输入?如果是这样,它对恶意输入是否健壮?它有上市安全问题的历史吗?例如,流行的PCRE正则表达式库具有缓冲区溢出等问题的历史特征,尤其是在其解析器中。这一发现并没有立即导致放弃PCRE,但它确实让我们更仔细地考虑测试和隔离。2.8许可代码是否获得了适当的许可?它有许可证吗?公司接受这样的执照吗?GitHub上的许多项目都没有明确的许可。公司可能会对依赖项的许可施加进一步的限制。例如,不允许使用像agpl这样的许可证授权的代码,这可能过于繁琐,或者像wtpl这样的许可证可能过于模糊。2.9依赖关系代码库是否有自己的依赖关系?间接依赖中的缺陷与直接依赖中的缺陷一样对程序有害。依赖项管理器可以列出给定包的所有依赖项,理想情况下,应按照此处所述检查每个依赖项。具有许多依赖项的包会引入额外的检查工作,因为这些相同的依赖项会引入需要评估的额外风险。许多开发人员可能从未见过完整的依赖项列表,也不知道它们依赖什么。例如,包括Babel、Ember和Reactall在内的许多流行项目都间接依赖于一个名为left-pad的小型库,该库由一个八行函数组成。2016年3月,作者从NPM中删除了这个包,无意中破坏了大多数Node.js用户的构建。当时的感觉至今记忆犹新。3.依赖测试检查过程应包括运行图书馆自己的测试。如果库通过了检查,并决定依赖它,那么下一步应该是编写新的测试,重点关注我们的应用程序所需的功能。这些测试通常从简短的、自包含的程序开始,这些程序旨在确保我们了解库的API并确保它能完成它应该做的事情。将这些程序转换为可以针对较新版本的软件包运行的自动化测试,付出额外的努力是值得的。如果发现错误并且有潜在的修复,那么希望很容易重新运行那些特定于项目的测试以确保修复没有破坏其他任何东西,值得对基本确定的潜在问题区域进行研究检查。4.依赖关系的抽象根据库的不同,更新可能会给软件包带来新的方向,可能会发现严重的安全问题,或者可能有更好的选择。由于所有这些原因,将您的项目轻松迁移到新的依赖项是值得的。如果该库将在项目源代码的许多地方使用,那么迁移到新的依赖项将需要更改所有这些不同的源位置。更糟糕的是,如果库在自己项目的API中公开,那么迁移到新的依赖项将需要更改调用API的所有代码,这些更改是我们可能无法控制的。为了避免这些开销,需要定义一个自己的接口,并使用依赖来实现对这个接口的封装。封装应该只包含项目需要从依赖库中获取的内容,而不是依赖库提供的所有内容。理想情况下,这将允许仅更改封装接口以替换不同但同样合适的依赖项。封装接口的实现针对每个项目向新接口的迁移进行测试。这种间接使得测试备用库变得容易,并且它可以防止在源代码树的其余部分意外引入依赖库的内部方法。反过来,这确保了在需要时可以轻松切换到不同的依赖项。5.隔离依赖关系在运行时隔离依赖关系也可能是适当的,以限制错误可能造成的损害。例如,谷歌浏览器允许用户在浏览器中添加依赖文件/扩展代码。因此,在糟糕的扩展中,可利用的错误无法自动访问浏览器本身的整个内存,并且可以防止进行不适当的系统调用。今天,隔离依赖关系降低了与运行该代码相关的风险。可疑代码的运行时隔离很困难,而且很少有人这样做。真正的隔离需要一种没有非类型代码的完全内存安全的语言。这不仅在C和C++语言中具有挑战性,而且在提供受限不安全操作的语言中也是如此,例如Java包含JNI时,或者Go和Swift包含它们的“不安全”特性时。即使在像JavaScript这样的内存安全语言中,代码也经常可以访问超出其需要的内容。针对此类问题的许多可能防御措施之一是更好地限制依赖性。6.避免依赖如果依赖看起来太危险而无法找到隔离它的方法,最好的答案可能是完全避免它,或者至少避免那些我们认为最有问题的部分。如果只需要依赖库的一小部分,最简单的解决方案可能是复制所需的内容,当然,保留适当的版权和其他法律声明。我们承担着修复错误、维护等的责任,同时也完全隔绝了更大的风险。一点点重复总比一点点依赖要好。7.升级依赖。升级带来了引入新错误的机会。如果没有相应的回报,为什么要冒这个风险?这种分析忽略了两种成本。首先是最终升级的成本。在软件方面,代码更改的难度不是线性的,进行10次小更改比一次等量的大更改更简单、更容易获得正确的结果。第二个问题是查找已修复错误的成本。特别是在可以利用已知错误的安全场景中,可能是通过攻击者的闯入。及时升级固然重要,但这意味着要向项目中添加新代码,这意味着更新依赖库新版本的风险评估。至少,浏览从当前版本到升级版本的变化差异,或者至少阅读发布文档,以确定升级代码中可能需要注意的区域。如果更改的代码太多以至于难以消化,则可以将其纳入风险评估。重新运行依赖库自己的测试也很有意义。如果它有自己的依赖项,则项目的配置完全有可能使用与库作者使用的不同版本的依赖项。运行库自己的测试可以快速识别特定于配置的问题。同样,升级不应该是全自动的。在部署升级之前,您必须验证它们是否适合您的环境。在大多数情况下,延迟升级比快速升级风险更大。8.相关问题重要的是要持续关注,甚至可能重新评估使用它们的决定。首先,确保您使用的是我们认为的特定库版本。大多数依赖管理器现在可以很容易地记录给定库版本的预期源代码的加密散列,然后在另一台计算机或测试环境中重新下载该库时检查该散列。这确保我们在检查测试时使用相同的依赖源。注意新的间接依赖关系也很重要。升级可以很容易地引入我们的项目现在所依赖的新包。它们也令人担忧,恶意代码可能隐藏在不同的包中。依赖关系也会影响项目的大小。升级是重新考虑使用依赖关系的自然时间,定期重新访问依赖关系也很重要。这个项目被放弃了吗?也许是时候开始计划替换这种依赖了。9.依赖,该说什么不该说软件重用的好处不容小觑,依赖比以往任何时候都多,给软件开发人员带来了积极的转变。即便如此,我们还没有充分考虑潜在的后果。关于软件依赖性,有三个主要的建议:认识到问题,我们需要集中精力解决它。建立今天的最佳实践需要使用依赖管理的最佳实践。这意味着开发一个从决策到评估再到降低和跟踪风险的流程。事实上,就像工程师专注于测试一样,有些人可能需要专注于管理依赖关系。为明天开发更好的相关技术。依赖管理器基本上消除了下载和安装的成本。未来的开发工作应侧重于减少使用依赖项所需的评估和维护成本。构建工具至少应该使运行依赖库自己的测试变得容易,并且还应该提供简单的方法来隔离可疑的依赖库。严格检查特定依赖项是一项大量工作,并且仍然会弹出异常。对于每一个可能的新依赖项,任何开发人员都不太可能真正投入这样的努力,尽管这里给出的可能只是一个子集。
