通過了一切的安全閘門,包括一個 404
一個內容過濾器回報了數個月的純淨結果。但它其實什麼也沒讀到。本文探討四種讓檢查悄然反轉為核可的方式,以及如何讓失敗的警訊響亮。
一個發布流程在每次部署後都會執行一項檢查:擷取已發布的頁面,在其中搜尋 (grep) 禁用詞彙,如果找到任何符合的項目,就撤回該文章。它每次都回報「乾淨」。在其整個生命週期中,它也從未能夠回報任何其他結果。頁面擷取一直都靜默地回傳空值,而對空值執行 grep 不會匹配到任何東西,而「沒有匹配」被設定為代表「安全」。
這個錯誤 (bug) 並不在於擷取。這個錯誤在於「找不到問題」和「無法查找」產生了相同的輸出,而其中只有一個是真實的。
反轉閘門的樣貌
一項檢查有三種可能的結果,而大多數的實作只編碼了其中兩種。它可以發現問題、沒發現問題,或是未能執行檢查。第三種情況就是安全性流失之處,因為撰寫檢查最簡單的方式,會讓「未能執行檢查」與「沒發現問題」無法區分。
以 shell 版本為例,這類檢查通常是這樣開始的:
curl -s "$URL" | grep -iEf patterns.txt && echo "BLOCKED"
那一行指令中的每個失敗都會歸結為靜默。一個 404 會讓 curl 得到空的回應內文,並以結束碼 0 退出,因為 -s 只會抑制進度顯示。DNS 失敗會產生空的回應內文。一個空的樣式檔案會讓 grep 匹配不到任何東西。管線的結束狀態只反映最後一個指令,所以即使 curl 完全失敗,grep 仍然會回報「沒有匹配」。四種不同的故障,一個無法區分的結果,而這個結果就是讓貼文得以通過。
這種樣態並非內容過濾器所特有。一個將連線錯誤視為「未回報錯誤」的健康檢查。一個在不匹配任何檔案的 glob 上叫用的 linter。一個規則集下載失敗的安全掃描器。在每種情況下,運作機制本身沒有問題,報告顯示為綠色,而這個綠色所代表的意義,卻與儀表板所暗示的正好相反。
同一個檢查以四種方式失敗
有問題的檢查以四種獨立的方式失敗,這點值得坦白說明,因為其中任何一種方式都足以造成失敗,且任何一種方式本身都很難被注意到。
檢查讀取的是不同的文件
最初的檢查是透過一個輔助程式來擷取頁面,該程式會在回傳前將 HTML 轉換為 markdown。這對閱讀來說很方便,但對比對來說卻是致命的,因為模式是針對標記語言(markup)編寫的。屬性、meta 標籤和結構化資料區塊在轉換過程中無法保留。檢查執行後,什麼也沒找到,而且從未看過那些最可能帶有意外識別碼的欄位。
這個教訓不僅適用於這一個輔助程式。篩選器是針對一種呈現形式(representation)編寫的。如果在來源和篩選器之間有任何東西重寫了該呈現形式,那麼篩選器檢查的就是一個在生產環境中根本不存在的文件。請擷取你的使用者所收到的位元組(bytes)。
雜訊太大,沒人讀取訊號
同一個檢查在每個頁面上都回報了七到八個匹配項,而且總是相同的:一個嵌入在廣告腳本中的發布商 ID,根據設計,它存在於網站的每個頁面上。
一個每次執行都喊「狼來了」的檢查,最終會沒人再去看它。更糟的是,它會訓練操作員添加一個全面性的抑制(suppression),而全面性的抑制正是真實匹配項消失的地方。
直覺的做法是將搜尋範圍縮小到文章主體。經過評估,這種做法是錯的,而且是朝著更危險的方向錯。廣告也會在文章元素內呈現,所以縮小範圍只減少了一半的誤報,同時卻新排除了標題、meta 描述和結構化資料,而這些內容都源自作者自己的文字,正是識別碼會出現的地方。
解決方法是移除雜訊,而不是縮小範圍。移除 script、style、iframe 和 noscript 區塊,解開廣告容器標籤的包裝而不刪除其文字內容,並透過在移除腳本前提取結構化資料,然後再將其附加回去來保留它。在相同的頁面上測量:之前有七個匹配項,縮小範圍後有四個,移除雜訊並全面覆蓋後則為零。
模式檔案被允許為空
如果拒絕清單(denylist)載入失敗,模式集就會是空的,而空的模式集不會匹配任何東西。這與一個真正乾淨的頁面所呈現的「乾淨」結果相同。
這個問題的解決成本很低,但幾乎從未被處理:
PAT="$(grep -vE '^\s*#|^\s*$' denylist.txt)"
[ -n "$PAT" ] || { echo "empty denylist, cannot verify"; exit 1; }
任何檢查所依賴的輸入都應受到同等對待。如果用來定義「不良」的東西可能會遺失,那麼它的缺席必須是個錯誤,而不是一個結論。
從來沒有人測試過閘門可能會失敗
最深層的問題是,這一切都不是假設性的。只要將檢查指向一個不存在的 URL,並觀察到它回報乾淨,大約一分鐘內就能發現這個問題。
閘門總是在「通過」的方向上進行測試。有人寫了一篇文章,執行檢查,看到它通過,然後就發布了。「失敗」的方向很少被執行,因為產生一個真正的違規感覺像是在做額外的工作,而且有點危險。因此,那個重要的分支,那個必須阻止發布的分支,是唯一從未運行的分支。
這種情況是系統性的,而非出於疏忽,這是有原因的。為了測試而產生一個真正的違規,意味著要寫下包含真實秘密的文字,而沒有人想提交這樣的內容。將檢查指向一個損壞的目標,意味著要故意破壞某些東西。這兩者都感覺像是在製造問題,而檢查看起來又運作正常,所以這項工作就被無限期地推遲了。
兩個習慣可以永久解決這個問題,而且都能避免這種不適感。
在 CI 中用一個已知的「不良」測試樣本來測試閘門。一個小檔案,包含一個來自您拒絕清單的詞彙,再加上一個斷言,確保檢查在遇到它時會以非零值退出。這個測試樣本永遠不會被發布,永遠不會接觸到真實頁面,並且如果有人破壞了模式載入或退出碼,它會大聲地失敗。如果提交一個真實的詞彙讓您感到困擾,可以使用一個專門為此目的而存在於拒絕清單中的哨兵條目。
用一個已知的「無法連線」的目標來測試閘門。將它指向一個保證會回傳 404 的 URL,並斷言它回報失敗,而不是乾淨。單是這一個斷言就能在第一天就捕捉到最初的錯誤,而且只需要一行程式碼。
這兩種測試都有一個值得一提的共同特性:它們在沒有任何真實內容的情況下執行閘門。「失敗」方向的測試之所以被跳過,通常是因為人們想像需要一個真實的違規情境,而一個測試樣本加上一個無效的 URL 則完全消除了這個要求。
讓失敗的聲音更響亮
重寫檢查以區分這三種結果,主要在於拒絕讓管線將它們合併:
BAD=0
for L in "${LANGS[@]}"; do
HTML="$(curl -fsSL "$BASE/$L/$SLUG")" \
|| { echo "fetch failed: $L" >&2; BAD=1; continue; }
[ -n "$HTML" ] || { echo "empty body: $L" >&2; BAD=1; continue; }
CLEANED="$(printf '%s' "$HTML" | strip_noise)" \
|| { echo "cleanup failed: $L" >&2; BAD=1; continue; }
if printf '%s\n' "$CLEANED" | grep -qiEf <(printf '%s\n' "$PAT"); then
echo "match: $L" >&2; BAD=1
fi
done
[ "$BAD" -eq 0 ] || retract
每個可能失敗的步驟,都會指派與實際匹配相同的失敗變數。curl -f 會將 HTTP 錯誤轉為結束代碼,而非空的回應主體。每個階段都在其各自的指令替換中執行,因此失敗是可捕捉的,而不會被管線的結束狀態所吞噬。從「某處出錯」到「乾淨」之間沒有任何路徑。
由此得出的通用規則是:檢查應該回傳「已驗證為安全」,絕非「未發現有害證據」。這兩者聽起來相似,但在機制失靈時的行為卻截然相反。前者要求檢查確實已執行、查看了正確的文件,並且有可供比對的「不良」定義。後者則是空字串免費且永久提供給你的結果。
關於「預設關閉」(fail-closed),有一點需要注意,因為它並非沒有代價。一個會因每個暫時性錯誤而阻擋的閘門,也會因網路瞬間中斷而阻擋,而如果阻擋的代價高昂,團隊就會繞過它。有兩件事可以讓這種情況變得可控。在宣告失敗前,重試暫時性的部分,例如抓取資料,這樣單一的連線中斷就不會中止發布。並且,透過保存工作成果並精確回報哪個階段失敗,讓從被阻擋狀態中恢復的成本變低。當錯誤阻擋的代價只是一分鐘的困惑時,「預設關閉」是可持續的;但當代價是一小時的重建時,它就是不可持續的。
今天就能執行的一項簡短稽核
針對你擁有的任何自動化閘門,問以下四個問題。
如果它檢查的對象遺失或無法連線,它會怎麼做?如果答案是「通過」,那你就存在這個錯誤。
它檢查的是使用者收到的相同表示形式,還是在過程中被輔助工具重新格式化的東西?
如果它的設定、拒絕清單或規則集載入失敗,會發生什麼事?空的規則集必須視為錯誤。
它上次刻意失敗是什麼時候?如果答案是「從未」,你就不知道它是否真的能失敗。
這個故事中的管線現在會在這四個項目上都以安全的方式失敗,而這項修正花費不到一個下午的時間。昂貴的部分不是修復本身,而是那幾個月來毫無意義的綠色勾勾。