@carllee1983/dbcli 4.0.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +305 -0
- package/dist/cli-runtime.mjs +1826 -1110
- package/dist/cli.mjs +4 -2
- package/dist/core.d.ts +43 -8
- package/dist/core.mjs +760 -191
- package/gemini-extension.json +1 -1
- package/package.json +4 -2
- package/plugins/dbcli-agent/.codex-plugin/plugin.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,311 @@ All notable changes to dbcli are documented here.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [7.0.0] - 2026-09-02 - 一條規則擋得住寫、擋不住讀,差別只在大小寫
|
|
9
|
+
|
|
10
|
+
ADR-0019 在自己的 Consequences 裡寫下這一則:一份設定的大小寫折疊仍然是三套規則。
|
|
11
|
+
這一版把它收成一套,並在收的過程中發現同一份設定還有一條完全繞過它的路——規則從來
|
|
12
|
+
沒有抵達 `dbcli q` 與 `dbcli report` 用的那個 adapter。設計決策記在
|
|
13
|
+
`docs/adr/0020-one-fold-rule-for-every-blacklist-comparison.md` 與
|
|
14
|
+
`docs/adr/0021-connection-only-adapters-say-without-rules.md`。
|
|
15
|
+
|
|
16
|
+
2026-09-01 直接呼叫四個比對器量到的起點——除了最後一列,每一列都是「寫入被拒、
|
|
17
|
+
讀取原文回傳」的設定,而操作者確認規則有效的方式,通常就是看寫入被擋下來:
|
|
18
|
+
|
|
19
|
+
| 規則 | 欄位 | SQL 讀 | MongoDB 讀 | 請求側 | 寫入 |
|
|
20
|
+
| --- | --- | --- | --- | --- | --- |
|
|
21
|
+
| `Password` | `password` | masked | **returned** | **allowed** | refused |
|
|
22
|
+
| `password` | `PASSWORD` | masked | **returned** | **allowed** | refused |
|
|
23
|
+
| `PASS*` | `password` | masked | **returned** | **allowed** | refused |
|
|
24
|
+
| `profile.ssn` | `profile.SSN` | **returned** | **returned** | **allowed** | refused |
|
|
25
|
+
| `profile.ss*` | `profile.SS_num` | **returned** | **returned** | **allowed** | refused |
|
|
26
|
+
| `pass*` | `password` | masked | masked | refused | refused |
|
|
27
|
+
|
|
28
|
+
### Security
|
|
29
|
+
|
|
30
|
+
- **Redis 的 `dbcli q` 與 `dbcli report` 不再繞過 blacklist。** 自 saved query
|
|
31
|
+
功能加入後,這兩條路徑用的是只有 connection、沒有規則的 generic adapter,命令層
|
|
32
|
+
同時對 Redis 產生空 target:`q @snippet` 讀得出受保護 key 的明文,內建的
|
|
33
|
+
`@diag/redis-key-stats` 會把受保護 key 名稱寫進持久報告。上面那張表量的是折疊,
|
|
34
|
+
這一條連折疊都到不了——規則根本不在那個 adapter 上。generic factory 現在預設接
|
|
35
|
+
完整 config,Redis target 重用既有的 command metadata;確實只測連線的呼叫端
|
|
36
|
+
(`init` 的設定測試、credential 輪替的密碼驗證)必須明寫
|
|
37
|
+
`createAdapterWithoutRules`。`inspect` 目前不列舉 Redis 物件,一併改走安全預設是
|
|
38
|
+
為了之後加上時不會重開同一個洞。設計取捨見 ADR-0021。
|
|
39
|
+
|
|
40
|
+
- **開發相依的三筆勸告釘到範圍外。** `autoprefixer > browserslist`(兩筆 high)與
|
|
41
|
+
`tailwindcss > postcss-nested > postcss-selector-parser`(一筆 low)都只在
|
|
42
|
+
devDependencies 的傳遞相依裡,上游的版本範圍還沒放寬,改用既有的 `overrides` 釘住。
|
|
43
|
+
不影響安裝 dbcli 的人拿到的相依樹。
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- **BREAKING:規則與欄位名的比對整條路徑都不分大小寫。** 先前只有第一段折疊,
|
|
48
|
+
而且只在 SQL 與 Elasticsearch 的讀取側折——MongoDB 的讀取遮罩與請求側完全不折,
|
|
49
|
+
於是規則 `Password` 之下 `$project: {"leak": "$password"}` 被放行,明文原樣回傳。
|
|
50
|
+
那正是 ADR-0018 Decision 1 要關掉的別名繞道,只是換了一個引擎抵達:欄位名的
|
|
51
|
+
大小寫由請求方選定,設定端再怎麼驗證也擋不住,因為那條規則本身就是對的。
|
|
52
|
+
代價與 ADR-0014、0015、0018 選的方向一致:PostgreSQL 允許 `"Password"` 與
|
|
53
|
+
`"password"` 並存、MongoDB 允許一份文件同時有 `profile.SSN` 與 `profile.ssn`,
|
|
54
|
+
規則寫其中一個現在兩個都遮。過度拒絕可以用更精確的規則收回來,反過來——受保護的
|
|
55
|
+
欄位因為請求換個大小寫就回傳——沒有任何東西會告訴你它發生過。
|
|
56
|
+
|
|
57
|
+
- **BREAKING:折疊涵蓋第一段之後的段落。** ADR-0018 刻意只折第一段,理由是後面的
|
|
58
|
+
段落是巢狀物件的鍵、大小寫有意義。那個理由對資料是成立的,對系統不成立:寫入側
|
|
59
|
+
本來就整條路徑小寫,所以「讀取側保持大小寫敏感」不是一個立場而是一個意外,而它
|
|
60
|
+
在每一次讀取上都往 fail-open 的方向解決。PostgreSQL 16 的 `jsonb` 欄位實測,
|
|
61
|
+
規則 `profile.ss_num` 與 `PROFILE.SS_num` 先前都原文回傳,現在都省略。
|
|
62
|
+
|
|
63
|
+
- **`--fields` 維持精確比對,不受這次改動影響。** 黑名單規則比對的是請求方選定
|
|
64
|
+
大小寫的名稱;`--fields` 的路徑是操作者指名眼前這份文件的鍵,兩個只差大小寫的
|
|
65
|
+
鍵是他們可能真的要分開處理的兩個欄位。
|
|
66
|
+
|
|
67
|
+
### Fixed
|
|
68
|
+
|
|
69
|
+
- **一套折疊規則沒有走到巢狀下潛與規則挑選。** 折疊改成 `foldCase` 之後(`ς`→`σ`、
|
|
70
|
+
`İ`→`i`),剩下的裸 `toLowerCase` 就不再是同一套:規則 `profile.ΑΣ` 在巢狀下潛時
|
|
71
|
+
回傳它指名的鍵、遮掉它沒指名的;設定在 `ασ` 底下的規則對 collection `ΑΣ` 查不到,
|
|
72
|
+
而查不到的意思是「沒有規則」——明文原樣回傳;Elasticsearch 條目 `ΑΣ` 構得到 index
|
|
73
|
+
`ασ`、構不到它的 backing index `.ds-ασ-2026`,擋不住 backing index 就是擋不住讀取。
|
|
74
|
+
`dbcli check` 另有一個獨立的形狀:它從 `BlacklistManager` 的私有 state 撈 Set 自己
|
|
75
|
+
比對,那份 Set 只有字面條目,萬用字元規則對這條路徑等於不存在(ADR-0019 Decision 4),
|
|
76
|
+
現在問 `isTableBlacklisted`。`dbcli doctor` 的未保護欄位報告先前完全不折,規則換個
|
|
77
|
+
大小寫寫就漏報。`blacklist-validator` 的 dedupe 與三個 `dml-plan`、
|
|
78
|
+
`query-risk-analyzer` 的顧問輸出不會放行(強制執行仍在 `BlacklistManager`),一併
|
|
79
|
+
收攏——一套折疊規則就是一套。
|
|
80
|
+
|
|
81
|
+
- **折疊本身仍然是兩套規則,差別只在一個希臘字母。** `foldFieldPath` 折整串、
|
|
82
|
+
`globMatches` 折每個字元,而 `String.prototype.toLowerCase` 的 `Final_Sigma`
|
|
83
|
+
是 Unicode 預設大小寫轉換裡唯一看上下文的規則:`Σ` 前面是字母、後面不是字母時
|
|
84
|
+
折成 `ς`,其餘位置折成 `σ`。於是規則 `ΑΣ*` 對它自己指名的欄位 `ΑΣ_num` 回答
|
|
85
|
+
`false`,明文原樣回傳。ADR-0020 要的是一套折疊規則,這是它還沒真的成立的地方
|
|
86
|
+
——寫入側從來不受影響,分歧只在讀取側往 fail-open 解決。
|
|
87
|
+
|
|
88
|
+
折疊改由 `foldCase`(`src/utils/case-fold.ts`)一個函式回答,並把 `ς` 併回
|
|
89
|
+
`σ` 讓它不再看上下文;`globMatches` 的 `caseInsensitive` 改成整串折文字一次。
|
|
90
|
+
折疊同時必須**保長**:`?` 與每個 `*`-free 區段都是固定寬度的窗口,而
|
|
91
|
+
`toLowerCase` 有唯一一個會變長的字元 `İ`(U+0130 → `i` + U+0307),所以它映成
|
|
92
|
+
`i`——那也是土耳其文給它的大小寫關係。掃過 U+0000–U+2FFFF 驗證:0 個碼位改變
|
|
93
|
+
長度、0 處上下文分歧、冪等;三項都寫成測試留在
|
|
94
|
+
`tests/unit/core/contiguous-section-matcher.test.ts`。
|
|
95
|
+
|
|
96
|
+
兩側折的**粒度**也要一樣:pattern 是逐碼元讀的,所以 astral 字元進到 token
|
|
97
|
+
時是半個代理對,而 `foldCase` 對半個代理對是恆等函式——文字那一側卻真的把碼點
|
|
98
|
+
映成了小寫。折疊之前兩側一起不折而碰巧相等;折了一側才露出來。規則 `𐐀` 於是
|
|
99
|
+
比不上欄位 `𐐀`,連自己都命不中,涵蓋每一種有大小寫的 astral 文字(Adlam 在
|
|
100
|
+
內),而 `maskMongoRows` 把所有欄位規則都送進這個比對器,所以純字面的 MongoDB
|
|
101
|
+
規則同樣受影響。parser 改為逐碼點前進,token 仍是一個碼元。字元類則改回比對
|
|
102
|
+
**未折疊**的名稱——它的大小寫由 regex 自己的 `i` 旗標回答(Decision 2),折過
|
|
103
|
+
再比在 BMP 上不多做任何事,卻會換掉 astral 字元的低位代理。
|
|
104
|
+
|
|
105
|
+
兩個既有限制不變,也不是這一則造成的:`?` 吃一個碼元,所以它從來就吃不下一個
|
|
106
|
+
完整的 astral 字元;字元類編譯時沒有 `u` 旗標,所以 astral 範圍是碼元的集合而
|
|
107
|
+
不是碼點的集合。
|
|
108
|
+
|
|
109
|
+
`İ` 映成 `i` 有一項**不是**過度拒絕的代價,而且是被迫的:字面規則 `İ` 在 main
|
|
110
|
+
上構得到拼成 `i` + U+0307 的欄位(`toLowerCase` 把兩者都送到那個序列),現在
|
|
111
|
+
雙向都構不到。對照 main 量到的:
|
|
112
|
+
|
|
113
|
+
| 規則 | 欄位 | main | 這個分支 |
|
|
114
|
+
| --- | --- | --- | --- |
|
|
115
|
+
| `İ` | `i` + U+0307 | 受保護 | **原文回傳** |
|
|
116
|
+
| `i` + U+0307 | `İ` | 受保護 | **原文回傳** |
|
|
117
|
+
| `İ` | `i`、`I` | 原文回傳 | 受保護 |
|
|
118
|
+
| `İ` | `İ` | 受保護 | 受保護 |
|
|
119
|
+
|
|
120
|
+
沒有第三個選項:保長要求 `foldCase(x).length === x.length`,而 `İ` 是一個碼元、
|
|
121
|
+
`i` + U+0307 是兩個,任何保長的折疊都不可能把兩者映成同一個字串。另一邊是上面
|
|
122
|
+
那個 `?` 對不齊——它在 `isTableBlacklisted` 上對**每一條**用到 `?` 的規則都是
|
|
123
|
+
fail-open,不是一對字元——所以這個 trade 的方向和這份記錄其餘部分一致。黑名單
|
|
124
|
+
在任何地方都不做 Unicode 正規化,兩種拼法就是兩個名字;需要兩種拼法的部署就
|
|
125
|
+
寫兩條規則。失去的是字面那條路徑,不是 glob:規則 `İ*` 在 main 上構得到 `İd`,
|
|
126
|
+
現在也構得到。
|
|
127
|
+
|
|
128
|
+
- **表格名稱是這份設定的第二套折疊。** `BlacklistManager` 用裸的 `.toLowerCase()`
|
|
129
|
+
折表格名,欄位卻走 `foldFieldPath`,於是同一個 `isTableBlacklisted` 裡精確比對
|
|
130
|
+
那一半與 `wildcardTables` 那一半折得不一樣——一條規則對 `Σ` 與 `İ` 有兩個答案,
|
|
131
|
+
取決於它有沒有帶 metachar。兩半現在都是 `foldFieldPath`。這會把上面那則 `İ` 的
|
|
132
|
+
代價一併帶到表格與 collection 名稱上,而那正是重點:一條路徑只因為沒拿到新的
|
|
133
|
+
折疊而保住舊答案,那是意外不是保護。
|
|
134
|
+
|
|
135
|
+
另有一則記錄而非修正:`globMatches('İ','i')` 為真而 `globMatches('[İ]','i')`
|
|
136
|
+
為假——字元類的大小寫由 regex 的 `i` 旗標回答(Decision 2 不許改寫 pattern 的
|
|
137
|
+
文字)。兩者都仍然命中 `İ` 自己,所以不是 fail-open,但那是同一個比對器裡的
|
|
138
|
+
第二個答案。
|
|
139
|
+
|
|
140
|
+
其餘代價與 ADR-0020 選的方向一致——一份文件同時有 `ς` 與 `σ` 結尾的兩個欄位、
|
|
141
|
+
或同時有 `İd` 與 `id` 時,指名其一的規則兩個都遮。
|
|
142
|
+
|
|
143
|
+
- **連續區段比對是 O(depth³),而深度由回應決定不由設定決定。** `namesProtectedField`、
|
|
144
|
+
`redactFields` 與 MongoDB 的 `findProtectedFieldReference` 都對每個起點列舉每個
|
|
145
|
+
終點、每個候選再 `slice().join('.')` 組成字串。一條規則的點分元件數是固定的,
|
|
146
|
+
所以那些區段裡有 O(n²) 個從一開始就不可能命中。改為依規則寬度取窗口,並讓呼叫端
|
|
147
|
+
把已經走過的元件陣列直接傳進去,而不是 `join` 完再 `split` 回來。三個呼叫端現在
|
|
148
|
+
共用 `path-matcher.ts` 的 `reachesProtectedSegments`;規則集的折疊與編譯也共用
|
|
149
|
+
一份依規則集記憶的結果,`findProtectedFieldReference` 先前每次呼叫都重做一遍。
|
|
150
|
+
|
|
151
|
+
同一台機器、`git worktree` 建的 main 對照組、同一輪、五次取中位數:
|
|
152
|
+
|
|
153
|
+
| shape | main | 這個分支 |
|
|
154
|
+
| --- | --- | --- |
|
|
155
|
+
| `redactFields` 1000 hits x 20 fields | 79ms | 25ms |
|
|
156
|
+
| `redactFields` 5000 hits x 20 fields | 387ms | 119ms |
|
|
157
|
+
| `namesProtectedField` depth=5 x10000 | 34ms | 14ms |
|
|
158
|
+
| `namesProtectedField` depth=40 x10000 | 6175ms | 103ms |
|
|
159
|
+
| `findProtectedFieldReference` depth=40 x10000 | 6206ms | 90ms |
|
|
160
|
+
|
|
161
|
+
深度 5→40 是 8 倍,成本先前是 182 倍,現在是 7.4 倍。
|
|
162
|
+
|
|
163
|
+
- **MongoDB 的請求檢查把帶 metachar 的條目同時放進字面集合。** 那正是 ADR-0020 的
|
|
164
|
+
falsification 段落對 ES shell 點名的形狀,只是在 `findProtectedFieldReference`
|
|
165
|
+
上沒被檢查到:`back\slash` 靠字串相等命中自己,`Back\Slash` 什麼都命不中。
|
|
166
|
+
兩條路徑現在共用同一個 `contiguousRulesFor`,含 metachar 的條目只當 pattern。
|
|
167
|
+
淨結果是這類規則在 MongoDB 上不再保護任何東西:讀取遮罩(`field-masker.ts`)
|
|
168
|
+
本來就只走 `compilePatterns`,所以請求端那層字面拒絕擋不住 `find()`,是一層看
|
|
169
|
+
得見卻不成立的保護。要保護一個名字裡真的有 `[` 或 `\` 的欄位,規則要寫成
|
|
170
|
+
跳脫形式(`col\[1\]`)。
|
|
171
|
+
|
|
172
|
+
- **帶萬用字元的點分規則碰不到巢狀鍵。** SQL 與 Elasticsearch 的讀取路徑上,
|
|
173
|
+
`profile.SS_num` 遮得掉 PostgreSQL `jsonb` 欄位裡的那個鍵,`profile.ss*` 原文回傳
|
|
174
|
+
——字面規則會下潛巢狀記錄,萬用字元規則只比對頂層鍵名,而 `jsonb` 的巢狀鍵在被
|
|
175
|
+
走訪之前不是任何地方的名字。MongoDB 的遮罩兩種都認得,所以這是同一個鍵在兩個引擎
|
|
176
|
+
上有兩種意思。`profile.*` 先前擋得住是因為尾綴形式匹配頂層的 `profile` 自己,與
|
|
177
|
+
巢狀無關。只有「點分且帶萬用字元」的規則、且結果裡真的有巢狀記錄時才會列舉路徑,
|
|
178
|
+
深度以最長的規則為上限。`omittedColumns` 對這類規則回報的是規則原文而非鍵名——
|
|
179
|
+
一條萬用字元規則可以在每一列命中不同的鍵,逐鍵回報會讓那份清單隨結果集成長。
|
|
180
|
+
`--fields` 的過濾改用已編譯的規則回答(`reachesOmitted`),所以
|
|
181
|
+
`--fields profile.SS_num` 在 `profile.ss*` 之下與字面規則一樣整個欄位消失,
|
|
182
|
+
不再是留著欄位但值為 `null`。
|
|
183
|
+
|
|
184
|
+
- **MongoDB 的遮罩碰不到巢狀陣列裡的文件。** `{list: [[{ssn: …}]]}` 在規則 `list.ss*`
|
|
185
|
+
之下原文回傳,因為 `maskValue` 只在陣列元素本身不是陣列時才遞迴。SQL 側遮得掉,
|
|
186
|
+
所以這是同一份設定的兩個答案,只是漏的那一邊換了。陣列在任何深度都是容器,不是
|
|
187
|
+
路徑的一段。
|
|
188
|
+
|
|
189
|
+
- **BREAKING:Elasticsearch shell 遇到讀不懂的欄位規則改為拒絕請求。** 先前
|
|
190
|
+
`pass[word`、`a.**` 這類條目在 ES shell 這條路上被靜默當成字面名稱,於是保護零個
|
|
191
|
+
欄位;現在它們會讓每一個 `dbcli es` 請求失敗,直到設定改掉為止。同一則的另一半是
|
|
192
|
+
反斜線:`back\slash` 先前在 ES shell 上遮的是回應鍵 `back\slash`(字面),現在
|
|
193
|
+
`\` 是跳脫字元,這條規則讀成 `backslash`——與其他引擎一致,但既有設定的意思變了。拒絕發生在收集
|
|
194
|
+
規則的當下,早於送出——擋在回程等於 cluster 已經執行過那個請求。與
|
|
195
|
+
`compileGlobRules`、`maskMongoRows` 同一個理由(ADR-0019 Decision 3)。
|
|
196
|
+
|
|
197
|
+
- **Elasticsearch shell 是同一份設定的第五個比對器。** `namesProtectedField` 與
|
|
198
|
+
`redactFields` 只做精確字串比對,也完全不編譯 glob,於是 `columns: {users: ["Password"]}`
|
|
199
|
+
(或 `["pass*"]`、`["profile.ssn"]`)之下,`dbcli es` 把 `dbcli query --index` 遮掉的
|
|
200
|
+
明文原樣送回來。兩者現在走同一個折疊函式與同一個 `compilePatterns` / `matchAny`;
|
|
201
|
+
無法解析的規則改為在收集規則時就拒絕,而不是在回應的第一個鍵上——擋在回程等於
|
|
202
|
+
cluster 已經執行過那個請求了。
|
|
203
|
+
|
|
204
|
+
- **`blacklist.tables` 的 glob 掃描讀的是小寫化過的條目。** `tables: ["[A-z]ecrets"]`
|
|
205
|
+
認不得 `_ecrets`:字元類別在儲存時被折小寫,`Z` 與 `a` 之間六個 ASCII 字元離開了
|
|
206
|
+
集合。改為保留原樣條目建 glob 清單,折疊留在比對。同一型的第三處在
|
|
207
|
+
`matchesIndexGlob`,ES 的 index 運算式比對也是拿黑名單條目本身當 pattern。
|
|
208
|
+
|
|
209
|
+
- **含跳脫字元的規則在 ES shell 上曾因大小寫給出相反的答案。** 含 metachar 的條目
|
|
210
|
+
同時留在字面集合裡,於是 `back\slash` 靠字串相等命中自己(原文剛好已是小寫),
|
|
211
|
+
而 `Back\Slash` 兩邊都接不到——glob 語意把 `\S` 讀成字面 `S`,字面比對又比不過
|
|
212
|
+
折疊後的名稱。含 metachar 的條目現在只當 pattern。
|
|
213
|
+
|
|
214
|
+
- **一條規則折到多個回傳欄位時,只有一個被列進 `omittedColumns`。** 結果同時有
|
|
215
|
+
`Password` 與 `password` 時兩欄都被遮,但通知只列一個,而呼叫端用精確名稱過濾
|
|
216
|
+
表頭,於是另一欄以空白欄位回來,看起來像 NULL 而不是「被遮蔽」。那份通知是操作者
|
|
217
|
+
判斷黑名單有沒有生效的唯一證據。
|
|
218
|
+
|
|
219
|
+
- **`isColumnBlacklisted` 完全不看萬用字元規則。** 它回答的是 `compactVisibleSchema`
|
|
220
|
+
與 `dbcli schema` 給 agent 看的那份摘要,於是 `pass*` 之下摘要照列 `password`,而讀取
|
|
221
|
+
遮罩會把它遮掉——同一條規則,兩個答案。現在走同一個 `compilePatterns` / `matchAny`。
|
|
222
|
+
|
|
223
|
+
- **ES shell 的規則不走設定載入器的正規化。** `'"Token"'` 在其他引擎上有效,在
|
|
224
|
+
`dbcli es` 上是死規則,因為這裡只做 `trim()`。改為共用 `normalizeBlacklistEntry`。
|
|
225
|
+
|
|
226
|
+
- **`isColumnBlacklisted` 折了被問的欄位名,沒折規則。** 規則 `Password` 對它自己
|
|
227
|
+
指名的欄位回答 `false`——比對的兩側折得不一樣,正是 ADR-0018 記下的那個失敗形狀。
|
|
228
|
+
這條路徑餵的是 `context` 的 schema 摘要。
|
|
229
|
+
|
|
230
|
+
- **glob 規則在比對時折疊,而不是把 pattern 的文字改小寫。** 把 `[A-z]` 小寫成
|
|
231
|
+
`[a-z]` 會讓它代表的字元集合悄悄變小(`Z` 與 `a` 之間那六個 ASCII 字元離開了
|
|
232
|
+
字元類別),規則保護的東西會比它寫的少。`globMatches` 新增 `caseInsensitive`
|
|
233
|
+
選項,在字元比對的地方折,pattern 的文字一個字都不動。
|
|
234
|
+
|
|
235
|
+
## [6.0.0] - 2026-09-01 - 一份黑名單設定,四個互不相同的比對器
|
|
236
|
+
|
|
237
|
+
規格 SQL 第 7、8、9 則與 MongoDB 第 3–6 則。設計決策記在
|
|
238
|
+
`docs/adr/0018-a-blacklist-rule-that-does-not-match-fails-loudly.md` 與
|
|
239
|
+
`docs/adr/0019-one-blacklist-rule-one-matcher.md`。
|
|
240
|
+
|
|
241
|
+
SQL 那半:本機 MariaDB、表 `probe_users (id, Password, note)`、值 `s3cret`,八種設定裡七種洩漏,而全部都被設定載入器無聲接受——操作者「規則有效」的唯一證據是 dbcli 沒有抱怨。
|
|
242
|
+
|
|
243
|
+
MongoDB 那半(本機容器 MongoDB 7.0.31 實測):同一組規則被四個比對器讀,沒有兩個一致——請求側是字面的點號成分比對、讀取遮罩懂 `foo.*`、寫入側是祖先走訪、集合名是 `Set.has`。於是 `user.*` 擋得住讀擋不住寫,`pass*` 到處都不生效卻仍印出「可能已遮罩」的提示,`secrets*` 沒有任何一個比對器認得。規格第 3 則實測後不成立:十四個 update operator 全被 ADR-0015 的請求側檢查擋下,寫入側那個缺口是真的但被外層蓋住——仍然照深度防禦補齊。
|
|
244
|
+
|
|
245
|
+
### Changed
|
|
246
|
+
|
|
247
|
+
- **BREAKING:欄位規則與回傳欄位名比對時,第一段摺成小寫。** 先前規則 `password` 對欄位 `Password` 完全不命中,而最嚴重的一則不是設定寫錯:規則大小寫**寫對了**,`SELECT Password AS PASSWORD` 照樣把值送回來,`query-only` 就做得到——遮罩比對的是回傳時的鍵名,而別名選了那個鍵名,與 ADR-0015 為 MongoDB `$project` 關掉的是同一個形狀,只是這裡經由大小寫抵達。只摺第一段:後面的段落是巢狀物件的鍵(JSON 欄位裡的 `profile.SSN`),不是 SQL 識別字。代價是 PostgreSQL 允許 `"Password"` 與 `"password"` 並存於同一張表,規則寫其中一個現在兩個都遮——過度拒絕,與 ADR-0014、0015 同一個方向。
|
|
248
|
+
|
|
249
|
+
- **BREAKING:以自己的表限定的欄位項改為載入失敗。** `{"users": ["users.password"]}` 從來沒有命中過任何東西。它不能被靜默改寫,因為欄位項裡的點號已經有第二個合法意思——`profile.ssn` 是巢狀路徑,正是第 8 則那個祖先走訪存在的理由。比對第一段是否等於它所在的表名鍵,是唯一不需要猜測就能分辨兩者的判準。**含有這種條目的設定會停止載入,直到改掉為止。**
|
|
250
|
+
|
|
251
|
+
- **寫入側改為與讀取側相同的祖先走訪。** `checkColumnBlacklistOnWrite` 是字面 `includes`,而 `filterColumnsForTables` 會沿點號向上走,於是規則 `profile` 之下 `profile.ssn` 能寫不能讀。沒有任何一種「被列入黑名單」的讀法能容許這件事。
|
|
252
|
+
|
|
253
|
+
- **BREAKING:`blacklist.tables` 的每個條目都是 glob,對所有引擎皆然。** 先前 `isTableBlacklisted` 是 `Set.has`,`tables: ["secrets*"]` 在 MongoDB 與 SQL 完全不擋,而使用者依 Redis 那側的文件正是那樣寫的(`blacklist-validator.ts` 裡 ES 的註解已經記下同一件事)。同一個鍵不該在 Redis 是 glob、在 ES 是 glob、在 SQL 是字面。代價是含 `*` 的設定在 SQL 連線上開始擋東西;方向只會多拒絕不會少拒絕,字面名稱仍匹配它自己。真的叫 `report*` 的表寫成 `report\*` 可回到字面比對。
|
|
254
|
+
|
|
255
|
+
- **BREAKING:`blacklist.columns` 的每一段都是 glob。** `pass*` 現在真的遮 `password`,先前它被 `compilePatterns` 拒絕,而被拒絕的結果是整份文件原樣回傳。萬用字元不跨點號:`pass*` 不匹配 `user.password`。唯一保留的特例是結尾整段的 `*`——`user.*` 仍然涵蓋 `user` 自己與它底下的一切,照純段落 glob 讀會變成只匹配 `user.<一段>`,那會讓已經部署的規則無聲縮小。
|
|
256
|
+
|
|
257
|
+
- **BREAKING:無法編譯的欄位規則改為中止操作。** 先前 `field-masker.ts` 看到 `patterns.length === 0` 就原樣回傳整份文件,而 CLI 照樣印「Some fields may have been redacted」——那句提示是操作者手上唯一的證據,而它是錯的。ADR-0019 Decision 3 與 ADR-0018 Decision 2 同一個理由。`a.*.b` 這種先前被拒的寫法現在合法,留下來會被拒的是真的壞掉的條目(空段落、空字串)。
|
|
258
|
+
|
|
259
|
+
- **請求側、讀取側、寫入側改用同一個比對器。** `reachesProtectedField` 與 `checkColumnBlacklistOnWrite` 現在走 `path-matcher`,所以帶萬用字元的規則在三個端點是同一個意思。仍然逐點號成分比對,不是子字串比對——`passwordless` 不會被 `password` 誤傷。
|
|
260
|
+
|
|
261
|
+
- **`insert` 傳扁平化的欄位路徑,不再是頂層鍵。** `insert --data '{"user":{"password":"x"}}'` 在規則 `user.password` 之下寫得進去(實測確認),因為 `Object.keys(data)` 只看得到 `user`。SQL 的扁平 data 扁平化後結果相同,行為不變。
|
|
262
|
+
|
|
263
|
+
- **`update` 的寫入欄位收集涵蓋所有 operator。** 先前只看 `$set` / `$unset`,`$rename`、`$inc`、`$push`、`$bit` 等十二個 operator 寫的欄位不進黑名單檢查。實測那些 operator 全被請求側擋下,所以這是補一個構不到的洞——一個只因為另一個控制夠嚴才擋得住的控制不算控制,與 ADR-0015 同一個理由。
|
|
264
|
+
|
|
265
|
+
### Fixed
|
|
266
|
+
|
|
267
|
+
- **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 名不含換行,因此不受影響。
|
|
268
|
+
|
|
269
|
+
- **設定裡的前後空白與外層引號會被去掉。** `[" password "]`、`["\"password\""]`、``["`password`"]``、鍵 `" users "` 全部靜默無效。ES 那側的 `es-index-target.ts` 早就 trim 兼解引號,SQL 側沒有。
|
|
270
|
+
|
|
271
|
+
- **表規則同時以完整名稱與最後一段查找。** `{"public.users": …}` 對 `SELECT * FROM users` 不生效,反向也一樣——`extractTableReferences` 會保留限定名稱的完整字串,所以每個鍵只對它被寫成的那一種拼法生效。這是查找的改動而非解析時的猜測:沒有任何地方決定操作者指的是哪一種拼法,同一張表的兩種拼法解析到同一份規則。
|
|
272
|
+
|
|
273
|
+
- **`$rename` 的風險說明不再宣稱它不外洩。** `dml-plan.ts` 的 RENAME tier 訊息寫著 `field rename does not exfiltrate data`,那是錯的:改名之後受保護欄位的值躺在讀取遮罩不認得的名字底下。程式碼裡的斷言本身要當成待驗證的宣稱。
|
|
274
|
+
|
|
275
|
+
- **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`。
|
|
276
|
+
|
|
277
|
+
## [5.1.0] - 2026-08-31 - 一次被黑名單擋下的查詢,紀錄裡指向的是沒被擋的那張表
|
|
278
|
+
|
|
279
|
+
`docs/specs/2026-08-30-cross-engine-blacklist-gaps.md` 的 audit 第 10 則。設計決策記在 `docs/adr/0017-the-audit-target-stays-wrong-and-the-record-stops-depending-on-it.md`。
|
|
280
|
+
|
|
281
|
+
### Added
|
|
282
|
+
|
|
283
|
+
- **SQL 的稽核紀錄帶 `metadata.blacklist_checked`:黑名單拿這句語句比對過的每一個識別子。** 黑名單走 tokenizer(`extractTableReferences`),稽核的 `target` 走另一套單名推導(`extractTableName`),兩份解析給出兩個答案。實測:`SELECT * FROM a JOIN salaries s …` 的 `target` 只有 `a`;`CREATE TABLE dump AS SELECT * FROM salaries` 的 `target` 是被讀的 `salaries`,被建立的 `dump` 不在紀錄裡;`INSERT INTO staging SELECT * FROM salaries` 的 `target` 是 `staging`,被讀的 `salaries` 不見。最尖銳的一則規格沒寫:把 `salaries` 設進黑名單後跑那句 JOIN,拒絕是對的,而**那次拒絕的稽核列 `target` 是 `a`**——用 `target` 去查「有沒有人試圖碰受保護的表」查不到,真正的表名只活在 `error` 的自由文字裡。
|
|
284
|
+
|
|
285
|
+
- **這份清單原樣保存,不做過濾。** `extractTableReferences` 是刻意過度收集的:那句 JOIN 回傳 `["a","salaries","s","id","s.id","a.id"]`,那句 CTAS 回傳 `["dump","salaries","CREATE","TABLE"]`——別名、含點的欄位參照、SQL 關鍵字都在裡面。對黑名單而言這是對的,多一個識別子只會讓它多拒絕。規格原本提的 `metadata.tables` 因此會把 `CREATE` 當成表名寫進稽核;改為以欄位名說明實情,而不是再造一套與黑名單對不起來的解析。
|
|
286
|
+
|
|
287
|
+
### Changed
|
|
288
|
+
|
|
289
|
+
- **`target` 維持原樣,刻意不改。** 它是下游拿來 filter 的欄位,而兩種候選修法(改成「被作用的那張表」、或改成 tokenizer 的首張)都會在沒有人被告知的情況下改變既有查詢的結果。這次修的是「有一張表完全不在紀錄裡」,把還在的那個欄位一起搬動並不會讓缺席修得更好。`target` 與 `blacklist_checked` 對某些語句會不一致,那是刻意的,不是待清理的 bug。
|
|
290
|
+
|
|
291
|
+
## [5.0.0] - 2026-08-31 - shell 裡一句真的改了資料的 UPDATE,稽核裡沒有任何一列
|
|
292
|
+
|
|
293
|
+
`docs/specs/2026-08-30-cross-engine-blacklist-gaps.md` 的 audit 第 11 則自己註明是純讀碼結論、值得先端到端確認。確認了,結論成立,而且比讀碼看到的更嚴重。本機 MariaDB、`read-write` 連線、每次先清空 audit:`dbcli query "SELECT …"` 寫一列,而在 `dbcli>` 提示符打的同一句寫零列;一句帶 WHERE 的 `UPDATE` 走完全程、改掉了資料,同樣零列。設計決策記在 `docs/adr/0016-the-sql-shell-audits-every-statement-in-two-rows.md`。
|
|
294
|
+
|
|
295
|
+
### Changed
|
|
296
|
+
|
|
297
|
+
- **BREAKING(對解析 audit 紀錄的下游而言):`metadata.es_shell_phase` 改名為 `metadata.shell_phase`。** 兩個 shell 現在共用同一個鍵。ADR-0016 Decision 2 的理由是讀 audit 的人不該需要先知道一列是哪個引擎產生的,而兩個鍵名正是那件事。任何在 4.0.0 上解析 `es_shell_phase` 的東西都會斷。
|
|
298
|
+
|
|
299
|
+
- **SQL shell 為每一句語句寫稽核列,讀取也不例外。** 先前只有 tier-two 的寫入閘門決策會進 audit(`createShellWriteGate` 對非 tier-two 直接 return,而 `repl-engine.ts` 自己沒有任何稽核呼叫),所以提示符打的 `SELECT` 與一句真的改了資料的 `UPDATE` 同樣不留痕跡。較窄的方案(只記寫入與拒絕)被否決:一旦「沒有紀錄」有第二種解釋——走了哪個入口——它對任何一個入口都不再有意義。
|
|
300
|
+
|
|
301
|
+
- **形狀與 Elasticsearch shell 相同:送出前 `attempt`、回來或拋出後 `outcome`。** 理由沿用 `EsShellAuditSink` 寫在型別註解裡的那句:只在回程寫的一列描述不了一個沒有回程的語句——被中斷的長 `UPDATE`、`SIGTERM`、client 端逾時而伺服器仍在跑。被拒絕的語句只寫 `outcome`,因為它從未被嘗試。三個拒絕出口(權限、黑名單、寫入閘門)各自補上,其中權限那個出口先前連 tier-two 的決策列都沒有——無 WHERE 的 `DELETE` 在 `read-write` 下由權限檢查擋下,發生在寫入閘門之前。
|
|
302
|
+
|
|
303
|
+
- **稽核輪替的筆數上限從 1000 提高到 10000。** 在上面兩項之下,一次認真的互動 session 就能自己跑到 1000 列,然後輪替會丟掉寫入、留下讀取——剛好把這份檔案的價值反轉。位元組上限不變,它管的是磁碟。這個值先前有五份寫死的副本(schema、schema 自己的 `.default`、v1→v2 遷移、`config.ts`、`init-shared.ts`),已收成單一常數 `DEFAULT_AUDIT_ROTATION`。
|
|
304
|
+
|
|
305
|
+
### Fixed
|
|
306
|
+
|
|
307
|
+
- **shell 的權限拒絕訊息不再宣稱要求的等級是使用者已經持有的那個。** `repl-engine.ts` 把 `required` 算成 `classification.type === 'UNKNOWN' ? 'admin' : 'read-write'`——一個猜測,不是 `checkPermission` 實際判定的等級。於是 `read-write` 使用者刪整張表讀到 `Permission denied. Required: read-write (current: read-write)`,實際需要的是 `data-admin`。`minimumPermissionFor` 的註解記載這一類錯誤已在其他呼叫端修過,shell 的 SQL 這處是漏掉的一站。
|
|
308
|
+
|
|
309
|
+
### Note
|
|
310
|
+
|
|
311
|
+
`audit.strict: true` 現在也涵蓋 shell 裡的讀取:送出前那一列寫不出去就拒絕執行。這與 `dbcli query` 和 Elasticsearch shell 既有的行為一致,但 shell 的讀取路徑先前沒有這個失敗模式。不需要這個的人維持 `strict` 關閉即可——那是預設,寫失敗只留一行警告。
|
|
312
|
+
|
|
8
313
|
## [4.0.0] - 2026-08-30 - Elasticsearch 的 shell 從來沒有問過 permission,同樣的形狀在 Redis 與 MongoDB 也成立,以及三個沒人比對的版本契約
|
|
9
314
|
|
|
10
315
|
**建議所有把 Elasticsearch 連線交給 AI agent 操作的使用者升級。** `dbcli shell` 連到 Elasticsearch 時,完全沒有檢查連線設定的 permission 等級就把請求送到叢集:`shell.ts` 在到達 SQL 與 Redis 共用的那道閘門之前就分支到 `es-shell.ts`。因此 `permission: query-only` 的連線可以送出 `POST /<index>/_delete_by_query` 清空索引、`DELETE /<index>` 刪掉索引、`PUT /<index>/_mapping` 改寫 schema —— 同樣這些請求走 `dbcli query` 一律會被拒絕。這條路徑也不寫任何 audit 紀錄,所以受影響的人事後無從查證發生過什麼。
|