# C2a 掃描報告 — 稽核輪次主體（CM-2098）

> 範圍：10 檔／1,939 行，跨兩個 repo。套件側 `jedi-compliance-audit` 8 檔（服務本體 `audit_round_app_service.py`，加上輪次表的 entity／查詢 entity／repo 介面與實作／domain service／mapper／model）；主專案側 2 檔（`api/project/routes/audit_round_route.py`、`api/project/serializers/audit_round.py`）。
> 掃描工具：Claude Code 官方 `claude-security` plugin，effort low，只看正式程式碼。**兩側各掃一次**，因為工具只認它所在那個 repo 的檔案。
> 掃描基準：套件側 `eafc7ae511d5`（monorepo 主 checkout，`feature/review`）；主專案側 `d0b69c115970`（`feature/review`）。兩邊工作區都有其他 session 還沒 commit 的改動。
> 驗證章：兩次都是 **verified**。只掃不修（掃描當時紀錄；後續修正見 README）。

---

## 1. 一句話結論

**卡片點名的「兩支讀取沒守門」是真的，而且範圍比卡片寫的更大：主專案這支路由檔裡 8 個讀取網址全部只驗「有沒有登入」和「客戶有沒有買稽核模組」，不驗「你是不是這個專案的人」。** 同一家客戶的任何員工，只要拿到一個專案編號，就能順著「輪次清單 → 稽核發現矩陣 → 風險 → 改善計畫 → 稽核團隊聯絡方式」一路讀完別人專案的全部稽核結果。

這幾條大多已經登記在總表上（第 52、57 項），**修法也寫好了（CM-2037）**，但兩個 repo 的修法都還在 `fix/security-b1` 分支，沒有合回 `feature/review`。第 7 節有一個兩側接起來才看得到的狀況：**開發機現在跑的是「套件已修、主專案沒修」的混搭**，守門因此形同虛設。

本棒新增一條總表沒有的：**專案經理可以改寫、再刪掉稽核人員寫的「改善建議」**（C2a-6），修正分支也沒有補。

卡片點名的三種回退**查證後不是洞**：兩道角色檢查疊在一起，拿 A 專案的經理身分退不了 B 專案的輪次（第 5 節）。

---

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

「稽核輪次」是整個稽核流程的骨架：一個專案可以跑好幾輪稽核，每一輪會經過「規劃 → 啟動稽核 → 稽核中 → 結案 → 覆核」這些階段。這一棒要回答：

1. 開輪、啟動、開始稽核、結案、覆核、三種回退，每一支的角色檢查對不對？
2. 輪次清單、單筆輪次這兩支讀取，為什麼沒有角色檢查？
3. 查資料時除了用編號，有沒有再限定「而且要屬於這個專案」？

背景先說明：這支套件把權限守在「服務層」（`_check_role` 這類函式），網址那層只掛登入檢查和商務授權，這是本專案允許的正規做法。所以這裡找的不是「哪個檔沒寫守門」，而是「哪一支對外的方法漏了檢查」。

開工前先算了一次：`audit_round_app_service.py` 有 12 支對外方法，守門關鍵字出現 12 次，但全部集中在 9 支寫入和一個 helper；三支讀取（`list_rounds`、`get_round`、`list_stage_transitions`）一次都沒有，和盤點檔 §3 的結果一致。

---

## 3. 掃到什麼：總覽

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 哪一側發現 | 跟總表的關係 |
|---|---|---|---|---|---|---|
| C2a-1 | 輪次清單不問你是不是專案成員 | 同公司非成員看得到別人專案每一輪的名稱、狀態、建立者，還拿得到計畫、SSP、流程的內部編號，可以拿去打其他網址 | 同一客戶的登入帳號＋知道專案編號 | 套件側 `audit_round_app_service.py:211`（`list_rounds` 起點）；主專案側 `audit_round_route.py:74`（要把使用者編號傳下去） | 套件側掃描（面板 3:0，中） | **＝第 57 項**，既有案、不另計 |
| C2a-2 | 單筆輪次只用輪次編號查，不驗專案歸屬 | 同上，看得到單一輪次的完整狀態與關聯編號 | 同上＋知道輪次編號 | 套件側 `:218`（`get_round` 起點）；主專案側 `:85` | 套件側掃描（面板 3:0，降為輕） | 屬第 57 項同一批修法（CM-2037 已涵蓋），不另計 |
| C2a-3 | 階段歷程完全不守門，路由連商務授權都沒掛 | 看得到經理填的每一次「退回理由」原文與操作人 | 同一客戶的登入帳號＋知道輪次編號 | 套件側 `:870`（`list_stage_transitions` 起點）；主專案側 `api/flow_engine/routes/stage_rollback_route.py:56`（`get` 起點） | 套件側掃描（面板 3:0，降為輕） | **＝第 52 項**，既有案、不另計。細節歸 C2b |
| C2a-4 | 稽核發現、風險、改善計畫、AP 內容這幾個讀取網址都不驗專案成員 | 看得到別人專案逐條控制項的「符合／不符合」、缺失描述、風險等級、整改負責人 | 同一客戶的登入帳號＋知道輪次編號（C2a-1 就拿得到） | 主專案側 `audit_round_route.py:163`、`:258`、`:333`、`:419`、`:429` 各個 `get` 起點 | 主專案側掃描（面板 3:0，中） | 屬第 57 項同一批修法（CM-2037 已涵蓋），不另計。稽核結果／改善計畫的服務本體歸 C3 |
| C2a-5 | 稽核團隊名單只用 AP 編號查，不驗專案歸屬 | 看得到稽核人員的姓名、Email、電話 | 同一客戶的登入帳號＋知道 AP 編號（C2a-1 就拿得到） | 主專案側 `audit_round_route.py:234`（`ApPartiesRoute.get` 起點）；服務在 `app/flow_control/service/assessment_plan_app_service.py` 的 `list_ap_parties` | 主專案側掃描（面板 3:0，降為輕） | 屬第 57 項同一批修法（CM-2037 已涵蓋），不另計；「跨客戶也打得到」這一點見 4.5 |
| C2a-6 | 專案經理可以改寫、再刪掉稽核人員寫的「改善建議」 | 受稽核的一方能把稽核方的建議改掉或整筆刪掉，破壞稽核獨立性與佐證軌跡 | 必須是**專案經理**，且輪次在「整改中」階段 | 主專案側 `audit_round_route.py:440`（`RiskRemediationsRoute.post` 起點）；套件側 `poam_app_service.py:310`（`add_remediation` 起點） | 主專案側掃描（面板 **2:1**，輕） | **新的**，總表沒有；修正分支也沒補。服務本體屬 C3 範圍 |

**第 3 節表格六條都經過三人面板投票。** 第 5、6、7 節是我自己開檔追出來的，沒有經過投票。

---

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

### 4.1 C2a-1 輪次清單不問你是不是專案成員（套件側，＝第 57 項）

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

**場景**：甲是公司裡的一般員工，沒有被加進 P 專案。他從別處拿到 P 的專案編號（例如之前待過這個專案後被移出，或 AI 儀表板的專案清單會列出全公司專案），直接呼叫「列出稽核輪次」的網址，就拿到 P 專案每一輪的名稱、狀態、類型、建立者，還有 AP、SSP、流程執行的內部編號。有了這些編號，他可以接著打 C2a-2、C2a-4、C2a-5 的網址。

**為什麼會這樣**：套件的 `list_rounds` 只把專案編號換成內部 id，就把所有輪次撈出來回傳，沒有呼叫 `_check_role`，也沒有其他任何成員檢查。主專案路由只掛 `@jwt_required`（有登入）和 `@require_license("audit")`（客戶有買稽核模組）。資料庫隔離在 DEV 有開，但規則只檢查「上層專案是不是你這家客戶的」（見第 6 節），同一家客戶跨專案擋不住。

**該補的位置**：套件側 `audit_round_app_service.py:211` 方法起點補成員檢查；主專案側 `audit_round_route.py:74` 要把 `user.id` 傳下去。

**面板**：3 票全數認定成立，嚴重度三票裡兩票評中、一票評輕，定案是中。

### 4.2 C2a-2 單筆輪次只看輪次編號（套件側）

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

**場景**：同一個非成員拿到某個輪次編號（C2a-1 就拿得到；流程執行的名字 `ROUND-<輪次編號>-main` 也會露出來），呼叫「查看輪次」的網址，拿到這一輪的狀態、AP／SSP／稽核結果／改善計畫的關聯、母輪編號和建立者。

**為什麼會這樣**：`get_round` 用 `get_by_uid(round_uid)` 撈出輪次就直接轉成回應。repo 的 `get_by_uid`（套件側 `project_audit_round_repo_impl.py:29`）只用編號查，這是 repo 的正常寫法，問題在服務層沒補歸屬檢查。

**該補的位置**：套件側 `:218` 方法起點；主專案側 `audit_round_route.py:85`。

**面板**：3:0 成立，三票都評輕，嚴重度從中降為輕（只露出狀態與編號，看不到稽核內容本身）。

### 4.3 C2a-3 階段歷程完全不守門（套件側，＝第 52 項）

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

**場景**：非成員呼叫「階段歷程」網址，拿到這一輪每一次推進和退回的紀錄，包含經理親手寫的退回理由原文、操作人的帳號和暱稱。網址上的專案編號完全被忽略，隨便填都行。

**為什麼會這樣**：服務層 `list_stage_transitions` 沒有任何檢查。主專案路由 `StageRollbackResource`／`RoundStageTransitionsResource` 只掛 `jwt_required`，**連 `require_license("audit")` 都沒掛**。歷程表在資料庫完全沒有隔離（第 6 節）。

**面板**：3:0 成立，降為輕。總表第 52 項已登記，這裡不另計。「歷程表只用輪次 id 查」的 repo 細節由 C2b 接著看。

### 4.4 C2a-4 稽核發現、風險、改善計畫、AP 內容都讀得到（主專案側）

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

**場景**：甲接著 C2a-1 拿到的輪次編號，依序呼叫：
- `/audit-round/<輪次>/ar/findings`：逐條控制項的符合／不符合矩陣
- `/ar/risks`：系統風險與嚴重度
- `/poam-items`、`/poam-item/<項目>`：改善計畫、整改負責人、里程碑
- `/audit-round/<輪次>/ap`：稽核計畫內容

這幾份資料加起來，等於一張「這家公司哪裡資安做不好」的地圖。

**為什麼會這樣**：主專案路由檔裡這 5 個 GET 都不傳使用者編號，底下的服務方法也不驗成員；同一批服務的寫入方法都有驗。

**該補的位置**：主專案側 `audit_round_route.py` 以下各 `get` 方法起點：`:163`（AP 內容）、`:258`（發現）、`:333`（風險）、`:419`（改善計畫列表）、`:429`（改善計畫單筆）。服務本體的修法歸 C3 那一棒。

**面板**：3:0 成立，三票都評中。

### 4.5 C2a-5 稽核團隊名單與聯絡方式（主專案側）

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

**場景**：非成員從 C2a-1 拿到 `ap_uid`，呼叫 `GET /ap/<ap_uid>/parties`，拿到稽核團隊每個人的姓名、Email、電話。

**為什麼會這樣**：`list_ap_parties` 用 AP 編號直接查 OSCAL 的 AP 表和人員表，不驗任何角色或成員。**這條路完全沒有經過有隔離的資料表**：出貨版 `scripts/init/02-schema.sql` 在 OSCAL 這一區只對幾張「解析工作」表開了隔離，AP 表和人員表都沒有。所以理論上，別家客戶的 AP 編號一旦外流（日誌、匯出的 OSCAL 檔、分享連結），**跨客戶也打得到**。AP 編號是隨機 UUID 猜不到，這是面板把它降為輕的主因。

**該補的位置**：主專案側 `audit_round_route.py:234`（`ApPartiesRoute.get` 起點）。

**面板**：3:0 成立，降為輕。

### 4.6 C2a-6 經理能改寫、刪掉稽核人員的「改善建議」（主專案側，新）

**現況**：已修（M12-6，FR-114 CM-2174，commit BE `a8f1a24b7`／套件 `b7022b8b`，1.21.0 出貨）

**場景**：輪次進入「整改中」後，稽核人員會在每個風險底下留一筆「改善建議」（系統裡標成 `lifecycle = recommendation`），系統規定這筆不能刪。專案經理（也就是受稽核的一方）先從改善計畫明細拿到這筆建議的編號，再呼叫「新增整改計畫」網址，送出 `{uuid: <那筆建議的編號>, lifecycle: "planned", title: "x", description: "x"}`。系統把它當成「更新既有那筆」，稽核人員寫的建議內容就被改掉了，標記也變成一般整改計畫。接著經理呼叫刪除，這時「建議不可刪」的檢查已經不成立，整筆刪掉。

**為什麼會這樣**：主專案 serializer（`audit_round.py:211-212`）的 `uuid` 和 `lifecycle` 都是自由字串、沒有限制值域；路由原樣傳給套件 `add_remediation`，底下 `upsert_remediation` 只要編號對得上就覆蓋。刪除時的保護只看「現在的 lifecycle 是不是 recommendation」。

**為什麼是輕、又為什麼有一票反對**：攻擊者必須本來就是這個專案的經理，不是外人。反對那一票（影響面）的理由是「經理本來就有權改整改計畫」。另外兩票認為，稽核建議屬於稽核方的紀錄，受稽核方不該能改。**這一點牽涉產品規則，放進第 10 節請首腦裁決。**

**該補的位置**：主專案側 `audit_round_route.py:440`（`RiskRemediationsRoute.post` 起點）；套件側 `poam_app_service.py:310`（`add_remediation` 起點）。修正分支 `fix/security-b1` 對這一段**沒有任何改動**（兩個 repo 我都開檔對照過）。

**面板**：2:1 成立，評輕，信心中等。

---

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

### 5.1 兩支讀取為什麼沒有角色檢查：漏接，不是設計

同一支服務的 9 支寫入都有 `_check_role`，三支讀取都沒有。修正分支 `fix/security-b1` 的套件版本已經新增 `_require_participant`（「任一專案成員都可讀，不限角色」），補在 `list_rounds`／`get_round`／`list_stage_transitions`，證明這是漏掉的，不是刻意開放。

### 5.2 三種回退（退回規劃／退回稽核規劃／退回稽核中）：**不是洞**

回退的網址是 `POST /project/<專案>/audit-round/<輪次>/stage/rollback`，路徑上有兩道關：

1. 主專案 `app/flow_engine/service/stage_rollback_service.py` 的 `rollback_stage`，先用**網址上的專案編號**查「你在這個專案是不是經理」，不是就擋。
2. 接著呼叫套件的 `rollback_to_*`，套件再用**輪次自己的專案 id**（`e.project_id`）做一次 `_check_role(..., "manager")`。

所以「我在 A 專案是經理，網址填 A 專案、輪次填 B 專案的」會在第二道被擋下。「檢查甲、動手改乙」在這裡**不成立**。

小瑕疵（不是資安問題）：網址上的專案和輪次對不上時，第一道不會發現，要到第二道才擋；另外回退路由也沒掛 `require_license("audit")`。

### 5.3 其他寫入：開輪、啟動、開始稽核、結案、覆核，全部守到

逐支開檔核對：

| 方法 | 用哪個專案 id 檢查 | 要什麼角色 | 結果 |
|---|---|---|---|
| `create_round` | 網址專案編號換出來的 id | 經理 | ✅ |
| `launch_audit` | 輪次自己的 `project_id` | 經理 | ✅ |
| `start_auditing` | 輪次自己的 `project_id` | 稽核員或經理 | ✅ |
| `finalize_audit` | 輪次自己的 `project_id` | 稽核員或經理 | ✅ |
| `close_round` | 輪次自己的 `project_id` | 經理 | ✅ |
| `launch_reverify` | 母輪自己的 `project_id` | 稽核員或經理 | ✅ |

每一支都是「先拿輪次，再用輪次本身的專案檢查，然後才動這一輪」，檢查的和改的是同一筆，沒有「檢查甲、改乙」。

### 5.4 開輪時可以指定流程範本，會不會拿到別家客戶的範本：**打不到**

套件 `create_round` 接受 `flow_template_uid` 參數，看起來可以指定任意範本。但主專案路由（`audit_round_route.py:61-65`）**根本沒有把這個參數傳下去**，serializer 也沒有這個欄位，外部送不進來。另外範本表在 DEV 有隔離，只看得到系統範本和自家範本。

### 5.5 覆核輪產生證據蒐集任務：本棒只確認歸屬由伺服器決定

`launch_reverify` → `_generate_reverify_prep_jobs` 用的專案 id、流程範本全部取自母輪與伺服器端查詢，請求內容只帶得進輪次名稱。任務產生器 `prep_job_generation_service.py` 本身歸 C2b 那一棒。

---

## 6. 資料庫隔離現況（DEV 唯讀實查，2026-09-24 12:49，查完 ROLLBACK）

| 表 | DEV 隔離開了嗎 | 規則在擋什麼 | 出貨版 `02-schema.sql` |
|---|---|---|---|
| `compliance.project_audit_rounds`（輪次） | 開 | 「上層專案這家客戶看得到」，不看專案成員 | **沒開**，一條規則都沒有 |
| `compliance.round_stage_transitions`（歷程） | 關，0 條規則 | 無 | 沒開 |
| `compliance.round_rollback_supersessions`（回退標記） | 關，0 條規則 | 無 | 沒開 |
| `compliance.projects`（專案） | 開 | 客戶範圍 | 開 |

白話說明：

- **DEV**：擋得住跨客戶，擋不住同一家客戶跨專案。
- **新客戶裝機**：輪次表連跨客戶都不擋。擋住它的只剩「查輪次前先查專案、專案表有隔離」這個程式順序，但 `get_round`／`list_stage_transitions` 是直接用輪次編號查、不先查專案的，所以在新客戶那邊，**這兩支靠輪次編號就能跨客戶讀**。唯一的門檻是輪次編號是隨機 UUID，得先外流才打得到。
- 出貨快照和 DEV 不一致，盤點檔 §5.3 已經提過，決策者已裁定另開查證卡，本棒不處理，只把影響寫出來。

---

## 7. 兩側接起來才看得到的事（runner 自行開檔核對，未經投票）

### 7.1 開發機現在跑的是「套件已修、主專案沒修」的混搭，守門形同虛設

- 主專案的虛擬環境裡有一個 `jedi_compliance_audit.pth`，把套件指到 `jedi-python-package/.claude/worktrees/jedi-wt-fix-security/`，也就是**修正分支 `fix/security-b1` 的套件**（版號 1.2.0）。`pyproject.toml` 鎖的還是 `==1.1.3`。
- 修正分支的套件把讀取守門寫成**選填參數**：`list_rounds(project_uid, curr_user_id=None)`，裡面是 `if curr_user_id is not None: 才檢查`。
- 主專案 `feature/review` 的路由**沒有傳 `curr_user_id`**（修正版的路由在 `fix/security-b1`，還沒合回來）。

結果是：開發機上套件「看起來有守門」，但因為主專案沒傳使用者編號，守門一次都不會觸發。手測時容易以為修好了。

**這個「選填參數沒傳就不檢查」的寫法本身是陷阱**：以後任何一個新呼叫端忘記傳，就會安靜地放行。主專案側掃描的面板理由也提了同一點，建議改成必填。

### 7.2 CM-2037 的修法在兩個 repo 都沒有合回

**現況**：已合回並隨 1.21.0 出貨（M12-1）；以下為掃描當時的狀態。

- 主專案 `cd9223592`（CM-2037）→ `git merge-base --is-ancestor` 確認**不在** `feature/review`。
- 套件 `f513ae88`（CM-2037）→ 在 `fix/security-b1`，也**不在**主 checkout 的 `feature/review`。
- 總表第 52、57 項掃描當時標「⬜ 未修」；現況：已修（SUMMARY #52、#57，1.21.0 出貨）。

---

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

**「工具報的六條存在嗎」：可信度高。**兩次掃描共 18 票全數投出。六條裡五條是 3:0 一致成立，一條（C2a-6）2:1 成立。三條被面板從中降為輕。

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

- 回退的兩道守門：逐行讀 `stage_rollback_service.py` 與套件 `rollback_to_*`。
- 隔離現況：DEV 查 `pg_class`／`pg_policies`（12:49，唯讀交易、ROLLBACK）；出貨版 grep `02-schema.sql`。
- 開發機載入哪一份套件：讀 `.venv` 裡的 `.pth` 檔。
- 修法有沒有合回：兩個 repo 各跑 `git merge-base --is-ancestor`。

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

1. 用的是最快的檔位（effort low），只有研究員一輪加投票一輪，沒有威脅建模和廣度掃描。
2. 兩側研究員各自看不到另一側；C2a-4、C2a-6 研究員有追進套件確認打得到，但套件的 POA&M／稽核結果服務本體不在本棒範圍，完整檢查留給 C3。
3. 全程沒有實際打任何網址。「看得到、改得掉」是讀程式讀出來的。

---

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

| 項目 | 套件側 | 主專案側 |
|---|---|---|
| 掃描範圍 | 8 檔／1,192 行 | 2 檔／747 行 |
| 基準 commit | `eafc7ae511d5`（dirty） | `d0b69c115970`（dirty） |
| 檔位 | effort low，只看正式程式碼 | 同左 |
| 研究員 | 派 2 支，回 2 支 | 派 2 支，回 2 支 |
| 候選 | 3 條，去重後 3 條 | 3 條，去重後 3 條 |
| 投票 | 9 票全數投出 | 9 票全數投出 |
| 票型 | F1 3:0 中；F2 3:0 輕（原中）；F3 3:0 輕（原中） | F1 3:0 中；F2 3:0 輕（原中）；F3 2:1 輕 |
| 驗證章 | **verified**（`CLAUDE-SECURITY-REVISION-eafc7ae511d5-dirty.json`） | **verified**（`CLAUDE-SECURITY-REVISION-d0b69c115970-dirty.json`） |
| 工具 run ID | `wf_8a12239a-d77` | `wf_641b7e70-971` |
| 耗時 | 約 14 分鐘（11 個 agent，零失敗，1 個回傳空結果） | 約 19 分鐘（11 個 agent，零失敗，1 個回傳空結果） |
| 工具原始報告 | 套件 repo `CLAUDE-SECURITY-20260924-044701/`（未入版控） | BE repo `CLAUDE-SECURITY-20260924-050420/`（未入版控） |

工具報告裡的 F 編號對到本報告：套件側 F1＝C2a-1、F2＝C2a-2、F3＝C2a-3；主專案側 F1＝C2a-4、F2＝C2a-5、F3＝C2a-6。

---

## 10. 待首腦裁決

1. **C2a-6（經理能改寫、刪掉稽核建議）要不要登記成總表新項次**。這一條要先裁產品規則：「稽核建議」是稽核方專屬、受稽核方完全不能動，還是經理可以改但要留痕？裁完才知道修法是「擋掉」還是「改成另存一筆」。建議登記。本條屬 C3 的服務範圍，C3 那一棒可能也會掃到，**請併卡時去重**。
2. **修正分支的「選填參數沒傳就不檢查」要不要改成必填**（7.1）。建議要：這種寫法漏傳時不會報錯，只會安靜放行，下一個新呼叫端很容易又忘記。可以在 FR-114 合回前順手改，或另開一張小卡。
3. **開發機 `.pth` 指向修正分支、主專案卻還在舊分支，要不要提醒手測人員**（7.1）。在 `fix/security-b1` 合回之前，開發機手測「輪次讀取守門」會得到錯的結論。
4. **C2a-5「稽核團隊名單跨客戶也打得到」要不要併進出貨基線查證卡**。根因是 OSCAL 的 AP 表和人員表沒開隔離，比本批範圍大，建議交給決策者已裁定要開的「出貨基線隔離不一致」那張卡一起看。
5. **回退路由補上 `require_license("audit")`，併進第 52 項的修正卡**（5.2 小瑕疵）。
