軟件依賴的一知半解
對(duì)系統(tǒng)架構(gòu)而言,外部系統(tǒng)依賴往往是系統(tǒng)質(zhì)量屬性的最大風(fēng)險(xiǎn),對(duì)軟件自身也是如此。軟件依賴有著嚴(yán)重的風(fēng)險(xiǎn),而這些風(fēng)險(xiǎn)常常會(huì)被忽視。我們可能尚未理解有效選擇和使用依賴關(guān)系的最佳實(shí)踐,甚至沒(méi)有理解何時(shí)選擇依賴關(guān)系。本文的目的是提高對(duì)風(fēng)險(xiǎn)的認(rèn)識(shí),并嘗試更多的解決方案。
在軟件開(kāi)發(fā)中,依賴項(xiàng)是程序員想要調(diào)用的附加代碼。添加依賴項(xiàng)可以避免重復(fù)工作,例如設(shè)計(jì)、測(cè)試、調(diào)試和維護(hù)特定的代碼單元,這個(gè)代碼單元被稱為包,或者庫(kù),或者模塊等,本文會(huì)混用。采用軟件依賴項(xiàng)很常見(jiàn),咱們都經(jīng)歷過(guò)手動(dòng)安裝所需庫(kù)的步驟,比如 C 的 PCRE 或 zlib; C++的 Boost 或 Qt; 或 Java 的 JUnit等。這些軟件庫(kù)包含了高質(zhì)量且經(jīng)過(guò)調(diào)試的代碼,需要大量的專業(yè)知識(shí)來(lái)開(kāi)發(fā)。對(duì)于一個(gè)需要這些軟件包提供的功能的程序來(lái)說(shuō),手動(dòng)下載、安裝和更新軟件包的工作要比從頭開(kāi)始開(kāi)發(fā)這些功能要容易得多。
依賴管理器,也稱為包管理器,可以自動(dòng)下載和安裝依賴包。由于依賴管理器使單個(gè)軟件包更容易下載和安裝,成本較低, 使得發(fā)布和重用較小的軟件包更經(jīng)濟(jì)。例如,Node.js 的依賴管理器 NPM 提供了對(duì)超過(guò)幾十萬(wàn)個(gè)包的訪問(wèn)。現(xiàn)在基本上每種編程語(yǔ)言都有依賴管理器: Maven (Java)、 Composer(PHP)和pip (Python)等都超過(guò)了10萬(wàn)個(gè)包。
這種細(xì)粒度的、廣泛的軟件復(fù)用的到來(lái)是這些年來(lái)軟件開(kāi)發(fā)中最重要的轉(zhuǎn)變之一。然而,如果我們不更加小心,就會(huì)導(dǎo)致嚴(yán)重的問(wèn)題。
1. 依賴的演變
包或者庫(kù)都是從 Internet 下載的代碼,將一個(gè)包作為依賴項(xiàng)添加自己的程序中,該程序暴露依賴項(xiàng)中的所有失敗和缺陷,因?yàn)樗耆蕾囉谶@些下載的代碼。這種方式聽(tīng)起來(lái)非常不安全。為什么人們這么做?因?yàn)樗芎?jiǎn)單,看起來(lái)很有效,是引用內(nèi)部依賴的自然延續(xù)。
過(guò)去,大多數(shù)開(kāi)發(fā)人員都信任自己所依賴的軟件,比如操作系統(tǒng)和編譯器。這些軟件是從已知的來(lái)源購(gòu)買的,雖然存在著漏洞的可能性,但至少開(kāi)發(fā)者知道他們?cè)诤驼l(shuí)打交道,通常有商業(yè)或法律資源可用。
在互聯(lián)網(wǎng)上免費(fèi)分發(fā)的開(kāi)源軟件已經(jīng)取代了許多早期購(gòu)買的軟件。一些項(xiàng)目建立起了眾所周知的聲譽(yù),例如早期軟件包 libjpeg (1991),HP STL (1994)和 zlib (1995)等,聲譽(yù)往往會(huì)成為了人們決定使用哪些依賴的重要因素,對(duì)信任軟件來(lái)源的商業(yè)和法律支持被聲譽(yù)支持所取代,這可能就是共識(shí)的力量。
依賴管理器進(jìn)一步縮小了開(kāi)源代碼重用模型的規(guī)模。現(xiàn)在,開(kāi)發(fā)人員可以在由數(shù)十行代碼組成的單個(gè)函數(shù)的粒度上共享代碼,這是一項(xiàng)重大的技術(shù)成就。無(wú)數(shù)的軟件包是可用的,編寫(xiě)代碼可能涉及大量的軟件包,但是用于信任代碼的商業(yè)、法律和聲譽(yù)支持機(jī)制并沒(méi)有繼續(xù)下去。開(kāi)發(fā)人員信任更多的代碼,而不需要太多的理由。
然而,采用不良依賴的成本可以看作是每個(gè)不良結(jié)果的成本乘以其發(fā)生的可能性之和。使用依賴項(xiàng)的場(chǎng)景決定了壞結(jié)果的成本。如果只是個(gè)人愛(ài)好,其中大多數(shù)壞結(jié)果的成本幾乎為零,因?yàn)橹皇窃谙硎軜?lè)趣,風(fēng)險(xiǎn)概率幾乎為零。但是,如果是一個(gè)維護(hù)多年的生產(chǎn)軟件,依賴關(guān)系中的 bug 的成本可能非常高: 服務(wù)器可能宕機(jī),敏感數(shù)據(jù)可能泄露,客戶可能受到傷害,甚至公司可能倒閉。高失敗成本使得評(píng)估和降低嚴(yán)重風(fēng)險(xiǎn)變得更加重要。
不管預(yù)期的成本是多少,都需要一些估計(jì)和減少添加軟件依賴性風(fēng)險(xiǎn)的方法。可能需要更好的工具來(lái)幫助,就像依賴管理器一直關(guān)注于降低下載和安裝的成本那樣。
2. 依賴的檢查
在使用代碼依賴時(shí),基本的檢查可以讓我們了解遇到問(wèn)題的可能性有多大。如果檢查中發(fā)現(xiàn)了可能出現(xiàn)的小問(wèn)題,可以采取措施準(zhǔn)備或者避免它們。如果檢查中發(fā)現(xiàn)了大問(wèn)題,最好不要使用這個(gè)包, 也許能夠找到一個(gè)更合適的,也許需要自己開(kāi)發(fā)一個(gè)。開(kāi)源軟件包是由其作者發(fā)布的,希望它們會(huì)有用,但是較少有可用性或支持的保證。系統(tǒng)掛了,不得不調(diào)試這些包,整個(gè)項(xiàng)目的質(zhì)量和性能風(fēng)險(xiǎn)都在我們自己身上。
因此,我們需要在依賴檢查時(shí)考慮一些因素。
2.1 設(shè)計(jì)
文件清楚嗎?API有清晰的設(shè)計(jì)嗎?如果作者能夠在文檔中很好地解釋依賴包的 API 及其設(shè)計(jì),那么他們?cè)谠创a中實(shí)現(xiàn)正確的可能性就會(huì)增加。使用清晰的、設(shè)計(jì)良好的 API 編寫(xiě)代碼也更容易、更快,并且更少出錯(cuò)。作者是否記錄了他們對(duì)客戶端代碼的期望,以使升級(jí)兼容呢?(例如 C++ 23的兼容性文檔。)
2.2 代碼質(zhì)量
代碼寫(xiě)得好嗎?讀一些代碼吧。作者看起來(lái)是否小心謹(jǐn)慎,始終如一?看到的代碼起我們想要調(diào)試的代碼嗎?需要有檢查代碼質(zhì)量的系統(tǒng)方法。例如,簡(jiǎn)單地編譯一個(gè)啟用了重要編譯器警告的 c 或 c + + 程序(例如-Wall) ,就可以讓開(kāi)發(fā)人員了解在避各種未定義行為方面的嚴(yán)重程度,看看有多少不安全的代碼。忽略關(guān)于死記硬背的建議,轉(zhuǎn)而關(guān)注語(yǔ)義問(wèn)題。
對(duì)不熟悉的開(kāi)發(fā)實(shí)踐要保持開(kāi)放的心態(tài)。例如,SQLite 庫(kù)提供了一個(gè)單獨(dú)的200,000行 c 源文件和一個(gè)單獨(dú)的11,000行稱為 amalgamation 的頭文件。這些文件的大小會(huì)引起最初的警覺(jué),但是深入進(jìn)去會(huì)發(fā)現(xiàn)實(shí)際的開(kāi)發(fā)源代碼包含了一個(gè)100多個(gè) c 源文件、測(cè)試和支持腳本的文件樹(shù)。事實(shí)證明,單文件分發(fā)是從原始數(shù)據(jù)源自動(dòng)構(gòu)建的,對(duì)于最終用戶,尤其是那些沒(méi)有依賴項(xiàng)管理器的用戶來(lái)說(shuō)更加容易。另外,編譯后的代碼也運(yùn)行得更快,因?yàn)榫幾g器可以看到更多的優(yōu)化機(jī)會(huì)。
2.3 測(cè)試
代碼有測(cè)試嗎?能運(yùn)行它們嗎?測(cè)試確定了代碼的基本功能是正確的,并且表明開(kāi)發(fā)人員對(duì)于保持代碼的正確性是認(rèn)真的。例如,SQLite 開(kāi)發(fā)樹(shù)有一個(gè)非常全面的測(cè)試套件,超過(guò)了30,000個(gè)單獨(dú)的測(cè)試用例,以及解釋測(cè)試策略的文檔。未來(lái)方案的修改可能會(huì)引入回歸測(cè)試,而這些回歸測(cè)試很容易被發(fā)現(xiàn)。
假設(shè)測(cè)試運(yùn)行通過(guò),還可以運(yùn)行時(shí)檢測(cè)(如代碼覆蓋率分析、競(jìng)爭(zhēng)檢測(cè)、內(nèi)存分配檢查和內(nèi)存泄漏檢測(cè))來(lái)收集更多信息。
2.4 調(diào)試
找到包里的問(wèn)題列表,里面有開(kāi)放的 bug 報(bào)告嗎?使用多久了?是否有許多錯(cuò)誤尚未修復(fù)?最近有什么錯(cuò)誤被修復(fù)了嗎?如果看到很多關(guān)于 bug 的公開(kāi)問(wèn)題,而且已經(jīng)公開(kāi)了很長(zhǎng)一段時(shí)間,這不是一個(gè)好的跡象。另一方面,如果關(guān)閉的問(wèn)題表明缺陷很少,并且發(fā)現(xiàn)是及時(shí)修復(fù)的,那就太好了。
2.5 維護(hù)
查看包的提交歷史,代碼被積極維護(hù)了多長(zhǎng)時(shí)間?現(xiàn)在還在積極維護(hù)嗎?積極維護(hù)了較長(zhǎng)時(shí)間的軟件包更有可能繼續(xù)得到維護(hù)。有多少人在包上做了提交?許多軟件包是業(yè)余時(shí)間創(chuàng)建和分享的個(gè)人項(xiàng)目,還有一些是一群付費(fèi)開(kāi)發(fā)人員數(shù)千小時(shí)工作的結(jié)果。一般來(lái)說(shuō),后一種類型的軟件包更有可能迅速修復(fù)錯(cuò)誤,進(jìn)行穩(wěn)定的改進(jìn),并進(jìn)行常規(guī)維護(hù)。
2.6 用法
是否有許多其他軟件依賴于此代碼庫(kù)?依賴管理器通常可以提供關(guān)于使用情況的統(tǒng)計(jì)數(shù)據(jù),或者可以使用搜索來(lái)評(píng)估其他人使用該包的頻率。更多的用戶至少意味著有很多人能夠很好地使用代碼,并且能夠更快地發(fā)現(xiàn)新的 bug。廣泛的使用還可以避免持續(xù)維護(hù)的問(wèn)題,因?yàn)橛信d趣的用戶可能會(huì)做出更多貢獻(xiàn)。
2.7 安全性
依賴包能夠處理不可信的輸入嗎?如果是,它是否對(duì)惡意輸入具有強(qiáng)大的抵抗力?它是否有列出安全問(wèn)題的歷史?例如, 流行的 PCRE 正則表達(dá)式庫(kù)有諸如緩沖區(qū)溢出等問(wèn)題的歷史,特別是在其解析器中。這一發(fā)現(xiàn)并沒(méi)有立即導(dǎo)致放棄 PCRE,但它確實(shí)使我們更仔細(xì)地考慮測(cè)試和隔離。
2.8 許可證
代碼是否得到了正確的許可?它到底有沒(méi)有許可證?公司是否接受這樣的許可證?很多 GitHub 上的項(xiàng)目都沒(méi)有明確的許可證。公司可能會(huì)對(duì)依賴項(xiàng)的許可證施加進(jìn)一步的限制。例如,不允許使用類似 agpl 許可證授權(quán)的代碼,它可能過(guò)于繁瑣,也不允許使用類似 wtpl 的許可證,它可能過(guò)于模糊。
2.9 依賴的依賴
代碼庫(kù)是否有自己的依賴項(xiàng)?間接依賴關(guān)系中的缺陷與直接依賴關(guān)系中的缺陷一樣對(duì)程序不利。依賴管理器可以列出給定包的所有依賴項(xiàng),理想情況下應(yīng)該按照這里描述的方式檢查每個(gè)依賴項(xiàng)。具有許多依賴項(xiàng)的包會(huì)帶來(lái)額外的檢查工作,因?yàn)檫@些相同的依賴項(xiàng)會(huì)帶來(lái)需要進(jìn)行評(píng)估的額外風(fēng)險(xiǎn)。
許多開(kāi)發(fā)人員可能從來(lái)沒(méi)有看過(guò)依賴關(guān)系的完整列表,也不知道它們依賴什么。例如,包括 Babel、 Ember 和 Reactall 在內(nèi)的許多流行項(xiàng)目間接依賴于一個(gè)名為 left-pad 的微型庫(kù),該包由一個(gè)單獨(dú)的八行函數(shù)組成。在2016年3月,作者從 NPM 中刪除了這個(gè)包,無(wú)意中破壞了大多數(shù) Node.js 用戶的構(gòu)建。當(dāng)時(shí)的轟動(dòng)至今記憶猶新。
3. 依賴的測(cè)試
檢查過(guò)程應(yīng)該包括運(yùn)行庫(kù)自己的測(cè)試。如果庫(kù)通過(guò)了檢查,并且決定依賴于它,那么下一步應(yīng)該是編寫(xiě)新的測(cè)試,重點(diǎn)是我們應(yīng)用程序所需的功能。這些測(cè)試通常以簡(jiǎn)短的獨(dú)立程序開(kāi)始,編寫(xiě)這些程序是為了確保我們能夠理解庫(kù)的 API,并確保它完成預(yù)期的任務(wù)。值得付出額外的努力,將這些程序轉(zhuǎn)換為可以針對(duì)包的較新版本運(yùn)行的自動(dòng)化測(cè)試。如果發(fā)現(xiàn)了一個(gè) bug 并且有了一個(gè)潛在的修復(fù),那么希望能夠輕松地重新運(yùn)行這些特定于項(xiàng)目的測(cè)試,以確保修復(fù)沒(méi)有破壞其他任何東西,值得對(duì)基本檢查所確定的可能存在問(wèn)題的領(lǐng)域進(jìn)行研究。
4. 依賴的抽象
根據(jù)庫(kù)的不同,也許更新會(huì)把軟件包帶向一個(gè)新的方向,也許會(huì)發(fā)現(xiàn)嚴(yán)重的安全問(wèn)題,也許會(huì)有更好的選擇。出于所有這些原因,將項(xiàng)目輕松遷移到新的依賴項(xiàng)是值得的。
如果庫(kù)將在項(xiàng)目源代碼的許多地方使用,那么遷移到新的依賴項(xiàng)將需要對(duì)所有這些不同的源位置進(jìn)行更改。更糟糕的是,如果庫(kù)在自己項(xiàng)目的 API 中公開(kāi),那么遷移到新的依賴項(xiàng)將需要對(duì)調(diào)用API 的所有代碼進(jìn)行更改,而我們可能無(wú)法控制這些更改。為了避免這些代價(jià),有必要定義一個(gè)自己的接口,并使用依賴項(xiàng)實(shí)現(xiàn)該接口的封裝。封裝應(yīng)該只包含項(xiàng)目從依賴庫(kù)中需要的內(nèi)容,而不是依賴庫(kù)提供的所有內(nèi)容。理想情況下,這允許僅更改封裝接口來(lái)替換不同但同樣適合的依賴關(guān)系。每個(gè)項(xiàng)目的遷移到新接口時(shí),將測(cè)試封裝接口的實(shí)現(xiàn)。
這種間接性使測(cè)試備用庫(kù)變得容易,并且它防止了在源代碼樹(shù)的其余部分中意外地引入依賴庫(kù)的內(nèi)部方法。反過(guò)來(lái),這又確保了在需要時(shí)可以輕松地切換到不同的依賴項(xiàng)。
5. 依賴的隔離
在運(yùn)行時(shí)隔離依賴項(xiàng)也可能是適當(dāng)?shù)模员阆拗棋e(cuò)誤可能造成的損害。例如,Google Chrome 允許用戶在瀏覽器中添加依賴文件/擴(kuò)展代碼。因此,在一個(gè)糟糕的擴(kuò)展中,一個(gè)可利用的 bug 不能自動(dòng)訪問(wèn)瀏覽器本身的整個(gè)內(nèi)存,并且可以被阻止進(jìn)行不適當(dāng)?shù)南到y(tǒng)調(diào)用。如今,隔離依賴關(guān)系可以降低運(yùn)行該代碼的相關(guān)風(fēng)險(xiǎn)。
可疑代碼的運(yùn)行時(shí)隔離是困難的,而且很少完成。真正的隔離需要一種完全內(nèi)存安全的語(yǔ)言,沒(méi)有非類型化的代碼。這不僅在 C和 C++ 語(yǔ)言中具有挑戰(zhàn)性,而且在提供受限制不安全操作的語(yǔ)言中也很具有挑戰(zhàn)性,例如 Java 在包含 JNI的時(shí)候,或者 Go和 Swift 在包含它們的“不安全”特性時(shí)。即使是在 JavaScript 這樣的內(nèi)存安全語(yǔ)言中,代碼通常也可以訪問(wèn)超出其需要的內(nèi)容。針對(duì)這類問(wèn)題的眾多可能防御措施之一,是更好地限制依賴。
6. 依賴的避免
如果一個(gè)依賴項(xiàng)看起來(lái)太危險(xiǎn),無(wú)法找到一種方法來(lái)隔離它,最好的答案可能是完全避免它,或者至少避免那些我們認(rèn)為最有問(wèn)題的部分。
如果只需要依賴庫(kù)的一小部分,最簡(jiǎn)單的解決方案可能是復(fù)制所需的內(nèi)容,當(dāng)然,保留適當(dāng)?shù)陌鏅?quán)和其他法律聲明。我們正在承擔(dān)修復(fù)錯(cuò)誤、維護(hù)等責(zé)任,但也完全與更大的風(fēng)險(xiǎn)隔離開(kāi)來(lái)。一點(diǎn)點(diǎn)復(fù)制總比一點(diǎn)點(diǎn)依賴要好。
7. 依賴的升級(jí)
升級(jí)帶來(lái)了引入新 bug 的機(jī)會(huì),如果沒(méi)有相應(yīng)的回報(bào),為什么要冒這個(gè)風(fēng)險(xiǎn)呢?這種分析忽略了兩個(gè)成本。首先是最終升級(jí)的成本。在軟件方面,代碼更改的難度不是線性的,做10個(gè)小更改比做一個(gè)等價(jià)的大更改更簡(jiǎn)單,也更容易做對(duì)。第二個(gè)問(wèn)題是發(fā)現(xiàn)已修復(fù)bug 的代價(jià)。特別是在安全場(chǎng)景中,已知的錯(cuò)誤可能會(huì)被利用,可能是攻擊者的闖入。
及時(shí)升級(jí)是很重要的,但這意味著向項(xiàng)目中添加新的代碼,這意味著要更新新版本依賴庫(kù)的風(fēng)險(xiǎn)評(píng)估。至少,需要瀏覽從當(dāng)前版本到升級(jí)版本的變更差異,或者至少閱讀發(fā)布文檔,以確定升級(jí)代碼中可能需要關(guān)注的領(lǐng)域。如果許多代碼正在更改,以致難以消化,那么可以將這種情況納入風(fēng)險(xiǎn)評(píng)估。
重新運(yùn)行依賴庫(kù)自己的測(cè)試也是有意義的。如果它具有自己的依賴項(xiàng),那么項(xiàng)目的配置完全有可能使用與庫(kù)作者使用的不同版本依賴項(xiàng)。運(yùn)行庫(kù)自己的測(cè)試可以快速識(shí)別特定于配置的問(wèn)題。同樣,升級(jí)不應(yīng)該是完全自動(dòng)的。在部署升級(jí)版本之前,必須驗(yàn)證它們是否適合自己的環(huán)境。
在大多數(shù)情況下,延遲升級(jí)比快速升級(jí)的風(fēng)險(xiǎn)更大。
8. 依賴的關(guān)注
重要的是要持續(xù)關(guān)注,甚至可能重新評(píng)估使用它們的決定。
首先,確保使用我們所認(rèn)為的特定庫(kù)版本。現(xiàn)在,大多數(shù)依賴管理器可以輕松記錄給定庫(kù)版本預(yù)期源碼的加密哈希值,然后在另一臺(tái)計(jì)算機(jī)或測(cè)試環(huán)境中重新下載這個(gè)庫(kù)時(shí)檢查這個(gè)哈希。這可以確保使用與我們檢查測(cè)試時(shí)相同的依賴源碼。
同樣重要的是,要注意新的間接依賴關(guān)系是否會(huì)爬進(jìn)來(lái)。升級(jí)可以很容易地引入新的包,而我們的項(xiàng)目現(xiàn)在依賴于這些包。它們也是值得關(guān)注的,惡意代碼可能被隱藏在一個(gè)不同的包中。依賴關(guān)系還會(huì)影響項(xiàng)目的大小。
升級(jí)是重新考慮使用依賴項(xiàng)的自然時(shí)機(jī),定期重新審視依賴關(guān)系也很重要。這個(gè)項(xiàng)目被放棄了嗎?也許是時(shí)候開(kāi)始計(jì)劃取代這種依賴性了。
9. 依賴,該說(shuō)不該說(shuō)的
軟件復(fù)用好處不應(yīng)被低估,依賴關(guān)系比以往任何時(shí)候都多,它給軟件開(kāi)發(fā)人員帶來(lái)了積極的轉(zhuǎn)變。即便如此,我們卻沒(méi)有完全考慮到潛在的后果。
- 關(guān)于軟件依賴有三個(gè)主要的建議:
- 認(rèn)識(shí)到問(wèn)題,我們需要集中精力來(lái)解決這個(gè)問(wèn)題。
- 為今天建立最佳實(shí)踐,需要最佳實(shí)踐來(lái)使用依賴關(guān)系的管理。這意味著制定從決策到評(píng)估,以及減少和跟蹤風(fēng)險(xiǎn)的過(guò)程。事實(shí)上,正如工程師專注于測(cè)試一樣,有些人可能需要專注于管理依賴關(guān)系。
- 為明天開(kāi)發(fā)更好的依賴技術(shù)。依賴管理器基本上消除了下載和安裝的成本。未來(lái)的開(kāi)發(fā)工作應(yīng)該側(cè)重于降低使用依賴項(xiàng)所必需的評(píng)估和維護(hù)成本。構(gòu)建工具至少應(yīng)該使運(yùn)行依賴庫(kù)自己的測(cè)試變得容易,還應(yīng)該提供簡(jiǎn)單的方法來(lái)隔離可疑的依賴庫(kù)。
對(duì)特定依賴關(guān)系的嚴(yán)格檢查需要大量工作,并且仍然有例外出現(xiàn)。對(duì)于每一個(gè)可能的新依賴,不太可能有開(kāi)發(fā)人員真正付出這樣的努力,盡管文中給出的可能只是一個(gè)子集。