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。