程式碼強制
概述
防護的好壞取決於它拒絕了什麼,當有人試圖繞過它時,而非它是否能順利通過快樂路徑。會存活在審查中的兩種失敗模式是:檢查的是危險值的表達形式而非實際值本身,以及因無法執行而回傳成功的控制項。這兩者在作者自身假設下所撰寫的所有測試中看起來都是綠燈。
使用方式
在撰寫或更改鉤子(hook)、防護(guard)、驗證器(validator)、權限檢查(permission check)、lint 規則(lint rule)或 CI 檢查點(CI gate)時,請遵循以下步驟——在開啟檔案之前,因為第 1 步往往會改變程式碼的歸屬位置。
典型案例:「在儲存庫外部阻擋 rm -rf」」(第 1 步將決策從指令字串上移開);「當遷移沒有回退指令時讓 CI 失敗」(第 3 步和第 4 步阻止當遷移列表回傳空值時通過檢查點);「拒絕沒有簽署提交的推送」(第 5 步列舉別名、--no-verify` 和第二個遠端)。
步驟
- 命名接受點(Sink)。 寫下最終到達受保護資源的精確值:展開後的 argv、請求主體、SQL 參數、解析後的路徑。針對該值進行驗證。
- 檢查你是否真的能看到它。 如果程式碼只有該值的表達形式——一個 Shell 指令字串、一個 diff、一行日誌、一個提交訊息——那麼這個決策是啟發式的,而非真正的檢查。請在註解和交付備註中說明,並將該層標記為建議性質。不要讓一個啟發式檢查成為你無法撤銷之後果的唯一屏障。
- 失敗時封閉(Fail closed)。 缺少執行環境、無法讀取輸入、解析器異常、未解析的變數、空結果:拒絕,並指出缺少什麼。一個因為無法執行而退出碼為 0 的防護就是一個被禁用的防護,而且它會默默地保持被禁用數個月。
- 保持探測失敗的可見性。 不要在決定防護能否執行的指令後加上
2>/dev/null,也不要有任何分支將空結果轉化為成功。如果容許探測靜默失敗,則第 3 步無法強制執行。 - 在編寫修復程式之前列舉繞過方式。 列出控制項可能被滿足或跳過的方式:複合指令、重複的旗標、第二次呼叫、別名或函數、缺少直譯器、另一台主機、子 Shell、重新命名的檔案。將此列表保留在提交訊息或測試檔案中。
- 將該列表中的每一項轉化為測試案例,並整合到專案的自動化檢查中。手動自行測試不算:它會隨著執行它的會話一起消失。
- 當繞過方式成本低廉且後果嚴重時,執行對抗性測試。 派遣一個全新上下文(fresh-context)的子代理,其任務是破解該防護,而非審查它;其輸出是嘗試及其結果的列表。對於最壞情況僅為警告的控制項則可跳過此步驟。
常見陷阱
- 解析 Shell 指令來做決定。 引號處理、
$()、&&、別名和env前綴都會改變最終執行的內容。這樣的層級只是一個減速丘;真正的檢查應屬於指令效果發生的地方。 command -v防護。if command -v tool > /dev/null; then check; fi會在缺少該工具的機器上靜默跳過檢查——而這正是最需要它的機器。反過來做:當工具缺失時拒絕。- 一個發現步驟什麼也沒找到。
git grep -E不支援\b,因此這樣的模式會匹配到零個檔案而不報錯,檢查點因讀取了空結果而通過。用一個金絲雀(canary)來保護該步驟:一個你知道必須出現在其輸出中的檔案。 - 只測試你實作的那種拒絕。 有趣的測試是你沒想到的輸入;這就是第 5 步要產生的東西。
- 僅靠手動測試的防護。 它在作者的機器上通過,然後慢慢腐爛。如果專案沒有地方可以接入測試,那麼缺少測試框架本身就是一部分工作。
- 撰寫防護的上下文無法破解它。 它與產生漏洞的上下文共享盲點,並會確認自己的設計。只有全新的上下文才具有對抗性。
限制條件
- 絕不能以受保護值的代理(proxy)來做決定,當實際值本身可達時。
- 絕不能在防護無法執行其工作的路徑上以退出碼 0 結束;應拒絕並指出原因。
- 絕不能壓制用於確定防護是否可運行的探測步驟的錯誤。
- 每個被列舉的繞過方式,在交付前都必須存在於自動化測試中。
- 清楚說明哪一層是建議性質、哪一層是強制性質;把啟發式方法當成保證,比完全沒有防護更糟。