我们的依赖问题

几十年来,对软件重用的讨论比实际更多。 如今情况恰恰相反:开发人员每天都以软件依赖关系的形式重用其他人的程序,而问题本身几乎还没有得到解决。

我自己的经验包括使用Google内部存储库十年的经验,其中将依赖项设置为优先概念,以及为Go编程语言开发依赖项系统

依赖关系带来了严重的风险,而这些风险常常被忽略。 向最小的软件的简单重用的过渡已经发生得如此之快,以至于我们尚未开发出有效选择和使用依赖项的最佳实践。 甚至在适当的时候做出决定,什么时候不做。 本文的目的是评估风险,并鼓励在这一领域中寻找解决方案。

什么是上瘾?


在现代开发中, 依赖关系是从程序中调用的附加代码。 添加依赖项可以避免重复进行已完成的工作:设计,编写,测试,调试和支持特定的代码单元。 我们将此代码单元称为 ,尽管在某些系统上使用库或模块之类的其他术语来代替包。

接受外部依赖关系是一种古老的做法:大多数程序员都下载并安装了必要的库,无论是C的PCRE或zlib,C的Boost或Qt,Java的JodaTime或Junit。 这些软件包具有高质量的调试代码,需要大量的创建经验。 如果程序需要此类软件包的功能,则手动下载,安装和更新软件包要比从头开始开发此功能容易得多。 但是前期的高额成本意味着手动重用非常昂贵:纤巧的包装更容易编写自己。

依赖项管理器 (有时称为包管理器)可自动执行依赖项包的下载和安装。 由于依赖性管理器使下载和安装单个程序包变得容易,因此降低固定成本使小型程序包的发布和重用变得经济。

例如,一个名为NPM的Node.js依赖性管理器提供了对超过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下载的代码。 添加依赖关系会将开发此代码的工作(设计,编写,测试,调试和支持)委托给Internet上您通常不认识的其他人。 使用此代码,您可以将自己的程序暴露于依赖项的所有失败和缺点所带来的影响。 现在,软件的执行实际上取决于 Internet上陌生人的代码。 这样说,听起来一切都不安全。 为什么有人甚至同意这一点?

我们同意,因为这很容易,因为一切似乎都可以正常工作,因为其他所有人也都可以做到,而且最重要的是,因为这似乎是百年历史的惯例的自然延续。 但是有一个重要的区别,我们忽略了。

几十年前,大多数开发人员还信任其他人来编写他们依赖的程序,例如操作系统和编译器。 该软件通常是通过某种支持协议从知名来源购买的。 仍然存在错误或彻底破坏的空间 。 但是我们至少知道我们正在与谁打交道,并且通常可以使用商业或法律手段施加影响。

在Internet上免费分发的开源软件现象已大大取代了购买软件的旧习惯。 当重用仍然困难时,很少有项目引入这种依赖性。 尽管他们的执照通常免除了任何“商业价值和对特定用途的适用性的保证”,但这些项目赢得了良好的声誉。 用户在做出决定时很大程度上考虑了这一声誉。 声誉得到了支持,而不是商业和法律干预。 那个时代的许多常见软件包仍然享有良好的声誉:例如BLAS(于1979年出版),Netlib(1987),libjpeg(1991),LAPACK(1992),HP STL(1994)和zlib(1995)。

批处理管理器将代码重用模型简化为极其简单:现在,开发人员可以在数十行中与各个函数精确地共享代码。 这是一项伟大的技术成就。 有无数种可用的软件包,一个项目可能包括很多软件包,但是商业,法律或声誉代码信任机制已经成为过去。 我们信任更多代码,尽管信任的理由更少。

上瘾的成瘾成本可以看作是一系列可能的不良结果的总和,乘以每个不良结果的价格乘以其可能性(风险)。


不良结果的代价取决于使用依赖项的上下文。 在一个范围的末端是一个个人爱好项目,其中大多数不良结果的价格都接近于零:您只是很开心,错误只花了一点时间就没有真正的影响,并且调试它们甚至很有趣。 因此,风险几率几乎不相关:乘以零。 另一方面,生产软件必须得到多年的支持。 在这里,依赖的成本可能非常高:服务器可能崩溃,机密数据可能被泄露,客户可能遭受损失,公司甚至可能破产。 在生产中,评估和最大程度降低严重故障的风险非常重要。

不管预期价格如何,都有一些方法可以评估并减少添加依赖项的风险。 软件包管理器很可能应该进行优化以降低这些风险,而到目前为止,他们一直致力于降低下载和安装成本。

依赖检查


您不会雇用从未听说过且一无所知的开发人员。 首先,您将了解有关他的一些信息:检查链接,进行采访等等。 取决于您在Internet上找到的软件包之前,对此软件包进行一些了解也是明智的。

当尝试使用此代码时,可以进行基本检查以了解问题的可能性。 如果在检查过程中发现小问题,可以采取措施将其消除。 如果检查发现有严重问题,最好不要使用该软件包:您可能会找到更合适的软件包,或者您需要自己开发它。 请记住,开源软件包是由作者发布的,希望它们会有用,但不能保证可用性或支持。 如果生产失败,则由您自行调试。 正如第一个GNU通用公共许可证警告的那样,“与程序质量和性能相关的所有风险都由您承担。 如果程序证明有缺陷,则您将承担所有必要的维护,修理或纠正的费用。”

接下来,我们概述了检查软件包并决定是否依赖它的一些注意事项。

设计方案


包装文件是否清楚? API是否有清晰的设计? 如果作者能够很好地向人解释API和设计,那么这就会增加他们也可以在源代码中很好地解释计算机的实现的可能性。 为清晰,设计良好的API编写代码更简单,更快,并且可能更少出错。 作者是否已记录了他们对客户端代码的期望,以便与将来的更新兼容? (示例包括C ++Go兼容性文档)。

代码质量


代码编写正确吗? 阅读一些摘要。 作者似乎谨慎,认真和一致吗? 它看起来像您要调试的代码吗? 您可能必须这样做。

开发您自己的系统方法来验证代码质量。 一些简单的事情(例如使用重要的编译器警告打开的C或C ++编译)(例如-Wall ),可以使开发人员认真思考如何避免各种未定义的行为。 最新的语言,例如Go,Rust和Swift,使用unsafe关键字来表示违反类型系统的代码。 看看有多少不安全的代码。 更高级的语义工具(例如InferSpotBugs)也很有用。 短毛猫的用处不大:您应该忽略有关圆括号样式等主题的标准技巧,而应关注语义问题。

不要忘记您可能不熟悉的开发方法。 例如,由于合并了多个文件,SQLite库作为具有200,000个代码和11,000行的标头的单个文件出现。 这些文件的大小立即引起了危险,但是进行更彻底的研究将得出开发的实际源代码:具有一百多个源C文件,测试和支持脚本的传统文件树。 事实证明,单文件分发是从原始来源自动构建的:对于最终用户(尤其是那些没有依赖管理器的用户)而言,这更容易。 (由于编译器看到了更多优化选项,因此编译后的代码也可以更快地工作)。

测试中


代码中是否有测试? 你能控制他们吗? 他们通过了吗? 测试确定该代码的主要功能正确无误,并表明开发人员正在认真尝试保留该代码。 例如,SQLite开发树包含一个非常详细的测试套件,其中包含30,000多个单独的测试用例。 有文档供开发人员解释测试策略。 另一方面,如果根本没有测试,或者测试失败,那么这是一个严重的危险信号:软件包将来的更改很可能导致回归,而回归很容易被检测到。 如果您坚持要在代码中进行测试(对吗?),则必须为传递给他人的代码提供测试。

假设测试存在,运行并通过,则可以通过运行工具来收集其他信息,以分析代码覆盖率, 检测竞争条件 ,检查内存分配以及检测内存泄漏。

侦错


查找此程序包的错误跟踪器。 是否有许多打开的错误消息? 他们开了多久了? 修复了几个错误? 最近有没有修复的错误? 如果有很多关于实际错误的未解决问题,尤其是很长一段时间没有关闭,这是一个不好的信号。 另一方面,如果错误很少发生并迅速修复,那就太好了。

技术支持


查看提交的历史。 该代码已被积极维护了多长时间? 现在积极支持吗? 长期受到积极支持的软件包可能会继续受到支持。 包装上有多少人在工作? 许多软件包是开发人员在闲暇时间创建的用于娱乐的个人项目。 其他则是一组付费开发人员数千小时工作的结果。 通常,第二种类型的程序包通常可以更快速地修复错误,稳定地引入新功能,并且通常可以更好地支持它们。

另一方面,某些代码确实是“完美的”。 例如,NPM的escape-string-regexp可能再也不需要更改了。

使用方法


有多少软件包依赖于此代码? 程序包管理器通常会提供此类统计信息,或者您可以在Internet上看到其他开发人员经常提到此程序包的频率。 大量的用户至少意味着对于许多代码来说效果很好,并且可以更快地注意到其中的错误。 广泛使用还可以部分保证持续提供服务:如果广泛使用的软件包失去了其维护者,则有兴趣的用户很可能会扮演其角色。

例如,像PCRE,Boost或JUnit这样的库被广泛使用。 这使您更有可能(尽管当然不能保证)已解决了您可能遇到的错误,因为其他人在您之前遇到了这些错误。

安全性


此软件包可用于不安全的输入吗? 如果是这样,它对恶意数据的抵抗力如何? 他是否有国家漏洞数据库(NVD)中提到的错误?

例如,当我和Jeff Dean在2006年开始从事Google Code Search (公共代码库的grep )合作时,流行的正则表达式库PCRE似乎是显而易见的选择。 但是,在与Google安全团队的对话中,我们了解到PCRE的问题由来已久,例如缓冲区溢出,尤其是在解析器中。 通过在NVD中寻找PCRE,我们自己对此深信不疑。 这一发现并没有立即导致我们放弃PCRE,但使我们对测试和隔离进行了更仔细的考虑。

发牌


该代码是否正确授权? 他甚至有执照吗? 您的项目或公司是否接受许可? GitHub项目的惊人部分没有明确的许可证。 您的项目或公司可能对依赖项许可证设置了其他限制。 例如,Google 禁止使用AGPL(过于严格)和WTFPL(过于模糊)等许可协议下的代码。

依存关系


这个软件包有它自己的依赖性吗? 间接依赖项的不足与直接依赖项的缺点同样有害。 程序包管理器可以列出给定程序包的所有可传递依赖项,理想情况下,应按照本节中的说明检查它们中的每一个。 具有许多依赖关系的程序包将需要大量工作。

许多开发人员从未查看过代码的可传递依赖项的完整列表,也不知道它们所依赖的内容。 例如,在2016年3月,NPM用户社区发现许多受欢迎的项目(包括Babel,Ember和React)间接依赖于一个称为8行函数left-pad的小程序包。 当left-pad的作者从NPM中删除该程序包时,他们无意中破坏了Node.js用户的大多数程序集,从而发现了这一点。 在这方面, left-pad并非例外。 例如,NPM中的750,000个数据包中有30%(至少间接地)取决于escape-string-regexp 。 根据Leslie Lamport对分布式系统的观察,程序包管理器很容易造成一种情况,即您甚至不知道包故障的存在,可能会使您自己的代码无法使用。

成瘾测试


验证过程应包括运行自己的程序包测试。 如果程序包通过了测试,并且您决定使项目依赖于此,则下一步应该是专门针对应用程序的功能编写新的测试。 这些测试通常是从简短的独立程序开始的,以确保您可以理解软件包的API并按照您的想法进行操作(如果您不理解或不执行所需的操作,请立即停止!)。 然后,需要付出额外的努力才能将这些程序转换为将在新版本的程序包中运行的自动化测试。 如果发现错误并且有潜在的修复程序,则可以轻松地为特定项目重新启动这些测试,并确保该修复程序不会破坏其他任何功能。

应特别注意在基线审查过程中发现的问题区域。 对于代码搜索,根据过去的经验,我们知道PCRE有时需要很长时间才能执行某些正则表达式。 我们最初的计划是为“简单”和“复杂”正则表达式创建单独的线程池。 最初的测试之一是将pcregrep与其他几种grep实现进行比较的基准测试。 当发现一个基本测试用例的pcregrep比最快的grep慢70倍时,我们开始重新考虑使用PCRE的计划。 尽管事实上我们最终完全放弃了PCRE,但今天该测试仍保留在我们的代码库中。

依赖抽象


软件包依赖项是您将来可以选择退出的解决方案。 也许更新将使软件包朝新的方向发展。 可能会发现严重的安全问题。 最好的选择也许会出现。 由于所有这些原因,值得简化项目到新依赖项的迁移。

如果从项目源代码中的许多位置调用了程序包,则需要对所有这些不同的位置进行更改以切换到新的依赖项。更糟糕的是,如果该包在您自己项目的API中提供,则迁移到新的依赖项将需要对所有调用您的API的代码进行更改,而这可能已经超出了您的控制范围。为避免此类成本,有意义的是定义您自己的接口以及使用依赖项实现该接口的瘦包装器。请注意,包装程序应仅包括项目从依赖项获得的需求,而不应包括依赖项所提供的所有内容。理想情况下,这允许您以后替换另一个同样合适的依赖项,只更改包装器。每个项目使用新接口的测试迁移将检查接口和包装器的实现,并且还简化了对任何潜在替代依赖关系的测试。

对于代码搜索,我们开发了一个抽象类Regexp该类定义了任何正则表达式引擎所需的代码搜索接口。然后,他们围绕PCRE编写了一个瘦包装器,该包装器实现了此接口。这种方法使测试备用库更加容易,并防止了将内部PCRE组件的知识意外引入源树的其余部分。反过来,这确保了在必要时可以轻松切换到另一个依赖项。

依赖隔离


在运行时隔离依赖项也可能是适当的,以限制由错误引起的可能损坏。例如,谷歌浏览器允许用户向浏览器添加依赖项-扩展代码。 Chrome于2008年首次推出时,它引入了一项关键功能(现已成为所有浏览器的标准功能),以隔离运行在单独操作系统进程中的沙箱中的每个扩展。写得不好的扩展程序中的潜在漏洞无法自动访问浏览器本身的所有内存并且无法进行不适当的系统调用。对于代码搜索,直到我们完全放弃PCRE,计划是至少将PCRE解析器隔离在类似的沙箱中。今天,另一种选择是基于轻量级管理程序的沙箱,例如gVisor。依赖隔离减少了执行此代码的相关风险。

即使有了这些示例和其他现成的选项,在运行时隔离可疑代码仍然太复杂并且很少执行。真正的隔离将需要一种完全内存安全的语言,而不会崩溃成无类型的代码。这些不仅在完全不安全的语言(例如C和C ++)中很复杂,而且在提供严格的不安全操作的语言中也很复杂,例如在打开JNI时使用Java,或者在启用不安全功能时使用Go,Rust和Swift。即使使用像JavaScript这样的内存安全语言,代码通常也可以访问比其所需更多的内容。在2018年11月,事实证明最新版本的npm包event-stream(用于JavaScript事件的功能流API)包含令人困惑的恶意代码在两个半月前添加。该代码从Copay移动应用程序的用户那里收集了比特币钱包,获得了对与事件流处理完全无关的系统资源的访问权。防止此类问题的多种可能方法之一是更好的依赖隔离。

放弃成瘾


如果上瘾的风险似乎太大,并且您无法隔离它,那么最好的选择可能是完全放弃它,或者至少排除最有问题的部分。

例如,当我们更好地了解PCRE的风险时,我们的Google代码搜索计划从“直接使用PCRE库”更改为“使用PCRE,但将解析器放入沙箱”,然后变为“编写新的正则表达式解析器,但保存PCRE引擎”,然后在“编写一个新的解析器并将其连接到另一个更高效的开源引擎”中。后来,杰夫·迪恩(Jeff Dean)和我也重新编写了引擎,因此不存在任何依赖关系,我们发现了结果:RE2

如果只需要一小部分依赖项,最简单的方法是复制所需的内容(当然,保留相关的版权和其他法律声明)。您负责纠错,维护等,但是您也完全避免了较大的风险。Go开发人员社区中有一句话:“复制胜于依赖。”

依赖关系更新


长期以来,人们普遍接受的软件智慧是:“如果有效,请勿触摸任何东西。”更新存在引入新错误的风险;没有回报-如果您不需要新功能,为什么要冒险?这种方法忽略了两个方面。首先,逐步升级的成本。在软件中,对代码进行更改的复杂性不是线性扩展的:十个小更改比一个相应的大更改工作量少且更容易。其次,检测已经纠正的错误的难度。尤其是在安全的环境中,已知的错误会被积极利用,每天不进行更新就增加了攻击者可以利用旧代码中的错误的风险。

例如,考虑一下Equifax 2017年的故事,高管们在国会面前的证词中详细讲述了该故事。 3月7日,在Apache Struts中发现了一个新漏洞,并发布了修补版本。 3月8日,Equifax收到了US-CERT通知,其中要求更新任何对Apache Struts的使用。 Equifax分别于3月9日和15日对源代码和网络进行了扫描;没有一次扫描发现易受攻击的Web服务器在Internet上打开。 5月13日,攻击者发现了Equifax专家找不到的服务器。他们使用Apache Struts漏洞入侵了Equifax网络,并在接下来的两个月中偷走了约1.48亿人的详细个人和财务信息。最终,在7月29日,Equifax注意到了一个黑客事件,并于9月4日公开宣布了这一事件。到9月底,Equifax的首席执行官以及CIO和CSO已辞职,国会已经开始调查。

Equifax的经验导致这样一个事实,尽管程序包管理器知道他们在构建期间使用的版本,但是您需要其他机制来在生产中部署期间跟踪此信息。对于Go语言,我们正在尝试自动在每个二进制文件中包含清单清单,以便部署过程可以扫描二进制文件以查找需要更新的依赖项。 Go还可以在运行时提供此信息,因此服务器可以访问已知错误的数据库,并在需要更新时独立地向监视系统报告。

快速更新很重要,但是更新意味着向项目中添加新代码,这应该意味着基于新版本更新依赖项使用的风险评估。至少,您希望查看显示从当前版本到更新版本所做的更改的差异,或者至少阅读发行说明以标识更新代码中最可能出现问题的区域。如果有很多代码更改,那么很难理解差异,这也是您可以在更新风险评估时包括的信息。

另外,您必须重新运行专门为项目编写的测试,以确保更新的软件包至少与早期版本一样适合该项目。重新运行自己的程序包测试也很有意义。如果软件包具有自己的依赖关系,则项目配置可能会使用这些依赖关系的其他版本(较旧或较新),而不是软件包作者所使用的版本。运行自己的程序包测试,使您可以快速识别特定于配置的问题。

同样,更新不必完全是自动的。在部署更新的版本之前,请确保它们适合您的环境

如果更新过程涉及重新执行已经编写的集成和资格测试,则在大多数情况下,更新的延迟比快速更新的风险更大。

关键安全更新的窗口特别小。Equifax被黑客攻击后,法医安全团队发现了证据,表明攻击者(可能是不同的)在3月10日(即公开披露的三天后)成功利用了受影响服务器上的Apache Struts漏洞。但是他们只在那发射了一个队伍whoami

注意你的瘾


即使完成了所有这些,工作仍未完成。继续监视依赖关系,在某些情况下甚至放弃它们,这一点很重要。

首先,请确保继续使用特定版本的软件包。现在,大多数软件包管理器都允许您轻松甚至自动记录给定版本软件包的预期源代码的加密哈希,然后在将该软件包再次下载到另一台计算机或在测试环境中时验证此哈希。这样可以确保生成的版本将使用您测试过的相同依赖项源代码。这种检查阻止了攻击者event-stream,将恶意代码自动注入到已发布的3.3.5版本中。取而代之的是,攻击者必须创建一个新版本3.3.6,并等待人们进行更新(而无需仔细查看更改)。

监视新的间接依赖项的出现也很重要:更新可以轻松引入新的程序包,而现在,项目的成功取决于这些程序包。他们也值得您的关注。在这种情况下,event-stream恶意代码被隐藏在另一个软件包中flatMap-stream,该软件包在新版本中event-stream作为新的依赖项添加。

爬行依存关系也会影响项目规模。在Google Sawzall开发期间-日志的JIT处理语言-作者发现在不同的时间,主要的解释器二进制文件不仅包含JIT Sawzall,而且还包含(未使用的)PostScript,Python和JavaScript解释器。每次,罪魁祸首都是某个Sawzall库声明的未使用的依赖项,再加上Google构建系统完全自动使用新的依赖项这一事实。这就是为什么Go编译器在导入未使用的包时会引发错误。

更新是修改决定以使用不断变化的依赖关系的自然时机。定期检查任何不符合规定的成瘾也很重要正在改变。似乎没有安全问题或其他错误可以解决吗?该项目被放弃了吗?也许是时候计划替代此依赖项了。

仔细检查每个依赖项的安全日志也很重要。例如,Apache Struts在2016年,2017年和2018年发现了远程代码执行中的严重漏洞。即使您有许多启动它并快速更新它的服务器,这样的记录也表明它是否值得使用。

结论


软件重用的时代终于来临,我不想低估收益:它给开发人员带来了极其积极的转变。但是,我们在没有充分考虑潜在后果的情况下接受了这种转变。当我们比以往任何时候都拥有更多的依赖关系时,信任依赖关系的先前原因会失去相关性。

我在本文中描述的对特定依赖项的批判分析代表了大量工作,并且仍然是例外而不是规则。但是我怀疑是否有开发人员真的在为每种可能的新瘾而努力工作。对于我自己的一些依存关系,我仅完成了部分工作。基本上,整个解决方案归结为以下内容:“让我们看看会发生什么。”很多时候,更多的事情似乎太多了。

但是Copay和Equifax攻击清楚地警告了我们今天如何使用软件依赖项。我们决不能忽视警告。我提供三个一般性建议。

  1. . , , , . , .
  2. . , , . , , . , , , .
  3. . . . , . , , . , , API. .

有很多好的软件。让我们一起努力,找出如何安全地使用它。

Source: https://habr.com/ru/post/zh-CN443620/


All Articles