iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0

我們把稽核紀錄(audit log)的骨架立了起來:一次互動一列、append-only、身分認 SSO、寫入 fire-and-forget、目標可抽換。但結尾我自己挖了個坑——我一路講得很篤定「落進日誌的一律是遮罩後的版本」,可這句話其實押了一個前提:得有人先把原文遮乾淨。問題是,那個「有人」會不會缺席?今天就把這個坑填掉,順便講一段真實的合規 bug:在某個設定組合下,行員打進來的原文確實曾經漏進過日誌。

本篇結構:

  • 「理應遮過」這個念頭埋下的雷
  • 真實的 bug:原文怎麼漏進日誌的
  • 機制:把遮罩搬到寫入邊界自己做
  • 取捨:為什麼寧可重複遮,也要擺在這個邊界

「理應遮過」這個念頭埋下的雷

回到那筆稽核紀錄。userInputMaskedanswerMasked 這兩個欄位的名字裡都帶 Masked,寫程式的人看到這命名,心裡會很自然地補上一個註腳:「喔,這裡放的是遮過的版本,上游已經處理好了。」

這句「上游已經處理好了」,就是雷的引信。

理由 Day 18 講縱深防禦時其實已經鋪過:守門是一個可抽換的零件,不是系統不變量。設定一改成 portal.guardrail.provider: mock,入境那道遮罩就形同虛設——不擋注入、也不遮 PII(即個資),輸入原封不動往下流。這種設定在現實裡並不罕見,下面任何一種情境都會讓那道城門當下就敞開:

  • 本地開發圖個方便,不想牽動外部服務。
  • 離線 demo 不想依賴守門供應商。
  • 某次線上除錯臨時用 header 把守門換掉(Day 14 講過能逐請求覆寫供應商)。
  • 甚至只是設定檔被誰改錯了一行。

於是問題變成這樣:如果稽核紀錄的寫入邏輯假設輸入已經遮過、直接把拿到的字串塞進 userInputMasked,那麼在 guardrail.provider: mock 的那一刻,欄位名還寫著 Masked,裡頭卻躺著一個貨真價實的員編 123456。日誌的 schema 沒變、欄位名沒變、看起來一切正常——這正是它最危險的地方:它不會報錯,只會安靜地把你最不想留的東西,留在一個 append-only、寫了就改不掉的地方。

真實的 bug:原文怎麼漏進日誌的

把這個 bug 的來龍去脈攤開講(連同修補一併記在 PR #163)。

當時 audit 這一段的寫法,大致是「上游遮罩 → 傳下來 → audit 照單寫入」一條直線。在 guardrail.provider: regex 之類正常的設定下,這條線跑起來毫無破綻:守門把 123456 遮成 ***,audit 拿到的本來就是 ***,寫進日誌剛剛好。測試這樣寫、demo 這樣跑,看起來滴水不漏。

破綻只在一個特定組合下現形——守門被關掉、稽核照寫:

portal:
  guardrail:
    provider: mock        # 入境守門形同虛設:不擋注入、不遮 PII
  audit:
    provider: postgres    # 但稽核照寫資料庫,一筆不漏

這組設定單看每一行都合理:有人就是想在不開守門的環境下,仍然保留完整稽核。但兩者湊在一起,audit 拿到的輸入是「沒被任何人遮過的原文」,而它又老老實實照寫——員編 123456 就這樣寫進了稽核紀錄的 userInputMasked 欄位裡。欄位名寫著 Masked,內容卻是赤裸的原文,名實不符。兩種設定組合的差別,攤開來就是這樣:

設定組合 入境守門行為 audit 拿到的輸入 落進 userInputMasked 的內容
guardrail.provider: regex(正常) 123456 遮成 *** ***(已遮) ***,名實相符
guardrail.provider: mock(破綻) pass-through,不遮 123456(原文) 123456,名實不符

對照當時的日誌,破綻一目了然:

2026-06-22 09:15:03.402  INFO [req=a1b2c3d4] Guardrail : provider=mock, input pass-through (no mask)
2026-06-22 09:15:03.661  INFO [req=a1b2c3d4] AuditLog  : write masked=false   ← 這行不該出現

第一行 pass-through (no mask) 是合法的設定行為,沒問題;真正的事故在第二行——audit 在原文沒遮的情況下,依然把它寫了下去。這是個典型的「合規漏洞」:沒有 crash、沒有錯誤碼、功能全部正常,唯獨在一個罕見的設定組合裡,讓個資悄悄落進了不可逆的永久紀錄。 這種 bug 最麻煩的不是修,是發現——它躺在那裡可能很久都沒人注意,直到某次抽查翻到那張表。

機制:把遮罩搬到寫入邊界自己做

修正方式說穿了只有一條:audit 不再相信輸入遮過,寫入前自己再遮一次。

這就是 Day 18 那張縱深防禦圖裡,最後那道「Audit 保底遮罩」的本體。原本的直線改成這樣:

   收到要寫的這筆紀錄(輸入字串可能遮過、也可能是原文)
        │
        ▼
 ┌────────────────────────────────────────┐
 │ Audit 寫入邊界                           │
 │   1) 不問上游遮沒遮,先套一次 PII 遮罩      │
 │   2) 遮完的結果才填進 userInputMasked     │
 └────────────────────────────────────────┘
        │  (保證落下去的是遮過的版本)
        ▼
   append-only 日誌

關鍵是這道遮罩不檢查「上游遮過了沒」——它根本不問這個問題,因為一「問」,就等於又把正確性押回它無法信任的上游。它只做一件事:拿到字串、套一次 PII 遮罩、輸出。這道操作有兩種輸入、一種結果:

  • 若輸入早被入境或送模型前那道閘遮過了,那 *** 再遮一次還是 ***,這道操作是冪等(idempotent)的,不會出錯。
  • 若輸入是漏網的原文,這一刀就把它補遮掉。

無論上游發生了什麼,走出這道邊界的字串都是遮過的。修補後的日誌就變成:

2026-06-22 09:15:03.402  INFO [req=a1b2c3d4] Guardrail : provider=mock, input pass-through (no mask)
2026-06-22 09:15:03.661  INFO [req=a1b2c3d4] AuditLog  : write-boundary mask applied pii=[EMPLOYEE_ID]

第一行守門照樣放水(合法設定,不去動它),但第二行 audit 在寫入邊界自己把員編遮了起來,落進表裡的是 ***,而 piiInput=[EMPLOYEE_ID] 這個中繼資料仍會記下「這次曾偵測到員編,且已遮」——這是稽核需要知道的事實,被遮掉的數字本身則不落地。

這裡有個 Day 18 強調過、必須守住的細節:audit 保底用的遮罩規則,跟入境、送模型前那兩道用的是同一支共用偵測元件——八類 PII 的遮罩形狀、那套「先特定、後通用」的套用順序(Day 17 講過,員編 6 碼和 Email 裡的數字串會打架,順序排錯就張冠李戴),都只寫在一個地方。保底不是另抄一份規則來補,那樣遲早會跟上游漂移(drift);它呼叫的是同一套標準,所以「保底遮的」和「上游該遮的」永遠是同一個東西。

取捨:為什麼寧可重複遮,也要擺在這個邊界

有人會問:守門好好的時候,audit 這一遮不就是白做工嗎?

是白做工,而且我們明知道。Day 18 算過這筆帳,這裡再具體落到 audit 這一站:正常設定下,輸入到 audit 手上早就是 ***,再掃一次 PII、再套一次遮罩,掃不出新東西、改不動既有結果,純粹是幾次正規表示式比對的 CPU 開銷——微秒等級,而且 Day 6 那條鐵律保證了這種純計算的字串掃描不會卡住事件迴圈,offload 之後連主流程都感覺不到。

賭注的另一頭,是「個資不落進永久紀錄」這個合規承諾。一邊省幾微秒,一邊是賭整條合規線會不會在某次設定失誤裡破洞——而且破在 append-only 日誌這種寫了就改不掉的地方。這個天平怎麼壓,其實沒得選。

那為什麼不乾脆從根上堵——在正式環境禁用 mock、強制所有路徑都先過真正的守門,不就沒事了?這個念頭很自然,但它又把賭注押回了上游。mock 是一個合法且必要的能力,你不可能為了堵這個漏洞就把本機開發、整合測試的便利性整個拿掉;何況就算封死了設定檔,逐請求 header 覆寫那條路(Day 14)一樣能臨時把守門換成空殼。與其去窮舉、封死每一條「守門沒生效」的路徑,不如承認一件事:寫入日誌的這一站,本來就該對自己即將寫下去的東西負最終責任。

至於為什麼這道保底非擺在「寫入邊界」不可——因為 Day 18 那句設防原則:關卡要守在「資料一旦越過就收不回來」的邊界上。稽核紀錄正是這樣一個邊界,它 append-only、只增不改,一旦原文寫進去,你沒有 update 也沒有 delete 可以擦掉它,只能眼睜睜留著。守在這條線上,意味著無論前面哪一道守門被關掉、被換掉,或單純漏了,最後這一關都不放原文過去。

最後誠實補一段邊界——保底擋得住什麼、擋不住什麼,得分開看:

範圍 保底是否負責 原因
規則涵蓋得到的 PII 格式 是,保證不漏 用同一套共用規則,認得的個資一定遮掉
規則涵蓋不到的 PII 格式 同 Day 16,偵測是啟發式(heuristic),列舉再全總有漏網
注入字串 注入是短路攔截、不是遮罩,不在它守備範圍

保底能保證的是「我認得的個資一定不漏」,認不認得,仍受限於那套共用規則的覆蓋面。它把「設定關掉守門」這個最常見、也最容易踩到的破口堵死了,但沒把規則本身變得無所不知。

把這次修補收束成一個重點:bug 的本質從來不是「忘了遮」,而是「把該自己做的事,賭在別人會先做」。 audit 保底遮罩做的,就是把這個賭注收回來——它不問上游遮了沒,只確保從它手上寫出去的每一筆,都是乾淨的。

明天 Day 26,我們把鏡頭拉遠,談可觀測性——當這些日誌、稽核紀錄、指標散落在非同步系統的各個角落,怎麼靠那組 req=a1b2c3d4 把一次請求的全貌重新串回來。


上一篇
Day 24|audit log
系列文
轉生到全端工程師沒多久就要負責公司的大平台??25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言