# V8 掃描報告 — 主專案稽核計畫服務 assessment_plan_app_service.py（CM-2131）

> 範圍：主專案 BE repo `app/flow_control/service/assessment_plan_app_service.py`，1 檔／742 行。
> 掃描工具：Claude Code 官方 `claude-security` plugin，effort low，scoped 掃描（指定單一檔）。
> 基準 commit：`65620f07e522`（`feature/review`，工作區有平行 session 的未提交改動，標 dirty）。
> 驗證章：**verified，3 條全部 3:0 通過**。只掃不修。

---

## 1. 一句話結論

**卡片猜的形狀「寫有守、讀沒守」成立，但卡片點名的那一支（`get_ap`）不是洞：整個系統沒有任何地方呼叫它。** 真正開在外面、沒守門的讀取是另外兩支：「看某一輪的稽核計畫」（`get_ap_for_round`）和「看稽核團隊名單」（`list_ap_parties`）。這兩支總表已經登記過（第 66 項），修法也寫好了（CM-2037），但還在 `fix/security-b1` 分支、沒合回。

這一棒有兩件**新的**：

1. **稽核計畫在「過了規劃階段就唯讀」這條規則有一個漏網的入口**（V8-3，低）：「重新產生草稿」只查角色、不查階段，已經在稽核中甚至已結案的輪次，都能把稽核計畫整份換成新的空白草稿。修正分支也沒補。
2. **CM-2037 對「稽核團隊名單」的修法擋不住跨客戶**（第 5.3 節；已裁退回重修，CM-2172 已修）：修正版寫成「找不到輪次就不檢查」，但別家客戶的輪次正好會被資料庫隔離藏起來、查出來就是「找不到」，於是守門直接略過，名單照樣吐出去。

---

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

稽核計畫（AP，Assessment Plan）是一輪稽核要查哪些控制項、抽查哪些對象、誰去查、什麼時候查。這支服務負責：

- 啟動稽核時，從凍結的 SSP（系統安全計畫）自動產生一份計畫草稿
- 讓稽核員設定要查的控制項、抽查對象、行程
- 新增稽核團隊成員
- 覆核輪（重新查上一輪沒過的項目）時，複製上一輪的計畫再縮小範圍

要回答的問題：**15 支公開方法，每一支在動資料或吐資料之前，有沒有先確認「你是這個專案的人」（讀）或「你是這個專案的稽核員／管理者」（寫）。**

路由層（`api/project/routes/audit_round_route.py:158-250`）只掛兩道門：有沒有登入（`@jwt_required`），以及客戶有沒有買稽核模組（`@require_license("audit")`）。**「你是不是這個專案的人」全部要靠這支服務自己查。**

---

## 3. 掃到什麼：總覽

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度與理由 | 來源 | 跟總表的關係 |
|---|---|---|---|---|---|---|---|
| V8-1 | 「看某一輪的稽核計畫」不問你是不是專案成員 | 同一家公司的非成員看得到別的專案要查哪些控制項、抽查哪些人和系統、行程怎麼排、每個方法的查核指引 | 同一客戶的登入帳號＋有稽核模組授權＋知道輪次編號（輪次清單也沒守，拿得到） | `assessment_plan_app_service.py:512`（`get_ap_for_round` 起點，解出輪次後就要查成員）；路由 `audit_round_route.py:163` 要把使用者編號傳下去 | **中**：只能讀、改不了，且跨客戶被輪次表的資料庫隔離擋住；但計畫內容等於告訴對方「稽核會查什麼」，不是無關緊要的資料 | 工具（面板 3:0，中） | **＝第 66 項**，既有案、不另計。修法 CM-2037 在 `fix/security-b1`（commit `cd9223592`），未合回 |
| V8-2 | 「看稽核團隊名單」不做任何檢查，而且跨客戶也打得到 | 看得到稽核人員的姓名、Email、電話 | 登入帳號＋有稽核模組授權＋知道稽核計畫編號（隨機 UUID 猜不到，要從別處外流） | `assessment_plan_app_service.py:706`（`list_ap_parties` 起點，查到計畫後要反查輪次、再查成員；**查不到輪次要擋，不能放行**） | **低**（面板從中降為低）：要先拿到一個猜不到的編號；但一旦拿到，沒有任何一層擋得住 | 工具（面板 3:0，降為低） | **＝第 66 項**，既有案、不另計。但修正分支的修法有缺口，見 5.3 |
| V8-3 | 「重新產生草稿」沒守「過了規劃階段就唯讀」 | 稽核員或管理者在稽核中、甚至已結案的輪次按「重生草稿」，輪次就改指向一份新的空白計畫；原本那份（稽核判定、改善計畫都是照它做的）跟輪次脫鉤 | 必須本來就是這個專案的**稽核員或管理者**；輪次有凍結 SSP、且不是結案覆核輪 | `assessment_plan_app_service.py:672` 後面（`_check_auditor` 之後補一行 `assert_round_phase(r, {"audit_planning"})`） | **低**：攻擊者本來就是專案內有寫入權的人，而且不外洩資料；但它破壞的是「稽核紀錄事後不可改」這條規則，同檔其他四支寫入都守了 | 工具（面板 3:0，低，信心中） | **新的**，總表沒有；修正分支也沒補（兩個 repo 都查過） |

**第 3 節三條都經過三人面板投票。** 第 5 節是我依卡片開檔追出來的，**沒有經過投票**。

---

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

### 4.1 V8-1 看某一輪的稽核計畫，不問你是不是成員（＝第 66 項）

**現況**：已修（M12-1，CM-2037＋CM-2172，1.21.0 出貨）

**場景**：甲是公司裡的一般員工，不在 P 專案。他先打「列出稽核輪次」（那支同樣沒守，見 FR-116 C2a）拿到 P 專案某一輪的編號，再打 `GET /api/1.0/audit-round/<輪次編號>/ap`，就拿到 P 專案這一輪的完整稽核計畫：要查哪些控制項、抽查哪些資產與人員、每條行程的時間、方法、查核指引、指派了誰。

**為什麼會這樣**：`get_ap_for_round`（`:511-524`）用輪次編號找到輪次、找到計畫，直接組好回傳，中間沒有呼叫任何角色或成員檢查。路由沒把使用者編號傳進來，服務想查也沒得查。

**為什麼不是跨客戶**：輪次表 `compliance.project_audit_rounds` 在 DEV 開了資料庫隔離（本棒 2026-09-24 15:15 唯讀實查：`rls=true`、4 條規則），別家客戶的輪次查不到，會回「找不到」。

**修法現況**：`fix/security-b1` 已補（新增 `_require_participant`，路由傳 `curr_user_id`）。但修正版寫成 `curr_user_id` 沒傳就不檢查，建議首腦驗收 CM-2037 時確認路由真的有傳。

### 4.2 V8-2 稽核團隊名單，連跨客戶都擋不住（＝第 66 項）

**現況**：已修（M12-1，CM-2172 退回重修後修好，1.21.0 出貨）

**場景**：任何有登入、客戶有稽核模組的人，只要手上有一個稽核計畫編號（從輪次資料、分享連結、日誌、或以前待過的專案拿到），打 `GET /api/1.0/ap/<稽核計畫編號>/parties`，就拿到該稽核團隊每個人的姓名、Email、電話。**這個編號屬於別家客戶也一樣拿得到。**

**為什麼會這樣**：`list_ap_parties`（`:704-711`）用計畫編號查計畫表、再用計畫的 metadata 編號查人員表，**兩張表都在 `oscal` 區、都沒開資料庫隔離**（本棒實查：`oscal.assessment_plans`、`oscal.parties` 都是 `rls=false`、0 條規則），整條路沒有經過任何一張有隔離的表。這是第 128 項那片 oscal 區零隔離的又一個入口。

**為什麼是低**：稽核計畫編號是隨機 UUID，列舉不出來，要先外流。三位檢查員一致評低。

### 4.3 V8-3 重生草稿繞過「過了規劃階段就唯讀」（新）

**現況**：🗑️ 已拆除（M11-33，FR-114 CM-2177，commit `1e0879815`）

**場景**：某輪稽核已經進入「稽核中」，稽核員照著計畫填完一半判定。這時稽核員（或管理者）呼叫 `POST /api/1.0/audit-round/<輪次編號>/ap/generate-draft`，系統從凍結 SSP 重新產生一份新草稿，並把輪次的「稽核計畫」欄位改指向新草稿。原本那份計畫還在資料庫，但輪次已經不認它了；之後任何人打開這一輪看到的都是空白的新計畫。對已結案的輪次也一樣。

**為什麼會這樣**：同一支檔的其他四支寫入（設定控制項、抽查對象、行程、新增團隊成員）都經過 `resolve_ap_and_check_auditor`（`:191-209`），裡面有一行 `assert_round_phase(rnd, {"audit_planning"})`，程式註解寫明「稽核計畫只在 audit_planning 可編輯，推進後一律唯讀」。`generate_draft_for_round`（`:662-683`）沒走這個 helper，自己查了角色（`:672`），但**漏了階段那一行**。這是這支檔自己的「守了一半」：同一件事「改稽核計畫」有五個入口，四個守階段、一個沒守。

**要先有什麼**：必須是這個專案的稽核員或管理者。外人打不到。

**該補的位置**：`:672` 之後補 `assert_round_phase(r, {"audit_planning"})`。修正分支 `fix/security-b1` 這支方法沒有改動（已開檔對照）。

**產品問題（已裁：重新產生草稿網址拆除，CM-2177）**：會不會有「稽核中發現計畫要重排、所以要重生」的正當需求？如果有，應該走「退回規劃階段」那條正式流程（有記錄、有理由），而不是直接重生。放進第 8 節。

---

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

### 5.1 15 支公開方法逐支列表

| # | 方法 | 讀／寫 | 守門在哪 | 沒守的話洩漏／改動什麼 |
|---|---|---|---|---|
| 1 | `count_reviewed_controls`（`:92`） | 讀 | **守在呼叫端**：唯一呼叫者是階段推進前置檢查 `ApReviewedControlsSetCheck`（`oscal_stage_preconditions.py:57`） | 只回一個數字（選了幾個控制項）。見 5.4 |
| 2 | `resolve_ap_and_check_auditor`（`:191`） | 守門 helper | 自己就是守門：計畫→輪次→階段→角色（稽核員／管理者） | — |
| 3 | `resolve_ap_and_check_participant`（`:211`） | 守門 helper | 自己就是守門：計畫→輪次→專案成員（任一角色） | — |
| 4 | `get_ap_tasks_as_task_items`（`:282`） | 讀 | **守在呼叫端**：唯一呼叫者 `ap_docx_import_app_service.confirm_import`（套件 `jedi-compliance-audit`），呼叫前 `:189` 已 `resolve_ap_and_check_auditor` | 行程全文 |
| 5 | `create_draft_for_snapshot`（`:308`） | 寫 | **守在呼叫端**：`launch_audit`（套件 `audit_round_app_service.py:388`，管理者）與本檔 `generate_draft_for_round`（`:672`，稽核員／管理者） | 產生新計畫 |
| 6 | `clone_ap_for_round`（`:321`） | 寫 | **守在呼叫端**：唯一呼叫者 `launch_reverify`（套件 `:586`，稽核員／管理者） | 複製整份計畫 |
| 7 | `narrow_reviewed_controls_for_reverify`（`:474`） | 寫 | **守在呼叫端**：唯一呼叫者 `_generate_reverify_prep_jobs`，只被 `launch_reverify`（`:648`）呼叫，守門同上 | 縮小控制項範圍 |
| 8 | `get_ap_for_round`（`:511`） | 讀 | **沒守** | 整份計畫內容（V8-1，＝第 66 項） |
| 9 | `get_ap`（`:527`） | 讀 | **沒守** | **不可達**：全庫（兩個 repo）零呼叫、沒有路由掛它。死碼 |
| 10 | `set_reviewed_controls`（`:535`） | 寫 | `:538` `resolve_ap_and_check_auditor` | — |
| 11 | `set_assessment_subjects`（`:547`） | 寫 | `:550` 同上 | — |
| 12 | `set_tasks`（`:572`） | 寫 | `:581` 同上 | — |
| 13 | `generate_draft_for_round`（`:662`） | 寫 | `:672` 只查角色，**沒查階段** | V8-3 |
| 14 | `list_ap_parties`（`:704`） | 讀 | **沒守** | 稽核團隊姓名、Email、電話（V8-2，＝第 66 項，跨客戶） |
| 15 | `add_ap_party`（`:714`） | 寫 | `:718` `resolve_ap_and_check_auditor` | — |

**小結**：寫入 6 支（含重生草稿）全部有角色檢查，其中 5 支也守了階段；讀取 4 支裡 2 支守在呼叫端、2 支開在外面沒守、1 支死碼。

### 5.2 兩個守門 helper 本身：從計畫編號到專案，怎麼擋住跨客戶

路徑是：計畫表 `oscal.assessment_plans`（**無隔離**）→ 用計畫 id 查輪次表 `compliance.project_audit_rounds`（**有隔離**，規則是「超級管理員，或輪次所屬專案是你這家客戶的」）→ 輪次上的 `project_id` → 查專案成員。

- **跨客戶**：別家客戶的計畫編號在第一步查得到（計畫表沒隔離），但第二步查輪次會被隔離藏起來，回「找不到」、直接 404。**擋得住，依據是輪次表那一層隔離**，不是程式。
- **同客戶跨專案**：第二步查得到輪次，第三步查成員查不到，403。擋得住。
- 前提：DEV 的 `.env` 連線帳號是 `cm_app`（受隔離約束）；表的 `force` 旗標是 false，只對表的擁有者不生效，不影響 `cm_app`。

**下次不用重查**，除非輪次表的隔離規則改了。

### 5.3 🔴 CM-2037 對稽核團隊名單的修法擋不住跨客戶（已裁退回重修，CM-2172 已修）

**現況**：已修（CM-2172）

修正分支 `fix/security-b1` 的 `list_ap_parties` 改成：

```python
if curr_user_id is not None:
    rnd = self._rounds.get_by_assessment_plan_id(ap.id)
    if rnd is not None:
        self._require_participant(rnd.project_id, curr_user_id)
```

照 5.2 的路徑，**別家客戶的計畫查輪次會被隔離藏起來、回 `None`**，這時 `if rnd is not None` 不成立，檢查被整段略過，接著照樣把人員表吐出去。也就是說，**這個修法擋住了同客戶跨專案，但沒擋住跨客戶**，而跨客戶正是 V8-2 最嚴重的那一面。三位檢查員中也有人指出同一點。

該改成「查不到輪次就拒絕」（與 `resolve_ap_and_check_participant:221-223` 一樣，回 404）。這是修正卡的內容問題，不是本棒範圍，放第 8 節請首腦決定要不要退回 CM-2037。

### 5.4 五支「守在呼叫端」的方法，對照 FR-116 C2a

C2a 報告（第 5 節呼叫端表格）已確認 `launch_audit`（管理者）與 `launch_reverify`（稽核員或管理者）都用輪次自己的 `project_id` 守門，本棒開套件原始碼再對一次，行號一致（`:388`、`:586`）。逐支：

- `create_draft_for_snapshot`、`clone_ap_for_round`、`narrow_reviewed_controls_for_reverify`：呼叫端都在守門之後、同一個交易內，傳進來的 id 全部來自伺服器端查到的輪次，請求內容帶不進來。**成立守門，下次不用重查。**
- `get_ap_tasks_as_task_items`：`confirm_import` 先 `resolve_ap_and_check_auditor(job.source_uid)` 再呼叫，用的是同一個 `job.source_uid`。**成立守門。**
- `count_reviewed_controls`：呼叫端是階段推進的前置檢查。「推進階段」本身先查角色；但「查目前在哪個階段」（`stage_advance_service.py:82` `get_current_stage_info`）不擋角色就跑前置檢查，而且拿的是網址上的輪次、不核對它屬不屬於網址上的專案——**這就是總表第 53 項**，既有案。從這個方法漏出去的只有一個數字（選了幾個控制項），不另計。

### 5.5 `generate_draft_for_round`：角色有查；產生的計畫用新的 metadata

- 角色：`:672` 用輪次自己的 `project_id` 查稽核員／管理者，擋得住同客戶跨專案；跨客戶由輪次表隔離擋。**成立。**
- metadata：套件 `ap_draft_service.py`（`generate_draft_from_ssp`）每次都 `self._metadata_repo.add(...)` 建一筆新的 metadata，**不與凍結 SSP 共用**，所以在新草稿上加團隊成員不會寫到 SSP 的人員表。**不成立，下次不用重查。**
- 真正的問題是階段，見 V8-3。

### 5.6 `set_tasks` 的參與者／抽查對象編號，有沒有限定在本計畫底下

**沒有限定，但不是洞。** payload 裡的 `participants[].party_uuid` 和 `subjects[].subject_uuid` 直接寫進兩張連結表（`:614-625`），**只當字串存起來，從不拿它去 `get_by_uid` 查別的資料**；讀回來時（`_ap_detail:257-265`）也只回傳原字串，不去解析出姓名。所以填一個別家的編號，不會讀到別家任何東西，也不會改到別家的資料。最多就是稽核員自己的計畫裡存了一個對不上的參照（資料品質問題，而且只有本專案稽核員／管理者寫得進去）。**下次不用重查。**

另外，覆核輪複製計畫時（`clone_ap_for_round:358-370`）參與者編號是「用姓名對回新團隊」重新配對，對不上的保留原值，也只是字串。

### 5.7 `add_ap_party` 的角色值沒有白名單

**成立，但不是資安問題。** `payload.get("role")` 在路由 schema（`api/project/serializers/audit_round.py:108`）是自由字串、沒有 `OneOf` 限制，存進人員的 props（`responsible-role`）。全庫查過：這個值只拿來**顯示**和**匯出成 OSCAL**，沒有任何授權判斷讀它，所以填「admin」也不會多拿任何權限。只有本專案稽核員／管理者寫得進去。資料品質上建議補白名單，不列資安。**下次不用重查。**

### 5.8 `get_ap`（`:527`）：死碼

卡片的抽查說它「零守門」，這沒錯，但**全庫零呼叫**（BE、套件兩個 repo 都 grep 過，`get_ap` 只出現在定義本身與套件同名方法；路由沒有掛它，前端 `api.js` 也沒有）。它不是開在外面的洞。建議併 FR-092 死碼清理；若保留，照其他讀取一樣補成員檢查，免得哪天被接上路由就變成 V8-1 的第二個入口。

### 5.9 整支檔通讀的其他觀察

- 所有對外方法都有 `@transaction`；沒有底線的五支 helper 都在 docstring 寫明「caller 已在 @transaction 內」，實際呼叫端也都符合。
- 本檔直接 `new` 了 9 支套件 repo（`:82-90`）而不是經 DI 注入。這是分層規範問題，不是資安問題，不列。
- 密鑰專項：工具這次沒有撿回任何寫死密碼（本檔沒有，專項也沒報既有案）。

---

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

**「這幾條存在嗎」——可信度高。** 三條都是三人面板 3:0 通過，我也逐條開檔對過行號；V8-1、V8-2 的資料庫隔離狀態是本棒 DEV 唯讀實查（2026-09-24 15:15，查完 ROLLBACK）。V8-3 面板信心評「中」，是因為它牽涉產品規則（見第 8 節），不是懷疑程式碼行為。

**「只有這幾條嗎」——可信度高。** 範圍只有一支 742 行的檔，研究員看完整支，我也逐行通讀；15 支公開方法每支都列了守門位置（5.1），5 支守在呼叫端的方法都跨 repo 開到呼叫者那一行。沒碰的是：套件側 `jedi-oscal-v2` 的 `set_*`、`generate_draft` 本體（V2 那棒的範圍），以及套件側 docx 匯入服務本身（FR-116 範圍）。

⚠️ 所有結論都是**讀程式碼得出的**，沒有實際打 API 驗證。

---

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

| 項目 | 值 |
|---|---|
| run ID | `wf_4a55ac07-937` |
| 報告目錄 | `CLAUDE-SECURITY-20260924-071232/`（不入版控） |
| stamp | `CLAUDE-SECURITY-REVISION-65620f07e522-dirty.json` |
| verification.status | **verified**（`reason_kind` 無） |
| 形狀 | low：1 名研究員讀整支檔＋1 次密鑰專項（focus=attack-surface） |
| 候選 → 通過 | 3 → 3（F1 3:0 中、F2 3:0 中降低、F3 3:0 低） |
| 面板票數 | 9 票，0 票反對 |
| agent 數／失敗 | 11／0（`journal.jsonl` 無 `failed`） |
| 耗時 | 約 16 分鐘 |
| DEV 唯讀實查 | 13 張表的 `relrowsecurity`／`pg_policies`（2026-09-24 15:15:56 +08） |

---

## 8. 待首腦裁決

1. **V8-3 登不登新項次、登什麼等級**：我建議登低，併入 CM-2037 同一批修（都是這支檔、一行修法）。產品問題：稽核中需要重排計畫時，是不是一律走「退回規劃階段」？若是，重生草稿就該跟其他四支寫入一樣限定在規劃階段。
2. **CM-2037 對 `list_ap_parties` 的修法要不要退回**（5.3）：現在寫法「查不到輪次就略過檢查」，跨客戶那條路照樣通。建議改成查不到就 404。
3. **`get_ap` 死碼**：併 FR-092 清掉，或補守門保留。
