---
title: E3 檢查結果：舊 Drive 線——觸發／job 狀態／Drive 讀寫／正解匯入（jedi-evidence-classification）
---

# E3 檢查結果：舊 Drive 線——觸發／job 狀態／Drive 讀寫／正解匯入（jedi-evidence-classification）

> 檢查日期 2026-09-19｜對應卡片 CM-1949｜檢查範圍 4 個檔案 1,781 行

## 🔴 一句話結論

**四條發現全部三位檢查員一致通過，一高三中——最嚴重的一條是：只要登入這套系統的任何一個帳號，都能把客戶整個 Google 雲端硬碟裡的任意檔案下載下來。**

白話講整件事：**這條舊線上的六、七個查詢端點，只檢查「你有沒有登入」，不檢查「你要的這份東西是不是你的」。** 拿預覽端點來說，網址裡帶著一個 Google 硬碟的檔案編號，程式就拿著客戶授權給系統的那把鑰匙去把檔案抓回來——中間沒有任何一行程式問過「這個檔案屬於這個專案嗎」「這個人是這個專案的成員嗎」。

🔴 **一個決定嚴重度的關鍵事實，我另外開主專案的檔核對過**：系統向 Google 申請的授權範圍是 `https://www.googleapis.com/auth/drive`（`compliance-manager-be/app/cloud_integration/service/google_drive_integration_service.py:41`），這是**整個雲端硬碟的完整權限**，不是只能碰自己建立的檔案那種限縮版（`drive.file`）。所以打得到的範圍不是「證據資料夾」，而是**那個 Google 帳號的整個硬碟**——別的專案的證據、人事檔案、合約、財報，只要在同一個硬碟裡就都在射程內。這一條是這份報告裡最該先看的東西。

🔴 **另一個重要前提**：這條舊線的**觸發功能目前其實跑不起來**。背景工作的註解寫明，容器已經改成只讀宿主準備好的目錄、不再自己連 Drive 取檔，而這條舊線沒有「準備目錄」那一步，所以一按下去就會當場報錯（`evidence_classification_service.py:311-315`）。**但這件事只保護了「觸發」，保護不到「查詢」**——上面那四條全部是查詢端點，靠的是過去跑成功留下來的資料，跑不跑得起來完全不影響。**這條路線已經標為 legacy、下一版要刪**（`evidence_classification_route.py:37`），但**路由現在仍然掛著**，所以現在仍然打得到。

## 這一棒在檢查什麼

證據自動分類有新舊兩條線。**新的那條**（E2 已掃）是使用者把檔案上傳到系統裡，系統存在自己的地方再交給 AI。**這一棒看的是舊的那條**：直接去掃客戶 Google 雲端硬碟上某個資料夾裡的檔案，分類完再把結果與證據副本寫回硬碟。

四個檔案涵蓋這條線的全部：按下「開始分類」的觸發端點、查進度的端點、實際跟 Google 硬碟講話的那一層、以及匯入「正確答案」對照表的端點（用來評估 AI 判得準不準）。

**選它當第三棒的理由**：這條線沒有拆掉，就是攤在外面的攻擊面。它手上拿的是客戶授權的雲端硬碟鑰匙，進度登記簿是整個程式共用的一份記憶體資料，容器的執行紀錄會被上傳到客戶硬碟上。而 FR-110 剛把 Drive 憑證改成畫面上設定，取得憑證的路徑變了，但套件這邊的用法沒跟著變。

掃描目標 `~/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_classification_service.py` | 1,246 | 這條線的主邏輯：觸發、背景執行、Drive 寫回、六個查詢端點、正解匯入 |
| `jedi_evidence_classification/api/routes/evidence_classification_route.py` | 237 | 九個 HTTP 端點的進出口 |
| `jedi_evidence_classification/infra/evidence_drive_ops.py` | 183 | 跟 Google 硬碟講話的那一層：找檔、讀檔、下載、建資料夾、複製、上傳 |
| `jedi_evidence_classification/app/service/job_registry.py` | 115 | 進度登記簿（整個程式共用的一份記憶體資料，重啟就沒了） |

**這支套件的這條線從來沒有被掃過。** 跨 arc 總表 §3.2 第 ⑥ 項記著一個疑點——「查單一 job 狀態的端點沒有任何守門，而登記簿不分租戶」。**本棒正式驗證了這一項，確實成立**（見下方逐項查證②）。

## Coverage

四個檔案全部讀完，就是範圍給的全部。套件其餘部分——新的批次線（`evidence_batch_service.py`、`evidence_batch_route.py`）、容器執行器、領域服務、儲存庫、migration、插件接線——**不在本棒範圍內**。它們在報告裡只當對照組出現：新的批次線每個查詢端點都有成員檢查，這正是讓舊線的缺口看起來像「漏掉」而不是「刻意設計」的依據。

低強度跑法不做元件盤點、不做威脅建模、不跑額外廣度掃描，`completenessCheckOutcome` 是 `not-applicable`（指定範圍的掃描本就不適用）。派兩位研究員、兩位都回報；九個候選去重成六個，**四個全票通過、兩個被駁回**（一個 1:3、一個 0:3），沒有候選遺失、沒有候選未被審、沒有被上限砍掉。

**工具沒有執行任何程式碼**：沒有打過任何端點、沒有對 Google 硬碟發過任何一次請求、沒有跑測試。四條發現全部是讀原始碼推出來的。**F3 的 Drive 查詢注入特別要註明沒有實測**——Google 的查詢語法剖析器會不會照預期吃下那串注入，必須真的打一次 API 才知道，這正是它把握度只有「中」的原因。

**工具的偏食**：四條發現集中在「守門缺失」這一類。卡片列的七個重點裡，工具碰到④⑤（部分），**①②③⑥⑦完全沒報**，由首腦逐項開檔補查（見下方「卡片重點逐項查證」，每項都標明是工具報的還是人工查的）。

## 掃到什麼（總覽）

| # | 嚴重度 | 這是什麼問題（白話） | 出事會怎樣 | 要先有什麼才打得到 | 位置 |
|---|--------|---------------------|-----------|------------------|------|
| [E3-1](#e3-1) | 🔴 高 | 預覽端點你給什麼檔案編號就抓什麼，不檢查這份檔案是不是這個專案的 | **客戶整個雲端硬碟的任何檔案都能被下載**（授權範圍是完整硬碟權限，不是只碰自己建的檔） | 有任何一個能登入的帳號（不需要是任何專案的成員）＋ 知道一個硬碟檔案編號 | `evidence_classification_service.py:709` |
| [E3-2](#e3-2) | 🟡 中等 | 查結果、查報表的端點不檢查你是不是這個專案的成員 | 任何登入者可讀別的專案的分類結果：證據檔名、硬碟檔案編號、逐項判定、成本、容器執行紀錄 | 有任何一個能登入的帳號 ＋ 知道一個 run 資料夾編號（可從下面那條端點撈） | `evidence_classification_service.py:686` |
| [E3-3](#e3-3) | 🟡 中等 | 查 Google 硬碟用的查詢字串是用「字串接起來」組的，沒有把使用者給的值跳脫 | 可以改寫查詢條件，把搜尋範圍從「這個資料夾裡」擴大到「整個硬碟」 | 有任何一個能登入的帳號；**且 Google 的查詢剖析器要吃下這串注入——未實測** | `evidence_drive_ops.py:76` |
| [E3-4](#e3-4) | 🟡 中等 | job 列表端點不檢查你是不是這個專案的成員，而進度登記簿不分租戶 | 洩漏別的租戶的工作資訊：租戶編號、證據資料夾編號、誰啟動的、run 資料夾編號（正好是上面兩條需要的入場券） | 有任何一個能登入的帳號 ＋ 知道一個專案編號 ＋ 該工作在後端重啟後跑過 | `evidence_classification_service.py:575` |

**四條全部是本棒淨新增**，沒有與 E1、E2 重疊的越界發現。

**四條其實是同一件事的四個面向**：這條舊線的查詢端點只驗「有沒有登入」。E3-4 給你入場券（run 資料夾編號），E3-2 用它換到檔案編號，E3-1 用檔案編號換到檔案本身，E3-3 則是把搜尋範圍再放大一層。**修的時候要一起修，補一個不補其他等於沒補。**

## Findings

<a id="e3-1"></a>
### E3-1 — 預覽端點你給什麼檔案編號就抓什麼，能撈出客戶整個雲端硬碟（高，3:0，把握高）

**現況**：🗑️ 隨舊 Drive 線退場拆除（M07-1，CM-2222，1.21.0 出貨）

**這是什麼問題。** 預覽證據檔的網址長這樣：`GET /api/1.0/classification-run/<run資料夾編號>/file/<檔案編號>/preview`。程式拿到網址裡的那個檔案編號，**直接**去跟 Google 硬碟要這份檔案的內容，然後回傳。中間沒有任何一行檢查「這個檔案編號有沒有出現在這個 run 的結果裡」，也沒有檢查「這個人是不是這個專案的成員」。整條路上唯一的關卡是宿主套上的登入檢查（`api/routing.py` 的 `R()`），它只確認你是登入狀態。

同一個類別裡的**寫入**端點是有檢查的——存檔（`put_state:752`）與歸檔（`archive_run:892`）都會呼叫 `_require_run_folder_manager`，先從資料庫把 run 查出來、確認你是那個專案的管理者，查不到就直接拒絕。**這一支從來沒呼叫過它。**

**出事會怎樣。** 🔴 **能撈到的範圍是客戶那個 Google 帳號的整個雲端硬碟。** 我開主專案的檔核對過授權範圍：系統向 Google 申請的是 `https://www.googleapis.com/auth/drive`（`compliance-manager-be/app/cloud_integration/service/google_drive_integration_service.py:41`），這是**完整硬碟權限**——Google 另有一種只能碰「這個應用自己建立的檔案」的限縮權限叫 `drive.file`，系統沒有用那種。

所以拿得到的不只是證據檔：**別的專案的證據、人事資料、合約、財務報表——只要跟證據資料夾在同一個 Google 帳號的硬碟裡，就全部在射程內**。每份檔案上限 50MB（`evidence_drive_ops.py:101`），超過才會被擋。

這件事的性質是「客戶把雲端硬碟接上來，結果系統變成一個可以代理讀取整個硬碟的通道」，而使用這個通道不需要任何專案角色。

**要先有什麼才打得到。**
- **有任何一個能登入這套系統的帳號就夠了**——不需要是任何專案的成員，不需要是管理者。這是這條發現最該注意的地方：權限門檻幾乎等於零。
- 該租戶有接上 Google 硬碟（這是這個功能的正常狀態）。
- 知道一個硬碟檔案編號。取得方式很多：從下面 E3-2 那條端點讀出來、從任何一個硬碟分享連結的網址裡抄下來、或是從自己看得到的 run 結果裡撈。

**在哪裡。** `jedi_evidence_classification/app/service/evidence_classification_service.py:709`，`get_file_preview`（方法本體 696-730）：

```python
tenant_id = self._resolve_tenant_for_run_folder(run_folder_id, current_user_id)
try:
    data, mime_type, file_name = self._drive_ops.download_bytes(tenant_id, file_drive_id)
```

**我追到端點確認了守門確實只有登入**：`evidence_classification_route.py:154-175` 的 `ClassificationFilePreviewRoute.get` 只做三件事——取服務、取使用者、呼叫 `service.get_file_preview(run_folder_id, file_drive_id, user.id)`，然後把 bytes 用 `send_file` 丟回去。**route 層沒有任何權限檢查**，而 `api/routing.py:30-36` 的 `R()` 包的是宿主注入的認證 decorator，作用是「確認有登入」。

還有一個放大器：`_resolve_tenant_for_run_folder`（`:978`）在記憶體登記簿裡找不到對應工作時，**會退回用呼叫者自己的租戶**去拿鑰匙（程式註解自陳這是「實驗期簡化」）。所以 `run_folder_id` 填什麼都行，連填亂碼都行——它只是拿來決定用誰的鑰匙，而找不到就用你自己的。

**怎麼修。** 三件事一起做，缺一不可：
1. 在 `get_file_preview` 開頭，用 `run_folder_id` 去資料庫把 run 查出來（`self._run_ds.get_by_run_folder_id`），查不到就直接拒絕（fail-closed），不要退回「那就用你自己的租戶」。
2. 用查到的 run 的 `project_id` 做成員檢查——呼叫 `_require_run_folder_manager`（`:123`），或比照新批次線的 `EvidenceBatchService._require_participant` 做成員層級的檢查。
3. **確認 `file_drive_id` 真的出現在這個 run 的 `_state.json` 裡**（`files[].file_drive_id` 或 `placements[].placement_drive_id`），不在就拒絕。只做前兩步的話，專案成員仍然能拿這個端點去撈整個硬碟。

另外建議（不屬本棒範圍，但是根本解）：主專案向 Google 申請的授權範圍能不能從完整硬碟權限縮到 `drive.file`。縮了之後，即使這個端點被打，能撈到的也只剩系統自己建立的檔案。

**驗證情況。** 3 位檢查員全數確認。**首腦另外開檔核對**：端點守門確認只有登入（上面已述）、授權範圍確認是完整硬碟（`google_drive_integration_service.py:41`，grep 全 `app/cloud_integration/` 確認沒有任何一處用 `drive.file`）。

<a id="e3-2"></a>
### E3-2 — 查結果與查報表的端點不檢查你是不是這個專案的成員（中等，3:0，把握高）

**現況**：🗑️ 隨舊 Drive 線退場拆除（M07-2，CM-2222，1.21.0 出貨）

**這是什麼問題。** `GET /classification-run/<run資料夾編號>/state` 這個端點，拿網址裡的編號去 Google 硬碟把該次分類的完整結果檔讀回來，然後原封回傳。**只確認你有登入，不確認你跟這個專案有任何關係。**

同樣的缺口出現在這條線的其他五個讀取端點：驗證報表（`get_validation_report:1064`）、判定報表（`get_adjudication_report:1117`）、跨批總表（`get_runs_summary:1132`）、以及 job 列表（`list_jobs_for_project:556`，另記為 E3-4）。**這六支沒有任何一支碰過 `self._role_guard`。**

**出事會怎樣。** 任何登入者可以讀到別的專案的分類結果，內容包括：每份證據的**檔名**、**硬碟檔案編號**、AI 判它對應哪些合規項目與信心值、用了哪個 AI 型號、花了多少錢、以及**容器的執行紀錄**（`_container-log.txt`）。

其中「硬碟檔案編號」這一項特別關鍵——**它正是 E3-1 需要的入場券**。所以這條的實際影響不是「洩漏一些中介資料」，而是「把 E3-1 從『需要先知道檔案編號』降級成『不需要』」。兩條串起來就是完整的讀檔管道。

判定報表（`get_adjudication_report`）洩漏的東西更敏感一層：它是逐項比對「AI 判對了沒、人工改了什麼」的分析，等於把別的專案的稽核過程攤開。

**要先有什麼才打得到。**
- 有任何一個能登入的帳號。
- 知道一個 run 資料夾編號（33 碼的 Google 硬碟編號），或一組專案／AP 編號。取得方式：從同事分享的硬碟連結網址裡抄、從自己有權限的專案的前端頁面看到、或直接呼叫 E3-4 那條也沒有守門的 job 列表端點撈出來。

**在哪裡。** `jedi_evidence_classification/app/service/evidence_classification_service.py:686`，`get_state`（方法本體 666-692）。對照組在同一個檔案裡：`put_state:752` 與 `archive_run:892` 都有 `_require_run_folder_manager`；新批次線 `evidence_batch_service.py:810,871` 每次讀取都呼叫 `_require_participant`。**這是漏掉，不是設計。**

**怎麼修。** 在 `get_state`、`get_file_preview`、`get_validation_report`、`get_adjudication_report`、`get_runs_summary`、`list_jobs_for_project` 這六支的開頭，統一加成員檢查：先把 run 或專案解析出來（`self._run_ds.get_by_run_folder_id` / `IProjectDirectory.get_by_uid`），再呼叫 `IProjectRoleGuard.is_project_participant`，**解析不到就拒絕**——照 `_require_run_folder_manager:123` 已經寫好的 fail-closed 形狀做即可，那支現成的守門就在同一個檔案裡。

**驗證情況。** 3 位檢查員全數確認。首腦開檔核對過對照組（寫入端點有守門、新批次線有守門）確實存在。

<a id="e3-3"></a>
### E3-3 — 查 Google 硬碟的查詢字串用字串接起來組，沒跳脫（中等，3:0，把握中）

**現況**：🗑️ 隨舊 Drive 線退場拆除（M07-4，CM-2222，1.21.0 出貨）

**這是什麼問題。** 跟 Google 硬碟要「某個資料夾裡叫什麼名字的檔案」時，要送一段查詢條件過去。程式是用字串接起來組的：

```python
q_parts = [f"'{parent_id}' in parents", "trashed=false",
           f"name='{safe_name}'"]
```

注意 `name` 那一項有先處理過（上一行把單引號跳脫掉了），**但 `parent_id` 是原封不動接進去的**。而 `parent_id` 的來源是網址裡那個 run 資料夾編號——使用者想填什麼就填什麼。所以只要在編號裡塞進單引號和括號，整段查詢條件的邏輯結構就被改寫了。

隔壁的 `list_children`（`:52`）有同樣的寫法，而且連 `name` 的跳脫都沒有。

**出事會怎樣。** 原本查詢的意思是「**在這個資料夾裡**，沒被刪除，而且叫這個名字」。注入之後可以變成「在這個資料夾裡 **或者** 叫某某名字（不管在哪）」——搜尋範圍就從一個資料夾擴大到整個硬碟。找到的檔案編號接著會被 `read_text_file` 讀出來回傳給呼叫者（`get_state` 那條路），或是列出來給呼叫者看。

透過觸發端點的資料夾覆寫參數，同一個注入也構得到 `list_children`，那支決定「哪些檔案要送給 AI、要被複製到哪些資料夾」。

**要先有什麼才打得到。**
- 有任何一個能登入的帳號（受影響的讀取路徑本來就沒有角色檢查；走觸發端點那條則額外需要專案管理者身分）。
- 🔴 **Google 的查詢語法剖析器要真的吃下這串注入——這一點沒有實測。** 要確認必須真的對 Google 的 API 打一次，掃描不做這件事。這是把握度只有「中」的唯一原因；程式碼層面的缺陷（沒跳脫、值來自使用者）是確定的。

**在哪裡。** `jedi_evidence_classification/infra/evidence_drive_ops.py:76`，`find_child_by_name`（問題行是 `q_parts` 那一段的第一項）；同樣問題在 `:52` 的 `list_children`。

值怎麼流進來的：網址的 `run_folder_id` → `get_state:676` / `archive_run` / `_ensure_run_persisted_for_read:1014` → 這兩支。

**怎麼修。** 不要把編號原封接進查詢字串。兩件事一起做：
1. 驗證 `parent_id` 與 `file_id` 只含 Google 硬碟編號會出現的字元（`^[A-Za-z0-9_-]+$`），不符就直接拒絕。
2. 比照 `name` 已經在做的單引號跳脫，對編號也做一次。

兩支都要改（`find_child_by_name` 與 `list_children`）。第 1 點是根本解——編號的字元集本來就很窄，白名單擋掉之後注入無從發生。

**驗證情況。** 3 位檢查員全數確認缺陷存在。**把握度「中」不是因為票數，是因為「Google 那邊會不會照預期被注入」沒有實測。** 首腦開檔確認 `name` 有跳脫、`parent_id` 沒有，兩支的差異確實存在。

<a id="e3-4"></a>
### E3-4 — job 列表端點不檢查專案成員，而進度登記簿不分租戶（中等，3:0，把握中）

**現況**：🗑️ 隨舊 Drive 線退場拆除（M07-3，CM-2222，1.21.0 出貨）

**這是什麼問題。** `GET /project/<專案編號>/classify-evidence/jobs` 這個端點，網址裡的專案編號想填誰的就填誰的，**route 與服務層都沒有檢查你是不是那個專案的成員**。它會去查兩個地方，其中一個是記憶體裡的進度登記簿（`JobRegistry.list_for_project`），而那支**只用專案編號過濾，完全沒有租戶的概念**。

記憶體登記簿這件事要特別講：資料庫那一層有「每個客戶只能看自己資料」的隔離機制（RLS），但那是資料庫的功能——**記憶體裡的一份 Python 字典不受它管**。所以這條路是直接跨過租戶邊界，不是繞過，是根本不在管轄範圍內。

**出事會怎樣。** 回傳的每筆工作紀錄包含：**租戶編號**、專案編號、**證據資料夾的硬碟編號**、AP 編號、**是誰啟動的**、用了哪個 AI 型號、狀態、以及 **run 資料夾編號**。

最後那一項是關鍵——**run 資料夾編號正是 E3-2 與 E3-1 的入場券**。所以這條的價值不在它自己洩漏的東西，而在它讓前兩條「需要先知道編號」的前提消失了。

**要先有什麼才打得到。**
- 有任何一個能登入的帳號。
- 知道一個專案編號（UUID）。這東西在前端網址、匯出檔、分享連結裡到處都是。
- **跨租戶那一段需要目標專案在後端最後一次重啟之後跑過分類工作**——登記簿是記憶體裡的，重啟就清空。這個時間窗是把握度只有「中」的原因：缺陷確定存在，但實際打中需要碰上工作還在登記簿裡。

**在哪裡。** `jedi_evidence_classification/app/service/evidence_classification_service.py:575`（`list_jobs_for_project` 本體從 `:556` 開始）：

```python
registry_jobs = JobRegistry.list_for_project(project_uid)
```

登記簿那支在 `job_registry.py:103-105`：

```python
@classmethod
def list_for_project(cls, project_uid: str) -> list[dict]:
    with cls._lock:
        return [dict(j) for j in cls._jobs.values() if j.get("project_uid") == project_uid]
```

端點在 `evidence_classification_route.py:93-109`，沒有任何權限檢查。

**怎麼修。** 兩層都要補：
1. 服務層先用 `IProjectDirectory` 把專案解析出來，確認呼叫者是成員才往下做。
2. `JobRegistry.list_for_project` 改成收 `tenant_id` 並一起過濾——這樣即使守門哪天又被繞過，別的租戶的專案編號也永遠比對不中。

第 2 點值得單獨做，因為它是「深度防禦」：登記簿是唯一一個資料庫隔離機制管不到的地方，它自己應該要有租戶概念。

**驗證情況。** 3 位檢查員全數確認。首腦開檔核對過 `JobRegistry.list_for_project` 的過濾條件確實只有 `project_uid`，以及端點確實沒有守門。

## 卡片重點逐項查證

卡片列了七個重點。工具只碰到④⑤的一部分，其餘由首腦逐項開檔補查，逐項標明來源。

### ① 觸發端點的資料夾覆寫，有沒有比對正規來源（工具未報，人工查證 → **缺口確實存在**）

**查證結果：沒有比對，覆寫值直接採用。**

`trigger_classify`（`:137`）第 4 步：

```python
if evidence_folder_id_override:
    evidence_folder_id = evidence_folder_id_override
else:
    evidence_folder_id = self._derive_evidence_folder_id(tenant_id, ap_uid)
```

前端在 body 裡帶 `evidence_folder_id`，程式就**完全跳過**正規的解析流程（`_derive_evidence_folder_id:247`，那支會去查 `IEvidenceSource.get_folder_id` 的資料夾對應表）。**沒有任何一行把覆寫值拿去跟正規解析的結果比對。** 程式註解自陳這是「原型期／非標準版面配置的逃生門」。

守門確實有：第 2 步 `self._require_project_manager(project.id, current_user_id)`，需要是該專案的管理者。**但守門驗的是「你是這個專案的管理者」，不是「這個資料夾屬於這個專案」**——這正是卡片上指出的那個落差，確實存在。

**實際可達性要打個折**：第 5 步會拿覆寫的資料夾去呼叫 `list_children`，列不到就拋 `EC_EVIDENCE_FOLDER_NOT_FOUND`、空的就拋 `EC_NO_FILES_IN_EVIDENCE_FOLDER`。而再往下的容器執行**目前跑不起來**（見⑦）。所以現況下這個覆寫參數的實際效果是**一個探測器**——可以用回傳的錯誤碼分辨「這個資料夾存不存在／裡面有沒有檔案」，遍歷整個硬碟的資料夾結構；但不會真的把別人的檔案送去分類、也不會寫回 Drive。**容器那一步修好之後，這就會變成完整的「拿任意資料夾去分類並複製」。**

**建議**：覆寫參數要嘛拿掉（它是原型期遺留），要嘛加一道「覆寫值必須是這個 AP 的資料夾的子孫」的檢查。

### ② 查單一 job 狀態的端點有沒有守門（工具未報，人工查證 → **缺口確實存在，比列表那條更乾淨**）

**查證結果：完全沒有守門，而且比 E3-4 那條列表端點更容易打。**

`ClassifyJobStatusRoute.get`（`evidence_classification_route.py:79-90`）：

```python
service = runtime().service("evidence_classification_service")
job = service.get_job(job_uid)
if not job:
    raise NotFound(EC.EC_JOB_NOT_FOUND)
return runtime().reply(True, job)
```

三件事值得記：
1. **網址裡有 `project_uid` 這個參數，但程式從頭到尾沒有用它。** 只拿 `job_uid` 去查。
2. `service.get_job`（`:549-550`）就是 `JobRegistry.get(job_uid)` 的直通——`job_registry.py:83-87` 只做 `cls._jobs.get(job_uid)`，**沒有租戶過濾、沒有專案過濾、沒有任何檢查**。
3. 回傳的是**整包工作資料**，含租戶編號、證據資料夾編號、run 資料夾編號、啟動者、以及 **`error` 欄位的錯誤訊息原文**。

這一項對應跨 arc 總表 §3.2 第 ⑥ 條的疑點，**本棒確認成立**。它跟 E3-4 是同一類問題，但這條更乾淨：E3-4 至少要知道專案編號、而且跨租戶需要碰上時間窗；這條只要有一個工作編號（UUID）就行。工具把這條歸進了 E3-4 的影響面沒有單列，**首腦認為它值得在修正時被單獨點名**，因為修 E3-4 只改列表那支的話，這支會被漏掉。

**建議**：`get_job` 改成同時收 `project_uid` 與呼叫者身分，比對登記簿裡的 `project_uid` 與 `tenant_id` 都相符才回傳。

### ③ Drive 寫回：有沒有二次遮蔽、AI 給的資料夾名能不能搞鬼（工具未報，人工查證 → **遮蔽確實只有一層；資料夾名沒問題**）

**遮蔽的部分：沒有二次遮蔽，上傳的就是本機那一份。**

容器執行紀錄在 `classifier_container_runner.py:205-224` 寫成檔案，那裡**只遮一件事**——`docker run` 命令列上 `-e KEY=VALUE` 形式的環境變數值（換成 `KEY=***`）。**容器自己印出來的 stdout 與 stderr 是原封不動接進去的**：

```python
f"===== STDOUT =====\n{result.stdout or ''}\n"
f"===== STDERR =====\n{result.stderr or ''}\n"
```

上傳那一段（`evidence_classification_service.py:468-478`）直接呼叫 `self._runner.read_container_log(job_uid)`，而那支（`classifier_container_runner.py:255-261`）就是把檔案讀出來回傳，**中間沒有任何處理**。所以**上傳到客戶硬碟的那份紀錄，跟本機那份一模一樣**。

這一項與 E1 報告的②是同一個結論（遮蔽只遮命令列），本棒從「上傳路徑」這一側再次確認：**沒有第二道**。如果容器端哪天在 stdout 印出含憑證的訊息，那份訊息會直接上傳到客戶硬碟。

**失敗時原文會不會留在本機**：會。紀錄檔寫在工作目錄下（`jobs_base_dir / job_uid / _container-log.txt`），**這條路徑沒有任何刪除動作**。E2 那棒記過「刪整批工作目錄不清」的相關陷阱，方向一致。

**AI 給的資料夾名：查證後認為沒問題，卡片的疑慮不成立。**

`ensure_ao_folder`（`:410-435`）的流程是：拿 AI 回的 `ao_id` → 必須含 `[` 與 `]`，否則拋錯 → 拆成控制項編號與字母 → **去目錄（catalog）裡逐層查**，領域找不到拋錯、控制項找不到拋錯、評估項目找不到拋錯。通過之後，**實際用來建資料夾的名字取自目錄裡的值**（`dom_obj['name']`、`ctrl_obj['name']`、`ao_obj['text']`），**不是 AI 給的那個字串**。

所以 AI 沒辦法透過 `ao_id` 塞出任意資料夾名——它只能在目錄裡「選」一個已存在的項目，選不中就整個拋錯。（附帶一提，Google 硬碟的資料夾名裡 `/` 不是路徑分隔符號，本來就不構成穿越。）

**唯一殘留的觀察**：資料夾名來自目錄，而目錄來自專案的 living SSP，控制項名稱是使用者可編輯的。但那是另一條資料流，不在本棒範圍。

### ④ Drive 讀取的邊界（工具部分報 → E3-3，其餘人工查證）

**大小上限：兩支都有，形狀不同。** `read_text_file`（`:88`）預設上限 5MB，是在下載過程中逐塊檢查、超過就拋錯。`download_bytes`（`:101`）預設上限 50MB，做法更好一點——**先問檔案大小、超過就直接不下載**，下載過程中再檢查一次。兩支都不是無上限。

**鑰匙的取得：每次呼叫都重新拿。** `_service`（`:43`）每次都呼叫 `self._token_provider(tenant_id)` 拿新的存取權杖再建連線，沒有把鑰匙存起來重複用。這是好的做法。

**查詢字串拼接：見 E3-3。** 這是工具報到的部分。

### ⑤ 讀取端點的守門，以及「查無紀錄就直接去讀 Drive」（工具已報 → E3-1／E3-2，人工補查退路）

**查證結果：確實會退回直接讀 Drive，而且退路上用的是「呼叫者自己的租戶」。**

`_ensure_run_persisted_for_read`（`:999`）的邏輯是：資料庫裡有這個 run 就直接回；**沒有的話（本期之前的舊 run）就從 Drive 載三個檔回來補登**。補登那一段呼叫 `_resolve_tenant_for_run_folder`（`:978`），而那支在記憶體登記簿找不到對應工作時，**會退回用呼叫者自己的租戶**：

```python
# Fallback: use current user's tenant
user_ctx = get_user_context()
if user_ctx and user_ctx.tenant_id:
    return user_ctx.tenant_id
```

程式註解自陳的理由是「使用者只看得到自己租戶的 OAuth 能碰到的 run 資料夾，最壞情況是 Drive 回 404」。**這個理由在授權範圍是完整硬碟權限的前提下不成立**——「自己租戶的鑰匙能碰到的」不是證據資料夾，是整個硬碟（見 E3-1）。

所以卡片問的那個情境確實成立：**任何成員給一個資料夾編號，就能透過系統的鑰匙去讀客戶硬碟上的任意資料夾**——`get_state` 直接讀，報表端點透過這支補登路徑讀。

`get_file_preview` 的 `file_drive_id` 有沒有驗屬於該 run：**沒有**，這就是 E3-1。

### ⑥ 正解匯入（工具未報，人工查證 → **租戶來源正確，但內容驗證幾乎等於沒有**）

**租戶編號的來源：正確，從使用者身分拿，不是從 body。** `ClassificationGroundTruthImportRoute.post`（`evidence_classification_route.py:207-223`）傳的是 `tenant_id=user.tenant_id`，body 裡只取 `framework_id`、`mapping`、`note`。**這一點沒有問題。**

**守門：如卡片所述，是「租戶內任一專案的管理者」。** `_require_any_project_manager`（`:130-135`）呼叫 `is_any_project_manager`。程式註解說明理由是「正解表沒有專案維度，是租戶層的東西，RLS 已經鎖住租戶範圍」，並註明是使用者定調的。**這是刻意的設計決定，不是漏洞**——但要知道它的實際含意是：**租戶內任何一個專案的管理者，都能覆蓋整個租戶共用的正解基準**，包括他自己沒有參與的專案會用到的那份。

**內容驗證：這裡有缺口。** `import_ground_truth`（`:1098`）對 `mapping` 的檢查只有一行：

```python
if not isinstance(mapping, dict) or not mapping:
    raise BadRequestError(EC.EC_GROUND_TRUTH_INVALID)
```

**是字典、不是空的——就這樣。** 沒有大小上限、沒有檢查鍵是不是合理的檔名、沒有檢查值是不是評估項目編號的清單、沒有檢查項目編號存不存在。整包直接存進資料庫（`self._gt_ds.upsert(entity)`）。

影響有兩面：一是**資源面**，可以送一個極大的 JSON 進來（受限於 HTTP body 上限，但那通常設得很寬）；二是**資料品質面**，正解基準是用來評斷「AI 判得準不準」的尺，尺被亂改，驗證報表與判定報表就全部失真，而**這件事不會報錯、也沒有留下誰改的痕跡以外的線索**（`created_user` 有存，算是有跡可循）。

**建議**：加上 mapping 的筆數上限、鍵值型別檢查（鍵是字串、值是字串清單），以及項目編號要能在目錄裡找得到。這一項嚴重度不高，但修起來很便宜。

### ⑦ 舊線背景執行緒的身分（工具未報，人工查證 → **身分有帶、交易範圍正確，但這條路目前跑不起來**）

**身分：有帶。** `_run_classify_worker`（`:294`）開頭就是：

```python
if user_ctx is not None:
    set_user_context(user_ctx)
```

新執行緒裡重新建立使用者脈絡，這是對的（脈絡是執行緒區域變數，不重建會是空的）。

**交易範圍：正確。** `_finalize_drive_output`（所有 Drive 寫回與 `_persist_run_to_db:497` 都在裡面）被包在 `with session_scope():` 裡（`:332-338`），而且**刻意放在容器執行之外**——程式註解說明理由是「不要在長達十分鐘的容器執行期間一直佔著資料庫連線」。這個安排是對的。

**但這條路目前跑不起來。** `_run_classify_worker` 的說明文字寫得很明確（`:311-315`）：

> 🔴 這條路徑目前起不了容器：容器已改成只讀宿主 stage 好的目錄，不再自己連 Drive 取檔，而這裡沒有 stage 那一步。會在 `runner.run()` 當場拋 ContainerRunError 說明原因，不是靜默跑出空結果。

我核對了執行順序：`runner.run()` 在 `_finalize_drive_output` 之前，所以**背景執行緒會在容器那一步就死掉，Drive 寫回那一整段根本到不了**。這是失敗得很乾淨的形狀（當場拋錯、記進登記簿的 `error` 欄位），不是靜默失敗。

**這個事實對前面四條的影響要分清楚**：它讓①的資料夾覆寫只剩探測器的價值、讓③的上傳路徑目前不會被觸發。**但它完全不影響 E3-1 到 E3-4**——那四條全部是查詢端點，讀的是過去成功跑過留下來的資料與硬碟內容，跟觸發跑不跑得起來無關。

## 可信度：分兩層看

**工具報的四條（E3-1～E3-4）：可信度高。** 驗證章蓋 `verified`，九個候選去重成六個，十八票全數投出、零遺失、零未審，四條**全部 3:0 一致通過**（另兩個候選被 1:3 與 0:3 駁回）。全票這件事跟 E1 那棒形成對比——E1 三條都是 2:1，爭點是「出貨版跑不到」；這一棒的四條沒有人持保留意見，因為這些是查詢端點，不需要容器跑得起來。

E3-1 我**另外開檔逐項核對過**：端點守門確實只有登入、`get_file_preview` 確實沒呼叫任何守門、主專案的授權範圍確實是完整硬碟（grep 全 `app/cloud_integration/` 確認沒有一處用 `drive.file`）。這是報告裡唯一一條高風險，核對過才寫。

E3-3 與 E3-4 的把握度是「中」，**原因不是票數（兩條都是全票）**，是各自有一個未證實的前提：E3-3 是「Google 的查詢剖析器會不會照預期被注入」沒有實測，E3-4 是「跨租戶那一段需要碰上記憶體登記簿還留著資料的時間窗」。程式碼層面的缺陷本身兩條都是確定的。

**人工查證的部分（①～⑦）：可信度中高，但性質不同。** 這些是首腦單人開檔查的，**沒有經過三人投票**。查法是實際開檔讀、grep 原始碼、追到端點與宿主核對，不是憑印象；但單人判斷沒有對抗性審查，可能漏看。其中三個判斷特別值得複核：

- **②「查單一 job 狀態的端點值得被單獨點名」**是首腦的分類判斷（工具把它歸進 E3-4 的影響面）。決策者可能認為併進 E3-4 一起修就夠。
- **③「AI 給的資料夾名沒問題」**是基於「名字取自目錄、不是取自 AI 的字串」這個讀碼結論。若目錄本身（來自 living SSP，控制項名稱使用者可編輯）含特殊字元，是另一條資料流的問題，本棒沒追。
- **⑦「這條路跑不起來」**是採信了程式碼註解的自陳，我核對了執行順序（`runner.run()` 在寫回之前）支持這個結論，**但沒有實際跑過一次確認**。

**這份報告不能拿來說什麼**：不能說 `jedi-evidence-classification` 其他地方沒事（本棒只看四個檔，套件其餘部分在 E1／E2／E4／E5）；不能說這四條就是全部（低強度跑法，且工具對①②③⑥⑦完全沒報，是人工補的）；**不能說已經驗證過攻擊可行**——沒有打過任何一個端點、沒有對 Google 硬碟發過任何請求，全部是讀程式碼推出來的。

## 執行概況

| 項目 | 值 |
|------|-----|
| 驗證章（`verification.status`） | `verified` |
| 候選數 → 去重後 | 9 → 6 |
| 面板票數 | 18 票（6 個候選 × 3 位檢查員），**全數投出，零漏投** |
| 通過／駁回 | 4 通過、2 駁回（駁回票型 1:3、0:3） |
| 各條票型 | E3-1 3:0、E3-2 3:0、E3-3 3:0、E3-4 3:0（**四條全票**） |
| 嚴重度調降 | 無 |
| 研究員 | 派 2 位、回 2 位 |
| 代理程式總數 | 20（全數完成，0 失敗、0 略過、0 空回報） |
| 工具呼叫 | 1,099 次 |
| 掃描版本 | `955e409`，工作區 **dirty**（套件工作區有未提交的 `.claude/` 目錄，**不在掃描範圍內**，四個目標檔皆為 HEAD 版本） |
| run ID | `wf_ef63d0b1-127` |
| 耗時 | 約 91 分鐘（5,474 秒） |
| 設定 | mode `scan`、effort `low`、focus `attack-surface`、4 檔 1,781 行 |

**耗時與前兩棒對照**：4 檔 1,781 行跑 91 分鐘，約每檔 23 分鐘——但檔案大小差很多（主檔 1,246 行、最小的只有 115 行），以行數算是每千行 51 分鐘，跟 E1 的每千行 91 分鐘相比快了不少。**低強度仍然不等於跑得快**，它只決定「一名研究員、不做盤點與威脅建模」，不決定研究員自己想多深（那寫死在工具裡）。

**過程順利，沒有中斷、沒有重試、沒有撞額度。** 行數壓在 2,000 以下（1,781）的判準再次有效。

**dirty 標記要說清楚**：版本戳記是 `CLAUDE-SECURITY-REVISION-955e40964baa-dirty.json`，`-dirty` 來自套件工作區有一個沒進版控的 `.claude/` 目錄。**那不在掃描範圍內，四個被掃的檔案都是 `955e409` 的版本**，結論不受影響。

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

**修正卡當時未開**——依 FR-109 與 E1／E2 的先例，全部掃完分類後統一開（後續已統一派工；舊 Drive 線隨舊線退場拆除，CM-2222，1.21.0 出貨）。**當時的判斷： E3-1 是這個 arc 到目前為止唯一一條高風險，且打擊門檻是「有任何一個登入帳號」，建議不要等整個 arc 掃完才處理。**
