檢查日期 2026-09-19|對應卡片 CM-1951|檢查範圍 12 個檔案 842 行|FR-111 最後一棒
這一棒範圍內零漏洞——工具唯一提到範圍內的那一條被三位檢查員一致駁回,我開檔核對後同意;工具實際報出的四條全部落在別的檔案,而且四條都是第三棒(E3)已經報過的同一批問題,不是新發現。
換句話說:這一棒沒有替 FR-111 增加任何待修項目。 但它給了一個有價值的答案——卡片懷疑的那三類問題(「空白查詢回整張表」「查詢條件有幽靈預設值」「資料庫隔離只擋一半」),在這批檔案上一個都不成立,而且不是碰巧沒事,是程式裡有明確的防範並寫了理由。
唯一需要決策者知道的一件事:工具的研究員這次跑出範圍去挖了——它讀完這 12 個檔案覺得沒東西,就順著呼叫鏈追到隔壁的舊版程式,把 E3 已經報過的四條又報了一次。這不是工具出錯,是它盡責;但代表這批邊界檔案本身確實乾淨到讓它找不到題目。詳見「這份結果可信到什麼程度」。
前四棒看的是「事情怎麼做」(容器怎麼跑、批次怎麼分類、舊版怎麼讀檔、設定怎麼管)。這一棒只看「大門長什麼樣」——不管裡面的邏輯,只問四件事:
選它當最後一棒的理由:這四類問題只看邊界就能判,不需要懂業務邏輯——正因如此,它們也最容易在寫功能時被跳過。而且第二棒(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 | 分類結果的查詢條件格式 |
範圍內:零條。
工具提出六個候選,經三位檢查員投票後四個通過。但要分清楚這四個在哪裡:
| # | 工具判定 | 位置 | 是不是本棒新發現 |
|---|---|---|---|
| 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 那四條的可信度。
工具說什麼。 evidence_batch_file_domain_service.py:32 的 list_by_batch(batch_id) 把批次編號當查詢條件傳下去,而共用底層的規則是「條件是空的就不加這條」。所以如果 batch_id 是空的,這句查詢就變成「查全部檔案」,使用者拿一個檔案編號就能對到別的批次的檔。
為什麼三位檢查員都說不成立。 因為「batch_id 是空的」這個前提打不到。三位各自查了不同的路,結論一致:
evidence_batch_service.py:1008,我開檔確認 batch_id=batch.id 寫在建立參數裡),而且共用底層的更新只覆寫「有值」的欄位,事後也抹不掉。evidence_classification_service.py:582-597),回的是 run_folder_id(資料夾編號)而不是 uid。攻擊者拿不到可用的入場券。get_run_file_preview)開頭就呼叫 _require_run,那支查不到專案歸屬一律拒絕(evidence_batch_service.py:922-924,程式碼自己寫了理由:「沒有專案就沒有可判的授權對象——放行等於讓任何人讀到這筆」)。我的核對。 上面四點我逐項開檔看過,都屬實。另外補一件檢查員沒提但值得記的事:這條路上還有第二道防線——get_run_file_preview 拿到檔案清單後,還會比對「這個檔案編號有沒有出現在這個批次裡」(evidence_batch_service.py:871-876),而且程式碼旁邊寫了理由:
🔴 只能預覽這個 run 所屬批次內的檔:不比對的話,任何 run uid 配上任意 file uid 就能把別人批次的檔案讀出來。
這正是 E3-1 那條高風險漏洞的正確寫法。 新版寫對了,舊版沒寫——同一個套件裡兩種做法並存,這件事本身就是 E3 那四條該修的最好證據。
卡片列了六個重點。工具一個都沒碰(它的兩位研究員都跑去追舊版的呼叫鏈了),以下全部是人工開檔查證。
問的是:欄位清單有沒有多放行什麼?會不會被整包展開成資料物件(業界叫「大量指派」)?
結論:不會,而且是結構上不可能,不是碰巧。
理由是路由層根本沒有用欄位清單去解析請求——它是一個欄位一個欄位手動取的(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 分成兩支且寫了很長的註解說明為什麼不能混用(混用的症狀是主鍵撞號、整批分類結果寫不進去)。這是踩過坑之後留下的紀錄,屬於該留的註解。
要分兩層講,這兩層差很多。
第一層:「這 12 個檔案裡有沒有我漏掉的洞?」——可信度中等偏高。
正面依據:驗證機制完整跑完(驗證章 verified),18 票全數投出、沒有漏投、沒有候選被上限砍掉;而且卡片列的六個重點我全部逐項開檔核過,不是只靠工具。查詢格式 21 個欄位、資料庫 8 條規則、分頁上限、欄位放行方式,都是一項一項數過的。
要打折的地方,這次特別明顯:工具的五次研究嘗試沒有一次是專心看這批檔案的。它讀完覺得沒題目,就順著呼叫鏈跑去舊版程式挖,報回來的六個候選裡五個在範圍外。所以「工具說這批檔案沒問題」這句話的分量很輕——真正的依據是我的人工逐項查證。反過來說,這也是一個訊號:連盡責的研究員在這批檔案上都找不到題目。
第二層:「證據分類這整件事安全嗎?」——這一棒答不了,但 FR-111 五棒合起來可以答一大半。
本棒只看邊界,業務邏輯在 E2、容器在 E1、舊版線在 E3、設定在 E4。五棒合起來涵蓋了這個套件的主要路徑,但仍有兩塊沒碰:主系統這一側的接線(金鑰怎麼解析、儲存後端怎麼接),以及實際打端點驗證——本棒全部是讀原始碼推論,沒有打過任何一支端點、沒有連過資料庫寫入、沒有跑測試。
一件要說清楚的打折:④ 的資料庫規則我是讀建表腳本核對的,DEV 上的實際狀態引用的是卡片裡 2026-09-18 的唯讀查詢結果,本棒沒有重新查一次資料庫。腳本與實際狀態理論上一致(腳本套過就是那樣),但沒有當下實測。
| 項目 | 數值 |
|---|---|
| 掃描工具 | 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。
這是 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 的四條會隨之消失;如果要留,那四條就得補守門。這個取捨要由決策者定。