FR-088.6 P2:流程執行核心(套件本體 49 檔)——資安掃描報告

FR-088.6 P2:流程執行核心——資安掃描報告

  • 卡片:CM-1675(FR-088 第 6 棒,只掃不修)
  • 範圍:jedi-flow-engine 套件 49 支檔(流程執行/任務執行/流程變數三條鏈,加上新長出來的 api/、domain/ports.py、plugin/ 五檔)
  • 掃描版本:jedi monorepo ac9c152(branch feature/FR-075,工作目錄乾淨)
  • 工具:claude-security plugin,effort low、focus attack-surface
  • Run ID:wf_ca839a58-ac4
  • 報告日期:2026-09-13

1. 一句話結論

掃完了,套件本體這 49 支檔裡沒有新的資安問題。唯一被三個檢查員一致確認的一條,是「流程留言不檢查你是不是這個專案的人」——但這條上一棒(H2)就已經查到並登記了,本棒只是從套件這一側再證實一次,不算新發現。

換句話說:這一棒的價值不在「又找到什麼」,而在把套件的每一支公開方法逐一對回主專案的守門,確認「宿主到底包了幾條、漏了幾條」。答案是:宿主只在「完成任務」「退回任務」兩條路上有守門,留言那條漏了(已登記),其餘公開方法目前沒有任何外部網址可以直接打到。


現況(以 docs/security-report/M06*.md 為準):P2-1(即第 51 條流程留言)→ ✅ 已修(CM-2035);未來陷阱的備用服務(M06-16)→ 🗑️ 已拆除(CM-2215,1.21.0)。

2. 這一棒在檢查什麼(白話)

流程引擎這個套件,在系統裡的角色是「零件供應商」:它負責建立流程執行紀錄、把任務標成完成、算出下一個該辦的人是誰、讀寫流程上的留言與變數,然後存進資料庫。它自己沒有任何對外網址——所有請求都先打到主專案,主專案檢查完權限,才轉手叫這個套件。

所以這一棒問的是兩個問題:

  1. 零件有沒有「假設呼叫者已經檢查過」、但呼叫者其實沒檢查? 這是零件與宿主之間最典型的漏洞位置——兩邊都以為對方做了。
  2. 零件本身有沒有可以被外部覆寫、被塞任意資料的地方? 例如「把資料庫存取元件換掉」的注入機制,或者「流程變數可以存任意 JSON」這種沒有形狀限制的欄位。

另外,這次的範圍比卡片原訂多了 7 支檔——掃 P1 期間有平行工作把 plugin.py 拆成五個檔,還新長出了 api/ 目錄與 domain/ports.py。這些是全新程式碼、之前沒有任何一棒看過,本棒特別確認了它們。


3. 掃到什麼:總覽表

編號 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 怎麼修
P2-1
🟡 MEDIUM
⚠️ 重複,非本棒新發現
流程留言的讀與寫,只認呼叫者傳進來的流程編號,不檢查這個流程是不是他們專案的 任何登入帳號只要知道別家客戶的流程編號,就能讀走整串討論留言(含留言內容、留言者帳號與暱稱),也能用自己的名義往那個流程塞留言 ① 任一個有效登入帳號(不需任何特殊權限)
② 知道目標流程的編號(UUID 猜不到,但會出現在網址與清單 API 回應裡,而且那張資料表沒開客戶隔離,本來就會外漏編號)
套件側:jedi_flow_engine/app/service/element_variable_service.py:50(寫)、:26(讀)
宿主側:api/flow_engine/routes/flow_engine_route.py:44-64(GET)、:66-121(PUT)
在主專案的 app service 層解出 workflow_execution 之後呼叫 assert_project_participant(workflow_execution.id),讀寫都要加——與同模組「完成任務」(app/flow_engine/service/workflow_execution_service.py:611)、「退回任務」(:788)同一套做法

只有一條,而且是舊的。 這一棒沒有範圍外發現——工具因為設了 focus 會額外跑一次「找寫死的密碼金鑰」專項掃描(掃全 repo、不受 49 檔限制),這次在 jedi-flow-engine 目錄裡什麼都沒撈到。

P2-1 為什麼算重複

  • H2(第 1 棒)已把同一件事登記為總表 §3.1 第 51 條,決策者 2026-09-13 裁升為 HIGH(理由:可假冒同事發訊息釣魚)。
  • H4(第 5 棒)也報過同一條,首腦當時裁「重複,改列範圍外」。
  • 本棒是第三次撞到它,但角度不同:前兩棒是從主專案的網址入口看,本棒是從套件這一側看——證實了套件自己確實一行檢查都沒有,不是「套件有擋、宿主漏傳」。這個證實有價值(它排除了「在套件層已有兜底」的可能),但不該重複計數。

4. 每條發現的詳述

P2-1 — 流程留言的讀與寫都不檢查歸屬(MEDIUM,可信度 中;重複 §3.1 第 51 條)

白話說明

稽核流程的頁面上有一個討論區。使用者在上面留言時,瀏覽器會打一支網址,網址裡帶著「哪一個流程」的編號。後端拿到這個編號之後,直接照著它把留言讀出來或寫進去——中間沒有任何一步問「這個流程是不是你們專案的」。

在哪裡

套件側(本棒 scope 內)
  jedi_flow_engine/app/service/element_variable_service.py:49-76   update_element_variable(寫)
  jedi_flow_engine/app/service/element_variable_service.py:26-29   get_element_variable(讀)
  ——兩支都是拿 workflow_uid 直接查出 workflow_execution 就動手,零檢查

宿主側(本棒 scope 外,開檔核對用)
  api/flow_engine/routes/flow_engine_route.py:44-64   GET(只掛 @jwt_required)
  api/flow_engine/routes/flow_engine_route.py:66-121  PUT(只掛 @jwt_required)
  di_containers/flow_engine/workflow_excution_containers.py:2      確認宿主接的就是這支套件 service

對照組(有守門的兩支)
  app/flow_engine/service/workflow_execution_service.py:611   complete_job → assert_project_participant
  app/flow_engine/service/workflow_execution_service.py:788   revert_job  → assert_project_participant

怎麼修

守門加在主專案的 app service 層(不是 route 層——route 只負責處理網路請求的收發,查資料庫與判權限是 service 的事):解出 workflow_execution 之後呼叫既有的 assert_project_participant(workflow_execution.id),讀與寫兩條路徑都要加。不必新寫檢查函式,那支已經存在且同模組另外兩支正在用。

套件這一側也可以補第二道,但要小心形狀:如果加一張「權限檢查 port」(port =套件對外開的插槽,由主專案把實作插進來),沒插的時候必須直接拒絕、不可放行——插槽開著卻沒接,結果變成無聲放行,比不加還糟。

首腦核對註記(我自己開檔看過)

  • ✅ 套件側屬實:element_variable_service.py:49-76 整支從頭到尾只有「查流程 → 查變數 → 寫回」,沒有任何身分或歸屬判斷。讀的那支(:26)更短,連流程都不查,直接照 query 條件撈。
  • ✅ 宿主側屬實:flow_engine_route.py 的 WorkflowExecutionCommentRoute,get(:44)與 put(:66)都只掛 @jwt_required(),route 內直接查 workflow_execution 再讀寫留言,全程無守門。
  • ✅ 對照組屬實:主專案的 complete_job(:611)與 revert_job(:788)確實都呼叫了 assert_project_participant。所以「留言漏掉」是這個模組自己的不一致,不是刻意不守。
  • ✅ 資料庫層確實沒有兜底:在出貨基線 scripts/init/02-schema.sql 裡,31 支「開啟客戶隔離」的指令中,workflow_executions/job_executions/workflow_templates/element_variables 一張都沒有。workflow_executions 雖然寫了三條隔離規則(:24160/:24167/:24174),但開關沒開,等於規則寫好了沒生效。
  • ⚠️ 留言內容在前端怎麼被渲染(會不會變成「攻擊者存進去的內容,別人打開頁面時被當成程式執行」),本棒沒有追前端,與 H2 的保留意見相同。

5. 「重點看什麼」逐項回應

卡片列了九項疑點(含首腦補充的兩項)。證偽與證實同樣有價值,所以不成立的也寫清楚是什麼擋住了。

# 卡片的疑點 結論
1 公開方法清單 vs 宿主守門對照表 ✅ 做完了,見第 6 節。結論:套件三支 service 共 25 支公開方法,主專案真正接來用的只有 2 支 service(留言與任務查詢),且只有留言那 2 支方法有對外網址打得到。套件自己的 WorkflowExecutionService(含 complete_job/revert_job)主專案根本沒有 import——主專案有一支同名但完全獨立的自家 service,才是 route 真正呼叫的對象
2 repo 注入可被無條件覆寫 ✅ 確認機制存在,但無 request 期觸發路徑,不列為發現。詳見第 6.2 節
3 插件沒有 route 也沒有接線守衛 ✅ 確認 blueprint 是空的。plugin/assembly.py:39 建的 blueprint 從頭到尾沒有 add_resource/add_url_rule,register() 也沒掛任何 resource。套件本身沒有對外網址
4 三張表的隔離狀態 ✅ 屬實,用出貨基線 schema 核對(見上方核對註記)。四張表全部沒開客戶隔離。租戶欄是誰填的:套件四支 model 中只有 WorkflowTemplate 掛了租戶欄位的 mixin,其餘三張沒掛;element_variables 連欄位都沒有。有欄位的那幾張靠 jedi-common 在寫入前從登入身分自動填
5 流程變數是 JSONB 任意寫 ⚠️ 機制屬實,但不另計為資安發現。element_variable_service.py:31-76 的 add/update 確實不驗 value 的形狀與大小,type 欄位也是呼叫端隨便填。但能打到這裡的唯一外部入口就是 P2-1 那支留言 API,所以「可以灌爆」是 P2-1 的一個後果,H2 報告已寫進第 51 條(「存 JSON 陣列無筆數與長度上限」)。delete_element_variable_by_main_workflow_execution_id(:82)全 codebase 零呼叫,目前沒有任何路徑叫得到
6 建立任務時把 BPMN 屬性當 JSON 解析 ✅ 機制屬實,但是可用性問題不是資安問題。workflow_execution_service.py:118 的 json.loads(job.properties['devices']) 沒有 try,壞 JSON 會在啟動流程時丟例外。但寫得進 BPMN 範本的人本來就要有範本編輯權限,而且炸的是他自己啟動的那個流程,不擴散。on_job_created(:138-146)確實把整包資料交給主專案的 callback,但收的那一端是主專案自己的程式碼,不是外部輸入
7 完成任務的條件參數 condition_param ✅ 不成立,有東西擋住。套件的 complete_job(:205)確實收這個參數,但主專案根本沒有 import 套件的這支 service(見第 6.1 節)。主專案 route 呼叫的是自家同名 service,那一支有 assert_project_participant 守門。所以「使用者可控嗎」的答案是:這條路目前沒有外部入口
8 query entity 的 _gen_filters ✅ 不成立。_gen_filters 不在套件裡,它在 jedi-common 的 base_repository_impl.py:459,邏輯是「欄位值不是 None 才加條件」——傳 None 確實等於不過濾。但用到它的兩支(get_non_completed_jobs/get_completed_jobs)套件內外都零呼叫,是死程式碼;實際在用的 get_job_executions 每次都帶著 workflow_execution_id。沒有 FR-079 F13 那種「宿主誤用成全表查詢」的情形
9 plugin/ 五檔+api/+domain/ports.py(首腦補充) ✅ 看過了,沒有發現。詳見第 6.3 節。api/guards.py 不是守門、domain/ports.py 的兩張插槽目前零使用且沒有「忘記接線就放行」的形狀

6. 對照表與新程式碼的細節

6.1 套件公開方法 vs 宿主守門(卡片要求的主要產出)

先講最重要的發現:套件裡有一支 WorkflowExecutionService(549 行,含 complete_job/revert_job),主專案裡也有一支同名的 WorkflowExecutionService(app/flow_engine/service/workflow_execution_service.py)。兩支是不同的東西,而主專案的 route 呼叫的是自己那一支。 全 codebase 搜尋確認:主專案從來沒有 import 過套件的 workflow_execution_service。

這件事直接決定了整張表的形狀——套件那 13 支「零授權檢查」的公開方法,目前沒有任何外部網址打得到。

套件的 service 主專案有沒有接來用 有沒有對外網址打得到 守門狀況
ElementVariableService(留言/流程變數,7 支公開方法) ✅ 有(di_containers/.../workflow_excution_containers.py:2) ⚠️ 有,2 支:get_element_variable、update_element_variable 經留言 API 🔴 零守門(= P2-1/第 51 條)
JobExecutionService(任務查詢與增刪改,6 支公開方法) ✅ 有宣告(同檔 :3、:113) ✅ 無——容器裡建好了,但全 codebase 沒有任何地方取用這個 provider 不適用(沒有入口)
WorkflowExecutionService(流程執行,13 支公開方法含 complete_job/revert_job/start_workflow_execution) ❌ 沒有——主專案用的是自家同名 service ✅ 無 不適用(套件這支沒被接)
WorkflowTemplateService(範本,P1 範圍) ✅ 有(6 處 import) — P1 範圍,本棒不重審

這張表的三個含意:

  1. 卡片說的「complete_job/revert_job 零授權檢查是設計如此、宿主包一層」——實際情況比這更徹底:宿主不是包了一層,而是整支重寫了一份自己的。套件那份目前是沒人用的程式碼。
  2. 唯一真的漏的就是留言那兩支,而它已經登記在案。
  3. JobExecutionService 是一個「接好了但沒人用」的插頭。今天無害,但如果哪天有人接上去用,update_job_execution/delete_job_execution 這些方法一樣是零守門、零租戶檢查。建議記一筆,未來要用時務必先補守門——這不是現在的漏洞,是未來的陷阱。

6.2 repo 注入的無條件覆寫

app/wiring/__init__.py 的 set_repo_providers() 檔頭自陳「刻意採無條件覆寫,後注入者勝出」。核對結果:

  • 機制屬實:這個函式就是一個 global _providers = providers,沒有任何「已經設過就拒絕」的保護。程序內任何一次呼叫都能把四支資料庫存取元件整組換掉。
  • 但沒有 request 期可觸發的路徑:唯一的呼叫點是 plugin/wiring.py:24 的 bind_repo_providers(),由 plugin/__init__.py 在 import 期執行一次。全 codebase 沒有第二個呼叫點,也沒有任何網址或設定值能讓它在使用者請求的當下再跑一次。
  • 誰能呼叫:能 import 這個模組的程式碼——也就是已經在同一個 Python 程序內執行的程式碼。到了那一步,攻擊者本來就已經可以做任何事,覆寫 repo 不會讓他多得到什麼。
  • 結論:記錄下來,不列為發現。這是「啟動期組裝」的正常形狀,不是可被外部觸發的洞。

6.3 新程式碼:plugin/ 五檔、api/、domain/ports.py

這 8 支檔之前沒有任何一棒掃過,逐一確認結果:

檔 是什麼 結論
plugin/__init__.py 插件註冊入口(register())+對外 import 面 ✅ 沒有長出 route。檔頭明寫「本套件目前沒有 api 層,這是刻意的」,且有測試焊死 blueprint 必須是空的
plugin/assembly.py 建 blueprint ✅ :39 建出來的 blueprint 之後沒有掛任何 resource。確認是空的
plugin/contract.py 四個資料結構+常數 ✅ 純宣告,無行為
plugin/runtime.py 執行期組件的袋子 ✅ 唯讀袋子,register() 期裝好
plugin/wiring.py 把四支 repo 綁進 app 層 ✅ 見 6.2,import 期執行一次
api/__init__.py HTTP 邊界的 import 面 ✅ 只 re-export 一支 runtime()。沒有 route
api/guards.py 名字叫 guards,但它不是守門 ⚠️ 命名容易誤導,但沒有安全問題。這支檔裡只有一支 runtime(),功能是「從 Flask 拿出啟動期裝好的組件袋」。檔頭自己寫清楚了「本套件沒有 route,故這裡也沒有守門 decorator」,並要求「日後長出 route 的那一棒要補守門,並同時在 plugin/assembly.py 補接線檢查——缺認證接線就拒絕掛載」。這個註記寫得對,方向也對(缺接線就拒絕=出錯時預設擋下來)。與主專案 common/authz/workflow.py 不是並存的兩套守門——因為這支根本不是守門
domain/ports.py 兩張插槽(讀設定/發通知) ✅ 目前零使用,且沒有「忘記接線就無聲放行」的形狀。兩張都是純功能性插槽(讀設定值、寄信),不是權限判斷——所以就算沒接,最壞情況是功能不動,不會變成放行。真正需要小心「沒接=放行」的是權限類 port,而這裡一張都沒有

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

分兩層問,答案不一樣。

第一層:「報告裡這一條,真的存在嗎?」→ 可信度高

  • 三個獨立的檢查員(分別從「打得到嗎」「影響多大」「有沒有東西擋」三個角度)一致確認它成立,三票全數投給「是真的」。
  • 首腦(我)另外自己開檔核對過套件側、宿主側、對照組、資料庫 schema 四處,全部屬實。
  • 另有一條候選被三個檢查員一致否決(0 比 3),理由寫在下面——這說明檢查員不是照單全收。

被否決的那一條:有研究員報「流程變數這張表沒有掛租戶欄位的 mixin,而另外三張姊妹表有掛,所以它特別危險」。三個檢查員查了出貨基線 schema 之後一致否決:那三張姊妹表的客戶隔離開關其實也全是關的,所以根本不存在「別人有第二道防線、只有它沒有」這個反差。這個否決本身反而確認了一件事:四張流程相關的表在出貨基線裡,資料庫層一道隔離都沒有。

第二層:「這個範圍裡,真的只有這一條嗎?」→ 中等,有兩個保留

  1. 這一棒用的是 low 檔次:一輪研究員掃完 49 支檔,不做威脅建模、不跑第二輪廣度掃描。深度靠檢查員的三票補,廣度沒有第二次機會。
  2. 工具沒有留下「哪幾支檔被讀到結論、哪幾支只是掃過」的紀錄(coverage.research 是空的)。所以我不能宣稱 49 支檔每一支都被完整讀過——只能說範圍設定正確、研究員兩個都完整回報了。

但有一個反向的佐證:卡片列的九項疑點,本棒每一項都有明確答案(成立/不成立/機制屬實但無入口),沒有一項是「沒讀到」。特別是第 7、8 項——那兩項的答案都是「這條路目前沒有外部入口」,而且是靠全 codebase 搜尋確認的,不是靠猜。這表示研究員確實把套件與宿主的接縫走過了一遍。


8. 建議

8.1 不開新的修正卡

本棒沒有新發現,不需要開新卡。 唯一的 P2-1 已是總表 §3.1 第 51 條(決策者 2026-09-13 已裁升 HIGH、併入「程式層那批」一起開卡)。

8.2 建議在既有的第 51 條上補一行

補一句從套件側得到的證實:「套件層 element_variable_service.py:26/:50 同樣零檢查,所以不存在『套件有擋』的可能,守門必須加在主專案 app service 層。」 這句話會讓修正卡的工程師少查一輪。

8.3 建議記一筆「未來的陷阱」(不是現在的漏洞)

JobExecutionService(套件的任務增刪改)已經在主專案的 DI 容器裡建好,但目前沒有任何地方取用。它的 6 支公開方法零守門、零租戶檢查。今天無害,但哪天有人把它接上去用,就是一組現成的無守門端點。建議登記在總表 §3.3(掃描補洞類)或 §3.2,內容:「接好了但沒人用的插頭,未來啟用前必須先補守門。」


9. 執行概況(數字,工程師看的)

照 stamp(CLAUDE-SECURITY-REVISION-ac9c1522c28b.json)的 verification 欄實抄:

項目 值
status verified(面板完整跑完)
candidates 3(研究員提出的原始候選)
candidates_deduped 2(去重後真正送審的)
panel_votes 6(2 條候選 × 3 個檢查員,全數投出,沒有漏投)
panel_quorum_findings 1(達到票數門檻、進報告的)
panel_reviewed_findings 1
unreviewed_candidate_sites 0(沒有任何候選沒被審到)
incomplete_panel_candidates 0
researchers_dispatched / returned 2 / 2(派出兩個研究員,兩個都完整回報)
嚴重度降級 無(severityLowered 空)
驗證輪數 1(一輪跑完,沒有續跑)
耗時 3 小時 21 分(12,089 秒)
scope 檔數 49(git ls-files 事前對過)
掃描版本 ac9c1522c28b4d3514897a3ae8d6b47d53ef8209,工作目錄乾淨(dirty: false)

投票明細:

  • P2-1(流程留言零守門):3 比 0 通過。三個檢查員分別確認了「打得到」(路徑參數直達寫入點)、「影響是真的」(route 只掛登入檢查、無能力點守門)、「沒有東西擋」(逐一排除了各種可能的防線)。
  • 被否決的那條(流程變數表缺租戶欄位 mixin):0 比 3 否決,三個檢查員都以出貨基線 schema 為據。

工具產物落點(在 jedi monorepo 內,該目錄自帶 .gitignore 不入版控): /Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-flow-engine/CLAUDE-SECURITY-20260913-121441/


10. 紀律聲明

  • 只掃不修:本棒沒有修改任何一行程式碼。
  • 沒有動任何環境:資料庫的隔離狀態是讀出貨基線 scripts/init/02-schema.sql 得到的,沒有連線到任何環境,更沒有任何寫入。
  • 沒有執行過程式:所有結論都來自讀程式碼。沒有跑測試、沒有發出任何請求、沒有實際打過那支留言 API。
  • branch 沒切:jedi monorepo 與 BE repo 都在 feature/FR-075。
  • 每條發現都對照過 scope 清單與總表既有卡:P2-1 因此被標為重複而非新發現。