# FR-088.1 H2：任務完成／回退／留言／證明——資安掃描報告

- **卡片**：CM-1670（FR-088 第 1 棒）
- **範圍**：BE repo 23 支檔（任務證明與流程留言的網址入口到守門）
- **掃描時間**：2026-09-12（UTC 03:43 起，跑了約 3 小時 10 分）
- **掃描版本**：`84ea07ef9fe286d6b3bc33569539cdc38dd2ccde`（branch `feature/FR-075`）
- **工具**：Claude Code `claude-security` plugin v0.11.0，effort `low`、focus `attack-surface`
- **驗證章**：`verified`（三個獨立檢查員對每條發現各投一票，30 票全數投出，沒有漏投）
- **只掃不修**：本棒沒有改任何程式碼，也沒有動任何環境（只對 DEV 資料庫做了唯讀查詢）

---

## 1. 🔴 一句話結論

**掃完了，範圍內找到 3 個真問題，最嚴重的是「任何登入者只要知道一個任務編號，就能讀走別家客戶的稽核證明清單」——而資料庫層完全沒有第二道防線。** 另外工具在範圍外順手撈到 6 條密碼／金鑰被寫進版控的問題，**全部都是已經開過卡的舊案**，不算本棒的新發現。

---

**現況（以 `docs/security-report/M06*.md` 為準）**：F1／F8（證明清單讀取，M06-1）→ ✅ 已修（CM-2059）；F9（流程留言讀寫，M06-2）→ ✅ 已修（CM-2035）；建議後續第 3 點的流程定義外洩併 P1（M06-8）已修。範圍外的憑證項（CM-1607／1608／1629／1631 系列）：檔案殘留已清（CM-2048／2049／2051），密碼換發排在正式環境上版前。

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

稽核任務跑起來之後，使用者會在畫面上做四件事：把任務標成「完成」、把完成的任務「退回」、在流程上「留言」、替任務上傳「證明文件」。

這一棒檢查的就是：**當這四個動作從網址送進後端時，後端有沒有問一句「你是不是這個專案的人」。** 因為這些網址只帶一個編號（例如某個任務的編號），沒有帶「哪個專案」，所以如果後端不主動反查並比對，任何一個登入者都能把編號換成別人的，去動別人的資料。

---

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

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

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 |
|---|---|---|---|---|---|
| **F1**<br>🔴 HIGH | 「查某個任務的證明清單」這支 API **沒有檢查你是不是這個專案的人**，只檢查你有沒有登入 | 讀走別家客戶的稽核證明清單：檔名、描述、參考網址、Google 雲端硬碟連結、上傳者帳號、檔案指紋。**拿到檔案編號後還能再去下載檔案本體** | ① 任一個有效登入帳號（不需任何權限）<br>② 知道一個任務編號（是亂數 UUID，通常要從被轉寄的連結、截圖、log 或匯出檔取得，猜不出來） | `app/flow_engine/service/job_evidence_service.py:48` | 在第 48 行取到 `job_execution` 之後、第 55 行查證明之前，補一行 `self._workflow_execution_service.assert_project_participant(job_execution.workflow_execution_id)`——**同一個檔案的新增（:81）、更新（:145）、刪除（:168）都已經有這行了，只有「讀」漏掉** |
| **F8**<br>🟡 MEDIUM | 同一支服務裡「查單一筆證明」（`get_job_evidences_uid`）也**沒有檢查歸屬** | 同 F1，但目前打不到——回傳前程式會先自己壞掉（見下方詳述），所以是「守門缺口」不是「資料外洩」 | 同 F1 | `app/flow_engine/service/job_evidence_service.py:56`（缺口本體在 `:67` 的 `get_job_evidences_uid`） | 在 `get_job_evidences_uid` 取到 `job_evidence` 之後補 `assert_project_participant(job_evidence.workflow_execution_id)` |
| **F9**<br>🟡 MEDIUM | 「流程留言」的讀與寫**都沒有檢查你是不是這個專案的人** | 任何登入者可以對別家客戶的稽核流程塞留言、也能讀走整串既有討論。留言還會透過即時推播丟給該專案的正當成員，可以拿來假冒同事做釣魚（例如「請把稽核報告寄到 xxx@evil.com」）。留言存成 JSON 陣列且沒有筆數上限，也能被灌爆 | ① 任一個有效登入帳號<br>② 知道一個流程編號 | 寫：`api/flow_engine/routes/flow_engine_route.py:105`（整支 `put` 從 `:66` 起）<br>讀：同檔 `:44-64` 的 `get` | 在 app service 層（**不是在 route**）解出 `workflow_execution` 之後呼叫 `assert_project_participant(workflow_execution.id)`，和同模組的「完成任務」（`workflow_execution_service.py:655`）、「退回任務」（`:833`）同一套做法。讀寫都要加 |

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

工具設了 `focus` 就會額外跑一次「找密碼金鑰」的專項掃描，它會掃全 repo、不受本棒 23 檔範圍限制。以下六條**都不在本棒範圍內**，且經比對**全部落在既有卡片上**：

| # | 內容 | 對應既有卡 |
|---|---|---|
| F2（HIGH） | GitLab 個人存取權杖被寫進對話紀錄檔（9 個檔） | **CM-1607**（清版控殘留金鑰） |
| F3（MEDIUM） | POC 資料庫密碼寫死在文件產生腳本裡 `docs/system-design/scripts/generate_db_schema_docx.py:34` | **CM-1608**／**CM-1629** |
| F4（MEDIUM） | JWT 簽章金鑰被寫進對話紀錄檔（78 個檔含此字串） | **CM-1607**（FR-079 F12 已併入） |
| F5（MEDIUM） | OpenAI／Anthropic／Google 三家 API 金鑰被寫進對話紀錄檔 | **CM-1607** 系列（FR-079 B2 F2 已記） |
| F6（MEDIUM） | Google 雲端硬碟應用程式密鑰 ＋ 保護權杖用的加密金鑰一起外洩 | **CM-1631** |
| F7（MEDIUM） | Nexus 私有套件庫的 admin 帳密用 base64 寫死在前端打包腳本 `scripts/build/build_fe_image.sh:51` | **CM-1634** 系列（FR-083 D2 F2 已記同一組帳密） |

**結論：範圍外這 6 條不需要開新卡。** 但它們再次佐證同一件事——「把 `.env` 原樣貼進對話紀錄」這個習慣，讓同一批金鑰散進了幾十到幾百個檔案。

---

## 4. 每條發現的詳述

### F1 — 查任務證明清單沒有檢查歸屬（HIGH，可信度 高）

**這是什麼問題（白話）**
畫面上點開一個稽核任務，會看到「這個任務附了哪些證明文件」。後端提供這個清單的 API，只確認「你有登入」，**沒有確認「這個任務是不是你們專案的」**。

**出事會怎樣**
換一個任務編號就能看到別家客戶的證明清單，內容包含：檔名、描述、參考網址、Google 雲端硬碟的檢視連結、上傳者的帳號、檔案的 MD5 指紋、檔案編號。拿到檔案編號之後，還可以再去換簽章憑證把**檔案本體下載下來**。稽核證明常常裝的是內部組態、掃描報告、人員名單。

**要先有什麼才打得到**
1. 任一個有效的登入帳號（不需要是管理員，不需要任何角色）
2. 知道一個任務編號。編號是亂數 UUID（像 `3f2a…`），**猜不出來**，實務上要從被轉寄的畫面連結、截圖、log 摘錄、匯出報表，或「曾經是這個專案成員但被移除」取得
3. 資料庫層不會擋 —— 見下方首腦核對

**在哪裡**
- 入口：`api/flow_engine/routes/job_evidence_route.py:25-30`（`GET /api/1.0/job-evidences?job_execution_uid=<編號>`，只掛了 `@jwt_required()`）
- 缺口本體：`app/flow_engine/service/job_evidence_service.py:42-59`，第 48 行只用編號查出任務就往下走
- 對照組（**同一個檔案裡有做的**）：`:81`（新增）、`:145`（更新）、`:168`（刪除）都呼叫了 `assert_project_participant`

**怎麼修**
在 `get_job_evidences_by_job_execution_uid` 第 48 行取到 `job_execution`、第 55 行查清單之前，插一行：

```python
self._workflow_execution_service.assert_project_participant(job_execution.workflow_execution_id)
```

守門政策在 `common/authz/workflow.py`，任何角色的專案參與者都放行、只擋非參與者，查不到專案就預設擋下來。

**首腦核對註記（我自己開檔＋查 DEV 資料庫確認）**
- ✅ 開檔核對成立。`job_evidence_route.py:28-29` 確實直接用 `request.args.get('job_execution_uid')`；`job_evidence_service.py:42-59` 確實沒有任何授權檢查，而 `:81/:145/:168` 三處確實都有，註解還寫著「擋跨專案 / 跨租戶猜 uid」——**是漏掉不是刻意**。
- ✅ **資料庫層真的沒有第二道防線**。我用唯讀 SQL 查 DEV 資料庫，`compliance.job_evidences`（848 筆）與 `compliance.job_executions`（11,065 筆）的「每個客戶只能看自己資料」隔離機制（RLS）**開關是關的、規則 0 條**。出貨基線 schema（`scripts/init/02-schema.sql`）全庫只有 31 張表開了這個機制，這兩張都不在裡面。
- ⚠️ 一個細節要修正工具的說法：工具說「證據檔案本體可以再下載」，這一步我**沒有進到檔案模組去核對**（不在本棒範圍），所以這一環標記為**未經核對的推論**。前面的清單外洩本身已經確認成立。

---

### F8 — 查單一筆證明也沒有檢查歸屬（MEDIUM，可信度 高）

**這是什麼問題（白話）**
和 F1 同一支服務裡，還有另一支「用證明編號查單一筆」的方法，一樣沒有檢查歸屬。

**為什麼是 MEDIUM 不是 HIGH**
因為**目前打不到**。三個檢查員都指出：`api/flow_engine/routes/job_evidence_route.py:71` 把「一筆」資料餵進了一個宣告成「多筆」的序列化器，而那個資料物件是純 dataclass、不能被逐項迭代，所以在資料送出去之前程式就會自己壞掉（工具原本另外報的 F10 就是因此被 3 票全數否決）。**守門缺口是真的，資料外洩目前不成立。** 但這是「靠一個 bug 擋住」，那個 bug 一被修好，缺口就變成真的外洩。

**在哪裡**
`app/flow_engine/service/job_evidence_service.py:61-70`（`get_job_evidences_uid`），route 在同檔 `api/flow_engine/routes/job_evidence_route.py:59-72`

**怎麼修**
和 F1 一起補：

```python
self._workflow_execution_service.assert_project_participant(job_evidence.workflow_execution_id)
```

**首腦核對註記**：✅ 開檔核對成立，`get_job_evidences_uid` 確實只查 uid 就回傳。序列化器不匹配這件事我讀了 route 第 70-71 行確認形狀對不上（單筆丟進 `many=True`），與檢查員說法一致。

---

### F9 — 流程留言的讀與寫都沒有檢查歸屬（MEDIUM，可信度 中）

**這是什麼問題（白話）**
稽核流程頁面上有一個討論區可以留言。留言的「寫」和「讀」兩支 API，都只確認你有登入，**沒有確認這個流程是不是你們專案的**。

**出事會怎樣**
1. **竄改稽核紀錄的脈絡**：可以在別人的稽核流程裡塞留言，而留言會經即時推播丟進該專案成員的畫面，成員重新載入就看到一則「同事的留言」——可以拿來做釣魚（「請把報告寄到某某信箱」）。
2. **讀走別人的討論內容**。
3. 留言存進一個 JSON 陣列，**沒有筆數與長度上限**，可以一直塞把那一列灌爆。

**要先有什麼才打得到**
① 任一個有效登入帳號 ② 知道一個流程編號（同樣是 UUID，要取得而非猜出）

**為什麼是 MEDIUM 不是 HIGH**
影響的是討論留言而不是證明文件本體，且同樣需要先取得編號。但**如果把「假冒同事推播釣魚」算進來，實務衝擊不見得比 F1 低**——這點我保留意見，交決策者判斷。

**在哪裡**
- 寫：`api/flow_engine/routes/flow_engine_route.py:66-121`（`PUT /api/1.0/flow-engine/process/comments/<id>`），守門缺口在 `:105` 附近實際寫入處
- 讀：同檔 `:44-64`（`GET` 同一條路徑）
- 對照組：同模組「完成任務」`app/flow_engine/service/workflow_execution_service.py:655`、「退回任務」`:833`，兩支都有 `assert_project_participant`

**怎麼修**
把守門加在 **app service 層**（不是 route，route 只負責 HTTP 邊界）：解出 `workflow_execution` 之後呼叫 `assert_project_participant(workflow_execution.id)`，讀寫兩條路徑都要。另建議替留言內容加長度與筆數上限。

**首腦核對註記**
- ✅ 開檔核對成立。`flow_engine_route.py` 的 `WorkflowExecutionCommentRoute` 的 `get`（:44）與 `put`（:66）都只有 `@jwt_required()`，route 內直接查 `workflow_execution` 再讀寫留言，全程沒有任何歸屬檢查。
- ✅ 對照組確認：`complete_job`（:655）與 `revert_job`（:833）真的都有守門，**所以「留言漏掉」是這個模組自己的不一致，不是設計上刻意不守**。
- ✅ 資料庫層同樣沒有兜底：DEV 實查 `compliance.element_variables`（3,236 筆）隔離開關關、規則 0 條；`compliance.workflow_executions` 有 3 條規則但**開關是關的**，等於規則寫好了沒生效。
- ⚠️ 留言內容被前端怎麼渲染（會不會變成「攻擊者存進去的內容，別人打開頁面時被當程式執行」）**本棒沒有追前端**，只記錄不下結論。

---

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

卡片列了 8 項思考起點，逐項交代（**證偽和證實一樣重要**）：

| 卡片上的疑點 | 結果 |
|---|---|
| ① 流程留言 GET／PUT 零守門 | ✅ **成立** → F9。讀寫都沒守門，與「完成／退回」不一致 |
| ② 任務證明「讀」零守門、「寫」有守門 | ✅ **成立** → F1（清單）、F8（單筆）。寫的三處確實都有 |
| ③ `job_evidences`／`element_variables` 隔離關、零規則 | ✅ **成立**，我用唯讀 SQL 在 DEV 實查確認（見上表）。`job_executions` 也是關的、零規則 |
| ④ 守門政策三個「不守」分支 | ⚠️ **部分推翻卡片的假設，需要決策者看一眼**。`common/authz/workflow.py:38` 的註解寫「tenant admin 放行（RLS 仍限租戶）」，但我追到源頭 `jedi-iam/jedi_iam/middleware/context.py:134` 是 `is_admin=user.is_super_admin`——**這個旗標實際上是「平台超級管理員」不是「租戶管理員」**。所以卡片擔心的「租戶 A 的管理員能動租戶 B」**不成立**（因為根本不是租戶管理員）。但**註解是錯的**，而且它宣稱的兜底「RLS 仍限租戶」在這三張表上是空話（隔離根本沒開）。<br>另兩個分支：「沒有登入身分放行」屬背景任務路徑（同 CM-1559 同源，已有卡）；「`role_service` 沒注入放行」我查了 DI 接線（`di_containers/flow_engine/workflow_excution_containers.py:151`），正式路徑**有注入**，不會走到這個放行分支 |
| ⑤ 反查鏈對「輪次主流程」恆回 None → 100% 誤擋 | ⚠️ **狀況屬實但沒有引發繞過**。`workflow_execution_service.py:137-153` 確實在查不到時回 None，而守門對 None 是預設擋下來。我 grep 過全部呼叫端：**只有 FR-050 的階段回退傳了 `known_project_id` 繞過反查鏈，而且它是傳「已解析出的正確專案編號」、不是可被外部操控的值**，其餘呼叫端都沒有為了繞過而拿掉守門。**沒有發現「因為必被擋所以有人把守門刪掉」的情況** |
| ⑥ 完成／退回端點的 body 沒有 schema | ✅ **讀過了，不構成安全問題**。守門在 service 層（`:655`／`:833`）確實有；`revert_to_job_id` 指到別的流程的 job 時，`workflow_execution_service.py:857` 會因為在 BPMN 圖裡找不到節點而回 404（`GRC_JOB_NOT_FOUND`），不會亂跑；`request.get_json()` 遇到非 JSON 在 Flask 3.1 是回 400／415 而不是 500 |
| ⑦ 流程定義 GET 回整份範本 XML 給任何登入者 | ⚠️ **人工核對成立，但工具沒有報，故未經三人投票**。`flow_engine_route.py:29-37` 只有 `@jwt_required()`；套件端 `jedi-flow-engine` 的 `get_workflow_template_by_uid`（`workflow_template_service.py:46-56`）確實只用 uid 查、沒有租戶檢查；而 `compliance.workflow_templates` 在 DEV 實查是**規則 4 條但開關關的**（＝規則沒生效）。**結論：知道範本編號就能讀到別家客戶的流程圖（含自訂階段、角色、任務名稱）。** 這條的責任層在套件（屬 P1 範圍），本棒只記錄、建議併入 P1 那一棒處理 |
| ⑧ 我的任務清單 GET | ✅ **這條沒問題**。`flow_engine_route.py:175-186` 用的是登入者自己的 `user.id`，而 `infra/readmodel/tasks/my_grc_jobs_query.py:74-77` 的查詢確實是 `VwUserJobQueue.user_id == user_id`，使用者無法指定別人的 id。**不是 FR-087 說的那 4 支「用擁有者身分繞過隔離」的 view 之一**（那個問題的形狀是「view 沒有帶 security_invoker，導致查詢用 view 擁有者的身分跑」，這裡因為過濾條件綁死自己的 user_id，繞不過去） |

---

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

分兩層講，不要混在一起：

### 第一層：「報出來的這些，真的存在嗎」→ **可信度高**

- 三個獨立檢查員從零讀檔，對 10 條候選各投一票，**30 票全數投出，沒有漏投**。9 條通過、1 條（F10）被 3 票全數否決。
- 通過的 9 條裡，8 條是 3:0 全數同意，1 條（F5，範圍外的 LLM API 金鑰）是 2:1。
- 檢查員主動把 2 條的嚴重度**往下調**（F3、F4 從 HIGH 降 MEDIUM），代表這個投票不是橡皮圖章。
- **範圍內 3 條我全部自己開檔核對過**，另外用唯讀 SQL 在 DEV 查了資料庫隔離狀態。核對結果與工具一致，只有 F1 的「檔案本體可下載」那一環我標為未核對的推論。

### 第二層：「這 23 個檔只有這些問題嗎」→ **不可宣稱**

- effort 設 `low`，代表**只派了一位研究員**通讀全範圍（實際派 2、回 2，含密鑰專項那支），不是逐檔窮舉。
- 工具**沒有申報逐檔閱讀帳本**（`coverage.research` 是空的），所以「哪幾個檔真的被讀完了」無從查證。
- **注意力被稀釋**：10 條候選裡有 6 條是範圍外的密鑰重複案，也就是說 30 票裡有 18 票投在範圍外，真正投在這 23 個檔上的只有 12 票。
- 兩條有份量的補充（上表的 ④ 註解與事實不符、⑦ 流程定義外洩）**是我人工追出來的，工具一條都沒碰**。

**白話總結：報出來的問題是真的；但「掃過了」不等於「這 23 個檔乾淨了」。**

---

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

照 stamp 的 `verification` 欄實抄：

| 項目 | 數值 |
|---|---|
| `status` | `verified` |
| `candidates` | 11（去重後 10） |
| `panel_votes` | 30 |
| `panel_quorum_findings` | 9 |
| `researchers_dispatched / returned` | 2 / 2 |
| `unreviewed_candidate_sites` | 0 |
| `verificationRun` | 1 |
| `lostCandidates` | 無 |
| `severityLowered` | F3（HIGH→MEDIUM）、F4（HIGH→MEDIUM），皆由檢查員投票結果決定 |
| 掃描範圍 | 23 支檔（`scopeFileCount: 23`，與 `git ls-files` 對上） |
| effort / focus | `low` / `attack-surface` |
| 完整度檢查 | `not-applicable`（範圍掃描不做全樹盤點） |
| Run ID | `wf_bd76357a-f9d` |
| 耗時 | 約 3 小時 10 分（11,366 秒），32 個 agent 全數完成、0 失敗 |

**沒有執行任何程式碼**：所有發現都是讀程式碼推導出來的，沒有實際打過 API、沒有驗證過任何攻擊手法。對資料庫只做了唯讀查詢（確認隔離開關與筆數）。

---

## 8. 建議的後續（交決策者裁）

1. **F1 + F8 一起修**（同一個檔案、同一種修法，補兩行）——建議開一張修正卡。
2. **F9 單獨修**（改的是另一個模組的 app service 層，且讀寫兩條都要）——建議開一張修正卡。
3. **上表 ⑦ 的流程定義外洩**責任在 `jedi-flow-engine` 套件，建議**併入 P1 那一棒**，不在本棒開卡。
4. **上表 ④ 的錯誤註解**（`common/authz/workflow.py:38` 把平台超管寫成 tenant admin）——衛生類，建議併入前述任一張修正卡順手改掉，**因為這行註解會誤導下一個讀它的人以為有租戶兜底**。
5. **這三張表要不要補資料庫層隔離**（`job_evidences` / `job_executions` / `element_variables`）——這是治本方向，但屬跨模組決策，且 `workflow_executions` 已經出現「規則寫好卻沒開開關」的狀況（FR-087 已記），建議與 FR-087 一起收口，不在本棒決定。

