# C1b 掃描報告 — 任務設定樹、目前 SSP、稽核人員待辦清單（CM-2100）

> 範圍：21 檔／1,404 行，跨兩個 repo 配對切。套件側 `jedi-compliance-audit` 19 檔／1,272 行，主專案側 2 檔／132 行。
> 掃描工具：Claude Code 官方 `claude-security` plugin，effort low，**兩側各跑一次**。
> 套件側基準 commit：`eafc7ae511d5`（monorepo 主 checkout，工作區有平行 session 的未提交改動）。主專案側基準 commit：`d0b69c115970`（同上，dirty）。
> 驗證章：**兩份都是 verified**。只掃不修（掃描當時紀錄；後續修正見 README）。

---

## 1. 一句話結論

**卡片預期的形狀成立：任務設定樹從網址到資料庫，整條路只查了「有沒有登入」和「客戶有沒有買這個功能」，沒有任何一步查「你是不是這個專案的人」。** 網址上的專案編號收下後完全沒用到，後面那個編號拿到什麼就拿什麼去查。只要查得到，就當作有權限。結果是同一家客戶裡任何一個登入帳號，都能看到任何一個專案的整棵控制項樹，以及每項任務指派給誰（含姓名）。

「目前 SSP」那支是同一種形狀，但只洩漏一個編號。拿到編號之後，其他 SSP 端點各自有 `SspPermissionChecker` 擋著，所以傷害小。

稽核人員待辦清單**不是洞**。查詢條件就是「呼叫者本人」，這個身分從登入憑證取，前端改不了。

跨客戶都擋得住，因為底下的專案、輪次、任務相關表都有開資料庫隔離（DEV 實查）。

補充一個前端的實況：這兩支網址目前**沒有畫面在叫**。任務設定樹原本的畫面已在 FR-114 CM-2047 決定拆除，「目前 SSP」在前端只剩一個沒人呼叫的方法。但後端網址還開著，直接打照樣回資料。

---

## 2. 這一棒在檢查什麼

這支套件（稽核流程）自帶三條「只讀」網址：

| 網址 | 給誰用 | 回什麼 |
|---|---|---|
| `GET /api/1.0/grc/project/<project_uid>/ap/<ap_uid>/task-setup/tree` | 任務設定頁 | 整棵「控制群組 → 控制項 → 評估目標」樹，加上每個評估目標底下的收證據任務數、指派人 uid 與暱稱、工作流程編號 |
| `GET /api/1.0/grc/project/<uid>/current-ssp-uid` | 前端從專案找 SSP | 該專案工作中 SSP 的編號與狀態 |
| `POST /api/1.0/grc/audits/my/list` | 「我的稽核任務」 | 呼叫者身為稽核員或管理者的專案裡，進行中的稽核輪次清單與判定統計 |

這支套件的權限守在 app service 層（`_check_role`、`assert_project_role` 等），路由層只掛登入與商務授權。這是 CLAUDE.md 允許的正規做法。所以要問的不是「路由層有沒有守門」，而是**三個問題**：

1. 每一支公開方法有沒有呼叫專案歸屬或角色檢查？
2. 網址上帶了兩個編號時，有沒有比對「後面那個屬於前面那個」？
3. 查資料時，除了編號之外，有沒有再限定「屬於某個專案」？

先數守門次數（卡片的三件事之一）：

| 檔案 | 守門字眼出現次數 | 對外方法數 |
|---|---|---|
| `app/service/task_setup_service.py` | **0** | 1 |
| `app/service/project_current_ssp_service.py` | **0** | 1 |
| `api/routes/task_setup_route.py` | 2（都是登入＋授權） | 1 |
| `api/routes/project_current_ssp_route.py` | 2（同上） | 1 |
| `api/routes/auditor_dashboard_route.py` | 2（同上） | 1 |

路由層那 2 次是 `@jwt_required()` 和 `@require_license(...)`，都不是「專案歸屬」檢查。

---

## 3. 掃到什麼：總覽

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度 | 來源 |
|---|---|---|---|---|---|---|
| C1b-1 | 任務設定樹不查專案歸屬 | 同一家客戶的任何員工能看到任何專案的控制項範圍、任務分派，以及每位被指派人的姓名 | 同客戶的登入帳號＋知道目標的專案、輪次或稽核計畫編號其中一個 | 套件側 `app/service/task_setup_service.py:18`（`get_task_setup_tree` 方法起點） | 低（面板 3:0 成立，把研究員報的中調降為低） | 套件側掃描 |
| C1b-2 | 「目前 SSP」不查專案歸屬 | 同客戶的非成員能確認某專案存在，並拿到它的 SSP 編號與狀態 | 同客戶的登入帳號＋知道專案編號 | 套件側 `app/service/project_current_ssp_service.py:35`（`get` 方法起點） | 低（面板 2:1 成立） | 套件側掃描 |
| C1b-3 | 任務設定樹網址上的專案編號收了不用，後面那個編號一次猜三種 | 放大 C1b-1 的可利用面：輪次編號、稽核計畫編號、專案編號，拿到任何一個都能用 | 同 C1b-1 | 套件側 `api/routes/task_setup_route.py:28`、`infra/repository/flow_control_task_setup_repo_impl.py:247`（`_resolve_ssp`） | 併入 C1b-1 修正 | runner 自行開檔（面板理由也提到），**未單獨投票** |

主專案側掃描零發現（第 5.3 節說明為什麼）。

---

## 4. 工具報的兩條（經三人面板投票）

### 4.1 C1b-1 任何員工都能看任何專案的任務設定樹

**現況**：🗑️ 已拆除（M12-4，CM-2176，commit `553381efe`，1.21.0 出貨）

**場景**

小王是 A 客戶的一般員工，只參與專案甲。他從分享連結、瀏覽紀錄或別的畫面知道了專案乙的編號，然後直接打：

```
GET /api/1.0/grc/project/隨便填/ap/<專案乙的編號>/task-setup/tree
```

回來的是專案乙的名稱、全部納入稽核範圍的控制項與評估目標、每個目標有幾個收證據任務、幾個已指派，還有**每位被指派人的 uid 與暱稱**，外加內部的工作流程範本和執行編號。專案乙的詳細頁在正常畫面上是擋他的（主專案的專案詳細查詢會限定「負責人、參與者或管理員」），這條網址卻不擋。

**為什麼會這樣（整條呼叫鏈都讀過）**

1. 路由 `task_setup_route.py:26-31`：只掛 `@jwt_required()` 和 `@require_license("project")`。收下 `project_uid` 和 `ap_uid` 兩個參數，**只把 `ap_uid` 往下傳**。`get_user_context()` 被呼叫了，但回傳值沒有被使用（第 30 行，註解寫「確保有登入」）。
2. App service `task_setup_service.py:18-29`：一行守門都沒有，直接轉給 domain service。
3. Domain service `flow_control_task_setup_domain_service.py:9-10`：直接轉給 repo。
4. Repo `flow_control_task_setup_repo_impl.py:68` 呼叫 `_resolve_ssp(ap_uid)`，只依編號找 SSP，找到就開始組樹（第 75-231 行）。

`require_license` 的實作在主專案 `common/authz/license.py`，只查「這家客戶的授權檔裡有沒有 project 模組」，完全不看使用者是誰、跟專案有什麼關係。

**跨客戶為什麼擋得住**：樹用到的表 `compliance.projects`、`project_audit_rounds`、`workflow_templates`、`workflow_executions`、`job_executions`、`task_assignees` 在 DEV 都開了資料庫隔離，政策都是「超級管理員，或租戶在允許範圍內」（2026-09-24 12:51 唯讀實查，查完已 ROLLBACK）。所以別家客戶的編號在第一步就查不到，會回一棵空樹。

**嚴重度為什麼是低**：只能讀、不能改；只限同一家客戶；編號是 UUID，要先從別處取得。但洩漏的內容包括**人名與任務分派**，比 C1b-2 實在。三位檢查員中有一位評為中。

**修法方向**：在 `TaskSetupService.get_task_setup_tree` 的開頭，把網址上的 `project_uid` 解成專案，用 `guards.assert_project_role`（允許所有讀取角色）檢查目前使用者是參與者。接著確認 `ap_uid` 解出來的專案**就是這一個**，不是就拒絕。

### 4.2 C1b-2「目前 SSP」誰都能問

**現況**：🗑️ 已拆除（M12-5，CM-2176，commit `553381efe`，1.21.0 出貨）

**場景**

同一家客戶的非成員拿專案乙的編號打 `GET /api/1.0/grc/project/<專案乙>/current-ssp-uid`，拿到專案乙工作中 SSP 的編號與狀態。

**為什麼會這樣**：`project_current_ssp_service.py:35-62` 用編號查專案（`get_by_uid`，只比對編號），查到就回 SSP 編號，中間沒有任何參與者檢查。

**為什麼傷害小（卡片點名要確認的）**：拿到 SSP 編號之後，能用它做什麼要看 SSP 端點自己的守門。主專案的 `common/authz/ssp.py:53` `SspPermissionChecker` 就是做這件事：讀取要 `require_participant`（是該專案參與者），寫入要 `require_manager`（是管理者，而且計畫還能編輯）。FR-113 O1 報告說各 SSP 端點有接這道檢查。本棒只開了檢查器本身，沒有逐支 SSP 端點重核，這一點沿用 O1 的結論。所以這支**只是洩漏一個隨機編號**，本身不是鑰匙。

投反對票的那位檢查員（影響面）理由正是如此：回傳的是隨機 UUID、一個狀態字串、寫死的 `ap_uid=""` 和 `is_editable=true`，沒有實質價值。另外兩位認為洩漏本身成立。**建議跟 C1b-1 同一張修正卡順手補**，不必另開。

---

## 5. 卡片點名的問題逐條回答（runner 自行開檔核對，未經三人面板投票）

### 5.1 `_resolve_ssp` 編號三猜：成立，而且把 C1b-1 放大了

`flow_control_task_setup_repo_impl.py:247-290`，依序把收到的編號當成：

1. **專案編號**（第 258-263 行）：查 `compliance.projects`，回專案的工作中 SSP。
2. **稽核輪次編號**（第 266-275 行）：查 `project_audit_rounds` 連 `projects`，回該輪的凍結快照，沒有就回母專案的工作中 SSP。
3. **稽核計畫編號**（第 278-288 行）：查 `oscal.assessment_plans` 連輪次連專案。

三條查詢的條件**都只有編號**，沒有「而且要屬於網址上那個專案」。方法說明（第 46-47 行）寫明這是「為兼容 route 實際傳入的識別子」而刻意做的。後果是攻擊者手上有專案、輪次、稽核計畫**任何一個**編號都能打，可利用面變成三倍。

另外注意第 ③ 條查的是 `oscal.assessment_plans`，**這張表沒開資料庫隔離**（DEV 實查 `relrowsecurity = f`）。但它後面接的 `JOIN compliance.projects` 有隔離，所以跨客戶的計畫編號查到這一步會被刷掉，**不會跨客戶**。修的人不要以為只靠第 ③ 條查詢本身就擋得住。

**這跟 FR-113 O1／B2 是同一種形狀**：網址帶了兩層編號（父層＋子層），程式只拿子層去查，從來不比對「子層屬不屬於父層」。

### 5.2 `project_extension_repo_impl.get_one_by_fields` 用 `living_ssp_id`／`owner_id` 查專案：安全

`project_extension_repo_impl.py:58-88` 查的是主表 `compliance.projects`（第 87 行 `self.session.query(Project)`）。這張表有資料庫隔離，所以跨客戶查不到。這支 repo 是共用的讀寫窗口，本身不該有守門；守門是呼叫者（app service）的責任。本棒範圍裡唯一的呼叫者是 `ProjectCurrentSspService`，問題在它沒守（C1b-2），不在 repo。

**結論：此項查證不成立，下次不用重查。**

### 5.3 稽核人員待辦清單（`auditor_dashboard_query`）：不是洞

跨側接縫，要兩邊接起來看：

- **套件側路由** `auditor_dashboard_route.py:31-36`：`user = get_user_context()`，把 `user.id` 傳進 service。這個 id 來自登入憑證，**請求內容改不到它**。請求內容只能帶 `status` 和 `keyword` 兩個篩選條件。
- **主專案側查詢** `infra/readmodel/audit/auditor_dashboard_query.py:55-58`：`JOIN project_participants pp ON pp.project_id = r.project_id AND pp.user_id = :uid AND pp.role IN ('auditor','manager')`。只列「我」是稽核員或管理者的專案。
- 判定統計那段（第 81-87 行）用 `ar_result_id` 查 `oscal.assessment_findings`（這張表沒開隔離）。但 `ar_result_id` 是從上面那條已限定「我」的查詢取出來的，不是使用者傳的，所以不會越界。
- `status`、`keyword` 都用參數綁定，沒有字串拼接，不會被注入 SQL。

**結論：與 FR-095 H1 的預期相同，此項不成立。** 主專案側掃描的研究員也追進了套件側路由確認 `user_id` 的來源，回報零發現；密鑰專項掃描也是零發現。

### 5.4 「檢查甲、動乙」：本棒沒有

本棒三支都是讀取，沒有 `delete_*`／`update_*`。卡片點名的「檢查甲、動乙」要在寫入類方法裡找，所以這一形狀本棒不適用。

`project_extension_repo_impl.py` 裡有 `add`、`update_owner` 兩支寫入方法，條件只有 `Project.id`。但它們的呼叫者（專案啟動、換負責人）不在本棒範圍，由 C2a／C1 那幾棒追。

### 5.5 前端到底還有沒有人叫這兩支

- 任務設定樹：前端 `src/views/project/TaskSetupView.vue:109` 叫的是 `/grc/project/<id>/task-setup/tree`，**網址形狀已經對不上後端**（少了 `/ap/<ap_uid>` 那段），而且這個畫面在 FR-114 CM-2047 已裁定拆除。專案規劃頁（`ProjectPlanningView.vue:706`）的註解也寫明舊的樹 API「已 dark」。
- 目前 SSP：`src/service/SspService.js:190` 有 `getCurrentSspUid`，但全前端搜不到呼叫者。

也就是說，**正常畫面不會觸發這兩支，但後端網址仍掛著**（套件 `api/routing.py:31-35`，由主專案 `api/flow_control/__init__.py` 掛上 `/api/1.0/grc`）。修法有兩個選項：補守門，或者直接拆掉這兩支網址。要拆的話要先確認沒有其他消費者（例如 AI 儀表板或外部整合），這一步本棒沒做。

---

## 6. 這份結果可信到什麼程度

**「工具報的兩條存在嗎」：可信度中到高。** 套件側三位檢查員共投 6 票全數投出。C1b-1 是 3:0 成立，C1b-2 是 2:1 成立。兩條的嚴重度都被面板從中調降為低。我自己也逐檔讀完整條呼叫鏈，結論一致。

**「第 5 節」：runner 自行開檔核對，未經三人面板投票。** 關鍵事實有實查：

- 資料庫隔離現況：DEV 查 `pg_class.relrowsecurity` 與 `pg_policies`（2026-09-24 12:51，唯讀交易，已 ROLLBACK）。`compliance` 底下七張相關表都有開，`oscal.assessment_plans`／`assessment_findings`／`system_security_plans`／`catalog_*` 都沒開。
- 前端呼叫者：開 FE repo 原始碼 grep 確認。
- `SspPermissionChecker` 的讀寫兩道檢查：開主專案 `common/authz/ssp.py` 確認。

**「只有這些嗎」：不保證。**

1. 用的是最快的檔位（effort low），研究員一輪加投票一輪，沒跑威脅建模與廣度掃描。
2. 兩側掃描各自看不到對方。第 5.3 節的跨側接縫是我接起來的，主專案側研究員也有自己追過去。
3. 沒有實際打任何網址。「看得到別人的專案」是讀程式讀出來的，沒有實際打出來。
4. 讀的是套件 monorepo 主 checkout（`feature/review`），**不是** FR-114 修正分支的 worktree。修正分支上這三支有沒有被動過，本棒沒核對。

---

## 7. 執行概況（數字，給工程師看）

| 項目 | 套件側 | 主專案側 |
|---|---|---|
| 掃描範圍 | 19 檔／1,272 行 | 2 檔／132 行 |
| 基準 commit | `eafc7ae511d5`（dirty） | `d0b69c115970`（dirty） |
| 檔位 | effort low，不設 focus | effort low，focus 生產程式碼 |
| 研究員 | 派 1、回 1 | 派 2（研究＋密鑰專項）、回 2 |
| 原始候選 | 2 條，去重後 2 條 | 0 條 |
| 投票 | 3 × 2 ＝ 6 票，全數投出 | 無候選，不投票 |
| 票型 | C1b-1 3:0（低、低、中）；C1b-2 2:1（低、低；影響面投不成立） | — |
| 驗證章 | **verified**（`CLAUDE-SECURITY-REVISION-eafc7ae511d5-dirty.json`） | **verified**（`CLAUDE-SECURITY-REVISION-d0b69c115970-dirty.json`） |
| 工具 run ID | `wf_0dd778dd-464` | `wf_971a29f5-350` |
| 耗時 | 約 12 分鐘（7 個 agent，零失敗） | 約 31 秒（2 個 agent，零失敗） |
| 工具原始報告 | 套件 repo `CLAUDE-SECURITY-20260924-044632/`（未入版控） | BE repo `CLAUDE-SECURITY-20260924-050436/`（未入版控） |

主專案側的研究員只用了 12 次工具呼叫就交零發現，時間很短，所以我開了它的逐步紀錄確認：兩支範圍檔都讀了，也追進了套件側路由看 `user_id` 從哪來。**零發現是真的讀完之後的結論，不是空跑。**

---

## 8. 待首腦裁決

1. **C1b-1＋C1b-2＋C1b-3 併一張修正卡**：在兩支 app service 開頭補專案參與者檢查，並比對「編號解出來的專案＝網址上的專案」。或者：
2. **直接拆掉這兩支網址**：前端都已經沒有畫面在叫。拆掉比補守門更徹底，但要先派人確認沒有其他消費者。建議拆，理由是「沒人用的網址，守門補得再好也是多一個要維護的入口」。
3. **M12 總報告那段「那幾條沒有追過」要更新**：`docs/security-report/M12-compliance-audit.md:110` 寫「稽核員待辦清單、任務設定、目前的系統安全計畫查詢，背後的程式一道歸屬檢查都查不到……既不能說有問題，也不能說沒問題」。本棒已追過：任務設定、目前 SSP 屬實有問題（低）；稽核員待辦清單沒問題。本棒依紀律不動 `docs/security-report/`，由首腦決定何時回寫。
