Repository navigation
v6.0.0 - 一份黑名單設定,四個互不相同的比對器
規格 SQL 第 7、8、9 則與 MongoDB 第 3–6 則。設計決策記在
docs/adr/0018-a-blacklist-rule-that-does-not-match-fails-loudly.md 與
docs/adr/0019-one-blacklist-rule-one-matcher.md。
SQL 那半:本機 MariaDB、表 probe_users (id, Password, note)、值 s3cret,八種設定裡七種洩漏,而全部都被設定載入器無聲接受——操作者「規則有效」的唯一證據是 dbcli 沒有抱怨。
MongoDB 那半(本機容器 MongoDB 7.0.31 實測):同一組規則被四個比對器讀,沒有兩個一致——請求側是字面的點號成分比對、讀取遮罩懂 foo.*、寫入側是祖先走訪、集合名是 Set.has。於是 user.* 擋得住讀擋不住寫,pass* 到處都不生效卻仍印出「可能已遮罩」的提示,secrets* 沒有任何一個比對器認得。規格第 3 則實測後不成立:十四個 update operator 全被 ADR-0015 的請求側檢查擋下,寫入側那個缺口是真的但被外層蓋住——仍然照深度防禦補齊。
Changed
-
BREAKING:欄位規則與回傳欄位名比對時,第一段摺成小寫。 先前規則
password對欄位Password完全不命中,而最嚴重的一則不是設定寫錯:規則大小寫寫對了,SELECT Password AS PASSWORD照樣把值送回來,query-only就做得到——遮罩比對的是回傳時的鍵名,而別名選了那個鍵名,與 ADR-0015 為 MongoDB$project關掉的是同一個形狀,只是這裡經由大小寫抵達。只摺第一段:後面的段落是巢狀物件的鍵(JSON 欄位裡的profile.SSN),不是 SQL 識別字。代價是 PostgreSQL 允許"Password"與"password"並存於同一張表,規則寫其中一個現在兩個都遮——過度拒絕,與 ADR-0014、0015 同一個方向。 -
BREAKING:以自己的表限定的欄位項改為載入失敗。
{"users": ["users.password"]}從來沒有命中過任何東西。它不能被靜默改寫,因為欄位項裡的點號已經有第二個合法意思——profile.ssn是巢狀路徑,正是第 8 則那個祖先走訪存在的理由。比對第一段是否等於它所在的表名鍵,是唯一不需要猜測就能分辨兩者的判準。含有這種條目的設定會停止載入,直到改掉為止。 -
寫入側改為與讀取側相同的祖先走訪。
checkColumnBlacklistOnWrite是字面includes,而filterColumnsForTables會沿點號向上走,於是規則profile之下profile.ssn能寫不能讀。沒有任何一種「被列入黑名單」的讀法能容許這件事。 -
BREAKING:
blacklist.tables的每個條目都是 glob,對所有引擎皆然。 先前isTableBlacklisted是Set.has,tables: ["secrets*"]在 MongoDB 與 SQL 完全不擋,而使用者依 Redis 那側的文件正是那樣寫的(blacklist-validator.ts裡 ES 的註解已經記下同一件事)。同一個鍵不該在 Redis 是 glob、在 ES 是 glob、在 SQL 是字面。代價是含*的設定在 SQL 連線上開始擋東西;方向只會多拒絕不會少拒絕,字面名稱仍匹配它自己。真的叫report*的表寫成report\*可回到字面比對。 -
BREAKING:
blacklist.columns的每一段都是 glob。pass*現在真的遮password,先前它被compilePatterns拒絕,而被拒絕的結果是整份文件原樣回傳。萬用字元不跨點號:pass*不匹配user.password。唯一保留的特例是結尾整段的*——user.*仍然涵蓋user自己與它底下的一切,照純段落 glob 讀會變成只匹配user.<一段>,那會讓已經部署的規則無聲縮小。 -
BREAKING:無法編譯的欄位規則改為中止操作。 先前
field-masker.ts看到patterns.length === 0就原樣回傳整份文件,而 CLI 照樣印「Some fields may have been redacted」——那句提示是操作者手上唯一的證據,而它是錯的。ADR-0019 Decision 3 與 ADR-0018 Decision 2 同一個理由。a.*.b這種先前被拒的寫法現在合法,留下來會被拒的是真的壞掉的條目(空段落、空字串)。 -
請求側、讀取側、寫入側改用同一個比對器。
reachesProtectedField與checkColumnBlacklistOnWrite現在走path-matcher,所以帶萬用字元的規則在三個端點是同一個意思。仍然逐點號成分比對,不是子字串比對——passwordless不會被password誤傷。 -
insert傳扁平化的欄位路徑,不再是頂層鍵。insert --data '{"user":{"password":"x"}}'在規則user.password之下寫得進去(實測確認),因為Object.keys(data)只看得到user。SQL 的扁平 data 扁平化後結果相同,行為不變。 -
update的寫入欄位收集涵蓋所有 operator。 先前只看$set/$unset,$rename、$inc、$push、$bit等十二個 operator 寫的欄位不進黑名單檢查。實測那些 operator 全被請求側擋下,所以這是補一個構不到的洞——一個只因為另一個控制夠嚴才擋得住的控制不算控制,與 ADR-0015 同一個理由。
Fixed
-
glob 的萬用字元涵蓋換行,與 Redis 一致。
globToRegex把*譯成.*,而 JavaScript 的.在沒有 dotAll 時不匹配\n;Redis 的stringmatchlen逐位元組比對,*吃任何位元組。於是secrets:*保護不到secrets:\nx,而parseRedisCommand在引號內保留換行,這條路是可達的;同一組 regex 也驅動sampleKeyNames,所以dbcli list也照樣顯示那個 key。$不需要配套改動:JavaScript 把它錨在輸入結尾(不像 Perl 允許結尾換行),所以字面 pattern 仍然不匹配尾端多一個換行的 key——那也正是 Redis 的答案。同一個函式也用於 Elasticsearch 的 index 運算式,那裡 index 名不含換行,因此不受影響。 -
設定裡的前後空白與外層引號會被去掉。
[" password "]、["\"password\""]、["`password`"]、鍵" users "全部靜默無效。ES 那側的es-index-target.ts早就 trim 兼解引號,SQL 側沒有。 -
表規則同時以完整名稱與最後一段查找。
{"public.users": …}對SELECT * FROM users不生效,反向也一樣——extractTableReferences會保留限定名稱的完整字串,所以每個鍵只對它被寫成的那一種拼法生效。這是查找的改動而非解析時的猜測:沒有任何地方決定操作者指的是哪一種拼法,同一張表的兩種拼法解析到同一份規則。 -
$rename的風險說明不再宣稱它不外洩。dml-plan.ts的 RENAME tier 訊息寫著field rename does not exfiltrate data,那是錯的:改名之後受保護欄位的值躺在讀取遮罩不認得的名字底下。程式碼裡的斷言本身要當成待驗證的宣稱。 -
glob 比對改為線性時間,不再走 regex。
globToRegex把每個*譯成.*,而多個.*對不匹配的名稱會災難性回溯:'a' + '*'.repeat(50) + 'b'比對一個 300 字元的字串,跑三分鐘沒有回來(2026-08-31 實測),改用globMatches之後同一則 0.23ms。每一次黑名單判定都走這條路——Redis key 規則、Elasticsearch index 運算式,而在本輪之後還加上所有引擎的欄位規則與表名。設定不必有惡意就踩得到(*_*_*_*是很自然的寫法),而可達的輸入包含來自資料庫而非操作者的 Redis key 名稱。這個缺陷比本輪更早,本輪把它的影響面從兩條路徑擴大到全部,所以在這裡一起修掉。globToRegex保留給真的需要RegExp物件的呼叫端,兩者以小字母表的窮舉比對釘住答案一致——那個窮舉當場就抓到第一版的一個真 bug:*之後的跳脫字元沒有清掉尾綴萬用字元旗標,於是*\a會匹配ab。