# C2b 掃描報告：階段歷程、回退標記、規劃完成度檢查（CM-2099）

> 範圍：15 檔／1,612 行，跨兩個 repo。
> - 套件側 `jedi-compliance-audit` 14 檔：接縫檔 `audit_round_app_service.py`，階段歷程、回退標記兩組（entity／repo 介面與實作／domain service／mapper／model），加上 `planning_readiness_checker.py`。
> - 主專案側 1 檔：`app/flow_control/service/prep_job_generation_service.py`。
>
> 掃描工具：Claude Code 官方 `claude-security` plugin，effort low，只看正式程式碼。**兩側各掃一次**。
> 掃描基準：套件側 `eafc7ae511d5`（monorepo 主 checkout，`feature/review`）；主專案側 `8ac028f8ccd7`（`feature/review`）。兩邊工作區都有其他 session 未提交的改動。
> 驗證章：兩次都是 **verified**。只掃不修（掃描當時紀錄；後續修正見 README）。

---

## 1. 一句話結論

**卡片要追的「歷程與回退兩張表只用輪次編號查」，實際情況是：寫入這兩張表的每一條路都先過了角色檢查，而且檢查的就是同一個輪次；讀取只有一條路（階段歷程），那條就是總表第 52 項。回退標記的三支讀取方法確認是死碼，整個系統沒有任何地方呼叫。**

工具這次報的三條，和 C2a 套件側那三條是**同一批**（輪次清單、單筆輪次、階段歷程）。原因是接縫檔 `audit_round_app_service.py` 兩棒都在範圍內。本棒**沒有新增漏洞**。

規劃完成度檢查的「查不到資料就放行」確認**只是提醒、不是權限檢查**；覆核輪產生證據蒐集任務時，歸屬也是伺服器決定的，這兩件都不是洞。

---

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

「階段歷程」是每一輪稽核往前推、往回退的時間軸，經理退回時要填理由，會記在這裡。「回退標記」是退回時，把已經產生的東西（凍結的 SSP、稽核計畫、改善計畫）標成「作廢」，不真的刪掉。這兩張表**沒有客戶欄位、資料庫隔離也沒開**，所以程式層只要漏一處，資料就一路通到底。

要回答：

1. 讀寫這兩張表的呼叫端，有沒有先確認「這一輪是你的」？
2. 回退標記的三支讀取方法，是死碼還是有路打得到？
3. 規劃完成度檢查在查詢失敗時會放行，會不會被拿來當權限檢查用？
4. 覆核輪產生證據蒐集任務，任務的歸屬是不是伺服器決定的？

---

## 3. 掃到什麼：總覽

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 哪一側發現 | 跟總表的關係 |
|---|---|---|---|---|---|---|
| C2b-1 | 階段歷程完全不守門，網址上的專案編號被忽略 | 同公司非成員看得到經理親手寫的退回理由與操作人；若新客戶機器上輪次表沒開隔離，別家客戶也打得到 | 登入帳號＋知道輪次編號 | 套件側 `audit_round_app_service.py:870`（`list_stage_transitions` 起點）；主專案側 `api/flow_engine/routes/stage_rollback_route.py:56`（`get` 起點） | 套件側掃描（面板 3:0，降為輕） | **＝第 52 項**，既有案、不另計；與 C2a-3 同一條 |
| C2b-2 | 輪次清單不問你是不是專案成員 | 同 C2a-1 | 同 C2a-1 | 同 C2a-1 | 套件側掃描（面板 3:0，中） | **＝C2a-1＝第 57 項**，重複、不另計 |
| C2b-3 | 單筆輪次只看輪次編號 | 同 C2a-2 | 同 C2a-2 | 同 C2a-2 | 套件側掃描（面板 3:0，輕） | **＝C2a-2**，重複、不另計 |

**主專案側（`prep_job_generation_service.py`）零發現**：研究員讀完沒有提出任何可疑點，面板沒有東西可投票。

**上表三條都經過三人面板投票**；第 4、5 節是我自己開檔追的，沒有經過投票。

---

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

三條都和 C2a 報告第 4.1～4.3 節是同一件事，場景與修法請看 [scan-C2a.md](scan-C2a.md)。這裡只補本棒角度多看到的一點：

**C2b-1 階段歷程**：面板特別指出，歷程表是在 FR-050 的 migration（`2026-07-19-fr050`）裡**刻意不開隔離**的，設計時預期「一定先經過輪次表查詢，由輪次表的隔離擋」。但輪次表在出貨版 `02-schema.sql` 沒開隔離（C2a 第 6 節），這個前提在新客戶機器上不成立。面板把這點列為「跨客戶」的前置條件。

**修正分支已補**：套件 `fix/security-b1` 的 `list_stage_transitions` 已補上「網址專案要和輪次歸屬一致，不一致回 404」與成員檢查（面板引用 commit `3e82b625`），但和 C2a 一樣**還沒合回**，也同樣是「選填參數、沒傳就不檢查」的寫法。

---

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

### 5.1 歷程與回退兩張表「只用輪次編號查」：寫入全部有守，讀取只有第 52 項那一條

我把整個系統（套件 monorepo 全部套件＋主專案 `app`／`api`／`di_containers`／`common`）裡碰這兩張表的地方全部找出來：

**階段歷程表（`round_stage_transitions`）**

| 動作 | 誰呼叫 | 用哪個輪次 | 呼叫前有沒有驗 |
|---|---|---|---|
| 寫（推進） | 主專案 `stage_advance_service.py:442` `advance_stage` | `wf_ctx["round"]`，由網址上的輪次編號撈出 | ⚠️ 見 5.4：角色是用**網址上的專案**查的，不是用輪次自己的專案 |
| 寫（回退 ×3） | 套件 `rollback_to_planning`／`rollback_to_audit_planning`／`rollback_to_auditing` → `_record_transition` | 同一筆輪次 `e.id` | ✅ 先用輪次自己的 `project_id` 驗經理 |
| 讀 | 套件 `list_stage_transitions`（`:877`） | 網址上的輪次 | ❌ 沒驗，＝第 52 項 |

**回退標記表（`round_rollback_supersessions`）**

| 動作 | 誰呼叫 | 呼叫前有沒有驗 |
|---|---|---|
| 寫 | 只有套件 `_mark_superseded`（`:892`），被三支 `rollback_to_*` 呼叫，標的是同一輪的 `ssp_id`／`assessment_plan_id`／`poam_id` | ✅ 先用輪次自己的 `project_id` 驗經理 |
| 讀 `list_by_round`／`list_by_entity_ids`／`is_superseded` | **沒有任何呼叫者** | 不適用 |

**結論**：repo 只用輪次編號過濾本身不是問題（repo 層本來就不該守門），關鍵在呼叫端。寫入路徑的呼叫端都已驗過，讀取路徑只有第 52 項那一條漏了。

### 5.2 回退標記的三支讀取方法：**確認是死碼**

`RoundRollbackSupersessionDomainService` 的 `is_superseded`、`list_by_entity_ids`、`list_by_round`，全系統 grep 沒有任何呼叫者。主專案的 DI 只把這個 domain service 注入給 `AuditRoundAppService`，而那裡只呼叫 `.add()`。

白話說明：系統目前「只記作廢、從不讀作廢」，所以沒有任何畫面會根據這張表把東西藏起來或顯示「已作廢」。這是**功能面的缺口**（回退後被標作廢的 SSP、稽核計畫、改善計畫，列表上看不出來），不是資安問題。DEV 目前這張表有 13 筆、歷程表有 48 筆（13:53 唯讀實查）。

建議列進 FR-092 死碼清單，或者確認產品是否本來要做「作廢顯示」卻漏做了（第 7 節）。

### 5.3 規劃完成度檢查「查不到就放行」：**只是提醒，沒有被當成權限檢查**

`planning_readiness_checker.py` 唯一的呼叫者是主專案 `oscal_stage_handlers.py` 的 `LaunchAuditOnCompleteHandler.execute`（「規劃」階段推進時）。追下去的順序是：

1. `StageAdvanceService.advance_stage` 先做**角色驗證**（`stage_advance_service.py:292`，不符就 403），`force=true` 還要求必須是經理。
2. 然後才呼叫 handler，handler 才跑這支檢查。
3. 檢查有問題時回「警告」，畫面跳確認框，使用者按確認（`force=true`）就放行。
4. 之後才呼叫套件 `launch_audit`，那裡**又再用輪次自己的專案驗一次經理**。

所以這支檢查前後都有真正的權限檢查夾著，它本身只決定「要不要跳提醒」。它失敗時放行，最壞情況是「該跳的提醒沒跳」，使用者本來就能按確認跳過，**不構成安全問題**。

另外確認兩個子查詢都沒有外洩：檢查 A（未完成的證據蒐集任務數）只回一個數字；檢查 B（缺哪些角色）只回角色名稱（manager／auditor／reviewer），都是給已經通過角色檢查的人看的。

### 5.4 附帶發現：推進階段用「網址上的專案」驗角色，不是用「輪次自己的專案」

`advance_stage` 的角色驗證（`stage_advance_service.py:292`）是 `_get_user_role(project_uid, user_id)`，`project_uid` 來自網址；但它推進的輪次 `round_uid` 也來自網址，**兩者沒有比對是否屬於同一個專案**。理論上，A 專案的經理可以填「A 專案＋B 專案的輪次」，通過第一道角色檢查。

**但實際上打不穿**：推進後真正改資料的 handler（`launch_audit`／`start_auditing`／`finalize_audit`／`close_round`）都會在套件裡**再用輪次自己的專案驗一次角色**，所以會在那裡被擋。歷程表寫入（5.1 表中第一列）是在 handler 成功之後才寫，所以也寫不進去。

漏網之魚只有兩個：

- `review_decision` 這個 handler 不呼叫套件、不做第二道檢查。但 DEV 的 `stage_objects` 顯示內建流程**沒有綁 review 階段**（程式註解也說「本期 builtin 無 review stage」），目前打不到。
- 「查看目前階段」`get_current_stage_info` 同樣只用網址專案驗角色，而且**不會擋**，只回「你能不能推進」，會把 B 輪次的階段、進度、前置條件結果回給 A 專案的人。

**已查證：這條已在 FR-088 H1 報告登記**（`scan-H1-stage-advance-rollback.md` F4：`get_current_stage_info` 用網址專案查角色、用網址輪次撈資料、兩者不核對；該報告第 144 行也建議 `advance_stage` 一併補），既有案、本棒不另計。

### 5.5 覆核輪產生證據蒐集任務：**歸屬由伺服器決定**

`prep_job_generation_service.generate` 有兩個呼叫者：

| 呼叫者 | `project_id`／`round_id`／`control_ids` 從哪來 |
|---|---|
| 套件 `_generate_reverify_prep_jobs`（覆核輪） | 全部取自伺服器端的輪次物件：`round_entity.project_id`、`round_entity.id`、由母輪稽核結果推導的「不符合控制項」 |
| 主專案 `project_start_app_service._generate_prep_jobs`（專案成立） | 由剛建立的專案與資源庫推導 |

請求內容**帶不進**任何歸屬欄位。新建的流程執行與任務由資料庫觸發器 `trg_wfexec_fill_tenant_org_insupd`、`trg_jobexec_fill_tenant_org_insupd` 自動補上客戶與部門（DEV 13:53 實查）。流程範本的 clone 走一般 insert，要受隔離規則 `with_check` 約束，範本表的隔離在 DEV 是開的。

一個小地方要提一下：`generate` 會直接用 SQL 更新 `oscal.catalog_control_parts.props`（專案成立模式才做）。它更新的是「這個專案的 cloned catalog」裡的 AO part，id 來自伺服器端查詢，不是外部輸入，所以不是洞。

---

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

| 表 | DEV 隔離 | 規則數 | 目前筆數 |
|---|---|---|---|
| `compliance.round_stage_transitions`（歷程） | 關 | 0 | 48 |
| `compliance.round_rollback_supersessions`（回退標記） | 關 | 0 | 13 |
| `compliance.project_audit_rounds`（輪次） | 開 | 4 | 不適用 |

出貨版 `02-schema.sql` 三張都沒開，詳見 C2a 報告第 6 節。

---

## 7. 待首腦裁決

1. **回退標記的三支讀取是死碼**（5.2）：列進 FR-092 死碼清單，還是先問產品「回退後被作廢的東西，列表上本來要不要標出來」？如果本來要做而漏掉了，這是功能缺口，不是清死碼的問題。
2. **推進階段「網址專案」與「輪次歸屬」沒有比對**（5.4）：已是 FR-088 H1 F4，既有案。本棒只補一點給修正卡參考：將來如果啟用 review 階段，`review_decision` 這個 handler 沒有第二道檢查，那時 `advance_stage` 自己的歸屬比對就會變成唯一防線。建議修 H1 F4 時照該報告第 144 行的建議，把 `advance_stage` 也一併補上。
3. **歷程表「刻意不開隔離、靠輪次表擋」的設計前提，在新客戶機器上不成立**（第 4 節）：建議併進決策者已裁定的「出貨基線隔離不一致」查證卡。

---

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

**「工具報的三條存在嗎」：可信度高。**套件側 9 票全數投出，三條都是 3:0；主專案側零候選。三條都是 C2a 已報的重複項。

**「第 5 節」：runner 自行開檔核對，未經投票。**關鍵事實都實查過：

- 兩張表的全部呼叫者：grep 整個套件 monorepo＋主專案 `app`／`api`／`di_containers`／`common`。
- 推進流程的檢查順序：逐行讀 `stage_advance_service.py` 與 `oscal_stage_handlers.py`。
- 內建流程沒有綁 review 階段、兩個觸發器存在、隔離狀態：DEV 唯讀實查（13:53，ROLLBACK）。

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

1. 用的是最快的檔位（effort low）。
2. 主專案側只跑了一分鐘、零候選，只能說明「一輪快速讀過沒看到」，不是完整稽核。
3. 全程沒有實際打任何網址。

---

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

| 項目 | 套件側 | 主專案側 |
|---|---|---|
| 掃描範圍 | 14 檔／1,386 行 | 1 檔／226 行 |
| 基準 commit | `eafc7ae511d5`（dirty） | `8ac028f8ccd7`（dirty） |
| 檔位 | effort low，只看正式程式碼 | 同左 |
| 研究員 | 派 2 支，回 2 支 | 派 2 支，回 2 支（兩支都回空） |
| 候選 | 3 條，去重後 3 條 | 0 條 |
| 投票 | 9 票全數投出 | 0 票（無候選） |
| 票型 | F1 3:0 中（輪次清單）；F2 3:0 輕，原中（階段歷程）；F3 3:0 輕（單筆輪次） | 不適用 |
| 驗證章 | **verified**（`CLAUDE-SECURITY-REVISION-eafc7ae511d5-dirty.json`） | **verified**（`CLAUDE-SECURITY-REVISION-8ac028f8ccd7-dirty.json`） |
| 工具 run ID | `wf_2e46bc79-203` | `wf_f44a65bc-93b` |
| 耗時 | 約 13 分鐘（11 個 agent，零失敗，1 個回傳空結果） | 約 1 分鐘（2 個 agent，零失敗） |
| 工具原始報告 | 套件 repo `CLAUDE-SECURITY-20260924-054855/`（未入版控） | BE repo `CLAUDE-SECURITY-20260924-054633/`（未入版控） |

工具報告的 F 編號對照：套件側 F1＝C2b-2（輪次清單）、F2＝C2b-1（階段歷程）、F3＝C2b-3（單筆輪次）。

主專案側比套件側早跑：決策者先送了主專案側的指令，兩次掃描互不相依，不影響結果。
