一 背景
為什么想寫此文
去年的Log4j-core的安全問題,再次把供應鏈安全推向了高潮。在供應鏈安全的場景,螞蟻集團在靜態代碼掃描平臺-STC和資產威脅透視平臺-哈勃這2款產品的聯動合作下,優勢互補,很好的解決了直接依賴和間接依賴的場景。
但是由于STC是基于事前,受限于掃描效率存在遺漏的風險面,而哈勃又是基于事后,存在修復時間上的風險。基于此,筆者嘗試尋找一種方式可以同時解決2款產品的短板。筆者嘗試研究了一下Maven是如何處理一個項目中的直接依賴和間接依賴的,并且在遇到相同依賴時,Maven是如何進行抉擇的,這里的如何抉擇其實就是Maven的仲裁機制。帶著這些問題,筆者嘗試調研了Maven的源碼和做了一些本地的測試實驗。總結了這篇文章。
坐標是什么?
在空間坐標系中,我們可以通過xyz表示一個點,同樣在Maven的世界里,我們可以通過一組GAV在依賴的世界里明確表示一個依賴,比如:
: com.alibaba 一般是公司的名稱
: fastjson 項目名稱
: 1.2.24 版本號
影響依賴的標簽都有哪些
1.
直接引入具體的依賴信息。注意是不在標簽內的情況。如果是在內的情況,請參考2號標簽。
2.
只聲明但不發生實際引入,作為依賴管理。依賴管理是指真正發生依賴的時候,再去參考依賴管理的數據。
這樣使用dependency的時候,可以缺省version。
另外還可以管控所有的間接依賴,即使間接依賴聲明了version,也要被覆蓋掉。
3.
聲明自己的父親,Maven的繼承哲學跟JAVA很類似,因為Maven本身也是用Java實現的,滿足單繼承。
一旦子pom繼承了父pom,那么會把父pom里的,等等屬性都繼承過來的。當然如果在繼承的過程中,出現一樣的元素,也是子去覆蓋父親,和Java類似。
繼承時,會分類繼承。dependencies繼承dependencies,dependencyManagement里的依賴管理只能繼承dependencyManagement范圍內的依賴管理。
每一個pom文件都會有一個父親,即使不聲明Parent,也會默認有一個父親。和Java的Object設計哲學類似。后面在源碼分析中我們還會提到。
4.
代表當前自己的項目的一個屬性的集合。
properties僅僅代表屬性的聲明,一個屬性聲明了,和他是否被引用并無關系。我完全可以聲明一系列不被人使用的屬性。
依賴的作用域都有哪些
一個依賴在引入的時候,是可以聲明這個依賴的作用范圍的。比如這個依賴只對本地起作用,比如只對測試起作用等等。作用域一共有compile,provided,system,test,import,runtime 這幾個值。
簡單總結一下:
compile和runtime會參與最后的打包環節,其余的都不會。compile可以不寫。
test只會對 src/test目錄下的測試代碼起作用。
provided是指線上已經提供了這個Jar包,打包的時候不需要在考慮他了,一般像servlet的包很多都是provided。
system和provided沒什么太大的區別。
import只會出現在dependencyManagement標簽內的依賴中,是為了解決Maven的單繼承。引入了這個作用域的話,maven會把此依賴的所有的dependencyManagement內的元素加載到當前pom中的,但不會引入當前節點。如下圖,并不會引入fastjson作為依賴管理的元素,只是會把fastjson文件定義的依賴管理引入進來。
二 單個Pom樹的依賴競爭
Pom文件本質
一個Pom文件的本質就是一棵樹。
在人的視角來觀察一個Pom文件的時候,我們會認為他是一個線狀的一個依賴列表,我們會認為下圖的Pom文件抽象出來的結果是C依賴了A,B,D。但我們的視角是不完備的,Maven的視角來看,Maven會把這一個Pom文件直接抽象成一個依賴樹。Maven的視角能看到除了ABD之外的節點。而人只能看到ABD三個節點。
既然是在一棵樹上,那么相同的節點就必然會存在競爭關系。這個競爭關系就是我們提到了仲裁機制。
Maven仲裁機制原則
1.依賴競爭時,越靠近主干的越優先。
2.單顆樹在依賴在競爭時(dependencies)(注意:不是dependencyManagement里的dependencies):
當deep=1,即直接依賴。同級是靠后優先。
當deep>1,即間接依賴。同級是靠前優先。
3.單顆樹在依賴管理在競爭時(注意:是dependencyManagement里的dependencies)是靠前優先的。
4.maven里最重要的2個關系,分別是繼承關系和依賴關系。我們所有的規律都應該只從這2個關系入手。
下圖中分別是2個子pom文件(方塊代表依賴的節點,A-1 表示A這個節點使用的是1版本,字母代表節點,數字代表版本)。
左邊這個子pom生成的樹依賴了 D-1,D-2和D-5。滿足依賴競爭原則1,即越靠近樹的左側越優先的原則,所以D-5會競爭成功。
但是B-1和B-2同時都位于樹的同一深度,并且深度為1,由于B-2更加靠后,所以B-2會競爭成功。
右邊的子pom生成的樹依賴了 D-1和D-2,并且位于同一深度,但由于D-1和D-2是屬于間接依賴的范圍,deep大于1,所以是靠前優先,那么也就是D-1會競爭成功。
常見場景
看到這里,想必大家已經了解了Maven的仲裁原則。但是在實際的工作中,光有原則還需要在代碼中可以靈活的運用才能有屬于自己的理解,這里筆者準備了5個場景,每個場景對應的答案都在后面,大家閱讀時,可以自己嘗試用Maven的原則來去推理,看看有沒有哪里不符合預期的情況。
場景一 難度(*)
場景描述
主POM里有這個屬性為1.2.24。
父親是spring-boot-starter-parent-3.13.0。父親里的是1.2.77。
并且在主pom中,消費了這個屬性。
那么針對主POM這顆樹,他最終會是使用哪一個fastjson呢?
場景示例
結構圖
場景二 難度(**)
在同一個主POM或者子POM中的dependencies中同時使用了Fastjson,第一個聲明了1.2.24的版本,第二個聲明了1.2.25版本。那么針對主POM或者子pom這棵樹,最終會選擇fastjson 1.2.24還是1.2.25呢?
場景示例
結構圖
場景三 難度(***)
下圖中左圖為主POM文件內的dependencyManagement里的fastjson為1.2.77,這個時候子POM中顯示聲明自己的版本1.2.78。那么針對子POM這顆樹,子POM會選擇聽從父命還是遵從內心呢?
場景示例
結構圖
場景四 難度(****)
主POM的dependenciesFastjson:1.2.24主POM的dependencymanagentFastjson:1.2.77
主POM的父親(springboot)的dependenciesFastjson 1.2.78
子POM里的dependenciesFastjson 1.2.25
這種情況下針對子pom來說,他會選擇4個版本中的哪一個呢?
場景示例
結構圖
場景五 難度(*****)
主POM的dependenciesFastjson:1.2.24主POM的dependencymanagentFastjson:1.2.77
主POM的父親(springboot)的dependenciesFastjson 1.2.78
子POM里的dependencies 不寫version
場景五跟場景四整體沒有差別,只是將子pom的dependencies的版本進行缺省。
這種情況下針對子pom來說,針對子pom,他會選擇3個版本中的哪一個呢?
場景示例
結構圖
答案
場景一
1.2.24會最終生效。
因為子會繼承父親的屬性,但是由于自己有這個屬性,那么則覆蓋!
繼承一定會伴隨著覆蓋的,這個設計在編程語言中還是比較普遍的。
場景二
1.2.25會最終生效。
參考 單顆樹在依賴在競爭時:當deep=1,即直接依賴。同級是靠后優先。
滿足Maven的核心競爭依賴策略!
場景三
1.2.78最終會生效。
一個項目里的dependencyManagement只能對不聲明version的dependency和間接依賴有效!
場景四
1.2.25會最終生效。這個比較復雜。
〇: 首先根據父子的繼承關系,1.2.24會覆蓋掉1.2.78。所以78版本淘汰
一: 由于一個項目里的dependencyManagement只能對不聲明version的dependency和間接依賴有效,所以
1.2.77無法對1.2.25起作用。
二: 由于父子的繼承關系,1.2.25會覆蓋掉1.2.24.
所以最終1.2.25勝出!
場景五
1.2.77會最終生效。
〇: 首先根據父子的繼承關系,1.2.24會覆蓋掉1.2.78。所以78版本淘汰
一: 由于一個項目里的dependencyManagement是可以對不聲明的version起作用,所以子pom的版本為1.2.77
二: 由于父子的繼承關系,1.2.77會覆蓋掉1.2.24.
所以最終1.2.77勝出!
三 多個Pom樹合并打包
多棵樹構建順序原則
現在的項目一般都是多模塊管理,會存在非常多的pom文件。多棵樹的情況下每棵樹的出場順序都是事先已經被計算好的。
這個功能在Maven的源碼中是一個叫Reactor(反應堆)實現的。它主要做了一件事情就是決定一個項目中,多個子pom誰先進行build的順序,這個出廠順序很重要,在合并打包時,往往決定了最終誰會在多個pom之間勝出的問題。
Reactor的原則
多棵樹(多個子pom)構建的順序是按照被依賴方的要在前,依賴方在后的原則。
項目要保證這里是不能出現循環依賴的。
Reactor的原則圖解
如下圖子pom1 在被子pom2和子pom3同時依賴,所以子pom1最先被構建,子pom3沒有人被依賴,所以最后構建。
SpringBoot Fatjar打包的策略
SpringBoot 打包會打成一個Fatjar,所有的依賴都會放在BOOT-INF/lib/目錄下。SpringBoot的打包是越靠后的構建pom越優先,因為一般會把springboot的打包插件放在最不被依賴的module里(比如上圖里的Pom3)。(SpringBoot的打包插件一般放在bootstrap pom里,這個名字可以我們自己起,一般都是依賴關系最靠上的module。在多模塊管理的springboot應用內,bootstrap往往是最不被依賴的那個module。)
子pom3最后參與構建,而且SpringBoot打包插件一般打的就是這個module。所以最終進入到SpringBoot打包產物的有A-2,B-2,E-2,F-2和D-1。因為A-2和B-2相比于其他幾個相同節點更靠近樹的主干。E-2和F-2也是同理。這個規律體感上是靠后優先了,因為靠后的樹天然更加靠近主干。
四 仲裁機制在Maven源碼中的實現
以Maven的3.6.3版本的源碼進行分析,我們嘗試分析Maven中對依賴處理的幾處原則,方能從源碼的層面上正向的證明仲裁機制的準確性。另外從源碼上也可以看出一些Maven上的機制為什么是這樣,而不是單單的他的機制是什么樣。因為筆者相信,任何機制都無法保證與時俱進下的先進性,所以筆者認為上文中提到的所有的仲裁機制有一天可能會發生變化,這些結論并非最重要,而是如何調研這些結論更為重要!
Maven是如何實現出繼承并且相同屬性子覆蓋父的
Maven中有2條非常重要的主線。一個是依賴,另一個就是繼承。Maven在源碼中實現繼承大體如下。在下圖中使用readParent進行對父親的模型獲取之后,便讓自己陷入這個循環中。唯一可以出去這個循環的方式就是追不到父親為止。并且把每次取到模型數據放到linega這個對象當中。下圖中最下面的assembleInheritance我們看他消費了linega這個對象,目的就是完成真實的繼承和覆蓋。
在assembleInheritance中我們會發現一個很有意思的現象,lingage是倒著進行遍歷,并且是從倒數第二個元素開始,這正是上文中我們提到了的Maven的一個設計哲學。Maven認為這個世界上所有的pom文件都存在一個父親,類似Java的Object。這里便是對這個哲學處理的一個淺邏輯。
另外Maven自上而下的去遍歷,更加方便自己去實現相同的元素子覆蓋父的能力,這也是筆者認為在編碼上的一個小心思。
Reactor反應堆在源碼中的實現
上文中我們還提到了一個非常重要的概念,就是反應堆。反應堆直接決定了各個子pom是如何決定構建順序的。在Maven的源碼中,他是在getProjectsForMavenReactor函數中進行實現的。并且我們從下圖中也可以看到,Maven的反應堆是不能解決循環依賴的,他直接捕獲了這種異常!
真正實現反應堆算法的是在ProjectSorter的構造函數中通過Dag進行實現的。Dag(有向無環圖)和廣度優先搜索是解決依賴場景是一個很好的方式。
在有向無環圖中通過每次挑選出入度為0的節點,再刪除該節點和此節點的相鄰邊,不斷重復上述步驟。就可以高效率的計算出DAG上的所有節點的依賴順序,Maven也正是用到了這個思路。
從這個源碼的視角也可以解釋為什么Maven必須要保證每一個子pom之前不能出現循環依賴。
同一個Pom文件內dependency 后聲明的優先的實現
在處理Dependencies時,Maven并沒有對此進行特殊處理,是直接使用的Map的方式進行覆蓋的。關于這里為什么這么設計,筆者并不清楚。筆者曾一度猜測這么設計是為了讓開發同學更好的編寫,因為靠后優先往往符合大部分人的編碼習慣。但是在這里我們看到了作者的一行注釋,意思大概是說,這樣設計是為了向后兼容Maven2.x,因為Maven2.x 是不會去校驗一個文件是否只存在一個同GA的唯一依賴。所以后面的maven的版本應該也是延續了這種風格。
當循環進行處理到1.2.25的時候,依然進行對normalized這個map進行put操作導致了 key值相同的情況下的覆蓋。
五 安全視角應如何避免間接依賴
分析
作為安全同學,筆者更希望的是針對這種多module的Maven項目可以梳理出一個經驗,怎樣去避免間接依賴的問題。
經過上面的分析,我們可以得出3條結論:
1.子pom聲明版本在安全視角是非常危險的,子pom不應該顯示聲明版本。
由于子pom會繼承主pom的元素,并且在繼承的時候會出現覆蓋的場景。那么針對CE或者SpringBoot打包時,有可能出現子pom的build的順序位置天然非常有優勢,容易造成子pom的版本進入最終的打包產物。
2.主POM的dependencyManagent可以管控到 間接依賴 和 不顯示聲明version的直接依賴。
3.主POM的dependencies不能出現危險版本。否則子pom天然的繼承了這個危險版本參與打包。
結論
以上幾條同時滿足,便可以解決間接依賴的問題。
即:
針對SpringBoot而言,子pom不應該顯示聲明版本,主Pom的dependencyManagent應該管控安全版本的依賴,并且主pom不能出現危險版本。(主Pom dependencies強行寫上安全版本更佳,這樣可以避免掉依賴的父親里存在殘留的不安全的依賴)
六 最后
Maven的源碼地址
https://archive.Apache.org/dist/maven/maven-3/
我是怎么分析的
本人在本地針對SpringBoot,做多輪測試。在根目錄下執行mvn clean package即可!
另外就是嘗試在源碼中找到這里的實現,這樣更能加深理解!
常用的分析命令
0. mvn clean package -DSkipTest 直接進行打包,進行結果分析
1. mvn dependency:tree 會把整個的maven的樹形結構輸出
2.mvn help:effective-pom -Dverbose 這個命令輸出的信息更加完整,輸出的是effectivepom