Skip to content

v7.0.0 - 一條規則擋得住寫、擋不住讀,差別只在大小寫

Choose a tag to compare

@CarlLee1983 CarlLee1983 released this 02 Sep 01:20
· 291 commits to main since this release
eed6725

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 這個分支
    redactFields 1000 hits x 20 fields 79ms 25ms
    redactFields 5000 hits x 20 fields 387ms 119ms
    namesProtectedField depth=5 x10000 34ms 14ms
    namesProtectedField depth=40 x10000 6175ms 103ms
    findProtectedFieldReference depth=40 x10000 6206ms 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 遮得掉 PostgreSQL jsonb 欄位裡的那個鍵,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 的文字一個字都不動。