# FR-088.2 H1：稽核階段推進與回退——資安掃描報告

- **卡片**：CM-1671（FR-088 第 2 棒）
- **範圍**：BE repo 21 支檔（稽核階段「推進」與「返回上一階段」兩個按鈕背後的網址入口到守門）
- **掃描時間**：2026-09-12（UTC 07:36 起，跑了約 4 小時）
- **掃描版本**：`306ef4453df4e9aeeef91427183d806e6a72e5dd`（branch `feature/FR-075`）
- **工具**：Claude Code `claude-security` plugin v0.11.0，effort `low`、focus `attack-surface`
- **驗證章**：`verified`（三個獨立檢查員對 11 條候選各投一票，33 票全數投出，沒有漏投）
- **只掃不修**：本棒沒有改任何程式碼，也沒有動任何環境（只對 DEV 資料庫做了唯讀查詢）

---

## 1. 🔴 一句話結論

**掃完了，範圍內找到 4 個真問題，最嚴重的是「任何登入者只要知道一個輪次編號，就能讀走別家客戶的稽核階段歷程——包括每次退回是誰做的、退回理由寫了什麼」。** 但派工卡上懷疑的「能不能跨專案把別人的稽核推進／退回（改資料）」，**實測不成立**——被下游套件的第二道檢查擋住了，詳見 §5。另外工具在範圍外順手撈到 4 條密碼／金鑰被寫進版控的問題，**全部都是已經開過卡的舊案**。

---

**現況（以 `docs/security-report/M06*.md` 為準）**：F5＋F6（階段歷程，M06-5）→ ✅ 已修（CM-2035）；F4（階段資訊，M06-6）→ ✅ 已修（CM-2035）；F8（署名可自填，M06-12）→ ✅ 已修（CM-2059）；`force` 兩來源（M06-13）→ 🚫 裁定不修（非資安，決策者 10-01）。範圍外的憑證項（CM-1607／1608／1629／1631 系列）：檔案殘留已清（CM-2048／2049／2051），密碼換發排在正式環境上版前。

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

每個稽核專案的每一輪，畫面上方都有一條橫幅顯示「現在走到哪個階段」（規劃 → 稽核計畫 → 稽核 → 改善 → 結案）。橫幅上有兩個按鈕：**「推進」**（進下一階段，會順帶觸發「啟動稽核」「結案」這類真的會改資料的動作）與**「返回上一階段」**（管理者專用）。旁邊還有一個**「階段歷程」**時間軸，記錄每次推進與退回。

這一棒檢查的是：**當這三個功能從網址送進後端時，後端有沒有問一句「你是不是這個專案的人」。**

關鍵在於這些網址同時帶了**兩個編號**：

```
/api/1.0/project/<專案編號>/audit-round/<輪次編號>/stage/info
                 ↑ 拿這個查「你是誰」      ↑ 拿這個做事
```

**程式用左邊的編號查你的角色，卻用右邊的編號去撈資料，而且從頭到尾沒有比對過「右邊那個輪次，是不是真的屬於左邊那個專案」。** 兩個編號都是呼叫者自己填的，所以理論上可以左邊填自己的專案（讓角色檢查通過）、右邊填別人的輪次（去撈別人的資料）。本棒要驗的就是這條路走不走得通。

---

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

### 3.1 範圍內（本棒的發現，共 4 條）

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 |
|---|---|---|---|---|---|
| **F5＋F6**<br>🟡 MEDIUM<br>（兩位研究員各報一次，同一個洞） | 「階段歷程」這支 API **完全沒有檢查你是誰**，只檢查你有沒有登入。網址上的專案編號甚至從頭到尾沒被程式用過 | 讀走別家客戶的稽核流程時間軸：每一次推進／退回的**操作者帳號、暱稱、從哪階段退到哪階段、退回理由全文、時間**。退回理由是管理者自由填寫的文字（資料庫規定退回時必填），內容通常直接寫明「這次稽核哪裡出了問題」 | ① 任一個有效登入帳號（**任何角色，連專案參與者都不必是**）<br>② 知道一個輪次編號（是亂數 UUID，猜不出來，要從轉寄的連結、截圖、log 或匯出檔取得） | `api/flow_engine/routes/stage_rollback_route.py:64`（整支 `get` 從 `:52` 起）<br>下游：`jedi-compliance-audit` 的 `audit_round_app_service.py:867`，只查「輪次存不存在」 | 在 app service 層（**不是 route 層**）用 `round_uid` 解出 `round.project_id`，比對它是否等於 `project_uid` 解出的專案；再用 `common.authz` 的 `assert_project_participant` 確認呼叫者是該專案參與者。做法可直接抄同檔 `StageRollbackResource`（`:24`）走的 `_check_role` 路徑 |
| **F4**<br>🟡 MEDIUM | 「查目前在哪個階段」這支 API **用你填的專案編號查你的角色，卻用你填的輪次編號撈資料**，兩者不核對；角色不符時**只是把「可否推進」標成 false，照樣把資料回給你** | 讀走別家客戶的稽核進度：目前階段代碼與名稱、整條流程的進度清單（哪些做完、哪些還沒）、流程執行編號、流程狀態、內部 job 編號、前置條件內容（例如「已審查控制項數量」這種來自對方稽核計畫的統計） | ① 任一個有效登入帳號<br>② 自己名下有一個專案編號可填（**不必是該專案的參與者**——查不到角色時程式回 `None` 不擋）<br>③ 知道一個輪次編號 | `app/flow_engine/service/stage_advance_service.py:84`（`get_current_stage_info`，整支從 `:81` 起）<br>角色查詢在 `:578-587` 的 `_get_user_role` | 在 `get_current_stage_info` 開頭、第 84 行 `_resolve_workflow_context()` 之後，比對 `ctx["round"].project_id` 與 `project_uid` 解出的 `project.id`，不符就 `raise ForbiddenError`；並把「查不到角色」從「回 None 繼續」改成擋下來 |
| **F8**<br>⚪ LOW | 推進／退回時，**操作者的顯示名稱可以由呼叫端自己指定**——程式用的是「呼叫端沒填才補上真名」的寫法 | 稽核歷程時間軸與流程留言上，會顯示攻擊者自選的名字（例如同事的名字）作為「這個階段是誰推進的」。**真正的帳號（login_name）仍然是伺服器自己填的**，所以只影響畫面顯示、不影響稽核軌跡的權威欄位 | 攻擊者本來就有權推進或退回該階段（也就是**已經通過角色檢查**了）——所以這是「自己人竄改署名」，不是外人打得到的洞 | `api/flow_engine/routes/stage_advance_route.py:59`（`ctx.setdefault("user_nickname", ...)`）；同樣寫法也在 `stage_rollback_route.py:40` | 把 `setdefault` 改成直接覆寫：`ctx["user_nickname"] = user_ctx.nickname`（身分欄位一律以登入身分為準，不接受呼叫端指定）；並在 `StageAdvanceRequestSchema`（`api/flow_engine/serializers/stage_advance.py:11-12`）給 `ctx` 加白名單，丟掉不認識的 key |

### 3.2 範圍外（工具的密鑰專項順手撈到，共 4 條，**全部是舊案不是新發現**）

工具只要設了 `focus` 就會額外跑一次「找密碼金鑰」的專項掃描，它掃全 repo、不受本棒 21 檔範圍限制。以下四條**都不在本棒範圍內**，且經比對**全部落在既有卡片上**：

| # | 內容 | 對應既有卡 |
|---|---|---|
| F1（HIGH） | POC 資料庫密碼寫死在文件產生腳本裡 `docs/system-design/scripts/generate_db_schema_docx.py:34` | **CM-1608**／**CM-1629** |
| F2（HIGH） | OpenAI／Anthropic／Google 三家 API 金鑰被寫進對話紀錄檔 | **CM-1607**（決策者 2026-09-08 已撤銷重發，剩字串清理） |
| F3（MEDIUM） | JWT 簽章金鑰被寫進對話紀錄檔，與目前 `.env` 使用中的值相同 | **CM-1607**（FR-079 F12 已裁定：安裝時每套各自產生，外流的只是開發機那把） |
| F7（MEDIUM） | Google 雲端硬碟 OAuth 應用程式密鑰被寫進對話紀錄檔 | **CM-1631** |

**結論：範圍外這 4 條不需要開新卡。** 與上一棒（H2）撈到的 6 條高度重疊，再次佐證同一件事——「把 `.env` 原樣貼進對話紀錄」這個習慣，讓同一批金鑰散進了幾十到幾百個檔案。

---

## 4. 每條發現的詳述

### F5＋F6 — 階段歷程 API 完全沒有守門（MEDIUM，可信度 高）

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

畫面上的「階段歷程」時間軸，會列出這一輪稽核從頭到尾每一次推進與退回：誰做的、什麼時候、從哪個階段到哪個階段、退回的理由是什麼。後端提供這份清單的 API，**只確認「你有登入」，其他什麼都不查**。

網址長這樣：`GET /api/1.0/project/<專案編號>/audit-round/<輪次編號>/stage/transitions`。看起來有帶專案編號，但**那個參數程式從頭到尾沒有用過**——它被解析進函式，然後就被丟在一旁。真正決定回傳什麼的只有輪次編號。

**在哪裡**

- 入口：`api/flow_engine/routes/stage_rollback_route.py:52-67`，整支 `get` 只掛了 `jwt_required()`（＝只驗有沒有登入），第 64 行直接把 `round_uid` 丟下去
- 下游：`jedi-compliance-audit` 套件的 `jedi_compliance_audit/app/service/audit_round_app_service.py:867-887`，`list_stage_transitions()` 只做一件事——查輪次存不存在，不存在就回 404。**沒有任何角色或參與者檢查**

**首腦人工核對（2026-09-12）**

逐項開檔核對，全部屬實：

1. 路由確實註冊在 `api/flow_engine/__init__.py:92`，路徑與報告一致
2. `list_stage_transitions` 全文讀過，確認只有 `get_by_uid` + 存在性檢查，回傳的 7 個欄位含 `operator`／`operator_nickname`／`reason`
3. **資料庫層確實沒有第二道防線**——實際連 DEV 資料庫查過（唯讀 `SELECT`）：
   ```
   compliance.project_audit_rounds      → RLS 未啟用
   compliance.round_stage_transitions   → RLS 未啟用
   ```
   全庫只有 33 張表開了 RLS（就是「每個客戶只能看自己資料」的資料庫隔離機制），這兩張都不在其中。連 `compliance.projects` 與 `compliance.project_participants` 也沒開——這點與工具報告裡「RLS 會把專案限縮在自己租戶」的說法不符，**工具這句是錯的**（見下方「工具說錯的地方」）
4. **外洩的資料是真的存在的**——DEV 庫目前有 21 筆歷程紀錄、涵蓋 6 個輪次，`operator` 欄位實際存著 `blsadmin`、`blspan` 這類帳號與 `Billows Admin`、`Pan` 這類暱稱

**⚠️ 工具說錯的地方（需要修正報告原文）**

工具在 F4 的前提條件裡寫「`compliance.projects` 上的 RLS 會強制專案必須是自己租戶的」。**這句不成立**——`compliance.projects` 根本沒開 RLS。這不會讓 F4 消失，反而**讓門檻更低**：攻擊者連「專案得是自己租戶的」這個限制都沒有。租戶隔離目前靠的是程式層（`session_scope()` 注入的 session 變數 + 各查詢的過濾），不是資料庫層。

**怎麼修**

在 app service 層（依 CLAUDE.md 規範，route 層不做 DB 查詢）：

```python
# 在 list_stage_transitions 的呼叫端（或套件內該方法開頭）
rnd = self._rounds.get_by_uid(round_uid)
if rnd is None:
    raise NotFound(...)
project = self._project_domain_service.get_one(ProjectQueryEntity(uid=project_uid))
if project is None or rnd.project_id != project.id:
    raise ForbiddenError(...)          # 輪次不屬於這個專案
assert_project_participant(...)         # common.authz 資源域守門
```

同一支檔案裡的「返回上一階段」（`StageRollbackResource`，`:24`）已經有完整的 manager 檢查了（走 `stage_rollback_service.py:95` 的 `_get_user_role`），**只有「讀歷程」這支漏掉**。

**為什麼是 MEDIUM 不是 HIGH**

因為打到之前要先拿到輪次編號，而它是亂數 UUID、猜不出來，得從外部管道取得（轉寄的連結、截圖、log、匯出檔）。不過一旦拿到一個，就完全沒有第二道關卡——**程式層沒有、資料庫層也沒有**。

---

### F4 — 階段資訊 API 用 A 專案的身分讀 B 專案的資料（MEDIUM，可信度 高）

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

這支 API 負責畫面上方那條橫幅。它同時收兩個編號，**用你填的專案編號去查「你在這個專案是什麼角色」，卻用你填的輪次編號去撈資料**——而且不比對這兩者是否相配。

更關鍵的是：**角色查出來不符，程式也不擋**。它只是把回傳結果裡的「可否推進」標成 `false`，然後**照樣把整包階段資訊回給你**。

**在哪裡**

- `app/flow_engine/service/stage_advance_service.py:81-188`（`get_current_stage_info`）
- 第 84 行 `_resolve_workflow_context(round_uid)` — **只用輪次編號**撈輪次與流程（該函式在 `:529-549`）
- 第 118 行 `_get_user_role(project_uid, user_id)` — **只用專案編號**查角色（該函式在 `:578-587`）
- 第 120 行把兩者的結果組成 `user_can_advance` 布林值，**全檔沒有任何一處 `raise ForbiddenError`**

**會流出什麼**（依 `:169-187` 的回傳內容逐項對照）

目前階段代碼與名稱、階段種類、推進／退回按鈕文字、處理器代碼、該階段的主要角色清單、是否為最後階段、整條流程的進度清單（每一步是 done／current／pending）、**流程執行編號 `workflow_execution_uid`**、流程狀態、內部 `job_id`，以及前置條件的檢查結果與 context。

其中 `workflow_execution_uid` 值得單獨標記——它本身就是**另一批 API 的鑰匙**，拿到之後可以再去打其他吃這個編號的端點。

**首腦人工核對（2026-09-12）**

開檔逐行確認：`_resolve_workflow_context`（`:529-549`）確實只吃 `round_uid`，回傳的 dict 裡雖然有 `round` 物件（含 `project_id` 欄位），但呼叫端從沒拿它來比對。`_get_user_role`（`:578-587`）在查不到參與者紀錄時 `return None`——**不 raise**，所以「我不是這個專案的人」也能順利走完。輪次表確實有 `project_id` 欄位（`jedi-compliance-audit` 的 `infra/model/project_audit_round.py:28`），**該比對的資料就在手上，只是沒比**。

**怎麼修**

在 `get_current_stage_info` 第 84 行拿到 `ctx` 之後立刻插入歸屬檢查，與 F5/F6 同一套做法（比對 `ctx["round"].project_id` 與 `project_uid` 解出的 `project.id`，不符就擋）。`advance_stage`（`:191`）雖然後面有守門擋住寫入，但它第 224 行也是同樣的寫法，**建議一併補上**——現在擋得住是因為下游套件有第二道檢查，這屬於「別人替你擋」，不是自己的設計保證（見 §5）。

---

### F8 — 推進／退回的操作者署名可以自己填（LOW，可信度 中）

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

推進與退回的請求裡有一個叫 `ctx` 的欄位，是**完全開放的自由字典**（`api/flow_engine/serializers/stage_advance.py:12`：`ctx = fields.Dict`，沒有任何 key 限制）。後端用的是「呼叫端沒填才補上真名」的寫法：

```python
ctx.setdefault("user_nickname", user_ctx.nickname)   # stage_advance_route.py:59
```

`setdefault` 的意思是**「只有在這個 key 不存在時才填」**。所以呼叫端只要自己塞一個 `user_nickname`，就會原封不動地傳下去，最後寫進三個地方：稽核歷程表的 `operator_nickname` 欄位（`stage_advance_service.py:450`）、BPMN 流程的留言（`:387`）、以及流程討論串的作者欄（`:412`）。

**為什麼是 LOW**

兩個原因把傷害限制住了：① 攻擊者必須**本來就有權推進／退回**這個階段（已通過角色檢查），所以是自己人竄改署名、不是外人打得到；② 權威欄位 `operator`（login_name）仍然是伺服器從登入身分取的（`curr_user=user_ctx.login_name`），**沒被污染**——查稽核軌跡時真相仍在，只是畫面上會顯示錯的名字。

**在哪裡**

- `api/flow_engine/routes/stage_advance_route.py:59`
- `api/flow_engine/routes/stage_rollback_route.py:40`（同樣的 `setdefault` 寫法）
- 落點：`app/flow_engine/service/stage_advance_service.py:450`（歷程表）、`:387`（BPMN 留言）、`:412`（討論串作者）

**怎麼修**

`setdefault` 改直接覆寫（身分類欄位一律以登入身分為準）；並在 marshmallow schema 給 `ctx` 加白名單，丟掉不認識的 key。

---

## 5. 派工卡上的疑點：逐項回應

卡片「重點看什麼」列了 7 項，逐項交代（**證偽與證實同樣重要**）：

| 卡片上的疑點 | 結果 | 說明 |
|---|---|---|
| 🔴 **① 專案編號與輪次編號從不核對** | ✅ **部分成立**（讀成立、寫不成立） | 見下方詳述 |
| 🔴 **② `force` 旗標有兩個版本** | ❌ **不成立**（三個檢查員 0:3 否決，首腦複核同意） | 見下方詳述 |
| **③ 階段歷程 GET 零守門** | ✅ **完全成立** | 即 F5／F6，是本棒最嚴重的一條 |
| **④ 階段資訊 GET 對非參與者也回資料** | ✅ **成立**，且**不像是刻意的** | 即 F4。程式碼沒有任何註解說明這是刻意設計；`user_can_advance` 的用途是控制按鈕能不能按，不是「授權給非參與者看」。外洩範圍已逐項列在 F4 |
| **⑤ `ctx` 整包往下傳給 handler** | ✅ **讀過，找到 1 個問題**（即 F8） | 六支 handler（`oscal_stage_handlers.py:40-140`）與六支前置條件（`oscal_stage_preconditions.py:35-128`）全部讀過。**handler 從 ctx 只拿兩個值**：`force`（僅 `LaunchAuditOnCompleteHandler:41`）與 `decision`（僅 `ReviewDecisionHandler:140`）；六支前置條件**完全沒有讀 ctx**（簽章有 `ctx` 參數但函式內沒用）。唯一被使用者控制值污染的是 `user_nickname`（F8）。另外 `_next_stage_code` 是程式自己塞的，不是使用者給的 |
| **⑥ `decision` 與 `comment` 落到哪** | ✅ **讀過，沒有發現可報的問題** | `decision` 只接受 `approve`／`reject`（schema 層 `validate.OneOf`），`reject` 時 `comment` 必填（schema 層 + service 層雙重檢查）。`comment` 寫進 `compliance.job_execution_comments.content`（`Text` 型別，**沒有長度上限**，API 層也沒限長）。**留言的守門是好的**——`JobCommentService.add_comment`（jedi-task-platform）會自己反查 `workflow_execution_id` 並呼叫 `assert_project_participant`。**唯一可記的小事**：`reason` 與 `content` 都是無上限的 `Text`，理論上可灌大量文字，但這屬於容量問題不是權限問題，不足以單獨開卡 |
| **⑦ StageRegistry 後註冊者覆蓋** | ✅ **確認只在啟動時註冊，只記錄不報問題** | 全 codebase 搜過 `register_handler(` / `register_precondition(` / `register_rollback_handler(`，**呼叫點只有一處**：`di_containers/flow_control/flow_control_containers.py:701-705`，由 `register_stage_hooks_to_registry()` 呼叫；而該函式**只在 `core/app_factory.py:308` 被呼叫一次**，位置在應用程式啟動流程內。**沒有任何 request 期路徑能觸發註冊**，覆蓋風險不存在 |

另有第 8 項「跨層檢查：三張表隔離全關」——**已實測確認**（見 F5 核對段），`stage_objects`、`round_stage_transitions`、`project_audit_rounds` 三張表的 RLS 全部未啟用。**且比卡片說的更廣**：`compliance.projects` 與 `compliance.project_participants` 也沒開。

---

### ① 專案編號 vs 輪次編號：讀得到，但改不了

**成立的部分（讀）**：F4 與 F5／F6 就是這條，兩支 GET 端點都中。

**不成立的部分（寫）**：卡片問「我是 A 專案的 manager，打 `POST .../project/<A>/audit-round/<B 的輪次>/stage/advance`，能不能推進 B 的稽核？」——**不能。**

原因是**下游套件有第二道檢查**。`advance_stage`（`stage_advance_service.py:191`）雖然自己的角色檢查（`:289`）確實查錯了專案，但它在第 356 行呼叫 handler 執行業務動作時，每一支 handler 都會轉呼叫 `jedi-compliance-audit` 的 `AuditRoundAppService`，而**那裡會拿輪次自己的 `project_id` 再查一次角色**：

```python
# jedi_compliance_audit/app/service/audit_round_app_service.py
def launch_audit(self, round_uid, ...):
    e = self._rounds.get_by_uid(round_uid)
    self._check_role(e.project_id, curr_user_id, "manager")   # ← :385，用輪次自己的專案
```

首腦逐一開檔核對了**全部 6 條寫入路徑**，每一條都有這道檢查：

| 推進動作 | 套件方法 | 第二道檢查在 | 要求角色 |
|---|---|---|---|
| 啟動稽核 | `launch_audit` | `:385` | manager |
| 送出稽核計畫 | `start_auditing` | `:445` | auditor（manager 可代行） |
| 稽核定版 | `finalize_audit` | `:476` | auditor（manager 可代行） |
| 結案 | `close_round` | `:538` | manager |
| 退回到規劃 | `rollback_to_planning` | `:792` | manager |
| 退回到稽核計畫 | `rollback_to_audit_planning` | `:823` | manager |
| 退回到稽核 | `rollback_to_auditing` | `:847` | manager |

而且 handler 是在第 356 行執行、BPMN 推進在第 382 行——**順序上先檢查後動作**，且整支包在 `@transaction` 內，handler 拋錯會整筆 rollback。所以跨專案推進會在第一步就被 403 擋下，BPMN 也不會被動到。

**但這件事值得記一筆**：擋住它的是**下游套件的自我保護**，不是本層的設計。`stage_advance_service.py` 這一層目前的狀態是「自己沒守，靠別人守」。哪天某支 handler 忘了檢查、或新增一支不走 `AuditRoundAppService` 的 handler，這個洞就會立刻變成可寫入。建議把 F4 的歸屬檢查**同時補在 `advance_stage` 上**（不只補 `get_current_stage_info`），讓這層自己站得住。

**回退同理**：`stage_rollback_service.py:95` 也是拿 `project_uid` 查角色，但第 137 行的 handler 一樣會走到套件的 `rollback_to_*`，在那裡用輪次自己的專案再檢一次（`:792`／`:823`／`:847`）。

---

### ② `force` 兩個版本：確實有兩個版本，但吃不到東西

卡片的懷疑是：外層 `force=false`、`ctx={"force": true}`，非 manager 的階段負責角色能不能藉此跳過「規劃就緒檢查」啟動稽核？

**兩個版本是真的存在**：

- `stage_advance_service.py:209`：`ctx.setdefault("force", force)` — **只在 ctx 裡沒有 force 時才填**，呼叫端塞的值會活下來
- `:303` 的「非 manager 不可 force」檢查，看的是**外層** `force` 變數
- `oscal_stage_handlers.py:41`：`force = bool((ctx or {}).get("force"))` — 讀的是 **ctx 裡的**

所以「檢查看外層、使用看內層」這個錯位**確實成立**。但**跳過的東西沒有價值**：

`ctx["force"]` 唯一的用途，是讓 `LaunchAuditOnCompleteHandler` 跳過 `PlanningReadinessChecker` 的**兩項軟提醒**（證據蒐集任務還沒做完、流程模板要的角色還沒指派）。首腦開檔確認（`jedi-compliance-audit/app/service/planning_readiness_checker.py`），這個檢查器的設計就是**提醒而非守門**——

- 它的 docstring 明寫「**軟提醒非硬擋**」
- 它本身就是 **fail-open**（拿不到資料就跳過該項，不擋推進）
- 它回傳 warning 時，正常流程是讓使用者在畫面上按「確認」再送一次 `force=true`——**本來就是給使用者放行的機制**

而**真正的守門一個都跳不掉**：第 292 行的角色檢查（非 main_role 且非 manager 直接 403）在 handler 之前執行、`:303` 的「force 需 manager」看的是外層變數（塞 ctx 繞不過它）、第 314 行真正的前置條件檢查（`_run_precondition`）**看的也是外層 `force`**，不是 ctx。

檢查其他 handler：**沒有第二支讀 ctx 裡的 force**（全 codebase 只有 `oscal_stage_handlers.py:41` 這一處），六支前置條件也都不讀 ctx。

**結論：這是程式碼異味（同一個意思有兩個來源、寫法不一致），不是漏洞。** 三個檢查員各自獨立判為 0:3 否決，首腦複核同意。建議順手清理（把 `setdefault` 改成直接覆寫 `ctx["force"] = force`），但**不需要開修正卡**。

---

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

分兩層講，這兩件事不一樣：

### 第一層：「報出來的這些，是真的嗎？」——**可信度高**

- 三個獨立檢查員對 11 條候選各投一票，**33 票全數投出、沒有漏投**，驗證章 `verified`
- 範圍內的 4 條發現，**首腦全部自己開檔核對過**，並實際連 DEV 資料庫做唯讀查詢驗證（RLS 狀態、實際外洩的資料內容）
- 而且**反向也驗了**：三條被否決的候選（都主張「可以跨專案改資料」），首腦獨立追了一次完整攻擊路徑，逐一開檔核對 7 條寫入路徑的第二道檢查，**結論與檢查員一致**
- 核對過程中**抓到工具一個錯誤**（見 F5 的「工具說錯的地方」）：它宣稱 `compliance.projects` 有 RLS 保護，實查沒有。已在報告中更正

### 第二層：「是不是只有這些？」——**不保證**

- 本次是 `low` effort，**一位研究員掃全範圍**（工具實際派了 2 位、2 位都回報），不是逐檔逐類的窮舉
- **零發現不等於乾淨**。本棒有明確「讀過但判定沒問題」的項目（卡片疑點 ⑤⑥⑦），也有「工具沒特別報、首腦也沒逐行看」的部分——例如 `stage_object_*` 那一串檔案（8 支，屬階段定義的讀取鏈），工具與首腦都沒在上面發現問題，但**沒有做到逐行確認**
- 本範圍**是第一次掃**，沒有歷史基準可以對照

---

## 7. 執行概況（工程師看的，數字照 stamp 實抄）

| 項目 | 數值 |
|---|---|
| `verification.status` | **`verified`** |
| 候選發現數（`candidates`） | 12 |
| 去重後（`candidates_deduped`） | 11 |
| 面板票數（`panel_votes`） | **33**（11 條 × 3 個檢查員） |
| 達到共識的發現（`panel_quorum_findings`） | 8 |
| 沒被檢查到的候選點（`unreviewed_candidate_sites`） | **0** |
| 研究員派出／回報（`researchers_dispatched/returned`） | **2／2**（全數回報） |
| 嚴重度分佈 | HIGH 2（皆範圍外）／MEDIUM 5／LOW 1 |
| 面板調整 | F3 由 HIGH 降為 MEDIUM（三票一致） |
| 掃描耗時 | 14,359 秒（約 4 小時） |
| 執行代理總數 | 35 個（全數完成，0 錯誤、0 略過、0 空回報） |

- **Run ID**：`wf_1bb7fb14-b17`
- **工具產出**：`CLAUDE-SECURITY-20260912-073625/`（含 `.jsonl`／`.sarif`／stamp）
- **被否決的候選**：3 條（F9／F10／F11），全部主張「可跨專案改資料」，三票全否，首腦複核同意——理由見 §5①

---

## 8. 建議的後續處理

| 建議 | 內容 | 優先序 |
|---|---|---|
| **開修正卡 A** | 階段歷程 API 補歸屬檢查（F5／F6）＋ 階段資訊 API 補歸屬檢查（F4）。兩條同根同源、修法相同（比對 `round.project_id` 與 `project_uid`，再走 `assert_project_participant`），**建議合成一張卡** | 本棒最高 |
| **併入修正卡 A（或另開 LOW 卡）** | 署名可自填（F8）＋ `force` 兩版本清理。都在同一批檔案、同一種寫法（`setdefault` 用在不該用的地方），順手一起改成本最低 | 中 |
| **一併補上 `advance_stage` 的歸屬檢查** | 目前靠下游套件擋住，屬「別人替你擋」。修 F4 時建議把 `advance_stage`（`:224`）與 `rollback_stage`（`:95`）一起補，讓這層自己站得住 | 中（防未來退化） |
| **範圍外 4 條** | **不開新卡**，已在 CM-1607／1608／1629／1631 | — |
| **值得上報的跨棒觀察** | 這兩張表（`project_audit_rounds`／`round_stage_transitions`）沒有租戶欄位也沒開 RLS，屬 FR-087 T1 盤點（13 張有租戶欄的表）之外的死角。**同類型的表可能還有**，建議母卡收口時單獨盤一次 | 提給首腦判斷 |
