Repository navigation
v7.0.0 - 一條規則擋得住寫、擋不住讀,差別只在大小寫
ADR-0019 在自己的 Consequences 裡寫下這一則:一份設定的大小寫折疊仍然是三套規則。
這一版把它收成一套,並在收的過程中發現同一份設定還有一條完全繞過它的路——規則從來
沒有抵達 dbcli q 與 dbcli report 用的那個 adapter。設計決策記在
docs/adr/0020-one-fold-rule-for-every-blacklist-comparison.md 與
docs/adr/0021-connection-only-adapters-say-without-rules.md。
2026-09-01 直接呼叫四個比對器量到的起點——除了最後一列,每一列都是「寫入被拒、
讀取原文回傳」的設定,而操作者確認規則有效的方式,通常就是看寫入被擋下來:
| 規則 | 欄位 | SQL 讀 | MongoDB 讀 | 請求側 | 寫入 |
|---|---|---|---|---|---|
Password |
password |
masked | returned | allowed | refused |
password |
PASSWORD |
masked | returned | allowed | refused |
PASS* |
password |
masked | returned | allowed | refused |
profile.ssn |
profile.SSN |
returned | returned | allowed | refused |
profile.ss* |
profile.SS_num |
returned | returned | allowed | refused |
pass* |
password |
masked | masked | refused | refused |
Security
-
Redis 的
dbcli q與dbcli report不再繞過 blacklist。 自 saved query
功能加入後,這兩條路徑用的是只有 connection、沒有規則的 generic adapter,命令層
同時對 Redis 產生空 target:q @snippet讀得出受保護 key 的明文,內建的
@diag/redis-key-stats會把受保護 key 名稱寫進持久報告。上面那張表量的是折疊,
這一條連折疊都到不了——規則根本不在那個 adapter 上。generic factory 現在預設接
完整 config,Redis target 重用既有的 command metadata;確實只測連線的呼叫端
(init的設定測試、credential 輪替的密碼驗證)必須明寫
createAdapterWithoutRules。inspect目前不列舉 Redis 物件,一併改走安全預設是
為了之後加上時不會重開同一個洞。設計取捨見 ADR-0021。 -
開發相依的三筆勸告釘到範圍外。
autoprefixer > browserslist(兩筆 high)與
tailwindcss > postcss-nested > postcss-selector-parser(一筆 low)都只在
devDependencies 的傳遞相依裡,上游的版本範圍還沒放寬,改用既有的overrides釘住。
不影響安裝 dbcli 的人拿到的相依樹。
Changed
-
BREAKING:規則與欄位名的比對整條路徑都不分大小寫。 先前只有第一段折疊,
而且只在 SQL 與 Elasticsearch 的讀取側折——MongoDB 的讀取遮罩與請求側完全不折,
於是規則Password之下$project: {"leak": "$password"}被放行,明文原樣回傳。
那正是 ADR-0018 Decision 1 要關掉的別名繞道,只是換了一個引擎抵達:欄位名的
大小寫由請求方選定,設定端再怎麼驗證也擋不住,因為那條規則本身就是對的。
代價與 ADR-0014、0015、0018 選的方向一致:PostgreSQL 允許"Password"與
"password"並存、MongoDB 允許一份文件同時有profile.SSN與profile.ssn,
規則寫其中一個現在兩個都遮。過度拒絕可以用更精確的規則收回來,反過來——受保護的
欄位因為請求換個大小寫就回傳——沒有任何東西會告訴你它發生過。 -
BREAKING:折疊涵蓋第一段之後的段落。 ADR-0018 刻意只折第一段,理由是後面的
段落是巢狀物件的鍵、大小寫有意義。那個理由對資料是成立的,對系統不成立:寫入側
本來就整條路徑小寫,所以「讀取側保持大小寫敏感」不是一個立場而是一個意外,而它
在每一次讀取上都往 fail-open 的方向解決。PostgreSQL 16 的jsonb欄位實測,
規則profile.ss_num與PROFILE.SS_num先前都原文回傳,現在都省略。 -
--fields維持精確比對,不受這次改動影響。 黑名單規則比對的是請求方選定
大小寫的名稱;--fields的路徑是操作者指名眼前這份文件的鍵,兩個只差大小寫的
鍵是他們可能真的要分開處理的兩個欄位。
Fixed
-
一套折疊規則沒有走到巢狀下潛與規則挑選。 折疊改成
foldCase之後(ς→σ、
İ→i),剩下的裸toLowerCase就不再是同一套:規則profile.ΑΣ在巢狀下潛時
回傳它指名的鍵、遮掉它沒指名的;設定在ασ底下的規則對 collectionΑΣ查不到,
而查不到的意思是「沒有規則」——明文原樣回傳;Elasticsearch 條目ΑΣ構得到 index
ασ、構不到它的 backing index.ds-ασ-2026,擋不住 backing index 就是擋不住讀取。
dbcli check另有一個獨立的形狀:它從BlacklistManager的私有 state 撈 Set 自己
比對,那份 Set 只有字面條目,萬用字元規則對這條路徑等於不存在(ADR-0019 Decision 4),
現在問isTableBlacklisted。dbcli doctor的未保護欄位報告先前完全不折,規則換個
大小寫寫就漏報。blacklist-validator的 dedupe 與三個dml-plan、
query-risk-analyzer的顧問輸出不會放行(強制執行仍在BlacklistManager),一併
收攏——一套折疊規則就是一套。 -
折疊本身仍然是兩套規則,差別只在一個希臘字母。
foldFieldPath折整串、
globMatches折每個字元,而String.prototype.toLowerCase的Final_Sigma
是 Unicode 預設大小寫轉換裡唯一看上下文的規則:Σ前面是字母、後面不是字母時
折成ς,其餘位置折成σ。於是規則ΑΣ*對它自己指名的欄位ΑΣ_num回答
false,明文原樣回傳。ADR-0020 要的是一套折疊規則,這是它還沒真的成立的地方
——寫入側從來不受影響,分歧只在讀取側往 fail-open 解決。折疊改由
foldCase(src/utils/case-fold.ts)一個函式回答,並把ς併回
σ讓它不再看上下文;globMatches的caseInsensitive改成整串折文字一次。
折疊同時必須保長:?與每個*-free 區段都是固定寬度的窗口,而
toLowerCase有唯一一個會變長的字元İ(U+0130 →i+ U+0307),所以它映成
i——那也是土耳其文給它的大小寫關係。掃過 U+0000–U+2FFFF 驗證:0 個碼位改變
長度、0 處上下文分歧、冪等;三項都寫成測試留在
tests/unit/core/contiguous-section-matcher.test.ts。兩側折的粒度也要一樣:pattern 是逐碼元讀的,所以 astral 字元進到 token
時是半個代理對,而foldCase對半個代理對是恆等函式——文字那一側卻真的把碼點
映成了小寫。折疊之前兩側一起不折而碰巧相等;折了一側才露出來。規則𐐀於是
比不上欄位𐐀,連自己都命不中,涵蓋每一種有大小寫的 astral 文字(Adlam 在
內),而maskMongoRows把所有欄位規則都送進這個比對器,所以純字面的 MongoDB
規則同樣受影響。parser 改為逐碼點前進,token 仍是一個碼元。字元類則改回比對
未折疊的名稱——它的大小寫由 regex 自己的i旗標回答(Decision 2),折過
再比在 BMP 上不多做任何事,卻會換掉 astral 字元的低位代理。兩個既有限制不變,也不是這一則造成的:
?吃一個碼元,所以它從來就吃不下一個
完整的 astral 字元;字元類編譯時沒有u旗標,所以 astral 範圍是碼元的集合而
不是碼點的集合。İ映成i有一項不是過度拒絕的代價,而且是被迫的:字面規則İ在 main
上構得到拼成i+ U+0307 的欄位(toLowerCase把兩者都送到那個序列),現在
雙向都構不到。對照 main 量到的:規則 欄位 main 這個分支 İi+ U+0307受保護 原文回傳 i+ U+0307İ受保護 原文回傳 İi、I原文回傳 受保護 İİ受保護 受保護 沒有第三個選項:保長要求
foldCase(x).length === x.length,而İ是一個碼元、
i+ U+0307 是兩個,任何保長的折疊都不可能把兩者映成同一個字串。另一邊是上面
那個?對不齊——它在isTableBlacklisted上對每一條用到?的規則都是
fail-open,不是一對字元——所以這個 trade 的方向和這份記錄其餘部分一致。黑名單
在任何地方都不做 Unicode 正規化,兩種拼法就是兩個名字;需要兩種拼法的部署就
寫兩條規則。失去的是字面那條路徑,不是 glob:規則İ*在 main 上構得到İd,
現在也構得到。 -
表格名稱是這份設定的第二套折疊。
BlacklistManager用裸的.toLowerCase()
折表格名,欄位卻走foldFieldPath,於是同一個isTableBlacklisted裡精確比對
那一半與wildcardTables那一半折得不一樣——一條規則對Σ與İ有兩個答案,
取決於它有沒有帶 metachar。兩半現在都是foldFieldPath。這會把上面那則İ的
代價一併帶到表格與 collection 名稱上,而那正是重點:一條路徑只因為沒拿到新的
折疊而保住舊答案,那是意外不是保護。另有一則記錄而非修正:
globMatches('İ','i')為真而globMatches('[İ]','i')
為假——字元類的大小寫由 regex 的i旗標回答(Decision 2 不許改寫 pattern 的
文字)。兩者都仍然命中İ自己,所以不是 fail-open,但那是同一個比對器裡的
第二個答案。其餘代價與 ADR-0020 選的方向一致——一份文件同時有
ς與σ結尾的兩個欄位、
或同時有İd與id時,指名其一的規則兩個都遮。 -
連續區段比對是 O(depth³),而深度由回應決定不由設定決定。
namesProtectedField、
redactFields與 MongoDB 的findProtectedFieldReference都對每個起點列舉每個
終點、每個候選再slice().join('.')組成字串。一條規則的點分元件數是固定的,
所以那些區段裡有 O(n²) 個從一開始就不可能命中。改為依規則寬度取窗口,並讓呼叫端
把已經走過的元件陣列直接傳進去,而不是join完再split回來。三個呼叫端現在
共用path-matcher.ts的reachesProtectedSegments;規則集的折疊與編譯也共用
一份依規則集記憶的結果,findProtectedFieldReference先前每次呼叫都重做一遍。同一台機器、
git worktree建的 main 對照組、同一輪、五次取中位數:shape main 這個分支 redactFields1000 hits x 20 fields79ms 25ms redactFields5000 hits x 20 fields387ms 119ms namesProtectedFielddepth=5 x1000034ms 14ms namesProtectedFielddepth=40 x100006175ms 103ms findProtectedFieldReferencedepth=40 x100006206ms 90ms 深度 5→40 是 8 倍,成本先前是 182 倍,現在是 7.4 倍。
-
MongoDB 的請求檢查把帶 metachar 的條目同時放進字面集合。 那正是 ADR-0020 的
falsification 段落對 ES shell 點名的形狀,只是在findProtectedFieldReference
上沒被檢查到:back\slash靠字串相等命中自己,Back\Slash什麼都命不中。
兩條路徑現在共用同一個contiguousRulesFor,含 metachar 的條目只當 pattern。
淨結果是這類規則在 MongoDB 上不再保護任何東西:讀取遮罩(field-masker.ts)
本來就只走compilePatterns,所以請求端那層字面拒絕擋不住find(),是一層看
得見卻不成立的保護。要保護一個名字裡真的有[或\的欄位,規則要寫成
跳脫形式(col\[1\])。 -
帶萬用字元的點分規則碰不到巢狀鍵。 SQL 與 Elasticsearch 的讀取路徑上,
profile.SS_num遮得掉 PostgreSQLjsonb欄位裡的那個鍵,profile.ss*原文回傳
——字面規則會下潛巢狀記錄,萬用字元規則只比對頂層鍵名,而jsonb的巢狀鍵在被
走訪之前不是任何地方的名字。MongoDB 的遮罩兩種都認得,所以這是同一個鍵在兩個引擎
上有兩種意思。profile.*先前擋得住是因為尾綴形式匹配頂層的profile自己,與
巢狀無關。只有「點分且帶萬用字元」的規則、且結果裡真的有巢狀記錄時才會列舉路徑,
深度以最長的規則為上限。omittedColumns對這類規則回報的是規則原文而非鍵名——
一條萬用字元規則可以在每一列命中不同的鍵,逐鍵回報會讓那份清單隨結果集成長。
--fields的過濾改用已編譯的規則回答(reachesOmitted),所以
--fields profile.SS_num在profile.ss*之下與字面規則一樣整個欄位消失,
不再是留著欄位但值為null。 -
MongoDB 的遮罩碰不到巢狀陣列裡的文件。
{list: [[{ssn: …}]]}在規則list.ss*
之下原文回傳,因為maskValue只在陣列元素本身不是陣列時才遞迴。SQL 側遮得掉,
所以這是同一份設定的兩個答案,只是漏的那一邊換了。陣列在任何深度都是容器,不是
路徑的一段。 -
BREAKING:Elasticsearch shell 遇到讀不懂的欄位規則改為拒絕請求。 先前
pass[word、a.**這類條目在 ES shell 這條路上被靜默當成字面名稱,於是保護零個
欄位;現在它們會讓每一個dbcli es請求失敗,直到設定改掉為止。同一則的另一半是
反斜線:back\slash先前在 ES shell 上遮的是回應鍵back\slash(字面),現在
\是跳脫字元,這條規則讀成backslash——與其他引擎一致,但既有設定的意思變了。拒絕發生在收集
規則的當下,早於送出——擋在回程等於 cluster 已經執行過那個請求。與
compileGlobRules、maskMongoRows同一個理由(ADR-0019 Decision 3)。 -
Elasticsearch shell 是同一份設定的第五個比對器。
namesProtectedField與
redactFields只做精確字串比對,也完全不編譯 glob,於是columns: {users: ["Password"]}
(或["pass*"]、["profile.ssn"])之下,dbcli es把dbcli query --index遮掉的
明文原樣送回來。兩者現在走同一個折疊函式與同一個compilePatterns/matchAny;
無法解析的規則改為在收集規則時就拒絕,而不是在回應的第一個鍵上——擋在回程等於
cluster 已經執行過那個請求了。 -
blacklist.tables的 glob 掃描讀的是小寫化過的條目。tables: ["[A-z]ecrets"]
認不得_ecrets:字元類別在儲存時被折小寫,Z與a之間六個 ASCII 字元離開了
集合。改為保留原樣條目建 glob 清單,折疊留在比對。同一型的第三處在
matchesIndexGlob,ES 的 index 運算式比對也是拿黑名單條目本身當 pattern。 -
含跳脫字元的規則在 ES shell 上曾因大小寫給出相反的答案。 含 metachar 的條目
同時留在字面集合裡,於是back\slash靠字串相等命中自己(原文剛好已是小寫),
而Back\Slash兩邊都接不到——glob 語意把\S讀成字面S,字面比對又比不過
折疊後的名稱。含 metachar 的條目現在只當 pattern。 -
一條規則折到多個回傳欄位時,只有一個被列進
omittedColumns。 結果同時有
Password與password時兩欄都被遮,但通知只列一個,而呼叫端用精確名稱過濾
表頭,於是另一欄以空白欄位回來,看起來像 NULL 而不是「被遮蔽」。那份通知是操作者
判斷黑名單有沒有生效的唯一證據。 -
isColumnBlacklisted完全不看萬用字元規則。 它回答的是compactVisibleSchema
與dbcli schema給 agent 看的那份摘要,於是pass*之下摘要照列password,而讀取
遮罩會把它遮掉——同一條規則,兩個答案。現在走同一個compilePatterns/matchAny。 -
ES shell 的規則不走設定載入器的正規化。
'"Token"'在其他引擎上有效,在
dbcli es上是死規則,因為這裡只做trim()。改為共用normalizeBlacklistEntry。 -
isColumnBlacklisted折了被問的欄位名,沒折規則。 規則Password對它自己
指名的欄位回答false——比對的兩側折得不一樣,正是 ADR-0018 記下的那個失敗形狀。
這條路徑餵的是context的 schema 摘要。 -
glob 規則在比對時折疊,而不是把 pattern 的文字改小寫。 把
[A-z]小寫成
[a-z]會讓它代表的字元集合悄悄變小(Z與a之間那六個 ASCII 字元離開了
字元類別),規則保護的東西會比它寫的少。globMatches新增caseInsensitive
選項,在字元比對的地方折,pattern 的文字一個字都不動。