# FR-095.2 H1：專案 CRUD＋成員同步＋接線 plugin／DI——資安掃描報告

- **卡片**：CM-1781（FR-095 第 2 棒，宿主棒）
- **範圍**：BE repo 35 支檔——專案的建立／讀取／修改／刪除（`app/flow_control/service/project_service.py`、`app/project/service/project_start_app_service.py`、`app/module_frame/service/module_frame_service.py`）、專案關聯表（`app/associations/`、`domain/associations/`、`infra/associations/`）、摘要報告 model 與 repo、把任務平台套件接進主專案的 plugin（`core/plugins/participant.py`／`task.py`／`license.py`／`evidence_classification.py`）與 DI 容器（`di_containers/flow_engine/`、`di_containers/project/`、`di_containers/dashboard_apis/participant.py` 等）、以及專案守門本體 `common/authz/project.py`
- **掃描時間**：2026-09-14（UTC 07:05 起，跑了 4 小時整）
- **掃描版本**：BE `739a0a61d92e4214403265d158ccec85a7526a76`（branch `feature/FR-075`，**工作區有平行 session 未 commit 的改動**——首腦查過 H1 範圍內 35 檔的 dirty 狀態為零；掃描版本到 HEAD 的範圍 diff 只有 `module_frame_service.py` +5 行，是平行 session 補的「改 scope 欄位」守門，不影響本棒任何結論）
- **工具**：Claude Code `claude-security` plugin，effort `low`、focus `attack-surface`
- **驗證章**：`verified`——工具確認完整，無拒收原因。8 條發現、24 票全投、8 條全 3:0 通過
- **只掃不修**：本棒沒有改任何程式碼，也沒有動任何環境（只對 DEV 資料庫做了唯讀查詢）

> ⚠️ **runner 未交付，首腦補寫。** 掃描 runner 跑完掃描（stamp 19:05）但報告／commit／Notion 回寫四件全部沒做，本報告由首腦依驗收手冊第三節第 3 點補寫；**stamp 與發現以工具原始產物 `CLAUDE-SECURITY-20260914-070510/` 為準**（該目錄自帶 `.gitignore` 不入版控），逐條核對與嚴重度判定是首腦親做。

---

## 1. 🔴 一句話結論

**H1 掃完了。範圍內 4 個真問題（工具報 8 條，其中 3 條是舊案重複、另有 2 條是同一問題的兩層鏡像），最嚴重的是「專案摘要報告的清單與歷史紀錄兩支讀取功能只驗登入，同檔的新增／修改／刪除都有問『你是不是專案成員』、只有讀取漏掉」。**

這一條工具評 HIGH，**首腦降為 MEDIUM**，理由是時序：掃描當時（15:03 的版本）存放摘要報告的資料表**沒有開資料庫隔離**，任何登入者送一個空請求就能撈到**其他客戶**的稽核結論全文；但掃完 4 小時後，平行進行的 FR-094 第 9 棒（CM-1800，commit `aac15d06`）把這兩張表的隔離開了，**跨客戶那一半已被資料庫擋住**，剩下的是「同一個客戶內、不是這個專案的人也讀得到」——門檻沒變（只要登入），但外洩範圍縮小了一級。

另外三條：稽核輪次選單只驗登入、帶別人的專案編號就能看那個專案的輪次清單（MEDIUM）；租戶管理員改「模組框架」時可以把**原廠公版**的適用控制項清單整個改掉，因為那兩句是直接下 SQL、繞過資料庫隔離（MEDIUM）；摘要報告的歷史版本讀取帶任意報告編號即可讀（MEDIUM，與第一條同源、併同一張工單）。

卡片列的七項疑點，工具只碰到第 ③ 項的一半；其餘六項首腦自答（詳見第 5 節）——三項成立但都是 P1 已登記的同一件事（不另計）、一項是「有上游守門，不是漏洞」、一項是「設計自陳，記錄不判」、兩項無新發現。

---

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

第 1 棒 P1 查的是套件本身：管「專案成員名冊」的那六支服務，守門有沒有接上。這一棒查的是**主專案這一側怎麼用它**——也就是「把套件接進主系統的那些線」：

- **專案的建立／讀取／修改／刪除**：這些功能都要「問一下任務平台：這個人是不是這個專案的管理者」。要看的是主專案問了沒有、問的方式有沒有可以繞過去的地方。
- **專案啟動時的成員同步**：開一個新專案時，系統會自動把建立者和指定的人寫進成員名冊。這段寫入走的是哪條路、有沒有繞過守門。
- **接線本身**：主專案把套件「掛」進來時要傳幾樣東西（登入檢查、角色守門、人員目錄）；套件那邊 P1 查過「少傳就拒絕啟動」，這一棒要看主專案這邊**有沒有真的每一條線都接上**、接線的轉接器（adapter）有沒有把「查不到」偷偷轉成「放行」。
- **AI 儀表板的曝露面**：主專案把 7 支成員查詢與 1 支專案查詢申報給 AI 儀表板，讓使用者用自然語言問。要看這條路有沒有做歸屬檢查。

順帶掃到（在範圍內、但不是本棒主題）：摘要報告的 model 與 repo 在這 35 檔內，研究員追脈絡追到它的 service 與 route，發現了本棒最嚴重的那一條。

---

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

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

工具報 8 條：F1＋F2 是同一問題的 route 層／service 層鏡像、F3／F5／F7 是舊案重複（見 3.3）。去重去舊後是 **4 個問題**。

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 |
|---|---|---|---|---|---|
| **H1-1**<br>🟡 中（工具 HIGH，首腦降）<br>(工具 F1+F2) | 「專案摘要報告清單」`POST /api/1.0/project-summary-reports` **只驗登入、沒問「你是不是這個專案的人」**，查詢條件全非必填；資料層的查詢只過濾「未刪除」。同一個檔案的新增／修改／刪除／匯出**都有**呼叫 `_require_participant`，**只有讀取漏掉** | 送 `{"filters":{}}` 就拿到清單，內容含 `summary`（稽核結論全文，富文本）、報告名稱、專案名稱與編號、作者登入帳號。**掃描當時**這張表沒開資料庫隔離→跨客戶全撈；**CM-1800 之後**隔離已開→跨客戶被擋，**同客戶內不是這個專案的人仍讀得到** | **只要一個能登入的帳號**（任何角色，不必是任何專案的成員）。不需要知道任何編號 | 入口 `api/project_summary_report/routes/project_summary_report_route.py:72`（只 `@jwt_required()`）<br>缺口 `app/project_summary_report/service/project_summary_report_service.py:53`（清單分頁）、`:73`（清單不分頁）<br>資料層 `infra/project_summary_report/repository/project_summary_report_repo_impl.py:43` 只過濾 `is_delete` | 兩支讀取開頭補 `_require_participant`（同檔 `:35` 已有現成的），查詢加上 `project_id IN (呼叫者參與的專案)` 範圍條件、或把 `project_uid` 改必填先解析出專案再守門。同檔 `:78` 單筆 GET 也一起補（見核對註記） |
| **H1-2**<br>🟡 中<br>(工具 F6) | 摘要報告的**歷史版本**兩支讀取（`POST /project-summary-report/histories` 帶 `report_uid`、`GET /project-summary-report/history/<uid>`）**帶任意編號即可讀**，同檔的「還原到某版本」有守門、讀取沒有 | 歷史版本常保留後來從現行版本刪掉的稽核發現；拿到報告編號（H1-1 那條可整批取得）就能把整條修訂鏈連同每一版全文讀出來。隔離狀態同 H1-1（CM-1800 後跨客戶已擋） | 一個能登入的帳號＋一個報告編號（H1-1 免費提供） | 入口 `api/project_summary_report/routes/project_summary_report_history_route.py:26`／`:42`（只 `@jwt_required()`）<br>缺口 `app/project_summary_report/service/project_summary_report_history_service.py:26`／`:31`；對照組同檔 `:51` revert 有 `assert_project_participant` | 兩支先由 `report_uid`（或由歷史紀錄反查母報告）解析出 `project_id`，呼叫 `assert_project_participant` 再查詢，照抄同檔 `:51` 的寫法。**與 H1-1 併一張工單**（同一模組、同一修法） |
| **H1-3**<br>🟡 中<br>(工具 F4) | 稽核輪次選單 `GET /api/1.0/grc/project/<project_uid>/assessment-plans/menu` **只驗登入不驗專案成員**，網址上的 `project_uid` 直接送進資料層查詢；同模組其他專案端點都有守門 | 同客戶內未被加進 B 專案的人，拿到 B 的專案編號就能看 B 的全部稽核輪次：名稱、狀態、起訖日、建立／修改者帳號與暱稱、`ssp_uid`——可推敲別人專案的稽核進度與人員編制。跨客戶被 `projects` 表隔離擋住（FR-094 CM-1768 已開） | 同客戶一個登入帳號＋目標專案編號（可由總表第 10 條 AI 儀表板路徑取得） | 入口 `api/flow_control/routes/assessment_plan_route.py:35-36`（`@jwt_required()`＋`@require_license("project")`，**沒有** `@require_capability` 也沒有成員守門）<br>缺口 `app/flow_control/service/project_service.py:487-491` `get_assessment_plans_menu` 直接 `self._domain_service.get_assessment_plans_menu(project_uid)` | `:487` 開頭先用 `project_domain_service` 解析 `project_uid`→`project_id`，再 `assert_project_participant`（讀取類用「任一參與者」即可）。同檔 `:529` `get_ap_dashboard` 工具順帶指出同樣沒守門、也不驗 `ap_uid` 屬不屬於 `project_uid`，一起補 |
| **H1-4**<br>🟡 中（面板 MEDIUM／HIGH／MEDIUM 取 MEDIUM，首腦同意）<br>(工具 F8) | 租戶管理員改「模組框架」`PUT /api/1.0/module-frame/<uid>` 帶 `include_controls`，可把**原廠公版**（`scope=SYSTEM`）的適用控制項清單整個改掉——因為那兩句是直接下 SQL 的 `UPDATE oscal.profile_imports`／`DELETE FROM oscal.ssp_implemented_requirements`，**繞過資料庫隔離**；而 `module_frames` 的讀取規則又明文放行所有租戶讀 SYSTEM 列，所以呼叫者指得到一個他讀得到、但不該改得動的公版 | 對**所有客戶共用**的合規內容做破壞：從公版拔掉控制項（或塞進去），並刪掉範本 SSP 上對應的實作說明。之後每個用這份公版開新專案的客戶，都會複製到被竄改過的範圍——**客戶以為在範圍內的控制項其實根本沒被評估** | 持有 `module-frame.update` 能力點——預設的**租戶管理員**角色開通時就有（`module-frame` 不在租戶管理員的排除清單內，`core/plugins/identity.py:89`） | 入口 `api/module_frame/routes/module_frame_route.py:93-94`（`@jwt_required()`＋`@require_capability("module-frame.update")`，只驗「有沒有這個功能權限」，不驗「這一筆是不是你的」）<br>缺口 `app/module_frame/service/module_frame_service.py:120-122` → `app/oscal/service/resource_library_app_service.py:403`／`:411` raw SQL<br>放行規則 `scripts/init/02-schema.sql:24284` `module_frames_select` | `update_applicable_controls` 開頭查 module_frame 的 scope／tenant：`SYSTEM` 只允許平台管理員、`tenant_id` 不是呼叫者的就拒絕（`common/authz/sharing.py::assert_scope_writable` 已有同款判斷，改成套在「這一筆記錄」而不是只套在「改 scope 欄位」）。長期把兩句 raw SQL 改走 ORM 讓隔離生效，並補 `oscal.profile_imports`／`ssp_implemented_requirements` 的隔離——後者歸 FR-094 範圍 |

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

本棒沒有新增資安類條目——卡片七項疑點裡成立的三項（DI 漏注入、7 支查詢曝給 AI 儀表板、`enforce_role=False`）**都是 P1 已登記的同一件事的宿主側**（總表 §3.1 第 60／61／62 條），不另計。

| # | 內容 | 性質 |
|---|---|---|
| **H1-5** | **專案啟動時的成員同步刻意繞過套件守門、直接寫資料層**：`app/project/service/project_start_app_service.py:85-88` 註解自陳「直接用 domain service（lean）寫 participant，不走 full ProjectParticipantService」，`:158` 直接 `_participant_domain_service.add(...)`；`:164-170` 建立者不在名單時自動補一筆 manager。合理（建專案的人就是 manager），但這是本專案**第五處繞過套件守門**，與總表第 62 條「有注入才守門」是同一個設計形狀的另一面：**套件守門可被主專案自由繞過，守門的真相不在套件** | 非資安備查（設計自陳，登記總表 §3.2） |

### 3.3 範圍外

三條是舊案重複，工具的密鑰專項撈到 2 條、範圍內撈到 1 條已登記的：

| 工具條 | 內容 | 對應既有紀錄 |
|---|---|---|
| F3（HIGH） | 每套安裝內建同一組原廠 super admin 密碼（`scripts/init/06-admin.sql:41`） | 總表 §3.1 **第 3 條**（FR-079 F15，決策者已裁「先記錄」） |
| F5（MEDIUM） | AI 儀表板專案清單寫死 `is_admin=True`（`project_service.py:224`，在範圍內） | 總表 §3.1 **第 10 條**（FR-083 D2） |
| F7（MEDIUM） | 五支腳本與一份 memory 檔含 admin／blsadmin／blsit 明文密碼 | **CM-1608**（決策者已裁「測試機專用、只做衛生」） |

**4 條新、3 條重複、0 條首腦否決。**

---

## 4. 每條發現的詳述

### H1-1 — 摘要報告清單只驗登入，同檔只有讀取漏守門（中，可信度 高；工具評 HIGH，首腦因 CM-1800 時序降 MEDIUM）

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

**這是什麼問題（白話）**

每個稽核專案可以寫「摘要報告」——就是稽核結論那份文件。畫面上的報告清單是打 `POST /api/1.0/project-summary-reports` 拿的。這支 API 只確認「你有登入」，**沒有問「你是不是這個專案的人」**；查詢條件（專案編號等）全部非必填，送空的就是「不過濾」。

同一個 service 檔案裡，新增（`:111`）、修改（`:138`）、刪除（`:164`）、單筆讀取（by_uid `:100`）都呼叫了 `_require_participant`——**所以清單這兩支漏掉是不一致，不是刻意開放**。

**出事會怎樣**

拿到的是報告清單，每一列含 `summary`（稽核結論全文、富文本 HTML）、報告名稱、專案名稱與編號、作者與最後編輯者的登入帳號，可分頁翻完。

這裡要分**掃描前後**講：
- **掃描當時**（版本 `739a0a61`，15:03）：`compliance.project_summary_reports` 沒有客戶欄位、也沒開資料庫隔離。任何客戶的任何登入者送空請求，**全系統所有客戶**的稽核結論一次撈光。這是工具評 HIGH 的依據，當時是對的。
- **掃完 4 小時後**（CM-1800，commit `aac15d06`，18:52）：FR-094 第 9 棒用「透過 `project_id` 連到 `projects` 表的客戶欄位」的方式把這張表與歷史表的隔離開了（首腦 19:23 唯讀實查 DEV：隔離**已開**、4 條規則、43 筆）。跨客戶那一半被資料庫擋住了。
- **現況**：同一個客戶內，不是這個專案成員的人仍讀得到所有專案的摘要報告。門檻不變（登入即可），範圍縮到單一客戶內——**這是首腦降 MEDIUM 的唯一理由**，缺口本身沒修。

**要先有什麼才打得到**

一個能登入的帳號。任何角色，不必是任何專案的成員，不需要知道任何編號。

**在哪裡**

- 入口：`api/project_summary_report/routes/project_summary_report_route.py:72`——`@jwt_required()` 之外沒有任何守門
- 缺口本體：`app/project_summary_report/service/project_summary_report_service.py:53`（`get_project_summary_reports_and_pager`）、`:73`（`get_project_summary_reports`）
- 資料層：`infra/project_summary_report/repository/project_summary_report_repo_impl.py:43`——`filter(ProjectSummaryReport.is_delete == 0)` 之外沒有範圍條件
- 對照組（同檔有做的）：`:100`／`:111`／`:138`／`:164`

**怎麼修**

1. `:53` 與 `:73` 開頭補守門。同檔 `:35` 已有 `_require_participant(project_id)`，`:44` 有 `_require_participant_by_report_uid(uid)`，直接用。
2. 清單查詢要有範圍：要嘛把 `project_uid` 改必填、先解析出專案再守門；要嘛先算出「呼叫者參與的專案清單」當成 `project_id IN (...)` 的必要條件。**不要讓「沒條件」變成「全部給你」**。
3. 同檔 `:78` `get_project_summary_report`（單筆 `GET /project-summary-report/<uid>`）一併補——見下方核對註記。
4. 資料庫層的隔離 CM-1800 已補，不必在這張工單重做。

**首腦核對註記（首腦親開檔＋唯讀查 DEV）**

- ✅ **開檔核對成立**。`project_summary_report_service.py:53`／`:73` 兩支讀取無 `_require_participant`，同檔 `:100`／`:111`／`:138`／`:164` 有；route `:72` 只掛 `@jwt_required()`；repo `:43` 只過濾 `is_delete`。
- ✅ **資料庫隔離時序核實**。DEV 實查（2026-09-14 19:23）：`compliance.project_summary_reports` 隔離**已開**、4 條規則、無租戶欄、43 筆——是 CM-1800 用 EXISTS 子查詢繞 `projects` 表補的。掃描版本 `739a0a61`（15:03）早於 CM-1800（18:52），**掃描時確實未開**，工具當時的 HIGH 沒有錯，降級是因為環境在掃描後改變。
- ⚠️ 補寫時另見：同檔 `:78` `get_project_summary_report`（單筆 GET，route `:109-112`）**也沒有** `_require_participant`，工具 F1 的 Impact 段有提到這支、brief 只點名 `:53`／`:73`。修的時候三支一起補，不影響本條的嚴重度判定。

---

### H1-2 — 摘要報告歷史版本讀取帶任意編號即可讀（中，可信度 高）

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

**這是什麼問題（白話）**

摘要報告每次儲存都會留一個歷史版本。查歷史版本的兩支 API（`POST /project-summary-report/histories` 帶 `report_uid`、`GET /project-summary-report/history/<uid>`）只驗登入，拿到編號就直接查。同一個 class 裡的「還原到某個版本」（`:40`）**有**先解析報告、呼叫 `assert_project_participant`（`:51`）——又是「寫有守、讀沒守」。

**出事會怎樣**

歷史版本常保留後來從現行版本刪掉的稽核發現。攻擊者先用 H1-1 把報告編號整批倒出來，再對每個編號打 histories，整條修訂鏈連同每一版的 `summary` 全文都拿到。隔離狀態與 H1-1 同步：掃描時關、CM-1800 後開（`project_summary_report_histories` 同一支 migration 一起補），現在跨客戶擋住、同客戶跨專案仍可讀。

**要先有什麼才打得到**

一個能登入的帳號＋一個報告編號（H1-1 免費提供；沒有 H1-1 也可以猜或從曾參與的頁面拿）。

**在哪裡**

- 入口：`api/project_summary_report/routes/project_summary_report_history_route.py:26`（histories）、`:42`（單筆）——只 `@jwt_required()`
- 缺口本體：`app/project_summary_report/service/project_summary_report_history_service.py:26`（`get_project_summary_report_histories`）、`:31`（`get_project_summary_report_history_by_uid`）
- 對照組：同檔 `:51` revert 有 `assert_project_participant(self._participant_role_service, report.project_id)`

**怎麼修**

兩支開頭先解析出母報告的 `project_id`（histories 由 `report_uid`；單筆由歷史紀錄反查母報告），呼叫 `assert_project_participant`，照抄同檔 `:51`。**與 H1-1 併一張工單**——同模組、同一種缺口、同一支守門函式。

**首腦核對註記**

- ✅ 開檔核對成立：`:26`／`:31` 零檢查，`:51` 有；route 兩支只 `@jwt_required()`。
- ✅ `project_summary_report_histories` 的隔離同 CM-1800 已開（DEV 唯讀實查）。

---

### H1-3 — 稽核輪次選單只驗登入不驗專案成員（中，可信度 高）

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

**這是什麼問題（白話）**

一個專案底下有多個稽核輪次（Assessment Plan）。前端選輪次的下拉選單打 `GET /api/1.0/grc/project/<project_uid>/assessment-plans/menu`。route 掛了 `@jwt_required()` 與 `@require_license("project")`（客戶有沒有買專案模組），**沒有** `@require_capability`、service 也沒有成員守門——網址上的 `project_uid` 直接送進資料層查。同模組其他專案端點（改專案 `:374-375`、刪專案 `:579-590`）都有問「你是不是這個專案的 manager／成員」。

**出事會怎樣**

同客戶內、沒被加進 B 專案的人，拿到 B 的專案編號就能看 B 專案全部輪次：名稱、狀態、起訖日、建立／修改者帳號與暱稱、`ssp_uid`。可以推敲別人專案的稽核進度與誰在負責。跨客戶被 `projects` 表的隔離擋住（FR-094 CM-1768 已開），所以只在同客戶內。

**要先有什麼才打得到**

同客戶一個登入帳號（客戶要有專案模組授權）＋目標專案編號。編號可由總表第 10 條那條 AI 儀表板路徑（`is_admin=True` 列全租戶專案）取得，或從分享連結、曾參與過的頁面拿。

**在哪裡**

- 入口：`api/flow_control/routes/assessment_plan_route.py:26-40` `AssessmentPlanMenuResource`（`:35` `@jwt_required()`、`:36` `@require_license("project")`）
- 缺口本體：`app/flow_control/service/project_service.py:487-491` `get_assessment_plans_menu`——`:491` 直接 `self._domain_service.get_assessment_plans_menu(project_uid)`
- 順帶：同檔 `:529` `get_ap_dashboard(ap_uid, project_uid)` 同樣沒守門、也沒驗 `ap_uid` 屬不屬於 `project_uid`（工具 F4 Impact 段提及）

**怎麼修**

`:487` 開頭：用 `self._project_domain_service` 由 `project_uid` 解析 `project_id`，呼叫 `common.authz.project.assert_project_participant(self._participant_role_service, project_id)`（讀取類用「任一參與者」）。`:529` 比照，外加驗證 `ap_uid` 所屬專案 == `project_uid`。

**首腦核對註記**

- ✅ 開檔核對成立：`project_service.py:487-491` 全路徑無 `assert_project_participant`／`assert_project_manager`；route 只有 `jwt_required`＋`require_license`。
- ✅ `projects` 表隔離 DEV 現況已開（FR-094 CM-1768），故影響限同客戶內——與工具的判定一致。

---

### H1-4 — 租戶管理員可改原廠公版的適用控制項清單（中，可信度 中；面板 MEDIUM／HIGH／MEDIUM 取 MEDIUM）

**現況**：已修（M11-5＝M10-11，FR-114 CM-2178，commit `abf1cf8a4`；CM-2040，1.21.0 出貨）

**這是什麼問題（白話）**

「模組框架」（module frame）是一套稽核範本，分兩種：原廠公版（`scope=SYSTEM`，所有客戶共用）與客戶自己的。改模組框架 `PUT /api/1.0/module-frame/<uid>` 要有 `module-frame.update` 這個功能權限——**預設的租戶管理員開通時就有**。

改框架時如果帶 `include_controls`（適用控制項清單），service 會呼叫 `update_applicable_controls`，裡面是兩句**直接下 SQL**：`UPDATE oscal.profile_imports`（改控制項清單）與 `DELETE FROM oscal.ssp_implemented_requirements`（刪掉被拔掉控制項的實作說明）。這兩張 `oscal.*` 表**沒開資料庫隔離、也沒有租戶欄**，所以 SQL 想改哪筆就改哪筆。

而 `module_frames` 主表的讀取規則（`module_frames_select`）**明文放行所有租戶讀 SYSTEM 列**——租戶管理員列清單就看得到公版的 uid，指得到它。主表那一筆 `UPDATE` 會被主表隔離擋下（或靜默影響 0 列），但接下來那兩句 raw SQL 沒人擋。

**出事會怎樣**

租戶 B 的管理員送 `PUT /module-frame/<公版 uid>` 帶 `{"include_controls": ["ac-2"]}`：公版的控制項清單變成只剩一條、範本 SSP 上其他控制項的實作說明全刪。之後客戶 A 用這份公版開新專案，複製到的就是被竄改過的範圍——**客戶以為在範圍內的控制項根本沒被評估**，範本上先前寫的實作說明也沒了。這是對所有客戶共用內容的完整性破壞。

**要先有什麼才打得到**

`module-frame.update` 能力點（租戶管理員預設有；`core/plugins/identity.py:89` 的排除清單不含 `module-frame`）＋系統有 SYSTEM 範圍的框架（出貨本來就有）。

**在哪裡**

- 入口：`api/module_frame/routes/module_frame_route.py:93-96`（`@jwt_required()`＋`@require_capability("module-frame.update")`）
- 缺口本體：`app/module_frame/service/module_frame_service.py:120-122`（掃描版本；HEAD 同段）→ `app/oscal/service/resource_library_app_service.py:403`（`UPDATE oscal.profile_imports`）、`:411`（`DELETE FROM oscal.ssp_implemented_requirements`）
- 放行規則：`scripts/init/02-schema.sql:24284` `module_frames_select`
- ⚠️ 掃後平行 session 補的 5 行（`module_frame_service.py:113-116` `assert_scope_writable`）**只守「改 scope 欄位」這件事**，控制項清單那條沒守——本條在 HEAD 仍成立

**怎麼修**

1. `update_applicable_controls`（或 `update_module_frame` 進到 `include_controls` 分支之前）開頭查這筆 module_frame 的 `scope` 與 `tenant_id`：`SYSTEM` 只允許平台管理員；`tenant_id != 呼叫者 tenant_id` 拒絕。`common/authz/sharing.py::assert_scope_writable` 已有同款判斷，改成套在「這一筆記錄」上。**不要拿主表隔離當守門**——出事的是 `oscal.*` 那兩張沒隔離的表。
2. 長期：兩句 raw SQL 改走 ORM 讓隔離能生效，並替 `oscal.profile_imports`／`oscal.ssp_implemented_requirements` 補隔離——**後者歸 FR-094 範圍**（那批目前是 13 張有租戶欄的表，這兩張連租戶欄都沒有，屬「要先改結構」那一級）。

**首腦核對註記（首腦親開檔＋唯讀查 DEV）**

- ✅ 開檔核對成立：`module_frame_service.py:120-122` → `resource_library_app_service.py:403`／`:411` raw SQL；`02-schema.sql:24284` `module_frames_select` 放行 SYSTEM。
- ✅ DEV 實查：`oscal.profile_imports`／`oscal.ssp_implemented_requirements` 隔離**關**、0 規則、無租戶欄。
- ✅ 掃後 diff 核實：平行 session 補的 `assert_scope_writable` 只在 `new_scope != mf.scope` 時觸發，`include_controls` 分支不經過它。
- ⚠️ 工具自標未確認：主表那一次 `update_module_frame`（`:117`，在 `:120` raw SQL 之前）在 `module_frames_update` 規則下是**靜默影響 0 列**（raw SQL 接著跑、攻擊成立）還是**直接拋錯**（整個方法中斷、raw SQL 跑不到），要實際打才知道。這是本條可信度標「中」而非「高」的原因：**攻擊是否真的走得到 raw SQL 那一步，沒有實測**。修法不受影響（無論如何都該在進 raw SQL 前守門，不能靠主表隔離的副作用擋）。

---

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

卡片列了七項思考起點。**工具只碰到第 ③ 項的一半**，其餘首腦自答。

| 卡片列的疑點 | 本棒結論 |
|---|---|
| ① **DI 漏注入角色服務＝靜默全開**（`project_participant_containers.py:113-117`，首腦開卡時已核 ✅） | ✅ **屬實，但不另計**——這是 P1 已登記的總表 §3.1 **第 62 條**的宿主側同一件事。首腦另 grep 六支 service 的 DI 建構：`project_participant_containers.py:84/:94/:102/:110`＋`task_assignee_containers.py:66` 共 5 處傳了 `participant_role_service`，**`ProjectGroupParticipantService`（`:113-117`）是唯一漏的**；沒有別的容器另建同款服務 |
| ② **7 支成員查詢曝給 AI 儀表板無歸屬檢查**（`dashboard_apis/participant.py`） | ✅ **屬實，工具未報**（注意力被 F5 那支 project 查詢吸走）。P1 報告第 6 節對照表已逐支登記；本棒登記為第 60／61 條的 AI 儀表板路徑，不另計。7 支明細見第 6 節末表 |
| ③ **四處 `enforce_role=False`＋`update_project` 條件式守門** | ✅ **屬實**，其中三處（`project_service.py:453/:463/:473`）上游是 `:374-375` 的「有注入才檢查」條件式——與第 62 條同型，不另計。**`module_frame_service.py:242`（HEAD `:247`）那處首腦補查**：所在方法 `start_project_from_module_frame` 方法內零守門，但 route `module_frame_route.py:117-118` 掛 `@jwt_required()`＋`@require_capability("module-frame.create")`——有功能權限守門（能建範本專案的人才能從範本開專案），且開專案的人自動成為該專案 manager 是設計（FR-048 D5）。**這處 `enforce_role=False` 有上游守門，不是漏洞**，記為「靠 route 層能力點擋住」 |
| ④ **接線 adapter 有沒有吞例外／把查不到轉放行** | ✅ **沒有**。`core/plugins/participant.py:90-102` `_ProjectRoleGuardAdapter.assert_manager` 直接委派 `common.authz.project.assert_project_manager`，無 try/except；註解自陳「canonical 留主專案是刻意的，同一支守門被 flow_control 與 task_survey 共用 8 處」 |
| ⑤ **守門函式本身**（`common/authz/project.py`） | ✅ P1 已核，無 fail-open、無 super_admin 短路；本棒不重審 |
| ⑥ **專案啟動時的成員同步** | ⚠️ **設計自陳、記錄不判**：`project_start_app_service.py:85-88` 註解「直接用 domain service（lean）寫 participant，不走 full ProjectParticipantService」——刻意繞過 app service 層守門直接寫表；`:164-170` 建立者不在名單時自動補一筆 manager。合理（建專案的人就是 manager），route `api/project/routes/project_route.py:24-26` 有 `@require_capability("project.create")`。但這是「第五處繞過套件守門」，與 ③ 同型，登記 H1-5 進總表 §3.2 |
| ⑦ **跨模組 resolver**（`ssp_project_resolver.py`、`drive_project_verify_service.py`） | ✅ 前者 docstring 自陳會解析「current user 在該 project 內的角色」（`:4`、`:111` `is_writable`），權限檢查另由 `common.authz.ssp.SspPermissionChecker` 封裝；後者以 `tenant_id` 為參數查 `project_participant_domain_service`（`:73-87`）。兩支都是內部 resolver 非入口，呼叫端守門歸各自 route。**無新發現** |

---

## 6. 主專案呼叫點 vs 套件方法 對照表

本棒是宿主棒，對照表反過來做：**主專案這 35 檔裡每一支對任務平台套件的呼叫點，呼叫前有沒有守門、守門是不是條件式**。首腦用 `grep -n "participant_service\.\|_participant_domain_service\.\|task_assignee"` 建，逐行開檔核對所在方法與 route。

**「條件式／enforce_role=False」欄的意思**：「條件式」＝守門包在 `if self._participant_role_service is not None:` 裡，DI 漏注入就跳過；「enforce_role=False」＝主專案呼叫套件時明示「這次不要檢查」，授權靠上游。

### `app/flow_control/service/project_service.py`

| 呼叫點 | 呼叫套件哪支方法 | 所在方法 | 呼叫前有沒有守門 | 條件式／enforce_role=False |
|---|---|---|---|---|
| `:144` | `ProjectParticipantService.get_participants_by_project_ids` | `list_projects` (:107) | ❌ 無 assert；可見性靠 repo 層 `user_id`／`is_admin` 過濾（route `/grc/projects/list` 傳 `is_admin=False`） | — |
| `:259` | `ProjectParticipantService.get_project_participants` | `get_project` (:238) | ❌ 無 assert；`:257` `_domain_service.get_project(uid, user_id, is_admin)` 可見性過濾算前置 | — |
| `:431` | `get_project_participants` | `update_project` (:335) | ✅ `:374-375` `assert_project_manager` | **條件式**（`if self._participant_role_service is not None`） |
| `:453` | `add_project_participant` | `update_project` | ✅ 同 `:374-375` | **條件式＋enforce_role=False**（`:460` 註解自陳「授權由 update_project 自身把關」） |
| `:463` | `update_project_participant` | `update_project` | ✅ 同上 | **條件式＋enforce_role=False**（`:467`） |
| `:473` | `delete_project_participant` | `update_project` | ✅ 同上 | **條件式＋enforce_role=False**（`:474`） |
| `:586` | `get_project_participants(project_id, user_id)` | `delete_project` (:556) | ✅ **這一行就是守門**：owner 或 manager 才放行（`:579-590`）；`is_admin` 短路 | — |
| `:633` | `get_project_participants(project_id, user_id)` | `batch_delete_projects` (:600) | ✅ 同上逐筆判定（`:626-637`） | — |
| `:686` | `ProjectParticipantDomainService.get_all` | `_notify_project_started` (:662) | ❌ 無（內部通知用，由 `notify_project_started` :652 觸發，非入口） | — |
| `:719` | `TaskAssigneeDomainService.get_all` | 同上 | ❌ 無（同上） | — |
| `:491` | （不呼叫套件；`_domain_service.get_assessment_plans_menu`） | `get_assessment_plans_menu` (:487) | ❌ **無**——**H1-3** | — |
| `:224` | （不呼叫套件；`list_projects(is_admin=True)`） | `get_projects_for_dashboard` (:212) | ❌ 寫死 `is_admin=True`——總表第 10 條 | — |

### `app/project/service/project_start_app_service.py`

| 呼叫點 | 呼叫套件哪支方法 | 所在方法 | 呼叫前有沒有守門 | 備註 |
|---|---|---|---|---|
| `:158` | `ProjectParticipantDomainService.add`（**直接寫 domain 層，不經 app service**） | `_add_one_participant` (:150) ← `_add_participants` (:163) | ❌ 方法內無（docstring 自陳「專案啟動 bootstrap：不做角色守門」）；route `api/project/routes/project_route.py:24-26` `@require_capability("project.create")` | **H1-5**：第五處繞過套件守門；`:164-170` 建立者不在名單自動補 manager |

### `app/module_frame/service/module_frame_service.py`

| 呼叫點 | 呼叫套件哪支方法 | 所在方法 | 呼叫前有沒有守門 | 條件式／enforce_role=False |
|---|---|---|---|---|
| `:242`（HEAD `:247`） | `ProjectParticipantService.add_project_participant` | `start_project_from_module_frame` (:225) | 方法內 ❌；route `module_frame_route.py:117-118` `@require_capability("module-frame.create")` ✅ | **enforce_role=False**——有上游能力點守門，開專案者成為 manager 是設計（FR-048 D5），**不是漏洞** |
| `:120-122` | （不呼叫套件；`resource_library_app_service.update_applicable_controls`） | `update_module_frame` (:106) | route `:93-94` `@require_capability("module-frame.update")` 只驗功能權限、不驗這筆是不是你的 | **H1-4** |

### `di_containers/dashboard_apis/participant.py`（7 支申報給 AI 儀表板）

AI 儀表板入口 `POST /api/1.0/ai-dashboard/auto-generate` 只掛 `@require_license("ai-dashboard")`，沒有能力點、沒有歸屬檢查；使用者用自然語言問，LLM 選一支 api_key 呼叫。**7 支全部無歸屬檢查、6 支 `required_params=[]`**（空條件＝全表，與 P1-1 同病）。

| 行 | api_key | 呼叫套件哪支方法 | `required_params` | 歸屬檢查 |
|---|---|---|---|---|
| `:19` | `participant.get_project_participants` | `ProjectParticipantService.get_project_participants` | `[]` | ❌（P1-1 同路徑，總表第 60 條） |
| `:31` | `participant.get_process_participants` | `ProcessParticipantService.get_process_participants` | `[]` | ❌ |
| `:43` | `participant.get_task_assignees` | `TaskAssigneeService.get_task_assignees` | `[]` | ❌ |
| `:55` | `participant.get_user_task_queue_by_sp` | 主專案 `MyJobsAppService.get_user_task_queue_by_sp` | `["user_id"]` | ❌（`user_id` 由 LLM 填，可填別人的） |
| `:68` | `participant.get_project_control_participants` | `ProjectControlParticipantService.get_project_control_participants` | `[]` | ❌（P1-2 同路徑，總表第 61 條） |
| `:82` | `participant.get_control_group_participants` | `ControlGroupParticipantService.get_control_group_participants` | `[]` | ❌ |
| `:96` | `participant.get_project_group_participants` | `ProjectGroupParticipantService.get_project_group_participants` | `[]` | ❌（這支服務全檔零守門，總表第 62 條） |

### 接線層（`core/plugins/participant.py`、DI 容器）

| 位置 | 內容 | 核對 |
|---|---|---|
| `core/plugins/participant.py:90-102` | `_ProjectRoleGuardAdapter.assert_manager` → `common.authz.project.assert_project_manager` | ✅ 直接委派、無 try/except、無 fallback |
| `core/plugins/participant.py:140` | `auth_required=jwt_required()` | ✅ 有傳（P1 已核套件側缺它會拒絕掛載） |
| `di_containers/flow_engine/project_participant_containers.py:84/:94/:102/:110`、`task_assignee_containers.py:66` | 5 處傳 `participant_role_service` | ✅ |
| `di_containers/flow_engine/project_participant_containers.py:113-117` | `ProjectGroupParticipantService` **未傳** `participant_role_service` | ❌ 總表第 62 條（P1 已登記） |

---

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

分兩層講。

### 第一層：「報出來的這些，真的存在嗎」→ **可信度高**

- 三個獨立檢查員對 8 條候選各投一票，**24 票全數投出、8 條全 3:0 通過**，面板第 1 輪收斂、無候選遺失、`unreviewed_candidate_sites` 為 0。stamp `verified`、無拒收原因——**這是 FR-095 兩棒裡第一次拿到乾淨的驗證章**（P1 是 `findings-refused`）。
- 檢查員主動把 F8 從研究員報的 HIGH **降為 MEDIUM**（三票 MEDIUM／HIGH／MEDIUM），投票不是橡皮圖章。
- **範圍內每一條首腦都親自開檔核對**，另用唯讀 SQL 在 DEV 查了四張表（`project_summary_reports`、`project_summary_report_histories`、`oscal.profile_imports`、`oscal.ssp_implemented_requirements`）的隔離狀態與筆數。核對結果與工具一致；**唯一改判是 H1-1 因 CM-1800 時序 HIGH→MEDIUM**，不是工具錯、是環境在掃描後變了。
- H1-4 可信度標「中」是誠實標記：主表更新在隔離規則下拋不拋錯沒有實測，攻擊能不能走到 raw SQL 那一步取決於它。修法不受影響。

### 第二層：「這 35 個檔只有這些問題嗎」→ **不可宣稱**

- effort `low`，派 2 位研究員（含密鑰專項）、回 2，不是逐檔窮舉。工具**沒有申報逐檔閱讀帳本**（`coverage.research` 為 `null`）。
- **24 票裡有 9 票投在範圍外舊案上**（F3／F5／F7 各 3 票），真正投在本棒 4 個新問題上的是 15 票；而 4 個新問題裡有 3 個（H1-1／H1-2／H1-4）**落點根本不在 35 檔的主題**（摘要報告 service 與 route、`resource_library_app_service.py` 都在範圍外，是研究員追脈絡追出去的）。換句話說，**本棒的主題——專案 CRUD、成員同步、plugin／DI 接線——工具只在 F4（輪次選單）這一條上有著墨**。
- **卡片七項疑點工具只碰到 ③ 的一半**（F4 附帶提到守門不一致），①②④⑤⑥⑦ 全是首腦自答。第 6 節的對照表是首腦逐行 grep＋開檔建的，不是工具產物。
- runner 未交付報告，所以**沒有 runner 的人工複查那一輪**——本棒的人工層只有首腦驗收一輪。

**白話總結：報出來的 4 條是真的；但這 35 檔的主題（接線與成員同步）工具幾乎沒看，那一塊的信心來自首腦的對照表，不是工具。**

---

## 8. 執行概況（工程師看的數字）

照 stamp `CLAUDE-SECURITY-REVISION-739a0a61d92e-dirty.json` 實抄：

| 項目 | 數值 |
|---|---|
| `status` | **`verified`**（`reason`／`reason_kind` 皆空） |
| `candidates` / `candidates_deduped` | 10 / 8 |
| `panel_votes` | 24（8×3，全投） |
| `panel_reviewed_findings` / `panel_quorum_findings` | 8 / 8 |
| `researchers_dispatched / returned` | 2 / 2 |
| `unreviewed_candidate_sites` | 0 |
| `incomplete_panel_candidates` | 0 |
| `verification_runs` | 1 |
| `findings` | 8（HIGH 3、MEDIUM 5；去重去舊後範圍內新 4 條） |
| `severityLowered` | F8（HIGH→MEDIUM），由檢查員投票決定（MEDIUM／HIGH／MEDIUM） |
| 掃描範圍 | 35 支檔（`scope_files: 35`，與卡尾補正段對上） |
| `revision` | `739a0a61d92e4214403265d158ccec85a7526a76`，branch `feature/FR-075`，**`dirty: true`**（平行 session 未 commit 改動；H1 範圍內 35 檔 dirty 為零） |
| effort / focus | `low` / `attack-surface` |
| 完整度檢查 | `not-applicable`（範圍掃描不做全樹盤點） |
| `skipped_components` / `unaccounted_top_level_dirs` | 皆空 |
| **Run ID** | **`wf_9b402a56-704`**（首腦從 workflow 目錄找到，目錄時間 19:07 與 stamp 19:05 吻合） |
| `scan_id` | `2d26da8a-1eb5-471e-9326-0058199e8c25` |
| 耗時 | 14,400 秒（4 小時整） |
| 工具原始產物 | `CLAUDE-SECURITY-20260914-070510/`（BE repo 根目錄，自帶 `.gitignore` 不入版控） |
| 掃描版本→HEAD 範圍 diff | 只有 `app/module_frame/service/module_frame_service.py` +5 行（`assert_scope_writable`），不影響任何結論 |

**沒有執行任何程式碼**：所有發現都是讀程式碼推導出來的，沒有實際打過 API、沒有驗證過任何攻擊手法。對資料庫只做了唯讀查詢。

---

## 9. 建議的後續（交決策者裁）

**現況**：本站 FR-095 已掃完；以下為掃描當時的建議，各項現況見前文發現小節的「現況」註記與 `docs/security-report/M10-task-platform.md`（資安問題已無未修，1.21.0 出貨）。

不做決定，只列選項與代價。

1. **H1-1 與 H1-2 一起修**——同一模組、同一種缺口（讀取漏 `_require_participant`）、同一支守門函式，涉及 `project_summary_report_service.py` 三支讀取（`:53`／`:73`／`:78`）＋ `_history_service.py` 兩支（`:26`／`:31`）。建議開**一張**修正卡。清單那支要決定「`project_uid` 改必填」還是「查詢加參與專案範圍」——前者改 API 契約要先查前端，後者不改契約但要多一次查詢。資料庫層 CM-1800 已補，不必重做。

2. **H1-3 單獨修或併第 45／52／53 條那批**——「收到專案編號卻沒拿來守門」同型，修法一行（解析 uid→assert）。順手把 `get_ap_dashboard` 的 `ap_uid` 歸屬驗證一起補。

3. **H1-4 分兩段**：程式層守門（`update_applicable_controls` 開頭查 scope／tenant）可以現在修、一張卡；資料庫層（`oscal.profile_imports`／`ssp_implemented_requirements` 連租戶欄都沒有）歸 FR-094「要先改結構」那一級，**不要跟程式層排同一輪**。

4. **「主專案繞過套件守門」這個形狀要不要收**——本棒又數到一處（H1-5 專案啟動直寫 domain），加上 P1 的四處 `enforce_role=False` 與一處 DI 漏注入，**主專案有六個地方不經套件守門就動成員名冊**。這與總表 §6 A 組「有注入才守門要不要整組改」是同一個決策：如果套件守門要改成「沒注入就拒絕」，這六處都要重新接。**跨模組決策，不在本棒定**。

5. **AI 儀表板那 7 支**——不是本棒新發現，但本棒把它們逐支列了出來（第 6 節）。修第 60／61／62 條時，AI 儀表板路徑會自動一起好（同一支 service 方法），不必另開卡；但 `get_user_task_queue_by_sp` 那支 `user_id` 由 LLM 填、可填別人的，這是總表第 8／10 條的同型，開 AI 儀表板那張卡時要一起看。

**與既有案的關係**：H1-1／H1-2／H1-3 屬總表 §2.9 🅰 組「只驗身分不驗歸屬」同型（**這是「讀取功能忘記檢查權限」第六次出現**）；H1-4 的資料庫層屬 🅱 組「連租戶欄都沒有」那一級。三條範圍外舊案（F3／F5／F7）已各有紀錄，不重複登記。
