# FR-088.4 H3：流程範本管理——資安掃描報告

- **卡片**：CM-1673（FR-088 第 4 棒）
- **範圍**：BE repo 13 支檔（流程範本的建立／修改／複製／發布／驗證端點，到 domain／infra／DI 接線，以及每輪稽核凍結副本的服務）
- **掃描時間**：2026-09-13（UTC 02:19 起，跑了約 2 小時 16 分）
- **掃描版本**：`e062d4959b37`（branch `feature/FR-075`，工作區 dirty）
- **工具**：Claude Code `claude-security` plugin，effort `low`、focus `attack-surface`
- **驗證章**：`verified`（三個獨立檢查員對 10 條候選各投一票，30 票全數投出，10 條全部 3:0 通過；檢查員把 5 條的嚴重度往下調）
- **只掃不修**：沒有改任何程式碼，沒有動任何環境（只對 DEV 資料庫做唯讀查詢）
- ⚠️ **本報告由首腦補寫**：runner 跑完掃描後沒有交付四件（報告／commit／Notion 回寫／狀態），首腦依手冊第三節第 3 點補寫，所有核對都是首腦自己開檔與查 DEV 做的

---

## 1. 🔴 一句話結論

**掃完了，範圍內找到 2 個真問題，最嚴重的是「任何登入者丟一份特製的流程圖給『檢查流程圖』這支 API，就能把後端一條工作程序卡住兩分鐘，連打四次整個系統對所有客戶停止回應」。** 另一條是流程範本的「讀」沒有掛能力點，同租戶內任何登入者都能拿到全部範本的完整流程圖。開卡七個疑點裡**四個成立、兩個確認沒問題、一個查出了機制但根因還要補查**。範圍外撈到 8 條密碼金鑰進版控，全是舊案。

---

**現況（以 `docs/security-report/M06*.md` 為準）**：F7（檢查流程圖 API，M06-4）→ ✅ 已修（FR-114.1-8）；F10（範本讀取，M06-11）→ ✅ 已修（CM-2035）。範圍外的憑證項（CM-1607／1608／1629／1631 系列）：檔案殘留已清（CM-2048／2049／2051），密碼換發排在正式環境上版前。

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

管理者可以在畫面上建立、修改、複製、發布自己公司的稽核流程範本，也能看原廠內建的範本；畫流程圖時系統會即時幫他「檢查這張圖合不合規則」。這一棒查的是這組功能的 13 支檔：**誰能看誰的範本、存進去的流程圖有沒有先檢查、原廠範本會不會被客戶改掉、「檢查流程圖」這個功能會不會被拿來打垮伺服器、每輪稽核凍結的那份副本為什麼沒標客戶**。

這是本 arc **唯一資料庫隔離是開的**一組（`flow_templates` 表，DEV 實查開關開、4 條規則），所以它同時是「隔離開了之後應用層還剩什麼洞」的對照組。

---

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

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

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 |
|---|---|---|---|---|---|
| **F7**<br>🟡 MEDIUM（研究員報 HIGH，檢查員 3:0 降 MEDIUM） | 「檢查流程圖合不合規則」這支 API **任何登入者都能打、丟進去的流程圖沒有大小上限、而檢查器算法是「每個分岔點都把全部連線掃一遍」** | 一份 40MB、塞 20 萬個分岔點與 20 萬條連線的流程圖，會讓一條後端工作程序算到 120 秒逾時被殺掉；後端預設只有 4 條工作程序，**連打 4 次整個系統對所有客戶停止回應**，重複送就能一直維持。整段還包在資料庫交易裡，會同時佔住資料庫連線池 | ① 任一個有效登入帳號（**不需要任何流程範本權限**）<br>② 請求在 50MB 以內（CM-1587 裁的全站上限，足夠塞幾十萬個節點）<br>③ 全站沒有請求頻率限制（首腦 grep 確認無 flask-limiter 或同類） | 入口 `api/flow_engine/routes/flow_template_route.py:130-144`（只掛 `jwt_required`）<br>schema `api/flow_engine/serializers/flow_template.py:28-29`（`fields.Str(required=True)`，無長度）<br>service `app/flow_engine/service/flow_template_app_service.py:143-157`<br>慢的點：jedi-flow-engine `common/utils/bpmn_topology_validator.py:168` 與 `:271` 兩處 `next((f for f in sequence_flows if …))` 線性掃描 | ① 端點補 `require_capability("flow_template.create"／"flow_template.update")`，與同檔寫入端點一致；② schema 的 `bpmn_xml` 加 `validate.Length(max=…)`（例如 2MB）；③ 驗證器開頭把 `sequence_flows` 建成 `{@id: flow}` 字典，兩處 `next(...)` 改查字典；④ 節點總數設上限（例如 5,000 個直接回 `bpmn_too_large`） |
| **F10**<br>⚪ LOW（研究員報 MEDIUM，檢查員 3:0 降 LOW） | 流程範本的「列表」與「單筆」兩支讀取 API **只驗登入、沒掛 `flow_template.read` 能力點**——而這個能力點確實存在、也綁在「流程範本管理」選單上，等於「前端選單擋住、API 完全敞開」 | 同租戶內任何最低權限的帳號（例如只有 viewer 角色、沒被授予流程範本權限的人），用 curl 就能列出並讀走該租戶全部範本＋所有內建範本的完整流程圖：階段設計、哪個角色負責、哪些條件會跳過缺失改善。**跨租戶讀不到**（資料庫隔離開著） | ① 該租戶內任一可登入帳號<br>② 未被授予 `flow_template.read` | `api/flow_engine/routes/flow_template_route.py:29-45`（列表）、`:58-66`（單筆）；同檔 `:20-26` 只定義了 create／update／delete 三個 decorator | 比照 `api/flow_control/routes/project_route.py:41` 的 `@require_capability("project.read")`，加一個 `_flow_template_read = require_capability("flow_template.read", …)` 掛到兩支 `get`。若產品決定「範本人人可看」，就把能力點與選單綁定一併移除，不要留「DB 說要管、API 不管」兩套真相 |

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

工具設了 `focus` 就會額外跑一次「找密碼金鑰」的專項，掃全 repo 不受 13 檔限制。8 條逐一對照：

| # | 內容 | 對應既有卡／總表條目 | 位置是新的嗎 |
|---|---|---|---|
| F1 | POC 資料庫密碼寫死在文件產生腳本 `docs/system-design/scripts/generate_db_schema_docx.py:34` | CM-1608／CM-1629 | 否（H2、H1 都撈過） |
| F2 | 三家 LLM API 金鑰進對話紀錄 | CM-1607 | 否 |
| F3 | Google Drive OAuth 密鑰進對話紀錄 | CM-1631 | 否 |
| F4 | JWT 簽章金鑰進對話紀錄（與 `.env` 現值相同） | CM-1607（FR-079 F12 已裁 DEV 金鑰不換） | 否 |
| F5 | GitLab 個人權杖進對話紀錄 | CM-1607 | 否 |
| F6 | POC 資料庫超級使用者密碼寫在 `docs/analysis/2026-05-28-poc-db-migration-plan.md:73` | 總表 §3.1 第 2 條已列同一位置 | 否 |
| F8 | Google Drive 權杖加密金鑰進對話紀錄（與部署 env 同值） | CM-1631 | 否 |
| **F9**（LOW） | **開發／測試帳號密碼寫在 `docs/claude/memory/reference_dev_login.md:11`** | **CM-1608**（決策者已裁 blsadmin 密碼測試機專用不換） | **是**——這支檔是 2026-09-06 決策者裁「memory 入版控」後跟著進來的，**憑證被工作過程寫進版控的第四種形狀**（前三：對話紀錄／Trivy 報告／腳本 default） |

**結論：不開新卡。** F9 的位置併入 CM-1608 清理清單（把密碼改成「查 `.env`」，帳號留著）。

---

## 4. 每條發現的詳述

### F7 — 「檢查流程圖」API 可被拿來卡死後端（MEDIUM，可信度 高）

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

畫流程圖的編輯器每次拖一條線，前端就會把整張圖送到後端問「這樣畫合不合規則」。這支 API 三件事都沒做：沒檢查你有沒有流程範本的權限（任何登入者都能打）、沒限制送進來的圖多大（只受全站 50MB 上限）、而且檢查器裡有兩處是「對每個分岔點，把全部連線從頭掃一遍」的寫法——圖越大，計算量是**乘法**成長，不是加法。

**出事會怎樣**

工具描述的攻擊：產生一份約 40MB 的流程圖，放 20 萬個分岔點、每個帶一條指向不存在編號的出線，再放 20 萬條連線。後端對每個分岔點掃 20 萬條連線 → 約 400 億次比對，永遠算不完，直到 120 秒逾時那條工作程序被殺掉重啟。預設 4 條工作程序，開 4 個連線就全佔滿；整段包在 `@transaction` 裡，資料庫連線池（10＋溢位 20）也一起被佔，沒被打到的功能也受影響。50MB 的 XML 展開成 Python dict 還會放大成數百 MB 記憶體，多發併行可能直接把容器打到 OOM。

**要先有什麼才打得到**

- 任一個有效登入帳號，**不需要** `flow_template` 任何能力點
- 全站沒有頻率限制（首腦 grep `api/ app/ core/ config/ common/` 無 `flask_limiter`／`Limiter(`）

**在哪裡（首腦逐一開檔核對）**

- `api/flow_engine/routes/flow_template_route.py:130-144`：`FlowTemplateValidateRoute` 的 `method_decorators = [jwt_required()]`，沒有 `require_capability`；docstring 自陳「FE editor inline warning 拖 sequenceFlow 時 debounce 呼叫」
- `api/flow_engine/serializers/flow_template.py:28-29`：`bpmn_xml = fields.Str(required=True)`，無 `Length`
- `app/flow_engine/service/flow_template_app_service.py:143-157`：`validate()` 直接 `BpmnTopologyValidator(rules).validate(bpmn_xml)`，`@transaction` 內
- jedi-flow-engine `common/utils/bpmn_topology_validator.py:168`（`_check_start_stage`）與 `:271`（`_check_gateway_conditions`）：`flow = next((f for f in sequence_flows if f.get("@id") == fid), None)`——每個出線都線性掃全部連線
- 主專案 `main.py:241-243`：`GUNICORN_WORKERS` 預設 4、`GUNICORN_TIMEOUT` 預設 120；`config/config.py:105`：`MAX_REQUEST_BODY_MB` 預設 50

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

檢查員 3:0 把研究員報的 HIGH 降為 MEDIUM。首腦同意：這是「讓服務停擺」不是「拿走資料」，且要先有一個登入帳號。但它與 P1 F1（惡意流程圖卡死執行緒）、FR-082 CM-1638（AI 聊天無上限）是同一組病——**「一個帳號讓全站不回應」在總表 §2.9 已獨立成 🅹 資源耗盡組**，PM 可依產品定位整組升級。

**怎麼修**

見總覽表四步。**與 P1 F1 的差別**：P1 是「存進去之後讀的人炸」，這條是「不用存、打一次就炸」——門檻更低，修法也更簡單（加能力點＋長度上限兩行就把門檻拉回「要有範本權限＋圖要小」）。第三、四步（驗證器改字典、節點上限）是治本，可與 P1 F1 的修正卡合成一張「BPMN 輸入強固化」。

---

### F10 — 範本讀取沒掛能力點（LOW，可信度 高）

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

系統定義了一個叫 `flow_template.read` 的權限（「檢視流程範本」），前端的「流程範本管理」選單就是靠它決定要不要顯示。但後端提供範本清單與單筆內容的兩支 API 沒有檢查這個權限，只檢查有沒有登入。

**出事會怎樣**

同租戶內一個沒被授予這個權限的員工（前端看不到選單），直接打 API 就能列出全部範本編號、再逐一讀走每份的完整流程圖——包含哪個階段由哪個角色負責、哪些閘道條件會讓流程跳過缺失改善階段。**跨租戶讀不到**：首腦 DEV 實查 `flow_templates` 的 select 規則是「超級管理員 OR 內建範本 OR 本租戶」，隔離開關開著。

**在哪裡（首腦核對）**

- `api/flow_engine/routes/flow_template_route.py:29-45` `FlowTemplatesRoute.get`、`:58-66` `FlowTemplateRoute.get`：`method_decorators = [jwt_required()]`，無能力點
- 同檔 `:20-26`：`_flow_template_create`／`_update`／`_delete` 三個 decorator，**沒有 `_read`**
- 能力點存在：`scripts/init/04-seed-core.sql:306`（id 117 `flow_template.read`）、`scripts/sql/2026-05-12-flow-template-permissions.sql:19`
- 同 repo 讀端點掛能力點的既有做法：`api/flow_control/routes/project_route.py:41/97/145` `@require_capability("project.read")`

**為什麼是 LOW**

檢查員 3:0 從 MEDIUM 降 LOW：影響侷限同租戶、內容是流程設計不是個資或憑證、且與 CM-1585／1589（讀端點漏守門）同型且已有修法可抄。首腦同意，但要記一筆：**這是本專案「讀取端點漏守門」第四次出現**（CM-1585 角色、CM-1589 部門／租戶／使用者、FR-079 公告、FR-081 意見回饋、現在流程範本），總表 §0「反覆出現的模式」那條要把次數更新。

**怎麼修**

一行 decorator 掛到兩支 `get`；或做產品決策把能力點拔掉。**不要維持現狀**——「DB 說要管、API 不管」的兩套真相會讓下一個人以為有守門。

---

## 5. 「重點看什麼」逐項回應（首腦自答）

| # | 卡片疑點 | 結論 | 核對 |
|---|---|---|---|
| 1 | 🔴 建立／更新範本時不解析 XML | ✅ **成立，但影響面比卡片想的小** | `create()`（`:69-84`）、`update()`（`:87-108`）、`duplicate()`（`:159-178`）確實都不解析；`publish()`（`:110-120`）才解析＋拓撲檢查。**但宿主側的 mapper 不解析**（`infra/flow_engine/mapper/flow_template_mapper.py` 無 `BpmnUtils`），列表也不帶 XML——所以壞 XML 存在 `flow_templates` 時，**讀它不會炸、發布會被擋**。真正會炸的是 P1 F3 那條：套件側 `workflow_templates` 的 mapper 每讀必解析。**兩張表兩種行為，宿主這張是安全的那個。** 記錄，不另計 |
| 2 | 🔴 `validate` 端點任意 XML 解析 | ✅ **成立 → F7** | 而且比卡片預期的更糟：不只「無次數限制」，還有算法平方級與 `@transaction` 佔連線池 |
| 3 | 寫入有能力點、讀取只有登入 | ✅ **成立 → F10** | RLS 四條規則首腦實查：select 允許 `is_super_admin` OR `is_builtin` OR 本租戶；update／delete 額外要求 `tenant_id IS NOT NULL`（內建範本 tenant 掛 root 1）。**平台管理員（root 租戶）看得到全部是合法的「上層看下層」**（FR-087 已證） |
| 4 | 內建範本守門 | ✅ **確認沒問題** | `_guard_builtin_writable` 四處呼叫（`:90/:112/:126/:138`）涵蓋 update／publish／unpublish／soft_delete；`create` 不會碰內建、`validate` 不寫、`duplicate` 是刻意複製到自己租戶。**沒有漏掉的寫入路徑** |
| 5 | 🔴 凍結副本不帶租戶 | ⚠️ **機制找到了，但根因要補查（不用重掃）；且 FR-087 的描述要修正** | **機制**：套件範本鏈（entity／mapper／repo／service）**零 tenant_id 處理**，租戶靠 jedi-common `db_mw.py:64` `set_tenant_info_before_insert` 在 flush 前從 `get_user_context().tenant_id` 自動填——**沒有 user context、或 context 的 tenant_id 是 None 就留空**。**DEV 實查修正 FR-087 §5.2 的說法**：191 筆無租戶的裡面，**只有 5 筆是輪次快照**（`provider='snapshot'`），**186 筆是 `Billows-Official` 的母版**（module-frame 匯入建的）；建立者 jediadmin（租戶 158、非超管）119 筆、blsadmin（租戶 102、超管）72 筆，時間 2026-07-27～08-09；186 筆母版裡 127 筆仍連著執行紀錄。**回填時要分兩類處理，不能一律當「凍結副本」**。**根因候選**：那段時間 module-frame 匯入路徑的 user context `tenant_id` 為 None（FR-069.16 之前 `X-Tenant-ID` 處理不同）——**要補查該路徑當時怎麼組 context，一個 `git log -L` 就能答，不用重掃** |
| 6 | 列表 `ilike` 通配字元 | ✅ **記錄，不算問題** | `flow_template_repo_impl.py:44` ORM 綁參數，`%`／`_` 未跳脫只影響搜尋範圍，12 筆的表掃全表無感 |
| 7 | DTO 回傳範圍 | ✅ **確認沒問題** | `flow_template_dto.py:10-21` 只有 uid／name／description／is_builtin／status／bpmn_xml（詳情才帶）／created_user＋nickname／updated_user＋nickname／時間，**無敏感欄位**，審計欄位有照 CLAUDE.md 規範補 nickname |

---

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

**第一層「這兩條存在嗎」→ 可信。** 首腦逐行開檔、DEV 實查 RLS 規則與 gunicorn／body 設定，與工具描述一致；30 票全投、兩條皆 3:0。

**第二層「這 13 檔只有這兩條嗎」→ 不可宣稱。** 30 票裡 **24 票投在範圍外的憑證重複案**，真正投在 13 檔的只有 6 票；無逐檔閱讀帳本；**沒有 runner 的人工那一輪**（這棒沒交付，人工核對全由首腦做，首腦只追了卡片七項與工具兩條，沒逐行讀 13 檔）。疑點 5 的根因待補查。

---

## 7. 執行概況（工程師看的）

| 項目 | 數值 |
|---|---|
| `verification.status` | `verified` |
| 候選／去重 | 10／10 |
| `panel_votes` | 30（10×3，全投出） |
| `panel_quorum_findings` | 10（全 3:0） |
| 嚴重度調降 | 5 條（F1／F2／F7／F8 HIGH→MEDIUM、F10 MEDIUM→LOW） |
| 研究員 派出／回報 | 2／2 |
| `unreviewed_candidate_sites` | 0 |
| 範圍檔數 | 13（stamp scope 與卡片 scope 逐檔相同，首腦比對） |
| 耗時 | 8,186 秒（約 2 小時 16 分） |
| 產物 | `CLAUDE-SECURITY-20260913-021915/` |
| runner 交付 | ❌ 四件皆無，首腦補寫 |

---

## 8. 建議的後續（交決策者裁，本棒不開卡）

1. **F7 與 P1 F1、F3 合成一張「BPMN 輸入強固化」卡**（總表 §2.9 🅹 組）：能力點＋長度上限兩行先把門檻拉回，驗證器改字典＋節點上限治本。
2. **F10 併入「讀端點漏守門」那組**（CM-1585／1589 同型，一行 decorator）。
3. **疑點 5 的 191 筆回填**要先補查根因、再分「186 筆母版」與「5 筆快照」兩類處理——這是 FR-087 第 43 條戊組的前置，**不補查就開隔離會弄壞 127 筆還在跑的流程**。
4. **F9 併入 CM-1608**：memory 檔裡的密碼改成「查 `.env`」。
5. **runner 未交付**：這是本 arc 第一次、全計畫第四次（C1／S7／B2／H3）。派工 prompt 已含「交付四件缺一不可」，仍漏——建議下棒派工時要求 runner **先回報「四件的落點」再開始掃**。
