# C1c 掃描報告 — 專案清單／詳細與稽核計畫選單的主專案路由（CM-2105）

> 範圍：11 檔／1,255 行，跨兩個 repo 配對切。套件側 `jedi-compliance-audit` 9 檔／865 行（專案與稽核計畫的序列化器、DTO、entity、repo 介面、domain service、mapper）；主專案側 2 檔／390 行（`api/flow_control/routes/project_route.py`、`api/flow_control/routes/assessment_plan_route.py`）。
> 掃描工具：Claude Code 官方 `claude-security` plugin，effort low，**兩側各跑一次**（工具只認它所在 repo 的檔案）。
> 掃描基準：套件側 `eafc7ae511d5`（monorepo 主 checkout，`feature/review`）；主專案側 `82c6627d23a2`（`feature/review`）。兩邊工作區都有其他 session 未 commit 的改動（dirty）。
> 驗證章：**兩份都是 verified**。只掃不修（掃描當時紀錄；後續修正見 README）。

---

## 1. 一句話結論

**這一棒淨新增 0 條。** 工具在兩側都抓到同樣兩條：「稽核計畫選單不查專案成員」和「稽核計畫儀表板不查專案成員、也不查那一輪是不是這個專案的」。兩條都已經寫在總表第 66 項那一列裡（儀表板那支在該列寫作「同檔 `:529` 的稽核輪次統計功能」）。要補一個事實：**修正分支 `fix/security-b1` 上這兩支都還沒修**。CM-2037 修的是另一支路由檔的 8 支讀取，沒涵蓋這一支檔。

卡片預期的「寫有守、讀沒守」**在專案那支檔不成立**。專案清單與專案詳細兩支讀取都有掛 `project.read` 能力點，查詢時也限定「負責人或參與者」。專案的改、刪、批次刪也都有能力點，服務層另有管理者或負責人檢查。這是本批目前唯一一支讀寫都守齊的路由檔。

稽核計畫那支檔確實「三支都沒守」，但第三支「更新稽核計畫」的服務方法是**空殼**：收到什麼都直接回「沒有東西」，不會寫進資料庫。所以它現在不是洞，只是一顆地雷，等哪天有人把它重建起來才會引爆（第 5.1 節）。

---

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

主專案這兩支路由檔從沒被正式掃過。背後的服務 `project_service.py` 已在 FR-095 H1 掃過，所以本棒只看路由這一層，回答兩個問題：

1. **每個網址有沒有漏掛能力點？** 能力點就是「你的角色有沒有被允許做這件事」的檢查，例如 `project.read`。
2. **網址上帶兩個編號時，有沒有比對「後面那個屬於前面那個」？**

兩支檔共 9 個網址：

| 網址 | 做什麼 | 掛了哪些檢查 |
|---|---|---|
| `GET /grc/projects/menu` | 專案下拉選單 | 登入＋商務授權＋`project.read` |
| `GET /grc/jobs/my/projects` | 「我的任務」頁的專案篩選 | 登入＋商務授權（沒有能力點，見 5.3） |
| `POST /grc/projects/list` | 專案清單 | 登入＋商務授權＋`project.read` |
| `GET /grc/project/<uid>` | 專案詳細 | 登入＋商務授權＋`project.read`，查不到回 403 |
| `PUT /grc/project/<uid>` | 改專案設定 | 登入＋商務授權＋`project.update`，服務層再查「是不是專案經理」 |
| `DELETE /grc/project/<uid>`、`POST` 批次刪 | 刪專案 | 登入＋商務授權＋`project.delete`，服務層再查「是不是管理員、負責人或專案經理」 |
| `GET /grc/project/<pid>/assessment-plans/menu` | 稽核計畫（輪次）選單 | **只有登入＋商務授權** |
| `PUT /grc/project/<pid>/ap/<ap_uid>` | 改稽核計畫 | **只有登入＋商務授權** |
| `GET /grc/project/<pid>/ap/<ap_uid>/dashboard` | 稽核計畫統計 | **只有登入＋商務授權** |

「商務授權」（`@require_license("project")`）只檢查這家客戶有沒有買專案模組，不看是誰在操作，所以不算守門。

守門次數（卡片開工三件事之一）：`assessment_plan_route.py` 3 個對外方法，守門字眼 0 次（另外只有 3 次 `require_license`）。`project_route.py` 7 個對外方法，其中 6 個掛了 `require_capability`，沒掛的那一個是 `MyAssignedProjectsResource`。

---

## 3. 掃到什麼：總覽

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度 | 來源 | 跟總表的關係 |
|---|---|---|---|---|---|---|---|
| C1c-1 | 稽核計畫選單不查專案成員 | 同一家客戶的任何員工，能看到別人專案的全部稽核輪次：名稱、狀態、起訖日、建立人與修改人的帳號和暱稱、SSP 編號 | 同客戶的登入帳號、客戶有買專案模組、知道目標專案編號 | 主專案側 `assessment_plan_route.py:35-38`（`AssessmentPlanMenuResource.get` 的裝飾器處）＋`project_service.py:487`（`get_assessment_plans_menu` 起點） | 主專案側面板 3:0 評**中**；套件側面板 3:0 評**低** | **兩側都掃到** | **總表第 66 項，不另計**。該項已升為高，不因本棒評分而改 |
| C1c-2 | 稽核計畫儀表板不查專案成員，也不查「這一輪是不是這個專案的」 | 同上，但只洩漏統計數字：任務總數、完成數、進行中數、未開始數、控制項數、完成率 | 同上，外加目標的專案或輪次編號（輪次編號可以從 C1c-1 拿到） | 主專案側 `assessment_plan_route.py:105-108`（`ApDashboardResource.get` 的裝飾器處）＋`project_service.py:529`（`get_ap_dashboard` 起點）＋輪次歸屬比對 `flow_control_project_repo_impl.py:511`（`_resolve_dashboard_round_id` 起點） | 主專案側 3:0 評**低**；套件側 2:1 評**低** | **兩側都掃到** | **總表第 66 項已點名「同檔 `:529` 一起補」，不另計** |
| C1c-3 | 「更新稽核計畫」沒有能力點、不比對編號歸屬，但服務方法是空殼 | **現在不會出事**：服務直接回「沒有東西」，不寫資料庫。哪天有人重建時沒補守門，同客戶的任何員工就能改別人專案的稽核計畫名稱與日期 | — | 主專案側 `assessment_plan_route.py:55-58`（`AssessmentPlanDetailResource.put` 的裝飾器處）＋`project_service.py:534`（`update_assessment_plan` 起點） | 目前不是漏洞 | runner 自行開檔，**未經面板投票** | 新的觀察，**建議記在 §7 待裁**，不列為發現 |

**帶去修正卡的一個事實**：`fix/security-b1` 分支上，這三支的路由、`project_service.py` 對應的三個方法、repo 的輪次歸屬比對**都沒有改動**。我用 `git diff feature/review fix/security-b1` 比對本棒範圍的檔案，`project_service.py` 只改了 AI 儀表板那支的 `is_admin`，`project_route.py` 只刪了一行註解。總表第 66 項雖然寫著「修法在 CM-2037」，但 CM-2037 實際只擴大涵蓋了 `api/project/routes/audit_round_route.py` 那 8 支。**本棒這兩支（`api/flow_control/routes/assessment_plan_route.py`）還沒有任何修正卡接手。**

---

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

### 4.1 C1c-1 稽核計畫選單，誰都能看別人專案的稽核輪次

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

**場景**

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

```
GET /api/1.0/grc/project/<專案乙>/assessment-plans/menu
```

回來的是專案乙的每一輪稽核：名稱、狀態、開始與結束日、誰建的、誰改的（帳號加暱稱），以及每一輪對應的 SSP 編號。同一個專案的詳細頁 `GET /grc/project/<專案乙>` 會回 403 擋他，這條網址卻不擋。

**為什麼會這樣**

1. 主專案路由 `assessment_plan_route.py:35-38`：只有 `@jwt_required()` 和 `@require_license("project")`，沒有 `@require_capability("project.read")`。
2. 主專案服務 `project_service.py:487-491`：直接把 `project_uid` 往下傳，沒有參與者檢查。
3. 套件 domain service `flow_control_project_domain_service.py:44-45`：純轉手。**方法參數本身就沒有「使用者」**，跟旁邊的 `get_project(uid, user_id, is_admin)` 不一樣。
4. 主專案 repo `flow_control_project_repo_impl.py:477-490`：用專案編號查專案，再撈出該專案的全部輪次。

**跨側的接縫（兩側研究員各自看不到的那一段）**：套件側的 domain 方法簽名沒有 `user_id`，所以就算主專案 repo 想做可見性過濾也拿不到身分。修法只能在主專案的服務層補（在 repo 查詢之前先檢查參與者），或者改套件介面把 `user_id, is_admin` 加進去。前者改動小，也跟總表第 66 項寫的修法一致。

**跨客戶為什麼擋得住**：`compliance.projects` 與 `compliance.project_audit_rounds` 在 DEV 都開了資料庫隔離，各 4 條政策（2026-09-24 16:05 唯讀實查，已 ROLLBACK）。別家客戶的專案編號，第一步就查不到。

**嚴重度**：主專案側面板三票是中、低、中，最後定為中；套件側三票是中、低、低，被降為低。差別在於主專案側研究員多寫了「拿到 SSP 編號之後可以接著打 `/ssp/<uuid>`」。但 SSP 那些網址各自有 `SspPermissionChecker` 擋著（見 C1b 報告 4.2），所以這個延伸不成立。總表第 66 項已經因為 C3 升為高（同一張修正卡取最重的那支），本棒的評分不改變總表。

### 4.2 C1c-2 稽核計畫儀表板，誰都能看別人專案的進度數字

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

**場景**

小王拿到專案乙的編號（或從 C1c-1 拿到專案乙某一輪的輪次編號），打：

```
GET /api/1.0/grc/project/<隨便一個專案>/ap/<專案乙的輪次編號>/dashboard
```

回來的是專案乙那一輪的任務總數、已完成、進行中、未開始、控制項數和完成率。前端 `ProjectAuditorOverview.vue:426` 正在叫這支，是現役功能。

**為什麼會這樣**

1. 主專案路由 `assessment_plan_route.py:105-116`：同樣只有登入＋商務授權。
2. 服務與套件 domain service 純轉手。
3. 主專案 repo `_resolve_dashboard_round_id`（`flow_control_project_repo_impl.py:511-532`）**先**把 `ap_uid` 當輪次編號查，條件只有 `WHERE uid = :u`，查到就用，**完全不看 `project_uid`**。只有在 `ap_uid` 查不到時，才退回用 `project_uid` 找當前那一輪。
4. 所以網址上的 `project_uid` 在「輪次編號有效」時只剩一個作用：`get_ap_dashboard` 開頭的 `if not project_uid: return {}`，也就是「有填就好，填什麼都行」。

這是總表第 52／166 項同一種形狀：網址帶了父層和子層兩個編號，程式只用子層去查，從不比對子層屬不屬於父層。

**面板**：主專案側 3:0，套件側 2:1。投反對票的那位（影響面）理由是「只有統計數字，價值太低」，另外兩位認為洩漏本身成立。

**修法方向**（總表第 66 項已寫，本棒補細節）：在 `project_service.get_ap_dashboard` 開頭解析專案、呼叫 `assert_project_participant`。在 `_resolve_dashboard_round_id` 第一段查詢加上 `AND project_id = (SELECT id FROM compliance.projects WHERE uid = :puid)`，不屬於就當查不到。只補前者不夠：成員可以拿自己專案的編號，搭配別的專案的輪次編號來打。

---

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

### 5.1 「更新稽核計畫」`AssessmentPlanDetailResource.put`：守門確實沒掛，但目前不是洞

路由 `assessment_plan_route.py:52-91` 只有登入＋商務授權，而且只把 `ap_uid` 往下傳，**網址上的 `project_uid` 收下後完全沒用到**。表面上跟 C1c-2 同型，而且這支還會改資料，應該更嚴重。

但服務 `project_service.py:534-548` 的 `update_assessment_plan` 本體只有一行：

```python
return None, False
```

註解寫「FR-038 2A: … method dark」。也就是 FR-038 換資料模型時把它關掉，一直沒有重建。**任何人打這支，資料庫都不會有任何改變**；因為 `name_changed` 是 False，也不會觸發雲端硬碟改名。前端 `api.js:204` 有定義 `GRC_AP`（註解寫 PUT），但全前端搜不到呼叫者。

所以結論是：**現在不是漏洞，但它是一顆地雷。** 將來有人重建這支方法時，只要照著網址「專案／輪次」的形狀接上去，卻忘了補守門和歸屬比對，就會直接變成「同客戶任何人能改別人專案的稽核計畫」。另外有一個功能面的小問題：它現在會把 `None` 序列化成 `{}` 並回 200，前端要是真的叫了，會以為改成功了。

建議首腦二選一裁決（記在第 8 節）：直接拆掉這條網址（前端沒人叫，跟 C1b 那兩支同一個處理方式），或者在修正卡裡順手先補上能力點和參與者檢查，讓將來重建時不會漏。

### 5.2 `project_route.py` 的讀取有沒有「寫有守、讀沒守」：不成立

卡片提醒前五棒三次撞到這個形狀，這一支檔要特別確認。逐支開檔：

- **專案清單** `ProjectListResource.post`（`project_route.py:93-131`）：掛了 `project.read`。服務傳入的 `is_admin` **寫死為 False**。repo `list_projects`（`flow_control_project_repo_impl.py:193-200`）在 `is_admin` 為 False 時，加上條件「負責人是我，或者我在參與者、群組參與者、控制項參與者、任務指派任一張表裡」。
- **專案詳細** `ProjectDetailResource.get`（`:142-165`）：掛了 `project.read`，`is_admin` 同樣寫死 False。repo `get_project_by_uid`（`:392-405`）不是負責人也不是參與者就回「沒有」，路由收到後回 403 `GRC_NOT_PROJECT_PARTICIPANT`。
- **專案選單** `ProjectMenuResource.get`（`:38-59`）：掛了 `project.read`。它傳的是 `viewer_is_super_admin()`，但 repo `list_projects_menu`（`:313-320`）是 FR-038 留下的空殼，永遠回空清單，所以沒有外洩面。這是功能問題：前端專案選單會永遠是空的（`get_projects_for_dashboard` 的註解也寫了這件事）。不歸本批管。
- **改、刪、批次刪**：都有能力點。`update_project` 在服務層呼叫 `assert_project_manager`（`project_service.py:374-375`，`participant_role_service` 在 `flow_control_containers.py:269` 有注入，不會因為沒注入而略過）。`delete_project`／`batch_delete_projects` 查「超級管理員，或負責人，或專案經理」。刪除走 `soft_delete_project(uid)`，只用編號找，但前面檢查的就是同一個 uid 解出來的專案，**不是「檢查甲、動乙」**。

**結論：這支檔讀寫都守齊，不是「寫有守、讀沒守」。下次不用重查。**

### 5.3 `MyAssignedProjectsResource`（`project_route.py:71-81`）沒掛能力點：預期內，不是洞

它查的是 `TaskAssignee.user_id == 呼叫者本人` 的專案（`flow_control_project_repo_impl.py:322-365`）。`user.id` 從登入憑證取，請求內容改不到，所以只會回「自己被指派的專案」。這跟 C1b 的稽核人員待辦清單是同一個道理。沒掛 `project.read` 的後果頂多是「沒有專案讀取權的角色，也能看到自己被指派的專案名稱」，這本來就是他該知道的。**不成立。**

### 5.4 清單的篩選條件全部選填，空條件會回什麼：只會回自己參與的專案

`FiltersSchema`（套件 `api/serializers/project.py:41-64`）的 `search`、`status` 都是選填。送空條件時，repo 會略過兩個 `if`，但「負責人或參與者」那道可見性過濾**不在篩選條件裡、而是無條件套上**（因為 `is_admin` 寫死 False）。所以空條件等於「我參與的全部專案」，不會多出別人的。

其他確認：
- `status` 受 `OneOf` 限定五個值。`search` 走 SQLAlchemy 的 `ilike` 參數綁定，不會注入 SQL。
- 每頁筆數上限在 `jedi_common` 的 `PagerSchema`，最大 1000（`MAX_PAGE_SIZE`），不能一次撈整張表。
- `sort` 的欄位要在 `_SORTABLE` 白名單裡才生效，其他欄位直接忽略。

**不成立。**

### 5.5 套件側 9 支範圍檔

- **序列化器、DTO、entity、mapper**：純資料形狀與轉換，沒有查詢、沒有守門該在的位置。`ProjectResponseSchema` 會回參與者清單，但只有通過詳細或清單可見性過濾的專案才會被序列化。`tenant_id` 只在 entity 與 mapper 內部流轉，不出現在回應裡。
- **repo 介面** `i_flow_control_project_repo.py`：宣告了 6 支方法。domain service 另外呼叫了 `get_assessment_plans_menu`、`get_ap_dashboard`、`count_incomplete_prep_jobs` 三支**介面上沒宣告**的方法，實作都在主專案的 repo 裡。這是介面漂移，不是漏洞，但會讓人讀套件時以為這三支不存在。
- **domain service**：全部純轉手。其中 `get_assessment_plans_menu`、`get_ap_dashboard` 兩支的參數沒有使用者身分（見 4.1 的接縫）。

### 5.6 卡片的開工三件事

- **① 守門次數對對外方法數**：見第 2 節。
- **② 找「檢查甲、動乙」**：本棒的寫入只有專案改、刪、批次刪，都是「檢查哪個 uid、就動哪個 uid」，不成立。稽核計畫的 PUT 是空殼（5.1）。
- **③ 讀取「憑什麼給你看」**：專案那支檔全部有交代（5.2～5.4）。稽核計畫那支檔兩支讀取都交代不出來（C1c-1、C1c-2）。

---

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

**「工具報的兩條存在嗎」：可信度高。** 兩側各自獨立掃到同兩條，12 票全數投出（主專案側 3:0、3:0；套件側 3:0、2:1）。我自己也從路由一路讀到 repo 的 SQL，結論一致。

**「第 5 節」：runner 自行開檔核對，未經三人面板投票。** 關鍵事實有實查：
- 資料庫隔離：DEV 查 `pg_class.relrowsecurity` 與 `pg_policies`（2026-09-24 16:05，唯讀交易，已 ROLLBACK）。`compliance.projects`、`project_audit_rounds`、`job_executions`、`workflow_executions` 都有開、各 4 條政策。`public.workflow_execution_control_mapping` **沒開、0 條政策**（儀表板統計會用到它，但它接的 `job_executions` 有隔離，而且輪次編號要先查到才會走到這一步）。
- 修正分支比對：`git diff feature/review fix/security-b1` 跑過本棒範圍的主專案檔案，套件介面與 domain service 也比對過，都沒有涵蓋本棒兩條。
- 前端呼叫者：開 FE repo 搜尋確認。儀表板有人叫（`ProjectAuditorOverview.vue:426`），稽核計畫的 PUT 沒人叫。

**「只有這些嗎」：不保證。**
1. 用的是最快的檔位（effort low），沒跑威脅建模與廣度掃描。
2. 兩側掃描各自看不到對方，4.1 的接縫是我接起來的。但這一批兩側的研究員其實都追過了邊界（套件側追進主專案的路由，主專案側追進套件的 domain service），所以才會兩側各報一樣的兩條。
3. 沒有實際打任何網址，全部是讀程式得出的結論。
4. 讀的是套件 monorepo 主 checkout（`feature/review`），不是 FR-114 修正分支的 worktree。修正分支的狀態是用 `git diff` 比對的（見上）。

---

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

| 項目 | 套件側 | 主專案側 |
|---|---|---|
| 掃描範圍 | 9 檔／865 行 | 2 檔／390 行 |
| 基準 commit | `eafc7ae511d5`（dirty） | `82c6627d23a2`（dirty） |
| 檔位 | effort low，不設 focus | effort low，focus 生產程式碼 |
| 研究員 | 派 1、回 1 | 派 2（研究＋密鑰專項）、回 2 |
| 原始候選 | 2 條，去重後 2 條 | 2 條，去重後 2 條 |
| 投票 | 3 × 2 ＝ 6 票，全數投出 | 3 × 2 ＝ 6 票，全數投出 |
| 票型 | 選單 3:0（中、低、低，降為低）；儀表板 2:1（低、低；影響面投不成立） | 選單 3:0（中、低、中，定為中）；儀表板 3:0（低、低、低） |
| 驗證章 | **verified**（`CLAUDE-SECURITY-REVISION-eafc7ae511d5-dirty.json`） | **verified**（`CLAUDE-SECURITY-REVISION-82c6627d23a2-dirty.json`） |
| 工具 run ID | `wf_cf47a957-464` | `wf_31425b34-f99` |
| 耗時 | 約 7 分鐘（7 個 agent，零失敗） | 約 10 分鐘（8 個 agent，零失敗；其中 1 個回空結果，是密鑰專項零發現） |
| 工具原始報告 | 套件 repo `CLAUDE-SECURITY-20260924-080357/`（未入版控） | BE repo `CLAUDE-SECURITY-20260924-082233/`（未入版控） |

密鑰專項零發現。

---

## 8. 待首腦裁決

（**現況**：兩支已由 CM-2176 拆除、選單與儀表板補成員檢查由 CM-2172 修好，M12-1／M12-4／M12-5，1.21.0 出貨）
1. **總表第 66 項要補一句「`api/flow_control/routes/assessment_plan_route.py` 這兩支還沒有修正卡接手」**。現在那一列寫「修法在 CM-2037」，讀的人會以為整項都在修正分支上，但 CM-2037 只涵蓋 `api/project/routes/audit_round_route.py`。建議把選單與儀表板這兩支併進 CM-2037 或另開一張，修法照 4.2 那兩處（參與者檢查＋輪次歸屬比對）。
2. **「更新稽核計畫」PUT（C1c-3）二選一**：
   - **拆掉這條網址**：前端沒人叫，服務是空殼。建議選這個，理由跟 C1b 相同，沒人用的網址守門補得再好也是多一個入口要維護。
   - **補守門但保留空殼**：在修正卡裡先補 `project.update` 能力點與參與者檢查，讓將來重建時不會漏。

   建議記進總表 §7 待裁。
3. **專案選單 `list_projects_menu` 永遠回空**，屬功能缺陷，不是資安問題，不歸本批。只在此提一句，供首腦決定要不要轉給功能線。
