寫這篇之前,我對今天的結果其實有一個很篤定的預設劇本:CodeQL 追到、人工追不到,證明進階工具有它不可取代的價值。實測完全不是這樣,這篇能寫成現在這個樣子,是因為先把那個劇本放下,老實面對測出來的東西。Day 6 測完 Semgrep,留了一個問題沒解:Semgrep OSS 版沒有跨檔案分析能力,只能看單一檔案。今天想接著問——換一個真正做跨檔案資料流追蹤的工具,會不會補上這塊?測的是同一個 Trail of Bits 套件裡的另一支:CodeQL。結果不是我原本預期的那樣,這個意外比我原本想證明的事更值得記。
codeql,Trail of Bits static-analysis套件的子技能,底層是 GitHub 官方的 CodeQL 命令列工具brew install --cask codeql)+ jq + uv,另外要另外下載對應語言的查詢包(例如 codeql/python-queries)值得花一點篇幅講清楚機制,不然接下來的結果看不出為什麼特別。CodeQL 的做法分兩階段:第一階段先把整個程式庫「萃取」成一個資料庫——不是真的執行程式,是把程式的語法樹、呼叫關係、變數的賦值與傳遞路徑,全部轉成可以查詢的結構化資料,存進一個資料庫檔案裡。這一步跟語言有沒有需要編譯有關:Python、JavaScript、Ruby 這類直譯語言不用真的跑起來,CodeQL 直接讀原始碼就能萃取;C、Java、Go 這類需要編譯的語言,CodeQL 得在旁邊真的攔截一次編譯過程,把編譯器實際看到的型別資訊也記下來,資料庫才會完整。
第二階段才是真正的分析:拿一條寫好的「查詢」(用 CodeQL 自己的查詢語言 QL 寫成),在這個資料庫裡問「有沒有一條路徑,從某個『來源』(source,例如網址參數、表單欄位)一路不經過任何『清洗點』(sanitizer),走到某個『終點』(sink,例如執行 SQL、開檔案、跑指令)」。這條路徑可以合法地跨越函式呼叫、跨越檔案邊界,因為資料庫裡本來就記錄了完整的呼叫關係,不需要像 Semgrep 那樣把每個檔案當成獨立單位分開看。這也是為什麼今天要測的東西,換成 Semgrep 完全無解——它從架構上就沒有「先建一個全域資料庫」這一步,天生只能看單一檔案的內容。
跟 Semgrep 比起來,CodeQL 的門檻明顯高一截,這點事先講清楚比較公平。裝好 CLI 本體之後,還得另外下載對應語言的查詢包(例如 Python 就要另外抓 codeql/python-queries,這個套件本身也要花時間下載),而且它的官方文件明講:不要直接把查詢包的名字丟給分析指令,因為每個查詢包都內建了一份「預設要跑哪些查詢」的過濾清單,直接用預設值很容易在你完全不知情的狀況下漏掉一整批查詢、卻拿到一個看似正常的空結果。正式的做法是自己產生一份明確的查詢清單,再拿這份清單去跑分析,確保不會被套件內建的隱藏過濾邏輯把結果篩空。
還有一個 macOS 特有的坑:Apple Silicon 上編譯需要真的建置的語言(C、C++、Java 這類),系統工具跟 CodeQL 自己的追蹤函式庫如果架構對不上,程序會被系統直接強制關閉,錯誤代碼是 137——第一次看到這個代碼很容易誤判成「建置失敗」,其實是架構不匹配,需要額外的工具鏈調整才能解決,不是程式碼本身有問題。今天測的是 Python,不需要真的建置,沒有踩到這個問題,但如果之後要測需要編譯的語言,這個坑要事先知道。
這些門檻不是在勸退,是想講清楚一件事:CodeQL 的定位本來就不是「跟 Semgrep 一樣快速、隨手一跑」的工具,它的設計假設你願意花時間把查詢集、資料擴充模型這些東西弄對,換來的是分析的深度跟精確度。如果你要的是隨手跑一次、看看有沒有明顯問題,Semgrep 的門檻低很多;如果你要的是徹底追蹤一個大型專案裡的資料流,CodeQL 的門檻是換取深度必須付出的代價。
Day 6 測 SQL injection 的時候,Semgrep 靠的其實是「字串拼接直接餵進 execute()」這種單一檔案內就看得完的語法模式,跟跨不跨檔案沒關係。這次想真的測到「跨檔案」這個能力本身,所以案例換了一種漏洞:路徑穿越(path traversal)。
三個檔案疊起來:routes.py 的 Flask 路由從網址參數拿到 filename,直接傳給 file_service.py 的 read_report();這支函式把字串接成 "reports/" + name,再傳給 storage.py 的 load_file();這支函式做的事只有一行——open(path)。單獨看 storage.py 這一行,完全看不出問題,open() 是最普通不過的動作;只有追回前面兩層、知道這個 path 最終源頭是一個沒經過任何清洗的網址參數,才知道它危險。乾淨版的差別只在 storage.py 多了一段檢查:把路徑解析成絕對路徑,確認沒有逃出指定的資料夾。
先用 Semgrep 對兩個版本各跑一次確認基準:兩邊都是 0 個結果。 這代表這次的案例設計成功排除了「靠單一檔案內語法模式就抓得到」的可能,接下來測的才是真正的跨檔案能力。
這個基準確認的步驟本身值得記一筆方法論:測一個工具「特有」的能力之前,得先確認案例沒有留一條後門讓別的、比較簡單的方法也能矇對答案。如果沒有先跑這一輪 Semgrep 確認,今天就算 CodeQL 抓到了,也沒辦法區分「這是靠跨檔案分析抓到的」還是「這個漏洞本來隨便什麼工具都抓得到,只是剛好也被 CodeQL 抓到」。先把簡單方法能矇對的可能性排除掉,才能放心把接下來的結果歸功給真正要測的那個能力。
建資料庫、跑 codeql/python-queries 這包官方查詢集,有洞的那個版本跑出 1 個結果,規則是 py/path-injection,位置精準指在 storage.py 的 open() 那一行,訊息寫著「這個路徑的值依賴一個使用者提供的值」。
更值得看的是它附的完整資料流路徑,一路展開,逐檔逐行:
routes.py:9 request.args.get() 取出 filename
routes.py:10 filename 傳進 read_report()
file_service.py:4-6 name 接成 path,再傳進 load_file()
storage.py:1-2 path 傳進 open()
三個檔案、四個跳點,全部自動串起來,不用我自己手動去對照呼叫關係。這種帶完整路徑的輸出,在 SARIF 格式裡叫做「code flow」,是 CodeQL 特有、Semgrep OSS 版沒有的東西——Semgrep 的發現只會告訴你「這一行有問題」,CodeQL 的發現會告訴你「這一行有問題,而且問題是從那邊那一行流過來的,中間經過這幾站」。對一個要動手修的人來說,這個差異決定了你是要自己回頭去猜資料從哪裡來,還是直接拿到一份現成的路徑圖。
更實用的是,這份路徑本身就可以直接拿來寫成回歸測試——每一站的檔案跟行號都給好了,等於是一份現成的「這條攻擊路徑長怎樣」的說明書,不用自己重新追一遍。乾淨版重跑一次,0 個結果——它正確辨認出那段「解析絕對路徑、確認沒有逃出資料夾」的檢查是有效的防護,沒有誤判。這輪測起來,CodeQL 完全做到了它宣稱的事:語法上看不出問題的 sink,只要有真正的跨檔案汙染路徑,還是抓得到;而且沒有對已經修好的版本亂噴假警報。
照原本的設計,接下來應該要派一個不用任何工具的盲測去讀同樣三個檔案,證明它追不到跨檔案的關聯,襯托出 CodeQL 的價值。結果完全不是這樣。
盲測基線不但完整重建了一模一樣的資料流——「request.args.get("filename") 沒驗證 → 字串接成路徑,沒清洗 .. → 直接 open()」,連攻擊 payload 都直接寫出來:GET /download?filename=../../../../etc/passwd。而且它抓到的問題比 CodeQL 那一條規則還多:這個路由完全沒有身份驗證,任何人都能觸發;filename 沒帶參數時 "reports/" + None 會直接丟例外;open() 沒包 try/except,檔案不存在或沒權限時的例外差異可以拿來當「探測資料夾裡有什麼檔案」的工具;讀到的內容原封不動當 HTML 吐回去,如果檔案內容剛好可控,這條路徑遍歷漏洞還能跟其他弱點串成反射式 XSS。
CodeQL 那條規則抓到的,是這五、六個問題裡最核心的那一個;盲測基線抓到的是同一個核心問題,外加四五個 py/path-injection 這條規則的設計範圍本來就不涵蓋的東西——因為它是一條專門找路徑穿越的規則,不是一個全面的程式碼審查。值得比較一下兩邊的產出形式:CodeQL 給的是一條有精確座標、可以重複驗證、附完整證據鏈的單一發現;盲測給的是一份讀起來更全面、但沒有座標、換一個人重寫一次措辭可能會不一樣的敘述。兩者不是互相取代的關係——真的要交出一份能重複執行、能接進 CI 的檢查,還是得靠 CodeQL 這種能精確重現的機制;要的是一次性、盡量看廣一點的審查,找人讀一遍可能更划算。
我原本設計這個案例,是想證明「跨檔案追蹤是進階工具才有、人工做不到的事」。今天測出來的答案更誠實:在一個只有三個檔案、呼叫鏈只有三層的小專案裡,追蹤跨檔案的資料流,並沒有超出仔細讀一遍程式碼的範圍。 這不是說 CodeQL 沒有用,是說今天這個案例的規模,剛好落在「小到人工還追得完」的區間——這個區間本來就不是跨檔案分析工具真正要解決的問題。
回頭想這件事,其實也不算太意外。人在讀三個檔案、追一條只有四個跳點的呼叫鏈時,本來就沒有超出正常工作記憶能處理的範圍——這跟 Day 8 測 i-have-adhd 那篇提到的「工作記憶容量小」剛好是同一件事的兩面:資訊量在可負荷範圍內,人工處理起來又快又準,甚至比一條寫死的規則更靈活,因為人會順手注意到規則沒設計要看的東西(像是今天的身份驗證缺失);資訊量一旦超出負荷,才是自動化工具真正該上場、也才真正贏得過人工的地方。CodeQL 存在的意義,不是「比人聰明」,是「不會因為呼叫鏈太長就記不住」,這跟聰不聰明是兩回事。
CodeQL 真正的價值,應該出現在案例規模超過人工能一次讀完的時候——幾十個檔案、呼叫鏈繞了七八層、中間夾雜著框架自己包裝過的請求處理邏輯,這種規模下沒有人有辦法在腦中維持完整的呼叫圖,這時候「自動建一個可查詢的資料庫、機器幫你把呼叫鏈全部串起來」才是省下人工做不到的事,不是省下人工「懶得做」的事。今天的三檔案案例沒有測到這一層,是這次測試設計上的侷限,不是 CodeQL 沒有這個能力——只是這個能力今天沒有被逼出來,因為題目出得不夠大。
py/path-injection 準確抓到路徑穿越,但完全不會提醒你這個路由沒有身份驗證——這不是它的缺陷,是它的設計邊界,同一批程式碼永遠需要不只一種角度去看。前八天陸續測出「工具有它看得到跟看不到的範圍」,今天多一層:工具的能力邊界,跟案例的規模互相牽動。 同一個工具在小案例上可能看不出優勢,換到大案例可能就是唯一可行的方法——這代表評估一個工具值不值得用,不能只問「它能不能做到某件事」,還要問「我的情境有沒有大到真的需要它做到那件事」。今天算是自己出的題目沒出對,但這個「出錯題目」本身也是一種收穫——至少現在知道下次要測這類工具的真正優勢,案例規模得往上加,不能停在三個檔案。
今天也順帶留一個沒解決的問題給自己:多大的專案才算「人工追不動」?三個檔案顯然不算,那十個檔案呢、三十個呢?這條界線在哪裡,今天沒有答案,只知道它存在,而且比三個檔案高很多。如果之後有機會拿一個真實世界、規模夠大的專案重跑一次同樣的測試,這條界線在哪裡才會真的被量出來,不會只是一句「應該在更大的規模才有優勢」的空話。
]這系列接下來還是每篇一個獨立的技能卡,累積起來變成一份你自己就能查的 skill 選用清單。