---
title: C1 掃描報告——接線總裝＋審閱簽核
---

# C1 掃描報告 — 接線總裝＋審閱簽核（CM-2102）

> 範圍：22 檔／1,739 行，跨兩個 repo。套件側 `jedi-compliance-audit` 20 檔（`plugin/` 組裝五支、`api/guards.py`、`common/guard.py`、`api/routing.py`，以及審閱簽核那一條直線：路由、服務、DTO、entity、repo 介面與實作、domain service、model）；主專案側 2 檔（`core/plugins/compliance_audit.py`、`api/flow_control/__init__.py`）。
> 掃描工具：Claude Code 官方 `claude-security` plugin，effort low，只看正式程式碼。**兩側各掃一次**，因為工具只認它所在那個 repo 的檔案。
> 掃描基準：套件側 `eafc7ae511d5`（monorepo 主 checkout，`feature/review`）；主專案側 `65620f07e522`（`feature/review`）。兩邊工作區都有其他 session 還沒 commit 的改動。
> 驗證章：兩次都是 **verified**。只掃不修（掃描當時紀錄；後續修正見 README）。

---

## 1. 一句話結論

**這一棒範圍內沒有新的漏洞。** 主系統交給套件的三道守門零件，就是主系統真正在用的那三道（沒有替換、沒有吞錯誤、查不到角色一律擋下）；卡片最擔心的「審閱簽核的角色檢查，零件沒裝就直接放行」寫法確實存在，但**目前整個系統只有一個地方在組裝這支服務，而且兩個零件都有傳**，所以現在不會出事。三人面板也對這條投了 0:3 不成立。

但這個寫法本身是一顆**未來地雷**：套件的「沒接好就拒絕掛載」只檢查網址那一層的三道守門，**管不到服務層這兩個零件**，哪天有人新開一個組裝點漏傳一個，審閱簽核就會整支變成「只要登入就能按」，而且沒有任何錯誤訊息。這跟總表第 62 項、§7 甲組「有注入才守門要不要整組改」是同一個設計形狀，建議併進那個待裁決策（第 6 節第 1 條）。

主專案側工具報了 5 條、全部 3:0 成立，但**全是舊案**：它們都在這個 blueprint 掛上去的「任務」那幾支網址（看、改、刪單筆任務、任務清單、強制開始），分別＝總表第 45、155、159 項，FR-115 W2／W4／W8 已經報過，**淨新增 0**。

---

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

「稽核」這一大塊功能是一支獨立套件（`jedi-compliance-audit`），主系統這邊只留一支接線檔。接線檔的工作是把三樣「守門零件」交給套件：

- **商務授權守門**：這家客戶有沒有買「專案」這個模組。
- **專案角色守門**：你在這個專案裡是不是經理（或審閱者、稽核員）。
- **身分判定**：你是不是超級管理員。

套件拿到之後，一邊掛在每條網址上（網址層），一邊綁進服務裡（服務層）。這一棒要回答兩件事：

1. 套件拿到的三道守門，是不是主系統真正在用的那三道？有沒有哪一道被換成「永遠放行」、或把「查不到角色」當成通過？
2. 「審閱簽核」這個功能（稽核經理或審閱者在控制項旁邊按「已審閱」打勾）的角色檢查，開頭寫著「兩個零件任一沒裝就直接跳過」。這個寫法現在是不是每一處都有裝？

---

## 3. 掃到什麼：總覽

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 跟總表的關係 |
|---|---|---|---|---|---|
| C1-1 | 甲專案的經理可以改乙專案的任務 | 改掉別專案任務的名稱、說明、設備、檢測目標，還能把自己加成指派人 | 同一家客戶、自己是任一專案的經理、知道任務編號 | 主專案側 `app/flow_control/service/job_service.py:308` `update_job` 開頭 | **＝第 45 項**（SUMMARY #63），不另計 |
| C1-2 | 甲專案的經理可以刪乙專案的任務，對方輪次凍結了也刪得掉 | 別專案的任務連同指派、問卷快照、流程節點一起刪掉 | 同上，另外自己專案的輪次還在規劃期 | 主專案側 `job_service.py:214` `delete_job` 開頭 | **＝第 45 項**（凍結檢查查錯專案那點 FR-115 已補記），不另計 |
| C1-3 | 任何人都打得開別專案任務的詳細內容 | 讀到指派人、部門、設備、檢測工具設定（帳密有遮） | 同一家客戶的登入帳號、知道任務編號 | 主專案側 `api/flow_control/routes/job_route.py:88`（服務 `get_job`） | **＝第 45 項**，不另計 |
| C1-4 | 任務清單不問你是不是這個專案的人 | 列出別專案每個查核項目底下的任務，也拿到上面三條要用的任務編號 | 同一家客戶的登入帳號、知道專案編號 | 主專案側 `job_route.py:69`（服務 `list_jobs`） | **＝第 155 項**，不另計 |
| C1-5 | 甲專案的經理可以把乙專案卡住的任務強制開始 | 別專案卡在合流的任務被推成進行中，稽核事件記在甲專案名下 | 自己是任一專案的經理、知道一張卡在合流的任務編號 | 主專案側 `app/flow_engine/service/job_force_start_service.py:82` `force_start`（實際該補在 `_load`） | **＝第 159 項**，不另計 |

**以上五條都是主專案側那次掃描、經三人面板投票的**；它們掛在本棒範圍 `api/flow_control/__init__.py` 組裝出來的網址上，但實際出問題的檔案不在本棒範圍。第 5 節是卡片點名的事，由我自己開檔核對，**沒有經過投票**。

---

## 4. 工具報的五條（主專案側，經三人面板投票，全是舊案）

五條是同一個形狀：網址同時帶「專案編號」和「任務編號」，系統只檢查你在網址上那個專案的身分，**從來不核對這筆任務是不是屬於那個專案**；資料庫的隔離只分客戶、不分專案，所以兩層都擋不住。總表 §4 🅰 組把它記成「有人守了一半」的第八種長相。

- **C1-1／C1-2／C1-3**：稽核經理在規劃頁看、改、刪任務。改和刪都有檢查「你是不是網址上那個專案的經理」，但任務是只憑任務編號去找的。所以甲專案的經理把網址填成自己專案、任務編號填乙專案的，就能改掉或刪掉乙的任務；刪除時的「輪次已凍結就不准刪」也是查甲專案，乙凍結了照樣刪得掉。看那一支連經理檢查都沒有，任何登入的人都打得開。**＝總表第 45 項**，修正分支 CM-2039（commit `8e33568d2`）已補單筆三支，目前分支上仍開著。
- **C1-4**：規劃頁左邊的任務清單。帶一個別專案的編號，把查核項目換成公開的標準條號一條條查，就列得出那個專案所有任務和任務編號，上面三條要的材料就是從這裡拿。**＝總表第 155 項**，修正分支也沒補。
- **C1-5**：FR-112 新加的「強制開始」按鈕。同樣只檢查網址專案的經理身分，查任務時沒用網址上的專案編號。**＝總表第 159 項**。

面板三票都評「中」（強制開始那條評「低」），跟總表現在的評等一致，沒有要調整的。

---

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

### 5.1 審閱簽核「零件沒裝就跳過檢查」：現在每一處都有裝，但套件的「拒絕掛載」管不到它

**先講這段程式在做什麼**：審閱簽核按下去時，服務會先檢查「你是不是這個專案的經理或審閱者」（套件側 `app/service/review_service.py:32` `_require_manager`）。這個檢查要兩個零件：一個是「查某人在專案裡是什麼角色」，一個是「用專案編號找專案」。第 41-42 行寫著：**兩個零件任一沒裝，就直接 `return`，等於不檢查、直接放行**。

**① 是不是每個建審閱服務的地方都有傳這兩個零件？是。**

- 我在主專案、套件主 checkout 兩邊都 grep 了 `ReviewService`，**正式程式碼只有一個地方在組裝它**：主專案 `di_containers/flow_control/flow_control_containers.py:500-507`。兩個零件都有傳（`participant_role_service`、`project_domain_service`）。
- 卡片提到要一併對照的 `di_containers/oscal/oscal_containers.py`：**這支檔完全沒有組裝 `ReviewService`**，也沒有組裝審閱標記的 repo 或 domain service。所以不存在「一邊有裝、另一邊少裝」的情況。
- 審閱標記的 repo 與 domain service 也只在 `flow_control_containers.py:331-336` 建一次。除了審閱服務，另一個用到它的是稽核輪次服務（`:370`），它只呼叫「開覆核輪時清掉那幾個控制項的舊勾勾」（`clear_marks_for_controls`），走的是開輪流程，開輪本身另有守門，不經過 `_require_manager`。
- 唯一另外 new 出 `ReviewService` 的是測試檔 `test/test_fr048_grc_guards.py:31`，兩個零件用假物件傳入，不影響正式環境。

**② 套件的「沒接好就拒絕掛載」管不管得到這兩個零件？管不到。**

- `plugin/assembly.py:18-34` 的 `_assert_wiring` 只檢查 `license_guard`、`project_role_guard`、`identity_guard` 三道**網址層**的守門有沒有交進來。
- 審閱服務的兩個零件不是從這裡交的：套件沒有自己組裝服務（沒有 `build_services()`），服務是主系統的 DI 容器建的，套件只拿到一個「呼叫就給你一支新服務」的入口（`plugin/runtime.py:29-41`）。所以掛載時套件**根本看不到**這支服務有沒有裝零件。
- 相對的，服務層「專案角色守門」本身是有保護的：`common/guard.py:39-45` 的 `_require` 沒接線就直接報錯，**不會**放行。會放行的只有 `review_service.py:41-42` 這一段自己寫的提早 return。

**結論**：現在不會出事，三人面板也 0:3 否決（理由同上：只有組裝的人決定要不要傳，發請求的人改變不了，而唯一的組裝點兩個都傳了）。**但這是「漏裝一條、整支靜默全開」的形狀**，與總表第 62 項同型，也和卡片提到的 FR-113 O9b「DI 漏接讓守門靜默失效」同一種病：

- 哪天有人為了新功能（例如 AI 儀表板、背景工作）另外組裝一支 `ReviewService` 而漏傳一個零件，審閱簽核就會變成「只要登入、有專案模組授權就能按」。
- 同一支套件裡已經有更安全的寫法可以照抄：`common/guard.py:15-20` 的說明原話是「缺守門要吵，不要安靜」。

**修法方向**（不在本棒做）：把第 41-42 行的 `return` 改成直接報錯（比照 `common/guard.py:_require`），讓漏裝的症狀是「按下去 500」而不是「誰都能按」。唯一需要注意的是那支測試：它本來就兩個零件都傳，不會受影響。建議併進總表 §7 甲組「有注入才守門要不要整組改」一起裁（第 6 節第 1 條）。

### 5.2 `_resolve_control_ids` 收了 `ap_uid` 沒用：審閱標記寫進去的，就是剛檢查過角色的那個專案

先講清楚兩個編號：網址是 `/project/<project_uid>/ap/<ap_uid>/control/<control_uid>/review`，`project_uid` 是專案、`ap_uid` 是稽核輪次。

**逐步對照**：

1. 路由（套件側 `api/routes/review_route.py:18-22`）把網址上的 `project_uid` 原樣交給服務。
2. 服務（`review_service.py:85`）先拿**同一個** `project_uid` 做角色檢查：用它找專案（`:43`），再查你在**這個專案**的角色（`:46-49`）。
3. 接著把**同一個** `project_uid` 交給 repo（`:86`）。repo 的 `_resolve_control_ids`（`infra/repository/flow_control_review_repo_impl.py:25`）用 `WHERE p.uid = :puid` 找專案，再沿著「專案 → 專案的現行 SSP → 它引用的標準 → 標準裡的控制項」這條鏈找控制項（`:54-67`）。
4. 寫進資料庫的 `project_id` 就是這條查詢從 `p.uid = project_uid` 找出來的那個專案（`:146`、`:161-170`）。

**所以角色檢查和寫入用的是同一個專案，沒有「檢查甲、動手改乙」的空隙。** 控制項也被限定在「這個專案現行 SSP 引用的那份標準」底下，填別專案的控制項編號會查不到、回 404。

`ap_uid` 沒用到，是**功能上的刻意取捨**，不是資安問題：`:43-48` 的說明寫得很清楚，審閱標記是專案層級的紀錄、沒有輪次維度，規劃期還沒有稽核計畫時也要能用。代價只有一個：網址上的 `ap_uid` 可以亂填，不影響結果。這不會讓人碰到別的專案，**此項查證不成立，下次不用重查。**

**順帶核對讀取那一端**：審閱勾勾在控制樹上顯示時，是由主專案側 `infra/readmodel/oscal/ssp_control_implementation_query.py:229-237` 只用控制項編號查，沒有帶專案條件。這個寫法之所以安全，前提是「每個專案的標準都是自己複製一份，控制項編號不會跨專案共用」（主專案 `app/oscal/service/ssp_control_implementation_service.py:732-733` 的說明這樣寫）。我在 DEV 唯讀驗證了這個前提：**沒有任何兩個專案共用同一份標準**（2026-09-24 15:28，查詢結果 0 組）。另外，DEV 審閱標記表目前是 0 筆。這支讀取在 FR-113 的範圍、不在本棒，只記下前提成立。

### 5.3 主專案三支守門 adapter：沒有吞錯誤，也沒有把「查不到角色」當成放行

主專案側 `core/plugins/compliance_audit.py` 的三支 adapter，逐支對照：

| adapter | 做了什麼 | 有沒有吞錯誤 | 查不到時怎樣 |
|---|---|---|---|
| `LicenseGuard`（`:59-70`） | 直接轉呼叫 `common.authz` 的 `require_license`、`viewer_licensed_modules` | 沒有 `try` | 由 `common.authz` 決定，照主系統一般規則 |
| `ProjectRoleGuard`（`:73-92`） | 直接轉呼叫 `common/authz/project.py` 的兩支 | 沒有 `try` | 見下 |
| `IdentityGuard`（`:95-101`） | 直接轉呼叫 `viewer_is_super_admin` | 沒有 `try` | 由 `common.authz` 決定 |

「查不到角色」這件事，重點在審閱簽核用的那支 `assert_project_role_fallback`（主專案側 `common/authz/project.py:33-57`）：它向下呼叫角色查詢，**查不到時角色查詢回傳 `None`**（套件 `jedi-task-platform` 的 `participant_role_service.py:34-83`，依序查控制項、群組、專案三層，都沒有就回 `None`）；而 `None` 不在「經理、審閱者」名單裡，第 56-57 行直接擋下、回 403。**查不到＝擋下，不是放行。**

參數位置也核對過了：兩支函式的參數順序本來就不同（`fallback` 版沒有 `user_id`），套件 `common/guard.py:58-66` → adapter `:87-92` → `common/authz/project.py:33-36` 三層逐個位置一致，沒有錯位。

**結論：三支 adapter 就是主系統真正在用的那三道守門，只是單純轉手。此項查證不成立。** 這個結論與 FR-095 H1 對 `participant.py` 的查核一致。

### 5.4 `api/flow_control/__init__.py`：接線缺了會吵，不會安靜

- 第 83-93 行：取不到套件的零件就直接報錯、啟動失敗，不會讓網址安靜消失。
- 第 93 行呼叫的 `attach`（套件側 `plugin/assembly.py:50-74`）依序做四件事：先驗三道守門（`_assert_wiring`）、把守門綁進服務層（`bind_service_guards`）、檢查兩張名冊、最後才掛網址。守門缺一道就不會掛。
- 套件網址層的守門殼（`api/guards.py:45-72`）取不到零件也是報錯，不放行。審閱的四支網址（`review_route.py`）每一支都掛了登入檢查和「專案」模組授權檢查。

⚠️ 這支檔同時在 FR-095 H2（CM-1783，未派）的範圍，本棒先掃到。**工具在這支檔上報的五條全是它掛上去的「任務」網址的舊案（第 4 節）**，H2 那邊比照不重報。

### 5.5 前面各批要求帶著追的三件事

- **守門次數對對外方法數**：審閱服務 4 支對外方法（`mark_control`、`unmark_control`、`mark_ao`、`unmark_ao`），4 支開頭都呼叫 `_require_manager`，沒有漏。
- **檢查甲、動乙**：見 5.2。新增與刪除都用同一個專案找控制項和標記；刪除只刪「這個專案、這個控制項、**你自己**」那一筆（`remove_review:192-197` 帶了 `user_id`），不能刪別人的勾勾。AO 也被限定在剛找到的那個控制項底下（`_resolve_ao_id:94-99`）。
- **讀取類要問憑什麼給你看**：本棒範圍內的審閱讀取，只出現在新增、刪除之後回傳的「這個節點目前有誰勾過」，那時已經過角色檢查。套件側沒有單獨的審閱讀取網址。

### 5.6 資料庫隔離現況（DEV 唯讀實查）

2026-09-24 15:24，以 `cm_app` 身分、唯讀交易、查完 ROLLBACK：

- `compliance.review_marks`（審閱標記）：**隔離有開**（`relrowsecurity = t`），4 條規則（讀、新增、修改、刪除）。每一條都靠「這筆標記的 `project_id` 在你看得到的專案裡」來擋；專案表本身也有開隔離。所以**跨客戶**擋得住，**同客戶跨專案**擋不住，要靠程式那一層（5.2 已確認程式有守）。
- ⚠️ 盤點檔 §5 那一列寫的是「開（4 條，繞專案表）」，跟 DEV 一致。同一列的出貨基線那一欄寫「⚠️ 關（0 條）」，這就是 README 已記的「出貨基線快照沒重產」問題，不歸本棒。

---

## 6. 本棒另外查到的（runner 自行開檔核對，未經投票）

### 6.1 審閱服務另一個「沒裝就靜默」的零件：審閱者名冊

`review_service.py:62`：名冊（`user_directory`）沒裝時，勾勾旁邊顯示的是使用者的內部數字編號，而不是暱稱。**這不是資安問題**（不會多給任何人看東西，顯示的也只是編號），而且主專案 `flow_control_containers.py:504` 有裝、套件 `plugin/wiring.py:60-64` 啟動時也會在沒裝時留警告。記下來，只是因為它和 5.1 是同一支服務、同一種「沒裝就安靜」的形狀，修 5.1 時順便決定要不要也改成吵。

---

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

**「工具報的那幾條存在嗎」——可信度高。**

- 套件側：研究員提了 1 條候選（就是 5.1 那段提早 return），三位檢查員 3 票全投、**0:3 否決**，理由都是「只有組裝的人能決定、而唯一組裝點兩個都傳了」，跟我自己核對的一致。
- 主專案側：5 條候選 15 票全投，**五條都 3:0 成立**，嚴重度沒有被調降。

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

- `ReviewService` 在正式程式碼只有一個組裝點：主專案與套件主 checkout 全文 grep；`oscal_containers.py` 逐行看過，沒有相關組裝。
- 套件主 checkout 與本機實際載入的修正分支 worktree（`.claude/worktrees/jedi-wt-fix-security/`）：本棒範圍 7 支關鍵檔逐檔 diff，**內容完全相同**，所以不管看哪一份，結論都一樣。
- 查不到角色回 `None` 再被擋：開套件 `participant_role_service.py` 與主專案 `common/authz/project.py` 確認。
- 資料庫隔離、「沒有兩個專案共用同一份標準」：DEV 唯讀實查（2026-09-24 15:24／15:28）。

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

1. 這次用的是最快的掃描檔位（effort low）：只有研究員一輪加投票一輪，沒有跑威脅建模與廣度掃描。
2. 主專案側工具報的五條，出問題的檔案都**不在本棒範圍**。研究員是順著網址追過去的，那些檔案本身沒有被當成稽核對象逐行讀完（它們已由 FR-115 W2／W4／W8 掃過）。
3. 全程沒有實際打任何網址。「會被改、會被刪」是讀程式讀出來的，不是實際打出來的。

---

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

| 項目 | 套件側 | 主專案側 |
|---|---|---|
| 掃描範圍 | 20 檔／1,419 行 | 2 檔／320 行 |
| 基準 commit | `eafc7ae511d5`（dirty） | `65620f07e522`（dirty） |
| 檔位 | effort low，focus 生產程式碼 | 同左 |
| 研究員 | 派 2 支、回 2 支（含密鑰專項） | 同左 |
| 原始候選 | 1 條 | 5 條 |
| 投票 | 3 票全投 | 15 票全投 |
| 票型 | C1（提早 return）0:3 否決 | 五條皆 3:0 |
| 驗證章 | **verified**（`CLAUDE-SECURITY-REVISION-eafc7ae511d5-dirty.json`） | **verified**（`CLAUDE-SECURITY-REVISION-65620f07e522-dirty.json`） |
| 工具 run ID | `wf_2345347c-77d` | `wf_592f3f10-fbe` |
| 耗時 | 約 4 分鐘（5 個 agent，零失敗，1 個回傳空結果） | 約 17 分鐘（17 個 agent，零失敗，1 個回傳空結果） |
| 工具產出原始報告 | 套件 repo `CLAUDE-SECURITY-20260924-071503/`（未入版控） | BE repo `CLAUDE-SECURITY-20260924-072304/`（未入版控） |

密鑰專項兩側都沒有撿到任何東西。

---

## 9. 待首腦裁決

1. **審閱簽核「零件沒裝就放行」要不要改成「沒裝就報錯」**（5.1）。建議併進總表 §7 甲組「有注入才守門要不要整組改」一起裁，不另開項次；若要記，歸類為「未來陷阱」，與第 62 項同型。修法只有兩行，現有測試不受影響。
2. **主專案側五條的淨新增為 0**：C1-1／2／3＝第 45 項、C1-4＝第 155 項、C1-5＝第 159 項。只需要在那三項加一句「FR-116 C1 第三次獨立命中」，不另計。
3. **FR-095 H2（CM-1783）派工時**，`api/flow_control/__init__.py` 已由本棒掃過，工具在這支檔上報的全是任務網址的舊案，H2 那邊比照不重報。
