---
title: E2 檢查結果：批次分類主幹——上傳／封口／分類／審核／歸檔／清理（jedi-evidence-classification）
---

# E2 檢查結果：批次分類主幹——上傳／封口／分類／審核／歸檔／清理（jedi-evidence-classification）

> 檢查日期 2026-09-19｜對應卡片 CM-1948｜檢查範圍 2 個檔案 1,900 行

## 🔴 一句話結論

**工具在這兩個檔裡一條發現都沒有，人工逐項把卡片七個重點全部開檔核完，也沒有找到可以讓人越權看到或改到別人資料的漏洞——守門是完整的。** 唯一通過投票的那條（連內部套件庫用明文連線）根本不在這次檢查的兩個檔裡，是研究員跑出範圍撞到的 `pyproject.toml`，而且**已經有卡在追**（CM-1634，這是第四次撞到同一件事），所以**標「重複不計」、不列為本棒的發現**。

白話講這一棒的結果：**證據批次這條主線寫得相當紮實。** 十個端點每一支都有守門、審核結果存回來時會擋掉不存在的檢查點代號、歸檔時用的是資料庫自己的檔案清單（所以前端就算偷塞別人的檔案編號也掛不上去）、背景執行緒每一次碰資料庫前都有把身分帶過去（含另外一條心跳執行緒）、跨租戶取檔在宿主那側有擋。

**但有三件事值得決策者知道**，都不是「現在有人能打進來」，而是「未來很容易踩到」或「留了不該留的東西」：

1. **分類失敗時，錯誤訊息的原文會直接存進資料庫給前端看**——目前經手的例外訊息裡沒有密碼金鑰，但這等於**把上游套件的錯誤訊息無條件轉出去**，哪天某支套件的錯誤訊息裡帶了主機路徑或連線字串，就會跟著出現在畫面上，而沒有人會發現。
2. **容器印出來的畫面訊息（含錯誤輸出）原文存進資料庫，而且整個專案的成員都讀得到**——這把 E1 那棒「容器輸出原文落地」的影響面確定下來了：不是只有管理者看得到，是**同專案的每一位參與者**。
3. **批次刪掉之後，後端主機上的工作目錄不會被清**——證據檔的副本在分類當下被拉到 `~/.cm-jobs/<批次>-<run>/files/` 下，那一份分類完會刪；但同目錄的 `_state.json`（完整的判定結果）、`_report-original.json`、`_container-log.txt` **永久留著**，使用者把整批刪掉也不會消失。

三件事都記在下面「卡片重點逐項查證」裡，**不另立成發現**——理由逐條寫明。

## 這一棒在檢查什麼

證據批次是使用者實際在用的主線（舊的 Google 雲端硬碟那條已經標為過時）。整條流程是：稽核專案的管理者建一個批次 → 上傳一批證據檔 → 按「完成上傳」把批次封口 → 按「分類」起一個背景工作去跑 AI 容器 → 跑完進審核階段，人工核對 AI 判的對不對、可以增刪 → 按「歸檔」把每份檔案掛進對應的稽核任務當證據 → 最後「清理」把批次裡沒用到的檔刪掉，或「刪除」整批砍掉。

這一棒看的就是**這條流程的服務本體與它的十個端點**。

**選它當第二棒的理由**：守門的形狀在這裡是「先用批次編號查出它屬於哪個專案，再判你是不是這個專案的管理者或參與者」——判斷對象要先解出來才知道，這種守門沒辦法寫在路由那一層，全部散在服務的各支方法裡，**所以只要有一支漏寫，就是一個誰都能打的洞，而且不會有任何錯誤訊息**。另外有三個地方各是一種「採信誰」的問題：分類跑在背景執行緒（身分要自己帶過去，帶漏了資料庫就什麼都看不到）、審核結果由前端整包送回來覆蓋（前端說什麼就是什麼嗎）、歸檔時採信那份審核結果（那份結果能不能被動手腳）。

掃描目標 `~/Projects/Jedicogy/module/jedi-python-package/jedi-evidence-classification`，branch `feature/review`，版本 `955e409`（工作區乾淨），mode `scan`，effort `low`，focus `attack-surface`。

範圍兩個檔案：

| 檔案 | 行數 | 角色 |
|------|------|------|
| `jedi_evidence_classification/app/service/evidence_batch_service.py` | 1,653 | 服務本體：狀態機、守門、分類背景工作、歸檔、清理 |
| `jedi_evidence_classification/api/routes/evidence_batch_route.py` | 247 | 十二個路由類別：解析請求、呼叫服務、組回應 |

**本套件的第一棒 E1 已掃完**（容器執行鏈，三條發現一中二低）。E2 是 E1 的上游（誰去啟動容器、容器回來的東西怎麼入庫），E5 是 E2 的資料層（查詢條件、儲存庫、資料庫的租戶隔離）。

## Coverage

兩個檔案全部讀完，就是範圍給的全部。

**沒讀的部分要講清楚**：領域服務、儲存庫實作、查詢條件物件、容器執行器（E1 已掃）、儲存後端轉接器，研究員為了追資料從哪裡來有讀進去當背景，**但沒有審**。宿主這側的守門轉接器（`compliance-manager-be/core/plugins/evidence_classification.py`，裡面的 `is_project_manager` 才是真正去查「你是不是管理者」的那段）不在本棒範圍，本棒只看套件有沒有正確呼叫它——**呼叫是正確的，但那支自己寫得對不對本棒沒驗**。

低強度跑法不做元件盤點、不做威脅建模、不跑額外廣度掃描，`completenessCheckOutcome` 是 `not-applicable`（指定範圍的掃描本就不適用）。派兩位研究員、兩位都回報。沒有候選被上限砍掉、沒有候選未被審、沒有元件被丟棄。

**🔴 這次工具的表現要如實講：範圍內零發現。** 三個候選裡通過投票的那一條在 `pyproject.toml`——**那個檔不在指定範圍內**，是研究員追著程式碼跑出去撞到的。另外兩條被三位檢查員一致駁回（0:3）。**所以這份報告的主體不是工具產出，是首腦逐項開檔查的結果**，下方七項每一項都標明了是誰查的。

**本棒把行數壓在 2,000 以下（1,900）的判準有效**：沒有中斷、沒有重試、沒有撞額度，研究員沒有被停滯偵測砍掉。

工具沒有執行任何程式碼：沒有打過任何端點、沒有起過容器、沒有連過資料庫、沒有跑測試。所有結論都是讀原始碼推出來的。

## 掃到什麼（總覽）

**本棒範圍內：零條發現。**

工具唯一通過投票的一條，性質是越界＋重複：

| # | 嚴重度 | 這是什麼問題（白話） | 出事會怎樣 | 要先有什麼才打得到 | 位置 | 處置 |
|---|--------|---------------------|-----------|------------------|------|------|
| — | 🟡 中等 | 連公司內部套件庫用的是沒有加密的明文連線 | 同一個網路裡有心人可以在安裝套件的當下掉包內容，被掉包的版本編號還會被記進鎖定檔，之後重裝會一直裝到同一份 | 攻擊者要先在公司網段上（例如接進同一個區網、或某台網路設備被入侵）；而且要剛好有人在跑會重新解析套件版本的指令 | `pyproject.toml:48` | **重複不計**，見下 |

**為什麼標「重複不計」：** 兩個理由都成立。其一，**越界**——這個檔不在本棒指定的兩個檔內，是研究員自己跑出去讀到的。其二，**早就有卡在追**——這是跨 arc 總表的 **CM-1634**，之前已經在 FR-081 I5、FR-085 C3-1（jedi-common）、FR-088 P1 F2（jedi-flow-engine）撞到過三次，**本棒是第四次**。整個 monorepo 26 個專案的設定都一樣，屬於全域性的供應鏈問題，**不是這個套件的疏漏，也不該記在這一棒的帳上**。

不過這次撞到有一個**額外的資訊值得補進 CM-1634**：`jedi-evidence-classification` 的鎖定檔（`poetry.lock`）**是有進版控的、而且帶著雜湊值**，所以在這個套件上，單純的重新安裝（`poetry install`）是有第二道防線的——與 FR-088 那輪發現「整個 monorepo 把鎖定檔排除在版控外」的情況不同。**真正沒有防線的是專案自己文件裡寫的那條更新路徑**（`poetry update`），那個動作會重新解析並改寫鎖定檔，掉包的雜湊值就這樣被寫成「正確答案」。

## 卡片重點逐項查證

卡片列了七個重點。**工具在範圍內零發現，所以以下七項全部是首腦開檔人工查的**，每項都標了來源。查法是實際開檔讀、grep 原始碼、一路追到宿主的呼叫端核對。

### ① 十個端點逐支對守門（工具未報，人工查證）

**結論：十二個端點全部有守門，沒有一支漏掉。** 逐支列出來給新手工程師核對：

| 端點 | 路由位置 | 服務方法 | 守門 | 掛在哪一行 |
|------|---------|---------|------|-----------|
| `POST /evidence-batches` 建批次 | `route.py:51` | `create_batch_for_round` | 管理者 | `service.py:400` |
| `GET /evidence-batches` 列批次 | `route.py:75` | `list_batches` | 參與者 | `service.py:424`／`:428`（兩條分支各一道） |
| `GET /evidence-batches/<uid>` 詳情 | `route.py:101` | `get_batch_detail` | 參與者 | `service.py:450` |
| `DELETE /evidence-batches/<uid>` 刪整批 | `route.py:115` | `delete_batch` | 管理者 | `service.py:720` |
| `POST .../files` 上傳 | `route.py:122` | `upload_files` | 管理者 | `service.py:501` |
| `DELETE .../files/<file_uid>` 刪檔 | `route.py:137` | `delete_file` | 管理者 | `service.py:530` |
| `POST .../seal` 封口 | `route.py:145` | `seal` | 管理者 | `service.py:540` |
| `POST .../classify` 分類 | `route.py:153` | `classify` | 管理者 | `service.py:982`（首腦已核） |
| `POST .../archive` 歸檔 | `route.py:174` | `archive` | 管理者 | `service.py:565` |
| `POST .../purge` 清理 | `route.py:181` | `purge` | 管理者 | `service.py:657` |
| `GET /classification-run/<uid>/state` 讀審核 | `route.py:195` | `get_run_state` | 參與者 | `service.py:810` |
| `PUT /classification-run/<uid>/state` 存審核 | `route.py:200` | `put_run_state` | 管理者 | `service.py:825`（首腦已核） |
| `GET .../file/<file_uid>` 預覽 | `route.py:210` | `get_run_file_preview` | 參與者 | `service.py:871`（首腦已核） |
| `GET .../validation-report` 報告① | `route.py:228` | `get_run_validation_report` | 參與者 | `service.py:895` |
| `GET .../adjudication-report` 報告② | `route.py:234` | `get_run_adjudication_report` | 參與者 | `service.py:906` |

**分不清「管理者」跟「參與者」的話**：寫入類動作（建、傳、刪、封口、分類、歸檔、清理、存審核結果）一律要**專案管理者**；讀取類（看清單、看詳情、預覽檔、看報告、讀審核結果）只要是**專案參與者**即可。這個分法本身合理。

**卡片特別點名要查的 `list_batches`（`:413`）——沒有問題，而且防得很明確。** 它強制必須帶 `round_uid` 或 `project_uid` 其中一個，兩個都沒帶直接回 400（`service.py:428-430`）。程式碼自己寫了理由：「沒有範圍就沒有專案可判授權，全開清單等於『拿到 token 就看得到全租戶的批次』」。**這正是總表第 70 項那個同型問題，這裡有防到。**

兩個補充觀察：

**補充一：`create_batch`／`add_file`／`remove_file`／`complete_upload` 這四支沒有守門，但這是正確的。** 它們**不是端點**——沒有任何路由直接叫得到。它們是內層方法，一律由有守門的外層方法（`create_batch_for_round`、`upload_files`、`delete_file`、`seal`）呼叫。程式碼把這件事寫在分隔註解上（`service.py:384-386`：「API 入口（route 直接呼叫這幾支；守門與防呆都在這層）」）。**不過這是一個需要看註解才知道的約定**——未來若有人把其中一支直接掛成端點，守門就沒了。建議在這四支的說明裡各加一句「內層方法，呼叫端須已完成守門」（E1 報告提過的同類建議）。

**補充二：認證（你是不是登入使用者）不在這個檔裡，但確實有。** 路由檔案自己說明了（`route.py:14-17`）：認證由 `mount_routes()` 套上宿主注入的裝飾器。我開了 `api/routing.py` 核對——**十二個路由類別全部經過 `R()` 包裝**（`:128-138`、`:66-78`），沒有一個漏掉。

### ② 審核結果整包覆寫與歸檔採信（工具未報，人工查證）

這是卡片最擔心的一項：前端把整份審核結果送回來覆蓋，歸檔時又照著它把檔案掛進任務。**查完的結論是：關鍵的三道都有擋，塞不進別人的東西。**

**檢查點代號（`part_id`）——有驗，而且是整批拒絕。** `put_run_state`（`service.py:832-841`）拿這次分類當下的目標集快照建一份合法清單，逐筆比對；**只要有一筆對不上就整個請求退回 400**，不會部分成功。程式碼寫了理由：「沉到 per-file 錯誤裡會看起來部分成功」。**所以塞別專案的檢查點代號塞不進去。**

**檔案編號（`file_uid`）——`put_run_state` 沒驗，但歸檔那側擋掉了，實際效果等同有驗。** 這一點要講得精確：`put_run_state` 確實**沒有**比對送進來的 `file_uid` 是不是屬於這個批次，理論上可以在審核結果裡塞一個別批次的檔案編號進去。**但它塞不出任何效果**，因為歸檔（`archive`，`service.py:576`）的做法是：**從資料庫撈出「這個批次自己的檔案列」逐列跑**，再拿每一列的 `file_uid` 去審核結果裡查（`placements_by_uid.get(row.file_uid)`）。方向是反過來的——不是照審核結果去找檔案，而是照資料庫的檔案去找審核結果。**所以審核結果裡多出來的編號是一把打不開任何鎖的鑰匙，完全沒有被使用。**

同樣的設計也出現在容器跑完回寫那裡（`_finalize_batch_run`，`service.py:1176-1180`）：一樣是撈資料庫的檔案列、拿 `row.file_uid` 去查容器回來的結果，對不上的直接跳過。**兩處都是同一個正確形狀。**

**這算缺口嗎——不算漏洞，但算一個未來的陷阱。** 目前沒有實際影響，因為兩個消費端都是「以資料庫為準」。**風險在於這是一個沒有寫下來的隱含約定**：第三個消費端只要改成「照審核結果去跑」就會出事。**建議在 `put_run_state` 加一道 `file_uid` 白名單比對**（跟 `part_id` 用同一個形狀，成本很低），把約定變成強制。**為什麼不另立成發現**：目前沒有可用的攻擊路徑，立成發現會讓決策者以為現在有洞。

**歸檔還有一道值得一提的把關：** `attach` 收的是資料庫那一列自己的 `file_id`（`service.py:601`），不是審核結果裡的任何值。**所以「掛哪一份實體檔上去」這件事，前端從頭到尾插不了手。**

順帶核了卡片沒問但同一區的一件事：`_placements_by_file_uid`（`service.py:758`）對標成「不適用」或「已刪除」的檔一律回空（`:772-774`），程式碼寫明理由是審核頁標旗標時不會一併清掉放置清單。**這個處理是對的**，否則使用者標了「不適用」的檔還是會被掛上任務。

### ③ 背景執行緒的身分（工具未報，人工查證）

**結論：帶得很完整，而且是本套件寫得最謹慎的一段。** 卡片提到主專案那邊 CM-1912 剛踩過「子執行緒不繼承身分、資料庫擋成 0 列還標成功」，**這裡沒有這個問題**。

逐一核對：

**身分從哪來——在請求內取好再交出去。** `classify()` 在還有請求脈絡的時候呼叫 `get_user_context()` 當參數傳給執行緒（`service.py:1060`），旁邊寫了紅字說明為什麼不能到執行緒裡才取。執行緒本體一進去就 `set_user_context(user_ctx)` 重建身分（`service.py:1096-1097`）。

**執行緒內每個碰資料庫的地方前面有沒有身分——有，三處全部在 `set_user_context` 之後。** 拉檔（`service.py:1113`）、回寫結果（`:1126`）、標記失敗（`_fail_batch`，`:1217`）。

**心跳那條執行緒——另外再傳一次，而且有人踩過坑留了紅字。** 心跳跑在自己的執行緒（不是主工作那條），身分變數不會被新執行緒繼承，所以建立時再傳一次（`service.py:1101`），心跳的迴圈一進去也自己 `set_user_context`（`service.py:1636-1637`）。類別說明裡的紅字把後果寫得非常清楚：少了這個就是無身分 → 資料庫看不到那一列 → 更新命中 0 列 → 而 0 列會被讀成「批次已結束」，於是**心跳從第一拍就自己停掉，整個機制靜默失效，資料庫上看不出任何異常**（`service.py:1616-1621`）。**這正是 CM-1912 那個坑，這裡不但避開了還留了警語。**

**其他相關的謹慎設計**（一併記錄，都是好的）：
- 交給執行緒的東西全部打包成純值（`service.py:1045-1058`），不傳資料庫物件過去——傳過去會在另一條執行緒上用到已經關掉的連線。
- 租戶編號一律用打包好的 `job["tenant_id"]`，不在執行緒裡重讀設定。這正是 memory `feedback_background_job_storage_config_no_context_trap` 記的那個坑。
- 宿主脈絡包住**整段含錯誤處理**（`service.py:1086-1087`），程式碼寫明理由：標記失敗本身也要查資料庫，只包住正常路徑的話失敗處理會因為同一個原因再失敗一次，批次就真的卡死了。
- 整段包在 try/except 裡，任何漏接的例外都會走到標記失敗，不會讓批次永遠卡在「分類中」。
- 另有一支開機掃描（`sweep_stale_classifying`，`service.py:1237`）處理「後端被砍掉重啟」的情況。

**這一項沒有任何問題。**

### ④ 失敗原因原文入庫（工具未報，人工查證）

**卡片的擔心成立：錯誤訊息的原文確實會存進資料庫、前端讀得到。** 但**目前經手的例外訊息裡沒有查到金鑰或密碼**，所以記為陷阱不記為漏洞。

**事實部分。** 非預期失敗時組的字串是 `f"{type(exc).__name__}: {exc}"`（`service.py:1179`），交給 `_fail_batch` 存進批次的 `failure_reason` 欄位（`service.py:1223`，截斷到 2000 字）。這個欄位會回給前端顯示。

**逐一追哪些例外會走到這裡**：

- **拉檔階段**（`stage_to_dir`）——這段在宿主，會碰儲存後端。若儲存後端連不上，例外訊息可能帶主機位址、桶名、路徑。**是否帶帳密取決於底層套件**（minio／seaweedfs 的客戶端），本棒沒有往下追到那一層，**這一點是不確定的，不下斷言**。
- **容器失敗**（`ContainerRunError`）——走的是另一條分支（`service.py:1175`），內容來自 E1 那棒已經查過的容器執行器。
- **程式自己的錯誤**（型別錯、鍵值不存在）——這類訊息裡通常是變數名與資料值，不是憑證。

**為什麼記為陷阱而不是漏洞：** 這個寫法的本質是**把上游套件的例外訊息無條件轉給使用者看**。今天安全是因為現在經手的那幾支套件不把敏感資訊放進例外訊息——**但這是依賴別人的行為，不是自己的保證**。哪天某支套件改了寫法，或換了一個儲存後端，敏感資訊就會靜靜出現在畫面上，**而不會有任何人發現**。這與 E1 報告②那條「依賴上游 AI 套件的例外訊息行為」是同一個形狀的問題。

**建議修法**（低成本）：在 `_fail_batch` 存進資料庫之前套一層過濾，把已知的敏感值（儲存後端密碼、AI 金鑰）以字面比對遮成 `***`；更保險的做法是分兩欄——給使用者看的用固定的分類訊息（「拉取檔案失敗」「容器執行失敗」），完整原文只寫進後端的紀錄檔。

**這一項與 ⑤ 應該一起修**，兩者是同一種「原文落地」。

### ⑤ 容器紀錄入庫與可讀範圍（工具未報，人工查證）

**卡片問的那句「participant（不是 manager）能不能讀到容器 stderr 原文」——答案是能，本棒把它確認下來了。**

完整鏈路：

1. 容器跑完，執行器把命令列（金鑰已遮）、標準輸出、**錯誤輸出原文**寫進 `_container-log.txt`（E1 報告②已查明錯誤輸出沒有被遮）。
2. 回寫時整份讀回來存進 `classification_run.container_log`（`service.py:1201`，經 `_read_container_log` `:1409`）。失敗時也存（`service.py:1232`）。
3. `get_run_validation_report`（`service.py:892`）把它交給報告產生器（`:897`）。
4. 報告產生器呼叫 `parse_container_log`（`validation_report_builder.py:218`）。
5. 那支函式（`report_common.py:196-197`）做的事是：**把 `===== STDERR =====` 之後的所有內容原封不動放進回傳值的 `stderr` 欄位**；另外還逐行抓出含 error／failed／exception／traceback 等字樣的行放進 `errors` 陣列（`:198-202`）。
6. 這份報告的端點守門是**參與者**（`service.py:895`）。

**所以結論是：容器印出來的錯誤輸出原文，同專案的每一位參與者都讀得到，不限管理者。**

**這對 E1 的意義**：E1 報告②的結論是「容器輸出原文落地並上傳雲端，但目前沒有已知的金鑰外流路徑」。**本棒把那條的影響面確定下來了——一旦哪天真的有東西被印出來，看得到的不是只有管理者，是整個專案的成員。** 這讓 E1②那條「建議在寫紀錄檔之前對輸出也套一次金鑰過濾」的優先度應該往上調。

**為什麼不另立成發現**：外流的來源在 E1 的範圍（容器印了什麼），本棒只是確認了下游的可讀範圍。立成新的一條會變成同一件事記兩次。**記在這裡，並建議把它併進 E1② 的修正卡當作「影響面」那一段。**

### ⑥ 清理與刪除（工具未報，人工查證）

**這一項查出三件事，其中一件是本棒最該記的缺口。**

**儲存後端的檔——有實刪，而且兩條路徑共用同一段。** `purge`（`service.py:645`）與 `delete_batch`（`:703`）都走 `_delete_stored_files`（`:683`），那支逐列呼叫 `self._storage.delete()`。**檔案是真的刪掉的。**

順帶核了刪除的保護：已歸檔的檔案**永遠不刪**（`PURGEABLE_FILE_STATUSES` 不含 `archived`，`service.py:102`），程式碼寫了三次理由——那些檔背後有任務證據指著同一份檔案，刪掉等於把任務上的證據抽掉，**而任務那側不會有任何錯誤訊息**。`delete_batch` 對已歸檔的批次直接擋（`:719`），而且回的是專用錯誤碼不是一般的狀態碼，理由是「一般的那句話對不上使用者的處境——他需要知道的是改用清理」。**這一段寫得很好。**

**🔴 工作目錄不清——這是真缺口。** 分類期間，證據檔會被拉到後端主機的 `~/.cm-jobs/<批次編號>-<run編號>/files/` 下。這個目錄裡最後會有：

| 檔案 | 內容 | 分類跑完會不會清 |
|------|------|-----------------|
| `files/`（證據檔副本） | 使用者上傳的原始證據檔 | ✅ **會**（`_cleanup_staged_files`，`service.py:1418`） |
| `_state.json` | **完整的判定結果**（每份檔對到哪些檢查點） | ❌ **不會** |
| `_report-original.json` | 容器的原始報告 | ❌ **不會** |
| `_container-log.txt` | 容器的畫面訊息與錯誤輸出 | ❌ **不會** |
| `catalog.json`／`prompt.json`／`manifest.json` | 目標集、指令、檔案清單（含**原始檔名**） | ❌ **不會** |

**而且 `purge` 與 `delete_batch` 兩支都沒有碰工作目錄**——我 grep 過整個套件，`jobs_base_dir` 只出現在執行器自己的四個地方（建目錄、讀報告、讀紀錄），**沒有任何一處刪它**。所以使用者按「刪除整批」之後，主機上仍留著那批的判定結果、原始檔名清單與容器紀錄，**永久**。

**嚴重度怎麼看：** 目錄權限是 `0o700`（只有擁有者可讀，`classifier_container_runner.py:88`），所以同機的其他一般帳號打不開；**留下的也不是證據檔本身**（那份有清）。**真正的問題是「使用者以為刪乾淨了但沒有」**——資料保留承諾與實際狀態不一致，這在合規稽核產品上是有意義的落差（客戶可能對稽核資料有保留期限的要求）。**加上磁碟會無限成長，沒有任何清理機制。**

**為什麼不另立成發現**：沒有越權讀取的路徑（權限已收），性質是資料保留與維運問題而非入侵路徑。**但建議開一張卡**：在 `purge` 與 `delete_batch` 收尾時一併刪掉該批次的工作目錄，或至少加一支定期清理。決策者可裁。

**狀態機擋不擋「分類中刪批次」——擋。** 查表（`resolve_transition`，`service.py:209`）是所有會改狀態的動作的唯一入口，分類中的批次對刪除動作查不到目標狀態，直接回衝突並給專用的「已封口」錯誤碼（`:218-219`）。**設計上這張表是「唯一的轉換真相」**，程式碼說明寫了理由：散成各方法裡的 if 判斷會在新增狀態時漏改，而漏掉的那處不會報錯、只會讓某個非法動作悄悄成功。**這個設計正確。**

### ⑦ 檔案上傳（工具未報，人工查證）

**兩個子項，一個是缺口、一個沒問題。**

**副檔名／大小／數量有沒有驗——套件側完全沒有驗，宿主只有一道總量限制。**

套件這側（`upload_files`，`service.py:494`）收到檔案就直接交給儲存後端存，**沒有檢查副檔名、沒有檢查單檔大小、沒有檢查一次傳幾個檔**。`_file_size`（`service.py:1527`）只是讀出大小記進資料庫，**不做任何判斷**。`_guess_mime`（`service.py:202`）是預覽時用副檔名猜格式，也不是驗證。

宿主那側查到的唯一一道是**整個請求的總大小上限 50MB**（`MAX_REQUEST_BODY_MB`，`config/config.py:105`，在 `core/app_factory.py:104` 設成 Flask 的上限，另有一道前置檢查 `:427-431`）。我在主專案與 `jedi-file-upload` 裡 grep 過副檔名白名單與單檔大小限制，**沒有找到**。

**這算多嚴重：** 要打到這裡得先是**專案管理者**（守門有擋），所以不是任何人都能傳。實際影響有兩層：其一，可以傳任意型別的檔（例如可執行檔）進儲存後端——**但後端不會去執行它**，風險在於它之後被誰下載；其二，50MB 以內可以塞很多個檔，每個都會進分類容器解析，**而容器沒有記憶體上限**（E1-3 那條）——**這兩條串起來就是一條完整的資源耗盡路徑**：管理者（或帳號被盜的管理者）上傳構造過的檔案，容器解析時吃光主機記憶體。

**為什麼不另立成發現**：需要管理者權限，且真正的傷害發生在 E1-3 已經記的那條（容器沒有資源上限）。**建議併進 E1-3 的修正卡當作「上游沒有把關」的補充**，並建議套件側加一份副檔名白名單（分類器實際只認得 Word／Excel／簡報／PDF／圖片，白名單本來就該很窄）與單檔大小上限。

**`_same_content_hints` 會不會洩漏別專案的檔名——不會，而且範圍收得很緊。** 卡片擔心的是拿內容雜湊跨批次找重複時會把別人的檔名回給你。**查完是安全的**，兩個理由：

其一，**範圍限在同一輪次**。`_same_content_hints`（`service.py:465`）只對 `batch.round_id` 查，宿主的實作（`core/plugins/evidence_classification.py:458-470`）先取這一輪有哪些任務，再只在那些任務的證據裡找。**同一輪次 = 同一個專案的同一次稽核**，本來就是這批使用者看得到的範圍。

其二，**回的內容裡沒有檔名**。回的是 `[(任務執行編號, 證據編號)]` 兩個識別碼（`service.py:487-490`），**不含檔名、不含內容**。

另外這段整個被 try/except 包住（`service.py:479-486`），查詢壞掉只是不顯示提示、不會擋住批次詳情打不開；而且**只吞「尚未實作」這一種例外**，其他例外仍留紀錄——程式碼寫明理由是「否則真正的查詢壞掉會被當成本來就沒有提示」。**這個處理是對的。**

## 可信度：分兩層看

**「這些結論對不對」——中高。** 七項全部是首腦實際開檔讀、grep 原始碼、追到宿主的呼叫端核對，不是憑印象；守門表那十五列是逐支對著行號抄下來的，可以照著開檔驗。**但這些沒有經過三人投票**——工具在範圍內零發現，所以面板從頭到尾沒有審過這兩個檔裡的任何判斷。單人判斷沒有對抗性審查。

其中**三個判斷特別值得複核**：②「`file_uid` 沒驗但兩個消費端都以資料庫為準，所以塞不出效果」——這是對**所有**消費端的斷言，我查的是本棒範圍內的兩處（歸檔、容器回寫），**若 E5 或其他棒發現第三個消費端，這個結論要重算**；④「目前經手的例外訊息裡沒有金鑰」——儲存後端底層套件那一層**沒有往下追**，這一點我標為不確定；⑦「主專案與 jedi-file-upload 沒有副檔名白名單」是 grep 的結果，**grep 找不到不等於不存在**（可能寫在設定檔或別的名字底下）。

**「只有這些嗎」——不能這樣說。** 三個限制要講明：其一，**工具在範圍內零發現**，所以這份報告等於是單人審查的結果，沒有第二雙眼睛；其二，本棒只看兩個檔，**守門真正執行的那段程式在宿主**（`is_project_manager` 到底怎麼查）**本棒沒驗**，那支若有問題，上面十五道守門全部一起失效；其三，資料層（查詢條件、儲存庫、資料庫的租戶隔離）在 E5，**本棒看到的「有守門」是應用層的守門，資料層有沒有第二道防線本棒不知道**。

**不能說已經驗證過攻擊不可行**——沒有打過任何端點、沒有起過服務、沒有連過資料庫。全部是讀程式碼推出來的。

## 執行概況

| 項目 | 值 |
|------|-----|
| 驗證章（`verification.status`） | `verified` |
| 候選數 → 去重後 | 3 → 3 |
| 面板票數 | 9 票（3 條 × 3 位檢查員），**全數投出，零漏投** |
| 各條票型 | 通過 1 條 3:0（越界，`pyproject.toml`）；駁回 2 條，各 0:3 |
| 本棒範圍內成立的發現 | **0 條** |
| 研究員 | 派 2 位、回 2 位 |
| 代理程式總數 | 11（全數完成，0 失敗、0 略過、1 空回報） |
| 工具呼叫 | 603 次 |
| 掃描版本 | `955e409`，工作區**乾淨**（戳記 `CLAUDE-SECURITY-REVISION-955e40964baa.json`，不帶 `-dirty`） |
| run ID | `wf_8541a72f-a9b` |
| 耗時 | 約 155 分鐘（9,273 秒） |
| 設定 | mode `scan`、effort `low`、focus `attack-surface`、2 檔 1,900 行 |

**耗時對照**：2 個檔 1,900 行跑了 155 分鐘，比 E1 的 7 檔 1,476 行／135 分鐘還久。**再次證實「行數比檔數準、而檔數比強度檔位準」**——1,900 行是目前訂的 2,000 行上限的最逼近值，跑起來就是兩個半小時。E1 報告的結論在這裡得到第二次驗證。

**過程順利，沒有中斷、沒有重試、沒有撞額度、研究員沒有被停滯偵測砍掉。** 本棒把行數壓在 2,000 以下的判準有效。

報告產物落在套件 repo 的 `CLAUDE-SECURITY-20260919-020549/`，含機器可讀的 JSONL、SARIF 與版本戳記。該目錄有自己的 `.gitignore`，不會進版控。

**修正卡當時未開**——依 FR-109／E1 的先例，全部掃完分類後統一開；後續已統一派工並修完（1.21.0 出貨）。**現況**：① 工作目錄刪不掉 → 已修（M07-6，CM-2227，1.21.0 出貨）；② 失敗原因原文入庫 → M07-10，列為非資安功能缺陷，隨下次功能開發收，尚未修；③ 容器紀錄可讀範圍 → 併入 E1 影響面，E1-2 已修（M07-7）；④ 上傳白名單 → 併入 E1-3，已修（M07-8，CM-2227）。以下為掃描當時的建議：① 工作目錄刪不掉（⑥，建議開卡）；② 失敗原因原文入庫（④，與⑤同一種問題，建議合併一張）；③ 容器紀錄參與者可讀（⑤，建議併進 E1② 當影響面）；④ 上傳沒有副檔名／大小白名單（⑦，建議併進 E1-3）。
