E5 檢查結果:邊界層——欄位驗證/查詢預設/資料層過濾/資料庫隔離規則(jedi-evidence-classification)

E5 檢查結果:邊界層——欄位驗證/查詢預設/資料層過濾/資料庫隔離規則(jedi-evidence-classification)

檢查日期 2026-09-19|對應卡片 CM-1951|檢查範圍 12 個檔案 842 行|FR-111 最後一棒

§1

🔴 一句話結論

這一棒範圍內零漏洞——工具唯一提到範圍內的那一條被三位檢查員一致駁回,我開檔核對後同意;工具實際報出的四條全部落在別的檔案,而且四條都是第三棒(E3)已經報過的同一批問題,不是新發現。

換句話說:這一棒沒有替 FR-111 增加任何待修項目。 但它給了一個有價值的答案——卡片懷疑的那三類問題(「空白查詢回整張表」「查詢條件有幽靈預設值」「資料庫隔離只擋一半」),在這批檔案上一個都不成立,而且不是碰巧沒事,是程式裡有明確的防範並寫了理由。

唯一需要決策者知道的一件事:工具的研究員這次跑出範圍去挖了——它讀完這 12 個檔案覺得沒東西,就順著呼叫鏈追到隔壁的舊版程式,把 E3 已經報過的四條又報了一次。這不是工具出錯,是它盡責;但代表這批邊界檔案本身確實乾淨到讓它找不到題目。詳見「這份結果可信到什麼程度」。

§2

這一棒在檢查什麼

前四棒看的是「事情怎麼做」(容器怎麼跑、批次怎麼分類、舊版怎麼讀檔、設定怎麼管)。這一棒只看「大門長什麼樣」——不管裡面的邏輯,只問四件事:

  • 前端送進來的欄位,程式放行了哪些? 多放行一個沒人注意的欄位,使用者就可能塞進程式沒打算讓他碰的東西(例如「這筆已刪除」「這筆屬於誰」)。
  • 查詢條件不填時,預設是什麼? 這批程式的共用底層有一條規則:「條件有填才加進去」。所以如果某個欄位的預設值不是「空」,它就會變成一條誰都沒察覺的固定篩選條件——查出來的結果永遠少一塊,而且不報錯。反過來如果該填的沒強制填,就變成「送一個空白查詢回整張表」。
  • 資料層取資料時帶了哪些條件? 同上,但看的是實際拼查詢的那一層。
  • 資料庫自己的隔離規則怎麼寫? 這是最後一道防線:就算程式層漏了,資料庫也該擋住「A 客戶讀到 B 客戶的資料」。

選它當最後一棒的理由:這四類問題只看邊界就能判,不需要懂業務邏輯——正因如此,它們也最容易在寫功能時被跳過。而且第二棒(E2)的主幹檔案已經 1,653 行、逼近單棒上限,這批資料層檔案切不進去,只能獨立成一棒。

掃描目標 ~/Projects/Jedicogy/module/jedi-python-package/jedi-evidence-classification,branch feature/review,版本 955e409,mode scan,effort low,focus attack-surface。

範圍十二個檔案:

檔案 行數 角色
migrations/003-evidence-batches.sql 220 兩張批次表的建表腳本,含權限與資料庫隔離規則
api/schemas/evidence_batch_schema.py 116 前端送進來/回出去的欄位清單
domain/service/classification_run_domain_service.py 110 分類結果的取用與更新
app/service/run_state_keys.py 82 容器回來的代號與任務側代號的互轉
infra/repository/evidence_batch_repository_impl.py 71 批次資料層(含逾時改判與心跳)
domain/service/evidence_batch_file_domain_service.py 67 批次檔的取用
domain/service/evidence_batch_domain_service.py 62 批次的取用
infra/repository/evidence_batch_file_repository_impl.py 28 批次檔資料層(純繼承)
infra/repository/classification_run_repository_impl.py 28 分類結果資料層
domain/entity/evidence_batch_query_entity.py 20 批次的查詢條件格式
domain/entity/evidence_batch_file_query_entity.py 20 批次檔的查詢條件格式
domain/entity/classification_run_query_entity.py 18 分類結果的查詢條件格式
§3

掃到什麼(總覽)

範圍內:零條。

工具提出六個候選,經三位檢查員投票後四個通過。但要分清楚這四個在哪裡:

# 工具判定 位置 是不是本棒新發現
F1 🔴 高(3:0 通過) evidence_classification_service.py:709(範圍外) ❌ E3-1 已報
F2 🟡 中(3:0 通過) evidence_classification_service.py:686(範圍外) ❌ E3-2 已報
F3 🟡 中(2:1 通過) evidence_classification_service.py:1066(範圍外) ❌ E3-2 已涵蓋(E3 報告第 109 行明列這六支端點)
F4 🟡 中(3:0 通過) evidence_drive_ops.py:76(範圍外) ❌ E3-3 已報
F5 ⬜ 駁回(0:3) evidence_classification_service.py:991(範圍外) —
F6 ⬜ 駁回(0:3) evidence_batch_file_domain_service.py:32(範圍內) —

唯一落在本棒範圍內的候選是 F6,被三位檢查員一致駁回,我核對後同意。 詳見下方「駁回的那一條」。

四條重複報的處理:依首腦驗收紀律,重複報由驗收者挑掉,不重複計入跨 arc 總表。E3 那份報告的修法寫得更完整(它把六支端點一次列齊並指出要一起修),以 E3 為準。這裡不再重述細節,只記錄「第二組獨立的研究員與檢查員,在不知道 E3 存在的情況下,獨立重現了同樣四條」——這反過來提高了 E3 那四條的可信度。

§4

駁回的那一條(範圍內唯一候選)

F6 — 「批次編號沒填時,查檔案會查到整個客戶的檔案」(駁回,0:3,我同意)

工具說什麼。 evidence_batch_file_domain_service.py:32 的 list_by_batch(batch_id) 把批次編號當查詢條件傳下去,而共用底層的規則是「條件是空的就不加這條」。所以如果 batch_id 是空的,這句查詢就變成「查全部檔案」,使用者拿一個檔案編號就能對到別的批次的檔。

為什麼三位檢查員都說不成立。 因為「batch_id 是空的」這個前提打不到。三位各自查了不同的路,結論一致:

  1. 新版批次線的分類結果一定有批次編號——建立的那一刻就填了(evidence_batch_service.py:1008,我開檔確認 batch_id=batch.id 寫在建立參數裡),而且共用底層的更新只覆寫「有值」的欄位,事後也抹不掉。
  2. 舊版 Drive 線的結果確實沒有批次編號(那條線根本沒有批次的概念),但它的內部識別碼從來不會回給前端——我 grep 過舊線唯一對外的資料組裝函式(evidence_classification_service.py:582-597),回的是 run_folder_id(資料夾編號)而不是 uid。攻擊者拿不到可用的入場券。
  3. 就算拿到了也會被擋——唯一會走到這條路的端點(get_run_file_preview)開頭就呼叫 _require_run,那支查不到專案歸屬一律拒絕(evidence_batch_service.py:922-924,程式碼自己寫了理由:「沒有專案就沒有可判的授權對象——放行等於讓任何人讀到這筆」)。
  4. 刪除批次時的殘留窗口也關著——刪批次會先刪結果列再刪批次列(同一個交易內),不會留下「批次沒了但結果還在、批次編號被清空」的孤兒列。

我的核對。 上面四點我逐項開檔看過,都屬實。另外補一件檢查員沒提但值得記的事:這條路上還有第二道防線——get_run_file_preview 拿到檔案清單後,還會比對「這個檔案編號有沒有出現在這個批次裡」(evidence_batch_service.py:871-876),而且程式碼旁邊寫了理由:

🔴 只能預覽這個 run 所屬批次內的檔:不比對的話,任何 run uid 配上任意 file uid 就能把別人批次的檔案讀出來。

這正是 E3-1 那條高風險漏洞的正確寫法。 新版寫對了,舊版沒寫——同一個套件裡兩種做法並存,這件事本身就是 E3 那四條該修的最好證據。

§5

卡片重點逐項查證

卡片列了六個重點。工具一個都沒碰(它的兩位研究員都跑去追舊版的呼叫鏈了),以下全部是人工開檔查證。

① 前端送進來的欄位放行了什麼(工具未報,人工查證)

問的是:欄位清單有沒有多放行什麼?會不會被整包展開成資料物件(業界叫「大量指派」)?

結論:不會,而且是結構上不可能,不是碰巧。

理由是路由層根本沒有用欄位清單去解析請求——它是一個欄位一個欄位手動取的(evidence_batch_route.py:52-70):

round_uid = body.get("round_uid")
threshold = body.get("confidence_threshold")
...
batch = _service().create_batch_for_round(
    round_uid=round_uid, provider=body.get("provider") or None, ...)

使用者多送一百個欄位也沒用,程式只會去拿它認得的那五個。所以 FR-109 V1 那條 apply=False 的病(宣告了驗證但叫框架跳過,請求被整包展開)在這裡打不到——我 grep 了全套件的 apply=False,零命中(這是跨 arc 總表建議的「全套件搜一遍」那項,本套件可標記為已查)。

順帶查了卡片問的 unknown=EXCLUDE/RAISE:兩個都沒設(用 marshmallow 預設,是 RAISE)。但如上所述這批 schema 在建立路徑上沒被拿來解析請求,所以這一項不影響安全。

一個值得記的好習慣:回出去的欄位清單是從 schema 自己推導的(BATCH_FIELDS = tuple(EvidenceBatchResponseSchema().fields.keys())),程式碼旁邊寫明理由——兩邊分開列的話多加一欄只改其中一處,症狀是前端永遠看不到那個欄位而且不報錯。這是對的做法。

② 查詢條件的預設值(工具未報,人工查證)

問的是:有沒有欄位的預設值不是「空」?那會變成一條看不見的固定篩選條件。

結論:三個查詢格式、共 21 個欄位,全部預設為空,零例外。

三個檔案我整份讀完了,而且其中兩個檔案自己就用紅字寫了這條規則:

🔴 每個欄位的預設值必須是 None:BaseRepositoryImpl 拿 __dict__ 組 WHERE,非 None 的預設會變成一條誰都沒察覺的幽靈條件。

這正是我們記在專案記憶裡的那條坑,而寫這支程式的人已經知道並寫下來了。 這一項不只沒問題,是主動防範。

③ 資料層過濾與分頁(工具未報,人工查證)

問的是:取列表時帶了哪些條件?有沒有分頁上限(沒有上限的話一次要一百萬筆會拖垮伺服器)?

分頁有上限,是 200 筆(evidence_batch_route.py:247:_int("page_size", 25, 1, 200))。要再多也只給 200。跨 arc 總表第 31 項那個「分頁無上限」的同型問題,這裡有防到。

「空白查詢回整張表」也防到了,而且防在更前面一層:列批次的入口強制必須帶「輪次」或「專案」其中一個,兩個都不帶直接回 400(evidence_batch_service.py:422-432)。程式碼寫了理由:

必須指定 round_uid 或 project_uid 其一——沒有範圍就沒有專案可判授權,全開清單等於「拿到 token 就看得到全租戶的批次」。

而且帶了範圍之後還會再檢查你是不是那個專案的成員(_require_participant)。這是跨 arc 總表第 70 項那條全站問題,本套件屬於「有防到」那一類——E2 已經對同一支函式做過同樣的判定,本棒從資料層這一側再確認一次,結論相同。

三支資料層檔案本身:兩支是純繼承(沒有自訂查詢),一支多了兩個自訂更新(逾時改判、心跳),兩個都是「條件與更新寫在同一句 SQL」,程式碼寫了理由——撈出來再逐列改的話,兩步之間跑完的批次會被蓋回失敗。這是對的寫法(避免了競態)。

④ 資料庫隔離規則的形狀(工具未報,人工查證)

問的是:兩張表的隔離規則怎麼寫?只擋讀還是連寫也擋?子表有沒有自己的客戶欄位?

結論:八條規則都在,讀寫都擋,子表有自己的客戶欄位(不靠父表傳遞)。

逐項核對:

查什麼 結果
兩張表各幾條規則 各 4 條(查/新增/改/刪),共 8 條,我一條一條數過
新增與修改擋不擋(卡片問的 WITH CHECK) 擋。新增那條明寫 WITH CHECK;修改那條只寫了 USING 沒寫 WITH CHECK,但這在 PostgreSQL 是等價的——沒寫 WITH CHECK 時資料庫會拿 USING 那段來當新值的檢查。所以「改掉客戶編號把資料搬給別人」是擋得住的
子表靠不靠父表傳遞 不靠。evidence_batch_files 自己就有 tenant_id 欄位且不可為空(建表腳本 :97)。FR-109 V1 第 ⑦ 項那種「靠父表傳遞」的鏈在這裡不存在
有沒有 FORCE 沒有(只 ENABLE)。檔頭自己說明了:DEV 上表的擁有者是 cmmgr、應用程式連線用的是 cm_app,兩者不同人所以 ENABLE 就生效;但如果哪個環境把表的擁有者改成連線帳號本人,就得補 FORCE,否則等於沒開

關於「只擋客戶、不擋部門」:卡片問的這一點屬實,而且檔頭寫了理由——部門那個判斷函式在「使用者沒有部門」時會回 false,而系統裡確實有沒填部門的使用者,加上去會讓那些人新增資料時被擋住且無從繞過。所以部門層級的隔離是交給程式層的成員檢查在做(E2 已驗過那一層是有的)。這是一個有意識的取捨並留了紀錄,不是疏忽。

DEV 實查(唯讀,2026-09-18 卡片已記):兩張表隔離已開、各 4 條規則、未 FORCE。正式環境的服務走 cm_app 連線所以受規則約束;但用 cmmgr 連線時會繞過(那是維運帳號,不是服務帳號)。

一個值得記的防呆:整段隔離規則包在「如果主系統的判斷函式不存在就跳過」的條件裡,而且留了說明。理由寫在檔頭:

不吃這套生態的 consumer 硬開 RLS 卻建不出 policy,該表會變成「一列都讀不到」而且不報錯——比不開更糟。

這是對的——半套的隔離比沒有隔離更難查。

⑤ 代號互轉(工具未報,人工查證)

問的是:容器回來的代號與任務側的代號對不上時,是丟掉還是保留?

結論:保留原值並標記為「轉不出來」,不丟掉。 這正是 E2 當時採信的前提,本棒開檔確認屬實(run_state_keys.py:62-65)。程式碼寫了理由:

靜默丟掉會讓使用者看到「分類完卻什麼都沒配到」。

沒有安全疑慮(純資料轉換、無外部輸入、無查詢、無寫入),但這個判斷本身是對的——靜默丟資料是我們在別的套件踩過的坑。

⑥ 三支取用服務是不是薄殼(工具未報,人工查證)

問的是:這三支有沒有多帶客戶條件?狀態欄位的合法值有沒有在這層驗?

結論:是薄殼,沒有多帶客戶條件,也沒有驗狀態值——這兩件都是設計上正確的。

  • 沒帶客戶條件:由資料庫的隔離規則兜底。但要注意兜底只在服務走 cm_app 連線時成立,用維運帳號連線時這層就沒了——這一點已經記在 ④。
  • 沒驗狀態值:狀態的合法轉換在上層(evidence_batch_service.py 的狀態機,E2 已驗過),這層只負責存取。放在這層反而會有兩份規則各自演化的問題。

三支裡唯一有較多邏輯的是分類結果那支,它的 upsert_run 與 update_run 分成兩支且寫了很長的註解說明為什麼不能混用(混用的症狀是主鍵撞號、整批分類結果寫不進去)。這是踩過坑之後留下的紀錄,屬於該留的註解。

§6

這份結果可信到什麼程度

要分兩層講,這兩層差很多。

第一層:「這 12 個檔案裡有沒有我漏掉的洞?」——可信度中等偏高。

正面依據:驗證機制完整跑完(驗證章 verified),18 票全數投出、沒有漏投、沒有候選被上限砍掉;而且卡片列的六個重點我全部逐項開檔核過,不是只靠工具。查詢格式 21 個欄位、資料庫 8 條規則、分頁上限、欄位放行方式,都是一項一項數過的。

要打折的地方,這次特別明顯:工具的五次研究嘗試沒有一次是專心看這批檔案的。它讀完覺得沒題目,就順著呼叫鏈跑去舊版程式挖,報回來的六個候選裡五個在範圍外。所以「工具說這批檔案沒問題」這句話的分量很輕——真正的依據是我的人工逐項查證。反過來說,這也是一個訊號:連盡責的研究員在這批檔案上都找不到題目。

第二層:「證據分類這整件事安全嗎?」——這一棒答不了,但 FR-111 五棒合起來可以答一大半。

本棒只看邊界,業務邏輯在 E2、容器在 E1、舊版線在 E3、設定在 E4。五棒合起來涵蓋了這個套件的主要路徑,但仍有兩塊沒碰:主系統這一側的接線(金鑰怎麼解析、儲存後端怎麼接),以及實際打端點驗證——本棒全部是讀原始碼推論,沒有打過任何一支端點、沒有連過資料庫寫入、沒有跑測試。

一件要說清楚的打折:④ 的資料庫規則我是讀建表腳本核對的,DEV 上的實際狀態引用的是卡片裡 2026-09-18 的唯讀查詢結果,本棒沒有重新查一次資料庫。腳本與實際狀態理論上一致(腳本套過就是那樣),但沒有當下實測。

§7

執行概況

項目 數值
掃描工具 Claude Code claude-security plugin 0.10.2.3
Run ID wf_9073608f-905
報告目錄 jedi-evidence-classification/CLAUDE-SECURITY-20260919-090510/
驗證章 CLAUDE-SECURITY-REVISION-955e40964baa-dirty.json
掃描版本 955e40964baa3cfc0e8766078db7d65075d3c50b(branch feature/review)
dirty true(工作區有未追蹤的 .claude/ 目錄,非範圍內檔案)
mode/effort/focus scan/low/attack-surface
範圍 12 檔 842 行(啟動前 git ls-files 驗過,與卡片一致)
研究員派出/回報 2/2
候選數 6(去重後仍 6)
面板票數 18(=候選 6 × 3 位檢查員),全數投出,無漏投
通過門檻的發現 4(其中 範圍內 0、範圍外 4,且四條全為 E3 重複報)
被駁回的候選 2(F5 0:3、F6 0:3)
verification.status verified(照 stamp 原文抄;意思是「驗證程序完整跑完」,不是「程式證明無漏洞」)
被上限砍掉的候選 0
未被審的候選 0
耗時 約 5 小時 32 分(19,906 秒)
agent 總數 20,全部完成、零錯誤

🔴 這一棒耗時異常的原因(值得記進方法論)

5 小時 32 分是 FR-111 五棒裡最久的一棒,而範圍是最小的(842 行)。 原因不是範圍,是研究員被系統強制中斷了五次:

第幾次 撐了多久 被中斷前累積的上下文
1 64 分鐘 約 24.4 萬
2 29 分鐘 約 20.8 萬
3 27 分鐘 約 20.8 萬
4 22 分鐘 約 19.9 萬
5 33 分鐘 約 23.2 萬 → 成功交件
密鑰專項 38 分鐘 約 21.4 萬 → 也被中斷一次後重跑

工具的日誌原文記為 [stall] agent "research:repository:all" stalled (no progress) after 3866s — retrying (1/5)。機制是:研究員三分鐘內沒有任何動作就被判定當掉、砍掉換一個新的從頭開始。四次被砍的紀錄末尾都是同一句、而且間隔都精準落在 3 分 12 秒。

根因不是檔案多,是研究員追出了範圍。從紀錄看到它們在做的事:在主專案全庫搜尋誰呼叫這些函式、在主專案的 SQL 腳本裡找隔離判斷函式的定義、讀範圍外的資料轉換檔。追出去讀的量遠大於那 842 行本身,上下文於是堆到 20 萬以上,單次思考就破三分鐘。

這印證了手冊裡「接縫檔要算進範圍」那條,但也顯示那條不夠:本棒的接縫不只是幾個檔案,是整個主專案的呼叫鏈——無論怎麼算都放不進 2,000 行的上限。下次遇到這種「本體很小但接縫發散」的棒,該做的不是再切小,而是在卡片裡把接縫需要的事實先查好寫進去(誰呼叫、隔離函式怎麼寫),讓研究員不必自己跑出去挖。這條建議記給下一個 arc。

§8

對 FR-111 的意義

這是 FR-111 的最後一棒,五棒全部跑完。 本棒淨新增 0 條,所以 FR-111 的總帳不變。

但它回答了一個重要的問題:卡片懷疑的三類「結構性通病」——空白查詢回整張表、幽靈預設條件、隔離只擋一半——在這個套件的批次線上一個都不成立。這條線是新寫的(FR-107),而新寫的部分把舊線踩過的坑都避開了:查詢格式紅字寫明預設必須為空、列表強制帶範圍並檢查成員、預覽比對檔案歸屬、兩張表都有自己的客戶欄位。

對照之下,E3 那四條全部落在舊的 Drive 線上,而舊線已由 FR-107.6 T-6.1 拿掉前端入口、後端保留一版待刪(見 followup_fr107_remove_legacy_drive_classification_line)。所以 FR-111 真正的結論是:這個套件的風險集中在「待刪的舊線」,不在現行的新線。 排修正時這一點會影響優先序——如果舊線照計畫在下一版刪掉,E3 的四條會隨之消失;如果要留,那四條就得補守門。這個取捨要由決策者定。