# FR-095.3 P2：任務指派＋六張參與者表資料存取＋migration——資安掃描報告

- **卡片**：CM-1782（FR-095 第 3 棒）
- **範圍**：jedi 套件 repo `jedi-task-platform/jedi_task_platform/` 底下 35 支檔——任務指派的網址入口／業務邏輯／資料存取、六張成員相關資料表的查詢層與資料表模型、兩支建表腳本
- **掃描時間**：2026-09-14（UTC 13:08 起，跑了約 2 小時 15 分）
- **掃描版本**：`ca60cfd8efc7bf22e92a0c0fb4632e9be856e76f`（branch `feature/FR-075`，工作區乾淨）
- **工具**：Claude Code `claude-security` plugin v0.11.0，effort `low`、focus `attack-surface`
- **run ID**：`wf_ae470f56-685`
- **驗證章**：✅ `verified` — 三個檢查員對 2 條發現各投一票、**6 票全數投出、2 條全 3:0 通過**，無退件
- **只掃不修**：本棒沒有改任何程式碼，也沒有動任何環境（只對 DEV 資料庫做了唯讀查詢）

---

## 1. 🔴 一句話結論

**掃完了，範圍內找到 2 個真問題，最嚴重的是「任何能登入的帳號送一個空請求，就能撈出全庫一萬兩千多筆任務指派紀錄」**——裡面有每個任務指派給誰、那個人的登入帳號與暱稱、誰是審核者。這和第 1 棒（P1）找到的成員名冊外洩是**同一個病在不同表上重演**：查詢條件全部非必填，程式把「沒有條件」當成「不用過濾」，直接回整張表。

第二個問題是**寫入端**：新增任務指派時，系統拿「你自己填的專案編號」去檢查你是不是那個專案的管理者，卻**不檢查你填的任務到底屬不屬於那個專案**。結果是：只要你在公司內任何一個專案當管理者（自己開一個專案就自動是管理者），就能把**別人專案的任務**登記成你自己專案底下的指派——然後那個任務會出現在你的待辦清單裡，而且主系統判斷「你能不能完成／退回這個任務」時正是靠這張表反查專案，可能因此被騙過。

另外我人工核對出 **4 件工具沒報的事**（第 5 節），其中最要緊的是：**六張表在 DEV 資料庫的隔離全是關的**（唯讀實查 2026-09-14），而**出貨給新客戶的基線檔裡也沒有這六張表的隔離設定**——套件雖然已經寫好隔離腳本（`003-participant-rls.sql`），但那支不在本棒範圍、也還沒套進任何環境。也就是說**上面兩個洞目前沒有任何第二道防線**。

---

## 2. 這一棒在檢查什麼（白話）

產品裡每個稽核任務（例如「檢查某條控制項的證據」）會指派給某些人去做，有人是執行者、有人是審核者。**「哪個任務指派給誰」這件事，就存在一張叫 `task_assignees` 的資料表裡**，DEV 上有一萬兩千多筆。

這一棒檢查三層是不是都空的：

1. **業務邏輯層**：新增／修改／刪除／查詢指派的那四支功能，有沒有問一句「你是不是這個專案的人」。
2. **資料存取層**（就是「真正下 SQL 去查表」的那一層，共六支）：查詢時有沒有限定在某個專案範圍內。**這一層很關鍵**——就算上一層守門寫對了，只要這一層「沒帶條件就回全表」，換個參數還是撈得到別人的資料。
3. **資料表本身**：這六張表有沒有「這筆資料屬於哪個客戶」的欄位、資料庫層的隔離有沒有開。

**把三層放同一棒，就是為了一眼看完是不是三層全空。答案是：接近全空。**

---

## 3. 掃到什麼：總覽表

### 3.1 範圍內（本棒的發現）

工具報 4 條候選，去重後是 **2 個問題**，兩條都 3:0 通過面板。

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 |
|---|---|---|---|---|---|
| **P2-1**<br>🟡 中<br>(工具 F1) | 「查任務指派清單」這支 API **完全沒有檢查你是不是這個專案的人**，查詢條件八個欄位全部非必填——送一個空請求 `{}`，程式把「沒條件」當成「不用過濾」，變成**無條件全表查詢** | **一次撈光全庫一萬兩千多筆任務指派**（DEV 實測 12,494 筆），每筆含：專案編號、控制項編號、任務編號與代碼、被指派人的登入帳號與暱稱、是不是審核者、建立者與修改者的登入帳號。**等於全站員工帳號清冊＋每個專案的稽核分工全貌**。也可以只帶一個 `user_uid` 反查「某個特定的人被指派了哪些任務」，用來鎖定目標 | **只要一個能登入的帳號**（任何角色，甚至不必是任何專案的成員）。不需要知道任何編號 | 入口 `participant/api/routes/task_assignee_route.py:44-52`<br>缺口本體 `participant/app/service/task_assignee_service.py:70`<br>條件全選填 `participant/api/serializers/task_assignee.py:4-12` | 照同一支檔裡 `delete_task_assignee`（:239-250）已經寫對的做法補三件事：① `project_id` 改必填（serializer 加 `required=True`），沒給就回 400；② 查詢前呼叫「你是不是這個專案的成員」檢查（主系統已有現成的 `common.authz.project.assert_project_participant`，套件側比照 `common/guard.py` 開一支 port 由主系統注入）；③ **拒絕完全不帶條件的查詢**，不要讓它退化成全表撈 |
| **P2-2**<br>🟡 中<br>(工具 F2，工具原評高，面板三票一致降為中) | 新增任務指派時，拿「**你自己在請求裡填的專案編號**」去判斷你是不是管理者，卻**從不檢查你填的那個任務屬不屬於這個專案**——兩個值各自獨立、中間沒有任何比對就寫進資料庫 | 你自己開一個專案（開專案的人自動就是管理者），就能把**別人專案的任務**登記成你專案底下的指派：①被指派的人（可以填自己）立刻在待辦清單看到受害專案的任務內容；②更嚴重的是，主系統要判斷「你能不能完成／退回某個任務」時，是**靠這張表反查該任務屬於哪個專案**（`app/flow_engine/service/workflow_execution_service.py:150-153`），塞進一筆假的就可能讓反查指向你自己的專案，於是守門放行，**你就能動別人專案的任務狀態** | 需要在公司內任一專案是管理者（**自己新開一個專案即可**，`app/project/service/project_start_app_service.py:164-179` 會自動把建立者補成 manager）；受害任務要跟你同一個客戶（跨客戶會在前一步被 `job_executions` 的隔離擋掉）；要升級到「能改別人任務狀態」還需要那個任務原本沒有指派人（反查沒有排序，沒有既有紀錄時必中） | 守門 `participant/app/service/task_assignee_service.py:144`<br>任務解析 `:148`<br>寫入 `:164` | 寫入前先把「這個任務真正屬於哪個專案」查出來（走既有的 `ITaskExistenceQuery` port，或經 `job_executions` → `main_workflow_execution` 對應），**對不上就回 403／404**；`control_id` 同樣要驗證屬於該專案；**守門要用「從資源反查出來的專案編號」而不是請求裡填的那個**——同檔 `delete_task_assignee` 已經是這個寫法，照抄即可 |

### 3.2 人工核對追加（工具未報，見第 5 節詳述）

| # | 內容 | 嚴重度 |
|---|---|---|
| **P2-3** | **六張表在 DEV 的資料庫隔離全是關的，而且出貨給新客戶的基線檔裡也沒有這六張表的隔離設定**——套件已寫好隔離腳本 `003-participant-rls.sql`（2026-09-14 新增，不在本棒範圍），但尚未套進任何環境、也尚未併進出貨基線。上面兩個洞目前**沒有任何第二道防線** | 🟡 中（放大 P2-1／P2-2 的影響範圍） |
| **P2-4** | 六支資料存取層**沒有任何一支自己加專案範圍條件**——全部沿用共用底層的「欄位有值才加條件」規則，也就是**「上層不帶條件＝回全表」是這六張表的共同行為**，不是 `task_assignees` 獨有 | 🟡 中（是 P2-1 的根因，也代表 P1 那三支同病同源） |
| **P2-5** | `task_assignees.project_id` 存的**不是**任務真正所屬的流程編號——DEV 實查 12,485／12,494 筆兩者對不上（那是設計如此，一個存專案、一個存流程），所以**資料庫層根本沒辦法用外鍵擋 P2-2**，只能靠程式檢查。基線檔裡雖然有一支會擋這件事的觸發程序（`trg_task_assignees_validate_job`），但**DEV 上這張表掛了 0 個觸發程序**，那支函式存在卻沒有被掛上去 | 🟡 中（說明 P2-2 為何無兜底） |
| **P2-6** | 卡片說「批次新增指派」是死端點、前端無呼叫者——**實際上前端有在呼叫**（`src/views/project/TaskSetupView.vue:292`，該頁在路由 `project-task-setup` 上是活的）。後端那支恆回空陣列，所以**前端這個「快速設定」按鈕按下去什麼都不會發生、也不會報錯** | 🟢 低（非資安，是功能靜默失效） |

### 3.3 範圍外

**本棒沒有範圍外發現。** 工具的密鑰專項（掃「有沒有把密碼金鑰寫進程式碼」）這次沒有回報任何憑證類問題，4 條候選全在範圍內。

### 3.4 與既有登記案的關係

- **P2-1 屬跨 arc 總表 §2.9 🅰 組「只驗身分不驗歸屬」**，與 P1-1／P1-2 是同型不同表。
- 六張表隔離關閉這件事，總表**第 43 條**只提過 `project_participants` 一句，**`task_assignees` 與其餘四張從未登記**——本棒補齊（見 P2-3）。
- **不重複計入**：總表第 5、10、43、44、45、46、51、59 條本棒未再觸及。

---

## 4. 每條發現的詳述

### P2-1 — 查任務指派清單沒有守門，空請求＝全表撈（中，可信度 高）

**現況**：已修（M10-5，1.21.0 出貨）

**① 這是什麼問題**
有一支 API 可以查「任務指派清單」。它只檢查「你有沒有登入」，**沒有檢查「你是不是這個專案的人」**。而且它接受的八個查詢條件（專案、控制項、任務、使用者……）**全部都是選填的**，所以你可以一個都不填。程式碰到「沒有任何條件」時，不是拒絕，而是當成「不用過濾」，於是把整張表回給你。

**② 出事會怎樣**
DEV 實測這張表有 **12,494 筆**。一次請求全部回來，每一筆包含：

- 專案編號、控制項編號、任務編號與任務代碼
- **被指派人的登入帳號（`user_uid`）與暱稱（`user_name`）**
- 這個人是不是審核者（`is_approver`）、角色
- **建立者與修改者的登入帳號**（`created_user`／`updated_user`）

合起來就是**全公司員工的帳號清冊，加上每個專案的稽核分工全貌**。登入帳號外洩可以拿去做針對性的密碼嘗試；「誰負責審核哪個專案」可以拿來挑社交工程的對象。也可以只帶一個 `user_uid` 條件，反查某個特定的人手上有哪些任務——鎖定特定高權限人員時很好用。

**③ 要先有什麼才打得到**
只需要**一個能登入的帳號**，任何角色都行，甚至不必是任何專案的成員。不需要事先知道任何編號。
跨客戶的部分還需要「這個部署尚未對這六張表開資料庫隔離」——**DEV 唯讀實查（2026-09-14）確認六張表隔離全關**，出貨基線裡也沒有（見 P2-3），所以目前條件成立。就算之後開了隔離，同一個客戶內「不是這個專案的人」照樣讀得到。

**④ 在哪裡**
- 網址入口：`participant/api/routes/task_assignee_route.py:44-52`（`POST /api/1.0/task-assignees`，只掛了主系統注入的「有沒有登入」檢查）
- 查詢條件全選填：`participant/api/serializers/task_assignee.py:4-12`
- **缺口本體**：`participant/app/service/task_assignee_service.py:70` — 整個方法只有兩行，直接把請求內容展開丟進查詢
- 「沒條件就不過濾」的根源：`jedi-common` 的 `base_repository_impl.py:512`，`if value is not None and hasattr(...)` 才加條件，全 None 就變成沒有 WHERE 的 `.all()`

**⑤ 怎麼修**
同一支檔案裡的 `delete_task_assignee`（:239-250）已經寫對了：先撈出紀錄、反查它真正屬於哪個專案、再對那個專案做守門。照那個形狀補三件事：

1. serializer 的 `project_id` 加 `required=True`，沒給就回 400
2. 查詢前加一道「你是不是這個專案的成員」檢查——主系統已有現成的 `common.authz.project.assert_project_participant`，套件側比照 `participant/common/guard.py` 既有的做法開一支 port 由主系統注入（不要在套件裡另寫一套）
3. 明確拒絕「完全不帶條件」的查詢，不要讓它退化成全表撈

**首腦核對回應**：卡片「重點看什麼」第 2 條（`POST /task-assignees` 只驗登入）**成立**，與首腦預判的形狀完全一致。

---

### P2-2 — 新增指派時用「自己填的專案編號」做授權，不驗任務歸屬（中，可信度 中）

**現況**：已修（M10-6，FR-114 CM-2071，套件 commit `ef9a313b`／BE commit `fccfe4db7`）

**① 這是什麼問題**
新增任務指派時，請求裡要填四個東西：專案編號、控制項編號、任務代碼、要指派給誰。程式的做法是：

- 第 144 行：拿**你填的專案編號**去問「你是不是這個專案的管理者？」
- 第 148 行：拿**你填的任務代碼**去查出任務的內部編號
- 第 164 行：把兩者**直接寫進資料庫**

中間**沒有任何一行去確認「你填的這個任務，真的是那個專案底下的任務嗎」**。兩個值各自獨立，你可以隨意搭配。

**② 出事會怎樣**
自己新開一個專案（開專案的人會自動被補成管理者），就通過了第 144 行的檢查。接著填上**別人專案的任務代碼**，就能把那個任務登記成你專案底下的一筆指派。後果有兩層：

- **第一層（資訊外洩）**：被指派的人（可以填你自己）立刻在待辦清單看到受害專案的任務——任務名稱、描述、狀態、流程資訊。
- **第二層（可以動別人的資料，比較嚴重）**：主系統判斷「你有沒有資格完成／退回某個任務」時，是**靠這張表反查「這個任務屬於哪個專案」**，再拿那個專案去問「你是不是這個專案的人」（`app/flow_engine/service/workflow_execution_service.py:150-153`）。你塞進去的那一筆假紀錄會讓反查回答「這個任務屬於攻擊者的專案」，於是守門放行——**你就能對別人專案的任務按完成或退回**。

**③ 要先有什麼才打得到**
- 要在公司內任一專案當管理者。門檻不高：**自己新開一個專案就自動是管理者**。
- 受害任務要跟你同一個客戶（跨客戶會在第 148 行那步被 `job_executions` 的隔離擋掉）。
- 要升級到第二層（改別人任務狀態），還需要反查時抓到你插入的那一筆。那個查詢沒有排序，所以**受害任務原本沒有指派人時必中**；原本有人的話就看抓到誰。

**④ 在哪裡**
- 守門那行：`participant/app/service/task_assignee_service.py:144`
- 任務解析那行：`:148`
- 寫入那行：`:164`
- 請求四個欄位都由呼叫端自填：`participant/api/serializers/task_assignee.py:15-19`
- 資料庫為什麼擋不住：`task_id` 這條外鍵**刻意不建**（`migrations/002-participant-fks.sql:12-15`，因為跨模組疆界）；隔離腳本的寫入規則也只檢查「這個專案看得到嗎」，不檢查任務歸屬

**⑤ 怎麼修**
寫入前先把「這個任務真正屬於哪個專案」查出來，**跟請求裡填的專案編號比對，對不上就回 403 或 404**。`control_id` 同樣要驗證屬於該專案。最關鍵的一點：**守門要用「從資源反查出來的專案編號」，而不是請求裡填的那個**——同一支檔的 `delete_task_assignee` 已經是這個寫法（:239-250），照抄形狀即可。

**面板為什麼把嚴重度從高降到中**：三個檢查員一致認為第二層那條升級鏈有前置條件（要反查剛好抓到插入的那筆），而第一層只是看到任務內容，屬有限影響。首腦同意這個判定。

**首腦核對回應**：卡片「重點看什麼」第 4 條（migration 自陳「一律先經 project_id 收斂」是否每條路都成立）——**不成立，這就是反例**：`project_id` 是呼叫端自填、未經驗證，「收斂」這件事在寫入路徑上根本沒發生。

---

## 5. 人工核對追加（工具未報，我自己開檔／查資料庫查證）

### P2-3 — 六張表隔離全關，出貨基線也沒有（工具未報，人工查證）

**這是什麼問題**：資料庫有一個叫 RLS 的機制（就是「每個客戶只能看到自己的資料」的資料庫層隔離）。這六張表**一張都沒開**。

**查證方式**：DEV 資料庫唯讀查詢（2026-09-14）：

```
control_group_participants   | f      ← f 代表關閉
process_participants         | f
project_control_participants | f
project_group_participants   | f
project_participants         | f
task_assignees               | f
```

再查主專案的**出貨基線**（新客戶安裝時用的 `scripts/init/02-schema.sql`）：全檔有 61 條開啟隔離的指令，**這六張表一條都沒有**。

**套件已經寫好腳本了，但還沒套**：套件裡有 `migrations/003-participant-rls.sql`（2026-09-14 新增，FR-094 第 9 棒 CM-1800 的產物）把六張表的隔離都補上了——但**那支檔不在本棒範圍**（本棒 scope 只含 001 與 002），而且從 DEV 實況看**還沒有套進任何環境**，主專案的出貨基線也還沒重產。

**影響**：P2-1 與 P2-2 目前**沒有第二道防線**。P2-1 跨客戶撈全表這件事，現在是真的做得到。

**建議**：這件事應該併進 FR-094 那條線追（套件 003 何時套進 DEV／STG／POC、出貨基線何時重產），不是本棒能修的。**注意**：就算套了隔離，P2-1 的「同客戶內非本專案的人也讀得到」仍然存在——隔離只擋跨客戶，擋不了同客戶內跨專案。

---

### P2-4 — 六支資料存取層沒有一支自己加專案條件（工具未報，人工查證，卡片「重點看什麼」第 3 條的正式回應）

**現況**：已裁定逐支補、底層不動（M10-9）；有洞的支已補，1.21.0 出貨

**卡片要我逐支看的六支加 `user_enrichment.py`，我全部開檔看過了。結論：**

| 檔案 | 有沒有自己寫的查詢方法 | 有沒有加專案範圍條件 |
|---|---|---|
| `task_assignee_repo_impl.py` | 有 5 支（`get_user_task_queue` / `add` / `update` / `delete` / `add_batch` / `get_existing_keys_multi` / `delete_by_project_id`） | **寫入與刪除類都有帶完整主鍵條件**；`get_user_task_queue` 帶 user_id＋task_id 清單；`get_existing_keys_multi` 帶 task_id＋user_id 清單。**都不是「回全表」型** |
| 其餘五支（`project_participant` / `control_group_participant` / `process_participant` / `project_control_participant` / `project_group_participant`） | 只有 `add` / `update` / `delete` / `delete_by_project_id`，全部帶完整主鍵條件 | 同上 |
| `user_enrichment.py` | 蓋掉底層 7 支讀取方法 | **不加任何條件，只做「讀出來之後把 user_id 換成姓名」** |

**真正的問題在這裡**：六支 repo **沒有任何一支自己實作 `get_all` / `get_one`**——那兩支走的是共用底層 `BaseRepositoryImpl`，規則是**「這個欄位有值才加條件，沒值就不加」**（`base_repository_impl.py:512`）。所以**「上層不帶條件＝回全表」是這六張表的共同行為**，不是 `task_assignees` 一張表的問題。

這解釋了為什麼 P1 那三支（成員名冊）和 P2-1（任務指派）會是同一個病：**它們共用同一個查詢底層，而那個底層的設計就是「不帶條件不過濾」**。修的時候要有心理準備：補在上層（service）是一支一支補；如果想一次擋掉，就要在底層加「拒絕空條件查詢」的規則，但那會影響所有用到這個底層的模組，屬跨模組決策。

**跨租戶 join 的疑慮不成立**：`user_enrichment.py` 把使用者資料補進來時，走的是主系統注入的名冊 port（`get_users_by_ids`），最終查的是 `public.users` 表——**那張表的隔離是開的**（DEV 實查為 `t`），所以查不到別家客戶的帳號，只會留白。這一點卡片有問，答案是**健康的**。

---

### P2-5 — `project_id` 存的不是流程編號，資料庫層沒辦法用外鍵擋 P2-2（工具未報，人工查證）

想確認 P2-2 有沒有資料庫層的兜底，查了三件事：

1. **外鍵**：`task_assignees` 只有一條外鍵 `fk_task_assignees_project`（指向 `projects`），**沒有指向任務的外鍵**——`migrations/002-participant-fks.sql:12-15` 明文寫「刻意不建，因為跨模組疆界」。
2. **`project_id` 與任務的關係**：DEV 實查 12,494 筆裡，有 **12,485 筆**的 `project_id` 跟該任務的 `main_workflow_execution_id` 對不上。這**不是資料壞掉**——`project_participant.py:20-23` 的註解明說「`project_id` 存的是專案編號，不是流程編號，兩者值域不重疊」。意思是：**資料庫沒有任何一欄可以用來檢查「這個任務屬不屬於這個專案」**，只能靠程式查。
3. **觸發程序**：出貨基線裡有一支 `trg_task_assignees_validate_job`，內容正是「檢查 task_id／project_id／process_id 三個編號對不對得到同一筆任務，對不上就報錯」——**如果掛上去，P2-2 就會被資料庫擋住**。但 DEV 實查：**這張表掛了 0 個觸發程序**，函式存在、沒有被掛上。

**結論**：P2-2 目前**三層都沒擋**（程式沒查、外鍵沒有、觸發程序沒掛）。修 P2-2 只能從程式層下手；如果想要資料庫兜底，把那支既有的觸發程序掛上去是現成的選項，但要先確認它的 `process_id` 欄位語意與現況一致（那支函式讀 `NEW.process_id`，而 `task_assignees` 表上**沒有這個欄位**，所以直接掛會壞——這也可能就是它從沒被掛上的原因）。

---

### P2-6 — 「批次新增指派」死端點：前端其實有在呼叫（工具未報，人工查證，非資安）

**現況**：🗑️ 整頁已拆除（M10-14，1.21.0 出貨）

**卡片說**：`batch_add_task_assignees` 是 stub 恆回空陣列，前端常數還在但 grep 無呼叫者，「報告非資安段記一筆可刪即可」。

**核對結果：前端有呼叫者，卡片這句要更正。**

- `src/views/project/TaskSetupView.vue:292` 有實際呼叫 `API.TASK_ASSIGNEES_BATCH`，在「快速設定」功能裡。
- 那個頁面**在路由上是活的**（`project-task-setup` 與 `project-task-setup-ap` 兩條路由都指向它）。
- 另一個頁面 `ProjectPlanningView.vue:2172` 有註解說明這個端點「已 dark、改走 per-job 寫入路徑」，但 `TaskSetupView.vue` **沒有跟著改**。

**實際後果**：使用者在「專案任務設置」頁按下快速設定，前端送出請求，後端 `task_assignee_service.py:186-190` 直接回空陣列，前端收到成功回應、跳出「設定成功」的提示——**但什麼都沒有寫入**。這是**功能靜默失效**，不是資安問題，但比「死端點」嚴重：使用者會以為設定好了。

**建議**：不要直接刪端點。先確認 `TaskSetupView` 的快速設定是不是還要留；要留就比照 `ProjectPlanningView` 改走 per-job 寫入路徑，不留才刪端點與前端常數。**這件事應另開卡，不屬本棒範圍。**

---

## 6. 套件公開方法 vs 宿主守門對照表

本棒 scope 內唯一的 app service 是 `TaskAssigneeService`，它有 8 支公開方法。逐支核對「主系統哪裡呼叫、呼叫前有沒有守門」：

| 方法 | 行號 | 誰呼叫 | 呼叫前有沒有守門 | 守門是否條件式 |
|---|---|---|---|---|
| `get_task_assignees` | :69 | 套件 route `task_assignee_route.py:50`（`POST /task-assignees`）；**另外 AI 儀表板也讀得到**（`di_containers/dashboard_apis/participant.py:42-53`） | ❌ **零守門**，只有「有沒有登入」 | — |
| `get_task_assignee_menu` | :74 | **無人呼叫**（套件內外 grep 皆零；只有 `docs/api/participant/generate_docx.py` 的文件字串提到） | — | **宿主零取用**，未來陷阱 |
| `get_task_assignees_with_inherits` | :94 | **無人呼叫**（同上） | — | **宿主零取用**，未來陷阱 |
| `get_user_task_queue` | :135 | **無人呼叫**（「我的任務」已改由主專案 `MyJobsAppService` 供應，FR-069 4.1／CM-1477） | — | **宿主零取用**，未來陷阱 |
| `add_task_assignee` | :140 | 套件 route `:66`（`POST /task-assignee`） | ✅ 有 `assert_project_manager` | ⚠️ **是**：`if enforce_role and self._participant_role_service:`。本服務的角色服務**有**注入（`di_containers/flow_engine/task_assignee_containers.py:66`），所以目前會檢查。但守門對象是自填的專案編號——見 P2-2 |
| `batch_add_task_assignees` | :175 | 套件 route `:27`（`POST /task-assignees/batch`）；**前端 `TaskSetupView.vue:292` 實際在呼叫** | ❌ 零守門 | 恆回空陣列，**寫不進東西所以目前無風險**，但見 P2-6 的功能失效問題 |
| `update_task_assignee` | :193 | 套件 route `:81`（`PUT /task-assignee`） | ⚠️ 有，但**撈不到既有紀錄就整段跳過**（:213-214） | ⚠️ **雙重條件式**：`if enforce_role and role_service` 外，再加 `if existing is not None and existing.project_id is not None` |
| `delete_task_assignee` | :234 | 套件 route `:102`（`DELETE /task-assignee`） | ✅ **本檔唯一寫對的一支**：先撈紀錄、不存在就 404、反查真實專案、與請求的專案交叉比對、再守門（:239-250） | ⚠️ `enforce_role` 仍是條件式，但資源反查是無條件的 |
| `delete_by_project_id` | :260 | **無人呼叫**（主系統的專案刪除沒走這支；DEV 實查有 9 筆指向已不存在任務的孤兒紀錄） | ❌ 零守門 | **宿主零取用**，未來陷阱 |

**這張表的兩個重點**：

1. **四支方法宿主完全沒用到**（`get_task_assignee_menu`／`get_task_assignees_with_inherits`／`get_user_task_queue`／`delete_by_project_id`）。它們現在沒有 route，所以打不到，但**留著就是未來陷阱**——哪天有人接上 route 或從別處呼叫，會發現它們一道守門都沒有。登記備查。
2. **`update_task_assignee` 的守門寫在條件後面**（卡片「重點看什麼」第 1 條）——見下一節。

---

## 7. 卡片「重點看什麼」逐條回應

卡片列了六項，逐條答：

### ① 更新任務指派時，撈不到既有紀錄就不做 manager 檢查（首腦核對✅屬實）

**成立，我追下去確認了跳過之後會發生什麼。** `task_assignee_service.py:213-214` 的條件是 `if existing is not None and existing.project_id is not None:`，撈不到就整段跳過守門，繼續往下走。

**但往下走的第一件事是 `validate_assignee_is_not_exist`（:216）——那支在查無紀錄時會拋 404**（`task_assignee_domain_service.py:40-47`）。所以**目前的實際結果是 404，不是無授權寫入**。

**這是「靠一個檢查擋另一個缺口」的結構**，跟 P1-4 找到的流程參與者是同一個形狀（那邊更糟，它 docstring 宣稱的 validate 根本不存在；這邊至少 validate 是真的）。風險在於：那支 validate 的存在與否跟授權無關，哪天有人覺得「查無就當作新增」把它改成 upsert，這道守門就靜默消失了。**建議修法**：把守門條件改成「撈不到就拋 404」，不要依賴後面那支。

### ② `POST /task-assignees` 只驗登入（首腦核對✅屬實）

**成立** → 就是 P2-1，工具也報了，3:0 通過。

### ③ 六支 repo 的查詢有沒有限定專案（工具未報，人工查證）

**已逐支開檔核對，對照表在第 5 節 P2-4。** 結論：六支 repo **沒有任何一支自己實作 `get_all`／`get_one`**，走的是共用底層的「有值才加條件」規則，所以「不帶條件＝回全表」是六張表的共同行為。`user_enrichment.py` 的跨租戶疑慮**不成立**（走名冊 port，底層 `users` 表隔離是開的）。

### ④ migration 自陳的前提是否成立（工具未報，人工查證）

**不成立。** `001-participant-tables.sql:16-21` 寫「租戶隔離由上游的專案層擋（participants 一律先經 project_id 收斂）」。

- **讀取路徑**：`get_task_assignees` 根本不帶 `project_id` 就能查（P2-1）——「一律先經 project_id 收斂」不成立。
- **寫入路徑**：`project_id` 是呼叫端自填、不驗證（P2-2）——「收斂」發生了，但收斂到的是攻擊者指定的值。

**補充一點**：這段註解本身其實已經被更新過了。我讀到的版本（第 16-21 行）結尾寫著「**不要指望應用層的 project_id 收斂（實測列表查詢不帶 project_id 就是全表撈）**」——也就是說**寫這支 003 隔離腳本的人（FR-094 第 9 棒）已經發現這件事並改寫了註解**。卡片引用的是舊版說法。

**`002` 的外鍵行為**：六條 `project_id` 外鍵都是 `ON DELETE CASCADE`，**刪專案時指派紀錄會跟著刪，不會留孤兒**——這一點是健康的。`task_id` 那條外鍵刻意不建（跨模組疆界），所以**指向已刪任務的孤兒擋不住**：DEV 實查有 9 筆（12,494 筆中）指向已不存在的任務。數量很小，屬殘留不屬設計缺陷。

### ⑤ 死端點 `batch_add_task_assignees`（首腦核對說 FE 無呼叫者）

**這條要更正：前端有呼叫者，而且頁面是活的。** 詳見第 5 節 P2-6。實際後果是**功能靜默失效**（按了沒反應也不報錯），比死端點嚴重，建議另開卡。

### ⑥ `participant/domain/ports.py` 的介面契約有沒有 Optional 讓宿主少給也能過

**查過了，結論分兩半：**

- **守門相關的 port 是硬的**：`IProjectRoleGuard`（:81-99）**沒有預設實作**，缺了就在 `common/guard.py:44-49` 直接 raise，**不放行**。`IUserDirectory` 的兩支核心方法（`get_user`／`get_users_by_ids`）也沒有預設值，缺了拒絕掛載。**這一半是健康的。**
- **有預設值的三個都不影響授權**：`IUserDirectory.get_org_units_by_ids`（:58-69）與 `get_users_by_login_names`（:71-78）預設回空字典；`IAuditLogger`／`IJobNotifier`／`IJobEnrichment` 缺了就降級（不記事件、不通知、附件數回 0）。**這三者失效的後果是畫面留白或稽核紀錄漏記，不會讓任何人多拿到權限。**

**但要指出一件事**：`IAuditLogger` 缺了會讓**稽核事件靜默不記錄**（只留一行 WARNING）。這在資安上不是「洞」，但是**事後追查的盲點**——出事時查不到誰指派了什麼。主系統目前有接（`core/plugins/participant.py:build_config` 有給 `audit_event_codes`），所以現況正常。登記備查。

---

## 8. 可信度分兩層

### 8.1 報出來的這些，存在嗎？→ **高**

兩條發現都經過三個獨立檢查員各自從零讀檔驗證，**6 票全數投出、2 條全 3:0 通過、驗證章 `verified`、無退件**。我另外逐條開檔核對過行號與程式碼內容，與報告一致。P2-1 的「空請求＝全表」有共用底層的程式碼為證（`base_repository_impl.py:512`），P2-2 的三行（守門／解析／寫入）我逐行讀過，中間確實沒有比對。

資料庫相關的四項人工核對（隔離狀態、外鍵、觸發程序、孤兒筆數）都是**對 DEV 資料庫的唯讀實查**，不是推測。

**沒有執行過任何攻擊**：本棒全部發現都來自讀程式碼與查資料庫，**沒有真的送過請求驗證**。上面寫的「打得到」是從程式碼推出來的，不是實測過的。

### 8.2 只有這些嗎？→ **中低**

- **effort 是 `low`**：一個研究員讀完整個範圍，**沒有做威脅建模、沒有做廣度掃**。`low` 的定位是「快速分流但仍經驗證」，不是「窮舉」。
- **工具沒有回報覆蓋率帳目**（`research_coverage` 是 null），所以**沒有逐檔記錄哪些檔被讀到結論、哪些沒讀到**。35 支檔裡工具只在 3 支檔上報出問題，其餘 32 支是「讀過沒事」還是「沒讀到」，**工具沒說，我無法從它的輸出判斷**。
- **我人工補讀的部分**：六支 repo、`user_enrichment.py`、三支 migration、四支 model／mapper／entity、ports.py、route、serializer——這些我實際開檔看過。**沒有逐檔精讀的是**：六支 mapper 裡的四支、三支 `__init__.py`、四支 model 中的兩支（都是純欄位宣告，風險低）。
- **本範圍從未掃過**，這是第一輪。

**所以：報出來的兩條可以信；「這 35 支檔沒有別的問題」這句話不能信。**

---

## 9. 執行概況（數字，照 stamp 實抄）

| 項目 | 值 |
|---|---|
| run ID | `wf_ae470f56-685` |
| 驗證章 `verification.status` | ✅ `verified` |
| 候選數 `candidates` | 4 |
| 去重後 `candidates_deduped` | 2 |
| 面板票數 `panel_votes` | **6**（＝2 條 × 3 個檢查員，全數投出） |
| 達標發現 `panel_quorum_findings` | 2（兩條都 3:0） |
| 未審候選 `unreviewed_candidate_sites` | 0 |
| 研究員 `researchers_dispatched/returned` | 2 / 2 |
| 嚴重度被下修 | F2：HIGH → MEDIUM（三個確認票一致給 MEDIUM） |
| 掃描耗時 | 8,107 秒（約 2 小時 15 分） |
| 掃描版本 | `ca60cfd8efc7bf22e92a0c0fb4632e9be856e76f`（`feature/FR-075`，乾淨） |
| effort／focus | `low`／`attack-surface` |
| 範圍檔數 | 35（與卡片核對一致） |
| 工具原始產物 | `jedi-task-platform/jedi_task_platform/CLAUDE-SECURITY-20260914-130805/`（套件 repo，該目錄自帶 `.gitignore` 不入版控） |

---

## 10. 給下一棒／首腦的提醒

1. **P2-1 與 P1-1／P1-2 是同一個根因**（共用查詢底層「不帶條件不過濾」），修的時候可以一起規劃——要嘛一支一支在 service 補守門，要嘛在底層加「拒絕空條件查詢」，後者影響面大屬跨模組決策。
2. **P2-3（六張表隔離未套、出貨基線未含）應該併進 FR-094 那條線追**，不是本棒能修的。套件的 `003-participant-rls.sql` 已經寫好了，缺的是「套進環境」與「重產出貨基線」兩個動作。
3. **P2-6 的前端功能靜默失效建議另開卡**——那不是資安問題，但使用者會以為設定成功。
4. **第 6 節對照表裡四支「宿主零取用」的方法**登記備查：`get_task_assignee_menu`／`get_task_assignees_with_inherits`／`get_user_task_queue`／`delete_by_project_id`，四支都零守門，現在打不到但接上就開。
