Repository navigation
v4.0.0 - Elasticsearch 的 shell 從來沒有問過 permission,同樣的形狀在 Redis 與 MongoDB 也成立,以及三個沒人比對的版本契約
建議所有把 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 紀錄,所以受影響的人事後無從查證發生過什麼。
它可以腳本化:shell 用管線餵入的 stdin 驅動與互動輸入相同的迴圈,所以一個 agent 用單一非互動指令就能做到上述任何一項。
影響範圍是所有 Elasticsearch 連線,1.22(ES shell 首次出現)起至 3.0.0 止。SQL、Redis、MongoDB 的 shell 不受影響 —— 它們走的是有閘門的那一條分支。沒有任何生產事故的紀錄,但這是從缺席推論出來的,而這條路徑本來就不寫 audit,受影響的操作者本來就無從發現。
ES shell 那條分支第九輪把「一個比對函式,有沒有把它要比的兩樣東西都正規化?」問到其他引擎上,找到 Redis 四個、MongoDB 兩個 CRITICAL。設計決策記在 ADR-0015。
Three numbers in this repository disagreed with reality and nothing checked any of them. The evidence pack and receipt formats still said version: 1 after v3.0.0 broke both of them, so two mutually incompatible layouts shipped under one version number and an old artifact was reported as a tampered one. Five plugin manifests said 1.51.2 while the package said 3.0.0. SECURITY.md promised fixes for 1.x. Each is now stated once, derived from one source, and guarded by a check that fails on drift.
Fixed
-
BREAKING(對
query-only與read-write的 Elasticsearch shell 使用者而言):ES shell 現在套用連線的 permission 等級。 請求由dbcli query使用的同一個分類器判定 —— shell 交給它的是真正的 method 與 path,而query只生得出一個合成的_search。無法證明是文件層級的操作一律落到需要admin的那一級:DELETE /<index>、DELETE /_all、_delete_by_query、PUT /_mapping、PUT /_settings、POST /_aliases、POST /_reindex。拒絕訊息會指出可行的等級,而且請求不會送出。先前能在query-only下跑這些請求的人,現在會被擋。 -
ES shell 的每個請求都寫入 audit,執行或被拒都寫。 side-effect tier 取自該請求的分類結果而非發起它的命令 —— 用命令的能力表來標記是一個已知缺陷,同一個破壞性操作曾因為經由不同命令而被記成三種不同的 tier。
-
分類器改讀伺服器實際路由的路徑。 先前它拿到的是原始文字(含 query string),而黑名單拿到的是去掉 query string 的路徑。分類器裡每個子字串比對因此都會命中攻擊者控制的參數值,而
filter_path是每個端點都接受、且吃任意字串的通用參數:POST /<index>/_delete_by_query?filter_path=_count判成搜尋、DELETE /<index>?filter_path=_bulk判成搜尋、PUT /<index>/_mapping?filter_path=_bulk判成搜尋,三者都在query-only下實測執行成功。現在 query string 被丟棄、百分比編碼與 dot segment 在分類前解析,欄位也從apiPath改名為rawPath並在模組內正規化,呼叫端不可能再傳錯一個。 -
比對改為位置感知的路徑區段,不再是子字串。
_search、_count、_bulk都是合法的文件 id,所以精確區段比對本身也不夠:POST /<index>/_doc/_search是「索引+id」的寫入請求,最後一段卻是_search。_search與_count只在一段或兩段路徑的端點位置才算數,文件 id 一律不透明、永不參與比對。 -
Elasticsearch 的讀取判定維持白名單,並補上讓它不堪用的那幾個形狀。 新增
_cat/*(不含_cat/aliases與_cat/tasks)、_cluster/health、GET/HEAD裸索引名稱。其餘一律落到需要admin的預設 —— 包含任何沒被列上的端點。這個方向是刻意的:白名單漏一項,使用者多付一個不必要的 admin 要求;拒絕集漏一項,使用者拿到一個繞過。記在 ADR-0014。 -
_bulk的 body 無法辨識或無法解析時判為DROP。 先前判SELECT,而 bulk 分支由路徑單獨選中,所以那是一個通用的降級管道。 -
路徑必須與 URL parser 產出的字串逐位元組相同,否則一律拒絕,而拒絕訊息會給出正確的寫法。dbcli 原本有一套自己的「路由後路徑」概念,它在近似
fetch的行為——而近似的價值等於它最糟的那個缺口。#就是一個:fetch會丟棄第一個#之後的一切,所以POST /_reindex#/_count在 dbcli 眼中是兩段的 count、伺服器收到的卻是POST /_reindex,那是任意索引對拷,因此同時也是黑名單繞過——把受保護的索引拷進可讀的索引再正常讀。tab、換行與\是同一形狀的另外三個缺口。現在改問傳輸層用的同一個 parser,adapter 也改用它組 URL,所以驗證過的字串就是送出的字串。 -
sourcequery 參數一律拒絕。 Elasticsearch 接受source=<json>&source_content_type=...取代 request body,而這條路徑上每個 body 側檢查都讀req.body—— 被保護的欄位名稱寫在偷渡的 body 裡時,那個為此存在的檢查完全看不到。參數以精確鍵名比對,_source、_source_includes、_source_excludes不受影響。 -
黑名單欄位名稱在 query string 裡也會被拒絕,因為 URI search 形式直接在參數裡指名欄位(
?q=password:*、?sort=password:asc、?docvalue_fields=),而值會以請求自選的 key 回傳。比對改為看點分元件而非整串相等:password.keyword是標準動態 mapping 對每個text欄位預設產生的 multi-field,而params._source.password是 Painless script 讀欄位的寫法——受保護的名稱可能落在點分路徑的任一端。回應遮罩套用同一條規則。 -
path 與 query 都從同一個
URL解析,不再用String.split('?')。split會在每一個?切開,而解構只取第二個元素,所以第二個?之後的一切都從這些檢查讀到的 query 中消失,adapter 卻拿到完整路徑:?filter_path=x?&source=<body>因此對每一個為了找出偷渡 body 而存在的檢查隱形。逐位元組相等也隨之擴及整個 request target,不再只有路徑。 -
引號字串形式的 request body 一律拒絕。 JSON 字串字面值是合法的 body,卻能挾帶 NDJSON 通過每一個只走物件與陣列的檢查 —— 一個 bulk delete 曾因此從無害的路徑名稱抵達黑名單索引。
-
_ingest與_tasks移出 shell 的 unscoped metadata 白名單:pipeline 定義常內嵌憑證,詳細 task 列表會帶出執行中查詢的 request source。 -
server-side script 的攔截點移到傳輸層,shell 不再繞過它。
assertNoElasticsearchScript原本只掛在ElasticsearchAdapter.execute(),而 shell 走的是另一個執行入口request()。因此query-only可以用POST /<index>/_search帶script_fields在叢集上執行任意 Painless,並把黑名單欄位以請求自選的 key 讀回來(欄位遮罩看的是 key 名稱,所以遮不到);read-write則可用POST /<index>/_update/<id>的ctx._source直接改文件——同樣的操作走dbcli update一律被擋。檢查現在放在request(),兩個入口共用。這也推翻了 ADR-0014 原本記下的一個「上限」:doc['pass' + 'word']確實不是任何字面掃描擋得住的,但一律拒絕script鍵的控制本來就存在於這個倉庫裡。 -
shell 在退出前把在飛的請求與 audit 排乾。
readline不會 await'line'handler,所以 EOF 的'close'會在請求還沒回來、audit 還沒寫出時就process.exit(0):printf 'DELETE /orders\n\n' | dbcli shell會把請求送到叢集而一列紀錄都不留。權限與黑名單檢查都在送出前同步完成,所以檢查會通過、封包會出去,唯一沒發生的就是稽核——也就是原始漏洞報告裡「不留 audit」的那一半。SQL shell 有同一個缺陷(它有序列化那一半,缺排乾那一半),一併修正。 -
audit 在送出之前先記一筆 attempt,回應之後再記 outcome,而且每一列都說得出操作。 只在回應後寫的紀錄描述不了一個沒有回來的請求:
_delete_by_query在 client 端逾時會 abort socket 而叢集把刪除做完,執行中被 SIGTERM 則連一列都沒有。同時每列補上<METHOD> <routed path>——先前DELETE /orders、POST /orders/_update_by_query、PUT /orders/_mapping、POST /orders/_close產生的是四列一模一樣的紀錄。 -
只設定
blacklist.columns、沒設定blacklist.tables時,指不出索引的路徑不再被放行。 整段黑名單檢查原本以tables是否為空決定要不要跳過,連帶跳過了「路徑指不出索引就拒絕」那一道守門員——而那道守門員才是擋住_sql、_mget、_search/scroll的東西。POST /_sql配SELECT * FROM users會把受保護欄位的值原文放在rows陣列裡回傳,而欄位名只出現在columns[].name的值裡、不是 key,所以回應遮罩結構上救不回來。 -
query string 的欄位比對補上 Lucene 語法字元。 切詞器只切
[\s,:()"'[\]{}],於是?q=+password:hunter2通過而?q=password:hunter2被拒。+、-、*、!、^、~、|、/、\一併視為分隔字元。 -
搜尋的 size 上限改讀路由後的路徑。 原本用
path.includes('_search')判斷,所以PUT /<index>/_doc/_search(id 剛好叫_search的寫入)與?routing=_search都被當成搜尋,把一個使用者從未輸入的size欄位寫進文件。這是檔案裡最後一處對原始路徑做子字串比對的地方。 -
dbcli audit tail與audit show的輸出逃脫控制字元。 ES shell 的 audit target 來自路徑,%0A解碼後是真正的換行,因此一列紀錄能在表格輸出裡長成兩列、其中一列是偽造的。JSONL 檔本身不受影響。 -
連線失敗訊息裡的 URL 帳密會被遮蔽。
nodes常寫成https://elastic:hunter2@host:9243,而該字串會整串進入 audit 的 error 欄;redactSensitive原本只認keyword=value形式,一個字元都吃不到 URL 的 userinfo。 -
server-side script 的比對改看形狀,不是兩個字面名稱。 上一項把檢查點搬到傳輸層,但它認得的名字只有
script與script_fields,而scripted_metric聚合把四個 script 槽拼成init_script、map_script、combine_script、reduce_script。query-only因此仍可跑任意 Painless,並把黑名單欄位以aggregations.<name>.value這個請求自選的 key 讀回來。現在任何等於script/script_fields或以_script結尾的鍵都算——一份名字清單擋不住一個會自己組名字的 API。掃描另外加上深度上限,'['.repeat(100000)這種合法 JSON 先前會讓它以RangeError收場。 -
黑名單欄位名含
-、*、|、/時重新擋得住。 上一項把 Lucene 運算子加進 query string 的分隔字元集,卻讓user-password被切成user與password——兩者都不在黑名單裡。ES 的欄位名本來就允許這些字元,而?sort=user-password:asc會把值原樣放在hits.hits[].sort,遮罩摸不到。現在保守與加寬兩套切法都跑、取聯集:多切一次只多幾個不命中的 term,少切一次會漏掉一個受保護欄位。 -
shell 的 block 在排入佇列的當下取快照。 上一項把
rl.on('line', async ...)改成queue.enqueue(submit),而submit是在任務跑起來時才讀共用的blockLines;readline 會把管線送來的行在同一個 tick 全部同步發完,於是兩個命令被合併成一個 block,parseEsRequest解析失敗,兩個命令一個都沒送出,audit 零列,exit code 仍是 0。互動模式下則是還沒打空行提交的內容被當成前一筆的 body 送進叢集。 -
attempt那一列不再宣稱成功。 它原本硬寫success: true,於是「還沒送出」與「送出並成功」在紀錄裡長得一樣——包含被傳輸層擋下、從未離開行程的請求——而且讓每個操作的成功計數翻倍。現在一律false,真相由outcome那一列說。 -
audit tail --for-agent與--brief保留 statement 與 phase。 brief 原本只留ts/command/target/success,所以「一列 audit 要說得出對誰做了什麼」只在audit show --no-brief修好了,agent 讀到的仍是兩筆一模一樣的紀錄。audit tail的表格另外新增statement欄位。 -
Elasticsearch 的 audit statement 不再套用 SQL 字面值遮罩。
redactSql會把數字換成0,於是DELETE /orders/_doc/12345記成DELETE /orders/_doc/0、POST /logs-2026.08.30/_delete_by_query記成POST /logs-0.0/...——操作對象被遮罩吃掉。ES 的 statement 是路徑不是語句,改用一般的敏感字串遮罩。 -
audit 表格的 cell 逃脫涵蓋非 C0 控制字元。 U+202E(RTL override)會讓該 cell 之後整段以右到左顯示,tier 與 success 欄可被視覺調換;U+2028/U+0085 在許多終端機裡同樣算換行。這是零權限的日誌注入——被 blacklist 拒絕的請求照樣寫紀錄,而 target 是攻擊者選的字串。
-
URL 帳密的遮蔽貪婪到最後一個
@。 密碼裡含字面@時(https://elastic:p@ssw0rd@host)先前只遮到第一個,尾巴留在紀錄裡。 -
搜尋的 size 上限與分類器讀同一個路徑函式。 先前 cap 不看 method 且讀解碼後的路徑,分類器看 method 且讀原始路徑,於是
PUT /orders/_search與POST /orders/%5Fsearch上兩者給出不同答案。都不可利用,但「同一個請求、兩個函式、兩種答案」是前幾輪 CRITICAL 的形狀。 -
wrapperquery 一律拒絕。 它帶的是 base64 編碼的 query,伺服器解碼後執行,而所有 body 側檢查都只走物件的鍵、碰不到字串內部。裡面可以放function_score.script_score,於是黑名單欄位的數值原文會以每筆 hit 的_score回來——那不是受保護的鍵名,回應遮罩不會動它;黑名單詞比對也看不到 base64 裡的欄位名。這與已經拒絕的字串形式 body 和?source=是同一個原則:dbcli 檢查不了的編碼 body 不放行,解碼一種編碼只會邀請下一種。 -
含
.的黑名單欄位名重新生效。namesProtectedField先比整串相等,再把 term 拆成單一元件比對——而拆出來的元件永遠不含.,所以blacklist.columns寫成user.password對整個檢查毫無作用。同一個函式也是回應遮罩的判斷,於是請求端放行?docvalue_fields=user.password.keyword、回應端原樣返回_source.user.password。ES 的 object field 一律以點分名稱呈現,那是最自然的設定寫法。比對改為連續的點分元件區段,遮罩則帶著走過的鍵路徑,才比對得到巢狀呈現的回應。扁平欄位名的行為完全不變——這正是這個缺陷七輪沒被發現的原因,每個測試用的都是扁平名稱。 -
_script後綴的比對縮回scripted_metric底下。 無條件的後綴規則會讓deploy_script、build_script這種一般欄位名在query-only的唯讀查詢上被拒絕,訊息還說它「executes script code on the cluster」——term/match/range/sort/exists都把欄位名放在鍵的位置。scripted_metric是唯一內層沒有字面script鍵的聚合,其餘 script 載體都已被第一條規則接住。 -
編碼後才出現的
..一律拒絕。%2F原封不動通過位元組同一性檢查,但normalizeEsPath會先解碼再讓..刪掉前一段,跨過一個伺服器根本不存在的段界。GET /secrets%2F..%2Fpublic/_search因此讓secrets從路徑區段檢查、index 抽取與 audit 三處同時消失。ES 是否解析得出那個 index expression 未經驗證——那正是拒絕而非正規化的理由。 -
exit之後排在佇列裡的命令不再執行。'line'handler 在同一個 tick 把管線的所有行 enqueue 完,所以rl.close()執行時後面的 block 早已在鏈上,而'close'的排乾語意是「全部跑完」——printf 'exit\n\nDELETE /orders\n\n'會把索引刪掉。兩個 shell 都補上關閉旗標。 -
只有空白的行屬於 block 的內容,不是它的結尾。 提交的判斷原本是
trim()後為空,於是編輯器留下的空白會把 block 截斷、前半段以一個沒有 body 的請求送出——而POST /_update_by_query沒有 body 是合法的、作用範圍是整個索引,且 audit 寫下的字串與使用者本來要送的那筆逐字相同。改成只在真正的空行提交:反方向的代價是分隔行帶空白時命令會黏成一塊而解析失敗,但那個失敗可見且什麼都不會送出。 -
shell 的錯誤訊息逃脫控制字元。 訊息內嵌使用者寫的路徑,而
ESC[2K ESC[1G會清掉整行並把游標移回行首,用後續字元蓋掉「Refused」,讓操作者看到一則自己寫的假成功訊息。audit 檔與audit tail早已處理這件事,唯獨 shell 自己的 stderr 沒有;逃脫邏輯抽成共用的escapeControlCharacters。 -
BREAKING(對用管線或腳本驅動 ES shell 的呼叫端而言):ES shell 的退出碼反映失敗。 先前一律
exit(0),所以dbcli shell < script.txt的呼叫端分不出「全部成功」與「一條都沒跑」——權限拒絕、blacklist 拒絕、strict-audit 拒絕全部只印一行紅字。這會改變既有腳本的行為:一個 session 內只要有任何一個請求失敗,dbcli shell就以1結束,先前依賴它一律成功的 CI 步驟會開始紅。記在 ADR-0014 Decision 10。 -
不含欄位名的 query 參數不再進入黑名單切詞器。
?routing=abc-name-1在黑名單欄位叫name時被誤擋,而routing/scroll/preference/filter_path的值沒有任何欄位名語意。 -
i18n 插值不再讓值改寫訊息。
MessageLoader.interpolate用的是String.replace(regex, value),而替換字串會展開$&、$'、$`與$1。這條分支第一次把操作者可控的字串餵進去(ES shell 的路徑、命中的 index expression、被拒的欄位名),於是拒絕訊息以及由它組成的 auditerror欄位可以被被拒的那個人部分改寫:GET /sec$&rets/_search記下來的索引名是sec{index}rets,$'則會把整句複製一份接在後面。改成單次掃描\{name\}加替換函式,順帶修掉「依序替換讓值裡的{other}被二次替換」。所有t_vars呼叫端一併受惠。 -
ES shell 的訊息改走 i18n。
src/commands/es-shell.ts的 i18n 呼叫數是 0,而同一層的src/commands/shell.ts是 14——CONTRIBUTING.md 明文寫「All user-facing messages must be translatable」,所以那是違規不是偏好。19 則訊息移進shell.es.*,blacklist table add這次新增的萬用字元拒絕訊息一併移進blacklist.refuse_wildcard。BlacklistRejection:這個前綴刻意留在程式碼裡不翻譯:recovery 路徑與數個測試比對的是它,翻譯過的前綴是壞掉的比對器,不是翻譯過的訊息。 -
es-shell.ts拆成兩個檔案。 894 行、runEsRequest363 行,對照 CONTRIBUTING.md 的 800 與 50。成因寫在 ADR-0014 裡:九輪,每一輪都往同一個函式再塞一個檢查。切線沿著檢查本來就有的分界——es-shell-guards.ts放「關於這個請求」的純函式(伺服器會路由到哪、指名了哪些索引與欄位、回應要遮掉什麼),不讀設定、不開連線、不寫 audit;session 檔留下讀取迴圈、tier gate 的呼叫、audit 接線與退出碼。行為沒有改變,5873 個測試全過。 -
audit.strict的強制點改在「效果發生前」的稽核寫入。 先前只有 ES shell 讀這個鍵,但它放在全域audit區塊、文件也寫得像全域開關:設了strict: true再把 audit 目錄設成不可寫,dbcli delete照樣執行。現在 ES shell 送出請求前那一列與 SQL 寫入閘門的決定紀錄都走writeAuditEntryBeforeEffect。事後才寫的紀錄不在範圍內——那時拒絕擋不回任何東西,只會把一次已完成的操作回報成失敗。同時:enabled: false配strict: true在讀設定時就失敗(那組合的語意是「不記錄、也不擋」);dbcli audit health印得出 strict 的狀態;沒有 sink 或 sink 回null(舊版writeAuditEntry的失敗形狀)在 strict 下都算失敗。 -
recover與inspect的 audit 摘要跟上 statement 與 phase。 第六輪修好了audit tail的 brief,但沒動briefifyForRecent,於是這兩條路徑上一次成功的請求仍呈現為兩列只差success的紀錄。topQueriedTable另外不再把 attempt 列重複計數,也不再把/_cat/indices這種路由路徑當成「最常查詢的資料表」。 -
黑名單條目本身可以是萬用字元、逗號清單、
_all、帶前後空白。indexExpressionReaches原本只展開請求端(逗號、萬用字元、date math、CCS、百分號編碼),把黑名單條目當純字面字串比對——於是blacklist.tables: ["secrets*"]對 Elasticsearch 完全無效,而["*"]這個讀起來像「全面封鎖」的寫法是零保護。加重因素是:同一個blacklist.tables陣列在 Redis 連線上就是以 glob 執行的,使用者文件也明文教dbcli blacklist table add 'secrets:*'。依文件寫下的設定,在 Redis 擋、在 ES 靜默放行。dbcli query --index走的checkIndexBlacklist與欄位遮罩的filterColumnsForIndexExpression有同樣的不對稱,一併修正。 -
dbcli blacklist table add接受真實的 Elasticsearch index 名。 驗證規則原本是 SQL 識別字的形狀(^[a-zA-Z_][a-zA-Z0-9_]*$),拒絕my-index、logs-2026.08.30、.kibana,等於 ES 使用者只能手編設定檔——而手編正是最容易把條目寫成 glob 的路徑,直接餵養上面那個缺陷。 -
拼錯或用 ES 詞彙寫的黑名單不再被靜默忽略。 zod 預設剝掉未知鍵,所以
blacklist.indices、blacklist.fields,以及寫在連線層級的blacklist(它只有頂層一份),解析後都是空黑名單且沒有任何警告——使用者看著設定檔以為有保護。這幾種形狀現在是解析錯誤。沒有改成全域.strict():那會拒絕無害的額外鍵。 -
blacklist add/remove不再把 v2 多連線設定壓成 v1。 它讀設定走的是 v1 路徑(對 v2 檔案回傳「選中那條連線」的扁平化結果),寫回時以 v1 schema 整包覆寫。加一條黑名單因此會讓connections、default、envFile、environment全部消失,而預設 permission 變成當時選中那條連線的值——ES shell 的 tier gate 讀的正是它。整個過程走writeConfigWithIntegrity,完整性紀錄同步更新,事後沒有 tamper 訊號。 -
根層
--config對 blacklist 指令生效。 每個子指令自己宣告了一個帶預設值的--config,所以 commander 永遠不會回落到根層那個:dbcli --config /path blacklist table add x會改到.dbcli而不是/path,並且回報成功。 -
百分號編碼的路徑段不再改變分級。
new URL().pathname不解百分號編碼,而 Elasticsearch 對路徑參數會解,於是GET /%2A是query-only的讀取而GET /*需要admin——同一個請求兩個 tier,正是isBareIndexSegment的註解明文禁止的情形。路徑段現在在routedSegments解碼一次,且不重新切分:解碼後的/留在原段內,重新切分會複製第七輪修掉的%2F..%2F缺陷。 -
無 index 的 metadata 白名單細到子資源。 它原本只比對第一段,於是整個
_cluster與_cat前綴放行——而_cluster/state回傳metadata.ingest.pipeline[](_ingest被移出白名單的理由就是它常內嵌憑證)、metadata.stored_scripts、以及黑名單索引的完整 mapping;_cat/tasks回傳執行中查詢的 source(_tasks被移出的理由)。_nodes/stats、_nodes/settings、_nodes/hot_threads同樣扣住。 -
search template 端點一律拒絕。 template 的
source是一段在叢集上渲染成完整 search body 的字串,stored template 的內容根本不在請求裡——所以指向黑名單索引的 terms lookup 對每一個 body 側檢查都是隱形的。與wrapper同一個原則。 -
黑名單條目的前後空白不再讓它變成死設定。 ES 的 index 名與欄位名都不能帶空白,所以
[" secrets "]保證無效,而先前沒有任何提示。 -
audit 的
target不再能由 SQL 註解或字串字面值指定。 它原本走一條不剝註解、不剝字串的 regex,只取第一個FROM|INTO|UPDATE之後的識別字,所以/* FROM audit_decoy */ DELETE FROM users會以audit_decoy入帳。target正是稽核者事後篩選用的主要欄位——找不到的紀錄與不存在的紀錄,對追查是同一件事。改成掃描:剝掉註解(含 PostgreSQL 的巢狀區塊註解)與字串字面值,保留引號識別字,並只在括號深度 0 比對關鍵字。順帶修好兩個日常誤記:DELETE FROM public.users記成public(schema 名)、SELECT EXTRACT(MONTH FROM created_at) FROM salaries記成created_at(欄位名)。 -
經
query執行的 DML 記成db-write而非readonly。writeAuditEntry在沒拿到sideEffectTier時取命令的能力等級,而query的能力是readonly。於是DELETE/UPDATE/INSERT/CREATE TABLE AS全部以readonly入帳,而被寫入閘門拒絕的DROP/TRUNCATE由recordGateDecision帶著db-write——以 tier 篩選破壞性操作會找到被擋下的那些、漏掉真正發生的那些。「讀」的判定沿用既有的 tier 語意(在query-only下被允許的就是讀),而不是另外維護一份唯讀型別清單。 -
blacklist table add對 SQL 與 MongoDB 連線拒絕萬用字元條目。 上一版為了 Elasticsearch 與 Redis 的名稱放寬了字元集,但沒有問「這個寫法對這個引擎有沒有意義」:那兩個引擎的黑名單比對是字面相等,secret*這種條目永遠不會命中,而 CLI 回報成功。錯誤訊息會說出原因,不只是「名稱非法」。 -
BREAKING(對
blacklist.tables設了 glob 的 Redis 使用者而言):insert/update/delete現在也套 glob 黑名單。 這三條只經過BlacklistValidator.checkTableBlacklist(字面相等,不懂 glob),而RedisAdapter的三個方法一次checkKeyArgs都沒有。於是dbcli blacklist table add 'secrets:*'之後dbcli delete secrets:api_key照刪——而'secrets:*'正是使用者文件教的寫法,只有寫成完整字面 key 才擋得住。檢查改掛在 adapter 邊界。先前能刪改這些 key 的腳本現在會被擋。 -
redis.mask對query/list/schema/insert/update/delete完全無效。 mask rules 是createRedisAdapter的第三個選擇性參數,八個呼叫端裡六個沒傳。文件明寫dbcli query "GET secret:api_key"回{"value":"[REDACTED]"},實際回明文。export與shell有傳,所以任何人想驗證這個功能時它都是好的。factory 改為接整個 config。 -
指令表與權限白名單的落差不再是黑名單繞過。
checkKeyArgs拿不到 command spec 時 fail-open,而 32 個被權限放行的指令沒有 spec:LPOP secrets:list在read-write下把黑名單 key 的值取出來兼銷毀,XRANGE secrets:stream - +在query-only下讀得到。改為 fail-closed(只在有設黑名單時生效),補齊 32 個 spec,並加契約測試釘住兩張表的關係。RedisCommandSpec.permissionTier直接移除:它是一份沒人強制、已在五個指令上分歧的副本。 -
SCAN不再列舉得出黑名單 key 名。SCAN 0 MATCH secrets:*只要query-only且完全不被檢查,而KEYS secrets:*要admin且被擋——低權限那條路才是通的。MATCH 現在會被找出來(不限位置、大小寫不敏感)並比對重疊;而只修 MATCH 擋不住裸SCAN 0,所以SCAN與KEYS的回應也會把受保護的 key 名濾掉。cursor 不動。 -
MongoDB 的整份文件轉移也擋得住。 底下那則「
blacklist.columns擋得住換名了」只擋「請求裡指名了受保護欄位」,而[{"$project":{"all":"$$ROOT"}}]一個欄位名都沒提就把整份文件放到自選的鍵底下回傳,遮罩比對的是完整路徑all.password、對不上錨定在password的規則。{"$objectToArray":"$$ROOT"}更徹底:文件變成[{k:"password",v:"p1"}],受保護的名字成了值,任何看鍵名的遮罩結構上都追不到。$replaceRoot/$replaceWith把子樹提到頂層,同樣讓user.password這種規則失去指涉。這些現在一律視為「一次指名了所有受保護欄位」;$getField的field若是運算式而非字串常數也一併拒絕——dbcli 判斷不出它會解析成什麼名字,就不轉發。$$ROOT原本有被處理,但是當成「要剝掉的前綴」(為了讓$$ROOT.password命中),單獨出現時被讀成一個叫ROOT的路徑。 -
SCAN的MATCH改為檢查每一個出現位置。 Redis 解析選項是後者覆寫前者,所以SCAN 0 MATCH benign:* MATCH secrets:*真正送出去的是secrets:*,而只讀第一個等於檢查了一個從未送出的 pattern。回應過濾本來就會把 key 濾掉所以沒有洩漏,但「指名就拒絕」這條規則在這裡沒有成立。 -
filterReturnedKeyNames對認不得的回應形狀改為 fail-closed。 原本原樣轉發,與同一批修補在checkKeyArgs選的預設相反。非字串的 key 同樣丟棄——無法與 glob 比對的東西,回答不了「這個受不受保護」。 -
MongoDBAdapter.insert補上兩個攔截點。 它既沒有assertNoMongoServerSideScript也沒有欄位檢查,而 #47 的註解宣稱所有路徑一致受檢。實際蓋住它的是insert.ts的checkColumnBlacklistOnWrite——掛在呼叫端的控制,正是 ADR-0015 Decision 1 要移除的安排。 -
$lookup帶進來的 collection 名改為大小寫不敏感比對。 請求側是精確比對、遮罩側 (findCaseInsensitive) 不敏感,於是同一份設定在遮罩生效、在請求側拒絕不生效。 -
MongoDB 的
blacklist.columns擋得住換名了。 遮罩只看回傳文件的鍵名,而 aggregation 自己決定那些鍵名:$project:{"leak":"$password"}、$addFields、$set都把值搬到別的鍵下原文回傳,query-only即可。$group:{"_id":"$password"}更是保證出口——_id為了保住文件參照而被無條件豁免。回應側追不完,所以改成請求側拒絕:請求裡指名受保護欄位就拒絕,與 Elasticsearch 的namesProtectedField同一個形狀,包含同樣的過度拒絕(值剛好等於受保護欄位名也會被拒)。檢查掛在assertNoMongoServerSideScript旁邊,那是所有 MongoDB 路徑本來就共用的攔截點。
Added
-
audit.strict設定(預設false)。 開啟時,送出前那一列 audit 寫不出去就拒絕執行請求。audit 一直是 best-effort——磁碟滿、目錄不可寫、lock budget 耗盡(可被刻意耗盡)時操作照樣執行、零紀錄,只有一行 stderr 警告,而管線模式通常看不到。對多數指令這是對的取捨;但 ES shell 這條路徑上 audit 就是控制本身,而先前連相反的取捨都無法表達。只管送出前那一列:outcome寫不出去時請求已經在叢集上了。 -
bun run manifest:check(andmanifest:sync) makes manifest drift fail the build. It checks every manifest's version againstpackage.json, the plugin name, that declared skill directories and entry files exist, that the root and portable copies agree, that the marketplace listing names the plugin, and thatSECURITY.md's supported major matches the package. Blocking in CI's drift job and inrelease:check.tests/unit/scripts/plugin-manifest-contract.test.tsbreaks one thing at a time in a scratch tree and asserts the script goes red, because a guard only ever run against a correct tree is how five manifests drifted through a green build. -
Frozen legacy artifact fixtures under
tests/fixtures/evidence-legacy/. Six files produced by the code that actually shipped — v2.1.0's builder and v3.0.0's — never regenerated. A test that builds its own "legacy" input proves the builder agrees with itself, which is precisely the check that would have caught none of this. -
CI runs a dependency audit and installs from a frozen lockfile.
bun auditran only insiderelease:check, by hand, shortly before a release; it is now its own blocking job on every push. Every job installs with--frozen-lockfile, and the workflow declarespermissions: contents: read.
Changed
-
BREAKING:
@carllee1983/dbcli/core不再匯出AdapterFactory。 它回傳的 adapter 的request()是 public,所以任何函式庫使用者都能拿到一條不經 permission、不經 blacklist、不寫 audit 的路徑。QueryExecutor與DataExecutor保留——它們自己帶著閘門。CLI 使用者不受影響。 -
_update_by_query從read-write收緊為admin:它是獨立的區段,精確比對之下落到破壞性預設,而它確實會改寫索引裡的每一份文件。 -
BREAKING (artifact format): evidence packs and receipts are
version: 2, and the format version is no longer tied to the package version. v3.0.0 changed the pack digest input, the id derivation, thecoveragefield and the receiptobservationwhile leaving both constants at1— ADR-0012 decided to amend the schema in place, which was right about the repairs and wrong about the number. A reader now classifies an artifact by version and structure before computing anything:v1-coverage(2.1.0 and earlier),v1-untagged-v3(3.0.0), current, or unsupported. Both legacy formats stay readable and integrity-checkable through frozen reimplementations of their own digests, and neither is evertrust: "current-valid". There is no migration and cannot be one — a pack's id derives from its digest, so rewriting an old pack would mint a new artifact wearing an old one's provenance, and a v2.1.0 receipt's hashedobservationcan only be "recovered" by inverting it, which is a guess and not a record. Recorded indocs/adr/0013-evidence-artifact-format-versions-are-independent-of-the-package-version.md, amending ADR-0012. -
evidence validate --format jsondistinguishes three answers where it used to give one.statusiscurrent-valid,current-references-expired,recognized-legacy, orunsupported, withtruststating plainly whether the pack may be relied on;recognized-legacyalso carrieslegacyFormat,producedBy, and that format's ownintegrity. An unknown version fails closed. Previously every one of these arrived asevidence pack digest mismatch, which tells the holder of a two-week-old file that someone tampered with it. -
SECURITY.mddocuments the major line that actually exists. It listed1.xas supported against a3.0.0package — a security document making a false promise about which releases get fixes.3.xis supported;2.xand1.xare explicitly not, with no backport commitment invented to soften it. -
Plugin and extension manifests carry the package version.
.claude-plugin,.codex-plugin,.cursor-plugin,gemini-extension.jsonand the portableplugins/dbcli-agentcopy all read1.51.2— two majors behind what a user installing from a marketplace would receive.