我們把稽核紀錄(audit log)的骨架立了起來:一次互動一列、append-only、身分認 SSO、寫入 fire-and-forget、目標可抽換。但結尾我自己挖了個坑——我一路講得很篤定「落進日誌的一律是遮罩後的版本」,可這句話其實押了一個前提:得有人先把原文遮乾淨。問題是,那個「有人」會不會缺席?今天就把這個坑填掉,順便講一段真實的合規 bug:在某個設定組合下,行員打進來的原文確實曾經漏進過日誌。
本篇結構:
回到那筆稽核紀錄。userInputMasked、answerMasked 這兩個欄位的名字裡都帶 Masked,寫程式的人看到這命名,心裡會很自然地補上一個註腳:「喔,這裡放的是遮過的版本,上游已經處理好了。」
這句「上游已經處理好了」,就是雷的引信。
理由 Day 18 講縱深防禦時其實已經鋪過:守門是一個可抽換的零件,不是系統不變量。設定一改成 portal.guardrail.provider: mock,入境那道遮罩就形同虛設——不擋注入、也不遮 PII(即個資),輸入原封不動往下流。這種設定在現實裡並不罕見,下面任何一種情境都會讓那道城門當下就敞開:
於是問題變成這樣:如果稽核紀錄的寫入邏輯假設輸入已經遮過、直接把拿到的字串塞進 userInputMasked,那麼在 guardrail.provider: mock 的那一刻,欄位名還寫著 Masked,裡頭卻躺著一個貨真價實的員編 123456。日誌的 schema 沒變、欄位名沒變、看起來一切正常——這正是它最危險的地方:它不會報錯,只會安靜地把你最不想留的東西,留在一個 append-only、寫了就改不掉的地方。
把這個 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 把一次請求的全貌重新串回來。