---
title: E5 檢查結果：邊界層——欄位驗證／查詢預設／資料層過濾／資料庫隔離規則（jedi-evidence-classification）
---

# E5 檢查結果：邊界層——欄位驗證／查詢預設／資料層過濾／資料庫隔離規則（jedi-evidence-classification）

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

## 🔴 一句話結論

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

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

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

## 這一棒在檢查什麼

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

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

## 掃到什麼（總覽）

**範圍內：零條。**

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

| # | 工具判定 | 位置 | 是不是本棒新發現 |
|---|---------|------|----------------|
| 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 那四條的可信度**。

## 駁回的那一條（範圍內唯一候選）

### 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 那四條該修的最好證據。

## 卡片重點逐項查證

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

### ① 前端送進來的欄位放行了什麼（工具未報，人工查證）

**問的是**：欄位清單有沒有多放行什麼？會不會被整包展開成資料物件（業界叫「大量指派」）？

**結論：不會，而且是結構上不可能，不是碰巧。**

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

```python
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 的意義

**這是 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 的四條會隨之消失；如果要留，那四條就得補守門。**這個取捨要由決策者定。**
