---
title: "FR-088.6 P2：流程執行核心（套件本體 49 檔）——資安掃描報告"
description: "流程執行、任務執行、流程變數三條鏈與插件組裝根的資安掃描結果"
---

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

- **卡片**：[CM-1675](https://app.notion.com/p/FR-088-6-jedi-flow-engine-42-jedi-monorepo-Opus-1M-low-3d9346da4cd08144be1bfe9b5d93f5a6)（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**<br>🟡 MEDIUM<br>**⚠️ 重複，非本棒新發現** | 流程留言的讀與寫，**只認呼叫者傳進來的流程編號，不檢查這個流程是不是他們專案的** | 任何登入帳號只要知道別家客戶的流程編號，就能讀走整串討論留言（含留言內容、留言者帳號與暱稱），也能用自己的名義往那個流程塞留言 | ① 任一個有效登入帳號（不需任何特殊權限）<br>② 知道目標流程的編號（UUID 猜不到，但會出現在網址與清單 API 回應裡，而且那張資料表沒開客戶隔離，本來就會外漏編號） | 套件側：`jedi_flow_engine/app/service/element_variable_service.py:50`（寫）、`:26`（讀）<br>宿主側：`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 因此被標為重複而非新發現。
