# W8 掃描報告：管理人「強制開始」任務，與流程引擎 FR-112 之後的改動（CM-2106）

> 範圍：4 檔，共 1,747 行（`api/flow_control/routes/job_force_start_route.py`、`app/flow_engine/service/job_force_start_service.py`、`app/flow_engine/service/join_wait_inspector.py`、`app/flow_engine/service/workflow_execution_service.py`）。
> 掃描工具：Claude Code 官方 `claude-security` plugin，effort low，只看生產程式碼。
> 掃描基準 commit：`9abe5e64e895`（工作區有其他 session 還沒提交的改動，所以 stamp 標了 `-dirty`）。
> 驗證章：**verified**。這一棒只掃描，不修改程式。

---

## 1. 一句話結論

**卡片問的頭號問題，答案是「只檢查網址上的專案，沒有回頭核對任務屬於哪個專案」。** 甲專案的管理人，只要在網址上填自己的專案，後面接乙專案的任務編號，就能把乙專案卡住的任務強制推成進行中。乙專案的管理人沒有同意，也不會知道；稽核紀錄上寫的還是甲專案。

同一個缺口也出現在「查詢卡住狀態」那支：它連「你是不是這個專案的人」都沒有檢查。任何登入者都能讀到別的專案正在等哪幾條分支，包括任務名稱、狀態和任務編號。這些任務編號剛好就是打上一條需要的材料。

兩條都是**同一家客戶內、跨專案**的問題。資料庫的客戶隔離已經開啟（下面第 5 節查過），所以不會跨到別家客戶。

流程引擎在 FR-088 之後改動的部分（合流閘道等兄弟分支、刪掉的 230 行死碼），**沒有找到新問題**，也沒有順手刪掉守門（第 5 節）。

---

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

FR-112（09-20）為了解決「流程卡住、只能直接改資料庫」，在規劃頁加了兩個功能：

- **查詢卡住狀態**：點一個任務，畫面告訴你它是不是被合流閘道卡住、在等哪幾條分支。設計上是「任一專案參與者可看」。
- **強制開始**：管理人按下去，就把卡住的待辦任務推成進行中，並寫一筆稽核事件。設計上「限專案管理人」。

兩支網址長這樣：`/grc/project/<專案編號>/job/<任務編號>/blocked-status` 和 `.../force-start`。**網址同時帶了專案和任務，這種形狀最常出的錯，就是「檢查的是甲、動手改的是乙」**（總表第 118、119 項是同形狀的先例）。所以這一棒的重點是：專案和任務有沒有被核對成同一組。

---

## 3. 掃到什麼：總覽

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度 | 來源 |
|---|---|---|---|---|---|---|
| **W8-1** | 強制開始只檢查「你是不是網址上那個專案的管理人」，沒有核對任務是不是這個專案的 | 甲專案管理人可以把乙專案卡住的任務推成進行中，乙專案的指派人會收到通知，稽核事件記在甲專案名下 | 在同一家客戶裡**至少是一個專案的管理人**，並且知道對方的任務編號 | `app/flow_engine/service/job_force_start_service.py:119`（`_load`，查到任務之後，要核對它屬於網址上的專案）；權限檢查 `:107` 要改成檢查任務實際所屬的專案 | **中** | 工具 3:0 票＋本棒開檔核對 |
| **W8-2** | 查詢卡住狀態完全沒有檢查專案成員，也沒有核對任務屬於哪個專案 | 同一家客戶的任何登入者，都能讀到別的專案在等哪些分支任務：名稱、流程節點編號、狀態、任務編號 | 同一家客戶的任何登入帳號（要有專案模組授權），並且知道一個任務編號 | `app/flow_engine/service/job_force_start_service.py:60`（`inspect` 開頭，要補「任務屬於網址上的專案」和「你是這個專案的參與者」兩道檢查） | **低** | 工具 3:0 票＋本棒開檔核對 |

兩條都是**新的**，總表沒有登記過。

---

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

### 4.1 W8-1 甲專案的管理人，可以強制推動乙專案的任務（中）

**場景**

某家客戶裡有兩個專案：甲是小王負責的內部稽核，乙是另一組人負責的外部稽核。小王是甲的管理人，跟乙沒有任何關係。乙專案有一個「主管覆核」任務，正在等兩條平行分支（兩個組員各自收證據）都做完才能開始。

小王拿到那個覆核任務的編號（取得方式見 W8-2）。他在規劃頁的網址上填**自己甲專案的編號**，後面接**乙專案的任務編號**，然後按「強制開始」。系統只確認「小王是甲的管理人」，檢查通過，接著就把乙專案的覆核任務從「待辦」改成「進行中」：

- 乙專案的主管收到「有任務要處理」的通知，但兩條分支的證據其實還沒收齊。
- 稽核事件寫的是 `project_uid=甲`，事後查紀錄，只會看到甲專案的管理人在甲專案做了一次強制開始，看不出他改的是乙專案。
- 乙專案的管理人從頭到尾不知道發生了什麼。

**為什麼會這樣**

- 權限檢查 `_require_manager`（`job_force_start_service.py:107-117`）用的是網址上的 `project_uid`：查出專案編號，確認呼叫者是該專案的管理人。
- 查任務的 `_load`（`:119-132`）**只用 `job_uid` 查**，查到任務後沒有問「這個任務是哪個專案的」。`project_uid` 傳進 `_load`，但函式裡根本沒用到。
- 所以權限檢查和實際修改的對象可以是兩個不同的專案。

**為什麼當初沒有從任務反查專案**：commit `525989b7a` 的說明寫得很清楚，是刻意的。DEV 上只有約 11% 的流程能從流程反查回專案，如果走反查這條路，九成的管理人會被誤擋。所以改成直接用網址上的專案檢查權限。**這個取捨本身合理，但少了一步：用網址上的專案檢查權限可以，前提是要先確認任務真的屬於這個專案。**

**嚴重度為什麼是中不是高**

- 攻擊者必須已經是同一家客戶某個專案的管理人，不是隨便一個登入者都打得到。
- 只推得動「待辦、而且確實卡在合流閘道」的任務（`:69`、`:75` 兩道前置條件），不是什麼任務都能改。
- 改壞的是流程的完整性（跳過了應該等的分支），不會直接外洩或刪除資料。

**修法方向（給修正卡參考，不是定案）**

在 `_load` 查到任務後，確認任務屬於 `project_uid`，不屬於就回 404。專案內的任務要怎麼認歸屬，系統裡已經有現成的判斷方式：`compliance.task_assignees.project_id`。規劃頁存指派人時（`infra/flow_control/repository/flow_control_job_repo_impl.py:600-603`）、輪次凍結的判定（同檔 `:327-351`）都用它。**要注意的陷阱**：如果某些卡住的任務在 `task_assignees` 裡沒有資料，這樣核對會把它們擋掉，等於又碰到 525989b7a 想避開的那個「誤擋」問題。所以修之前，要先在 DEV 查一次「待辦且卡在合流的任務，有多少筆沒有 `task_assignees`」。

### 4.2 W8-2 查詢卡住狀態，誰都能查別的專案（低）

**場景**

同一家客戶裡的一般員工小李，沒有參與乙專案。只要他手上有一個乙專案的任務編號，就能在網址上隨便填一個專案編號（甚至是不存在的），後面接那個任務編號，呼叫「查詢卡住狀態」。系統會回傳乙專案那個任務正在等的每一條分支：任務名稱、流程節點編號、目前狀態，以及**每條分支任務的編號**。

拿到這些任務編號後，小李如果同時是某個專案的管理人，就可以拿來打 W8-1；也可以拿去試其他「只看任務編號」的網址。

**為什麼會這樣**

- 路由只掛了「有沒有登入」和「客戶有沒有買專案模組」兩道門（`job_force_start_route.py:38-39`）。
- `inspect`（`job_force_start_service.py:57-61`）直接呼叫 `_load`，沒有任何成員檢查。說明文字寫「任一專案參與者可看」，但程式沒有做這件事；`project_uid` 同樣沒有被用到。

**嚴重度為什麼是低**：只能讀，讀到的是任務名稱、狀態這類流程資訊，不是證據內容；而且要先知道一個任務編號。它的主要風險在於幫 W8-1 提供材料。

**修法方向**：在 `inspect` 開頭補兩道檢查，跟 W8-1 用同一套：「任務屬於網址上的專案」，以及 `assert_project_participant`（專案參與者）。

---

## 5. 卡片點名要看的其他幾件事（runner 自行開檔核對，未經三人面板投票）

### 5.1 強制開始能不能推「不該推」的任務：**不能，兩道前置條件都在**

- 狀態必須是「待辦」（`job_force_start_service.py:69-70`）。已完成、已取消、進行中、已退回的任務都會被擋下，回 412（`GRC_412055`）。
- 上游必須真的是合流閘道，而且還有分支沒結束（`:72-76`）。上游不是合流閘道（`None`）或者已經等到了（空清單），一律擋下，回 412（`GRC_412056`）。
- 「哪幾條分支還沒結束」的判定，跟引擎自己推進時用的是同一支函式（`join_wait_inspector.find_pending_branch_ends`，引擎端在 `workflow_execution_service.py:494-502`）。兩者不會各說各話，所以沒有辦法讓畫面判定和引擎判定不一致，從中找縫隙繞過。
- **唯一能人為製造「卡住」的方式是改流程圖**（讓某條分支的任務不存在）。改流程圖本身是規劃頁的管理人動作，不在本棒範圍。

**這一項不成立。**

### 5.2 別的輪次、已凍結的輪次：**強制開始不檢查凍結，這是對的**

「退回」有一道「輪次開始執行後就凍結、不可退回」的檢查（`workflow_execution_service.py:167-188`）。強制開始沒有這道檢查，但這是符合語意的：任務卡住本來就發生在**執行期**（輪次已啟動、已凍結），強制開始正是給執行期用的出口。如果加上凍結檢查，這個功能就完全不能用了。

別輪的任務同樣受 5.1 兩道條件限制：已經走完的輪次，任務不是「待辦」，推不動。

**這一項不成立。**

### 5.3 `reason` 欄位：**有長度上限，會原樣寫進稽核事件**

- 長度上限 200 字，在 `api/flow_control/serializers/job.py:410`（`validate.Length(max=200)`），超過就回 400。W5-2 那種「欄位超長」的問題不會發生。
- 內容會原樣接在稽核事件訊息後面（`job_force_start_service.py:89`，經 `jedi_common.utils.audit_log.audit` 組成 `[AUDIT:JOB_FORCE_STARTED] ... reason=<原文>`），**沒有過濾換行字元**。實務影響很小：只有管理人能填，最多 200 字，而且寫進 `system_logs` 時是一整筆資料，不會被拆成兩筆。只在純文字日誌檔裡，可能讓人看到一行假的 `[AUDIT:...]`。**列為觀察，不另計。** 如果要一起處理，應該在 `audit()` 那層統一把換行跳脫掉，不要只修這一個欄位。

### 5.4 流程引擎在 FR-088 之後的改動：**沒有找到新問題，也沒有刪到守門**

FR-088 H4 掃描時的基準是 `8fc8c1c1`。之後這支檔案有三個 commit 改過，合計 +45／−230 行：

| commit | 做了什麼 | 核對結果 |
|---|---|---|
| `0b1998f83`（FR-092 第 9 棒） | 刪掉 6 個死方法，約 231 行 | 逐一 grep，被刪的 `add_job_comment`、`get_ext_workflow_execution_by_uid`、兩支 `_and_pager` 等等，在 `api/ app/ common/ di_containers/ domain/ infra/` 都已經沒有任何呼叫者。被刪的程式裡**沒有** `assert_*`、`ForbiddenError` 這類守門。流程討論區（H4-1）現在的寫法走 `element_variable_service`（`api/flow_engine/routes/flow_engine_route.py:105`），不受這次刪除影響，H4-1 的狀態沒有改變 |
| `efe31bf67`（CM-1964） | 合流閘道後面的任務，要等兄弟分支都結束才啟動 | 在 `complete_job`、`complete_main_workflow_job` 兩個推進迴圈裡加了一道 `_is_parallel_join_ready`（`:617`、`:754`）。這道檢查只會讓推進變嚴格，不會放寬。兩處的原有守門（H4-2 報的「只檢查是不是成員」）沒有改變 |
| `525989b7a`（CM-1980） | 把合流判定抽成 `join_wait_inspector.py` 共用 | 判定邏輯原封不動搬出去，引擎和強制開始共用同一支 |

**FR-088 H2、H4 已經登記的問題（H4-1 討論區沒有守門、H4-2 完成／退回任務只檢查是不是成員），現在都還是原樣，不重報。**

### 5.5 資料庫的客戶隔離：**任務、流程、專案三張表都已開啟**

H4-3 報告時，`workflow_executions`、`projects` 的隔離是關著的。本棒在 DEV 重查（2026-09-23 22:46，唯讀 `pg_class.relrowsecurity`）：`compliance.job_executions`、`compliance.workflow_executions`、`compliance.projects`、`compliance.task_assignees` 四張表都是 `true`。`job_executions` 的政策是「超級管理員，或租戶在允許範圍內」（`scripts/sql/2026-09-14-fr094-cm1770-rls-poams-evidence-jobs-parse-jobs.sql:130-160`）。

所以 W8-1、W8-2 的範圍確實停在「同一家客戶內」，不會跨到別家。**STG、POC 沒有查。**

---

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

**「工具報的兩條存在嗎」：可信度高。** 三位獨立檢查員從三個角度（打不打得到、影響多大、中間有沒有別的防線）各投一票，**6 票全數投出，兩條都是 3:0 成立**，嚴重度也沒有被調降。檢查員確認過中間沒有其他防線把任務和專案綁在一起，包括路由、服務和任務查詢的 repository。

**「第 5 節」：runner 自行開檔核對，未經三人面板投票。** 關鍵事實都有實查：四張表的隔離狀態有查 DEV；被刪方法有沒有呼叫者有 grep；`reason` 的長度上限和寫入方式有開檔確認。

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

1. 這次用的是最快的檔位（effort low）：一輪研究加一輪投票，沒有跑威脅建模和廣度掃描。
2. `workflow_execution_service.py` 整支 1,374 行都在範圍內，但研究員的重點放在 FR-088 之後改動的部分和新端點；其他部分 H4 已經掃過。
3. 全程沒有真的打任何網址。「可以推動別人的任務」是讀程式讀出來的，不是實際打出來的。
4. 修法提到的「用 `task_assignees` 認歸屬會不會誤擋」**還沒查資料**，修正卡要先查。

---

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

| 項目 | 數值 |
|---|---|
| 掃描範圍 | 4 檔，共 1,747 行 |
| 基準 commit | `9abe5e64e895`（工作區 dirty，有平行 session 的改動） |
| 檔位 | effort low，只看生產程式碼 |
| 研究員 | 派出 2 支，回來 2 支 |
| 原始候選 | 2 條，去重後 2 條 |
| 投票 | 三位檢查員 × 2 條 ＝ 6 票，全數投出 |
| 票型 | W8-1 3:0（三票都評中）；W8-2 3:0（三票都評低） |
| 驗證章 | **verified**（`CLAUDE-SECURITY-REVISION-9abe5e64e895-dirty.json`） |
| 工具 run ID | `wf_c314acba-b10` |
| 耗時 | 約 13 分鐘（8 個 agent，零失敗；其中 1 個回傳空結果，不影響候選數） |
| 工具產出的原始報告 | `CLAUDE-SECURITY-20260923-143410/`（沒有進版控） |

---

## 8. 待首腦裁決

1. **W8-1、W8-2 登記成總表新項次**，歸在「檢查甲、動手改乙」那一類（跟第 118、119 項同類）。建議兩條開**同一張修正卡**：同一支檔、同一個缺口，只修一邊的話，另一邊的任務編號照樣會外洩。
2. **修正卡開工前，先查一次資料**：「待辦且卡在合流的任務，有多少筆在 `task_assignees` 裡沒有資料」。如果數量不少，要改用別的方式認歸屬，不然會回到 525989b7a 想避開的誤擋問題。
3. **`audit()` 要不要統一跳脫換行**（5.3）：影響所有稽核事件，不只這一支。建議列成低優先的共用改動，不用併進這張卡。

---

沿革見 `FR-115` LOG 與 git log。
