# W2 掃描報告 — 任務使用問卷與檢測（CM-2091）

> 範圍：6 檔／1,522 行（`app/flow_control/service/job_handlers/survey_handler.py`、`job_handlers/__init__.py`、`app/flow_control/service/job_service.py`、`app/flow_control/dto/job_dto.py`、`app/flow_engine/service/job_evidence_service.py`、`infra/readmodel/tasks/job_batch_complete_query.py`）。
> 掃描工具：Claude Code 官方 `claude-security` plugin，effort low，focus 生產程式碼。
> 掃描基準 commit：`081b61cc812a`（工作區有其他 session 的未提交改動）。
> 驗證章：**verified**（5 條候選，面板 15 票全數投出，5 條全部 3:0 成立）。只掃不修。
> 與 W1（CM-2090，`scan-W1.md`）同一個 runner 連續跑；第 6 節回答「W1 看到的守門零件，在這條路上有沒有被用到」。

---

## 1. 一句話結論

**規劃頁的「任務」那組網址，在目前分支上只問「你是不是『網址上那個專案』的管理者」，從來不問「這張任務是不是那個專案的」。** 所以甲專案的管理者，把網址裡的任務編號換成乙專案的，就能改掉或刪掉乙專案的任務——連同乙專案問卷的填答資料一起刪（W2-1、W2-2）。讀的那半更鬆：看任務詳情、看任務證明清單，連「是不是專案成員」都不問（W2-3、W2-5）。

這五條裡四條是總表已登記的舊案，而且修正分支 `fix/security-b1` 已修好（CM-2039、CM-2035），**只是還沒合進目前分支**。**真正新的是「任務清單」那一支（W2-4）**：修正分支上也沒補，它是另外幾條的「入場券」——拿到清單，就拿到別專案所有任務編號。

**跟 W1 接起來看**，最要緊的是這件事：W1 查到問卷套件判斷「你能不能看／填這份問卷」，靠的是 `task_assignees`（任務指派表）裡記的專案編號。而 W2-2 這條路**可以替別專案的任務寫一列指派、專案編號填自己的**——寫完之後，問卷套件就會把那份問卷當成攻擊者自己專案的，攻擊者同時變成「被指派人」與「專案管理者」，可以讀寫別專案的問卷答案（第 6 節）。修正分支的 CM-2039 已經把這條封住。

卡片點名的其餘幾點都查過：問卷掛上任務時的客戶歸屬由資料庫隔離規則擋住（跨客戶打不到）、檢測工具帳密兩個出口都有遮、批次完成查詢有綁任務。理由在第 5 節。

---

## 2. 這一棒在檢查什麼

W1 看的是「主專案把問卷套件接上來的那一層」。W2 看的是**另一個方向**：主專案自己的「任務」模組，拿套件的東西來用——

- 規劃頁新增／修改任務時，把問卷掛上去（對每份問卷複製一份「快照」，再依「問卷 × 受檢設備」建立 `task_survey` 關聯），也把檢測工具綁上去。
- 任務回給前端時，把檢測工具的帳號密碼遮掉。
- 任務的證明清單（上傳的檔案、連結、問卷）怎麼讀。
- 批次完成任務時，檢查每張任務的問卷是不是都填完了。

**這些是主專案自己的路，套件的守門管不到這裡。**問卷套件擋的是「填答」那幾支網址；規劃頁建立、刪除 `task_survey` 走的是主專案直接呼叫套件的 domain service，套件那邊的守門根本不會經過。

---

## 3. 掃到什麼：總覽

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才會發生 | 在哪裡（該補檢查的位置） | 嚴重度 | 總表對照 | 來源 |
|---|---|---|---|---|---|---|---|
| W2-1 | **刪任務時不核對「這張任務是不是網址上那個專案的」** | 甲專案管理者可以把乙專案的任務整張硬刪，連問卷快照、已填的答案、指派人一起刪，乙專案的「規劃期凍結」也擋不住 | ① 是同一客戶內**任何一個**專案的管理者 ② 知道乙專案某張任務的編號 | `app/flow_control/service/job_service.py:211`（`delete_job` 起點） | **中** | 總表第 63 項（部分修）；修正分支 CM-2039 已修 | 工具 3:0 |
| W2-2 | **改任務時不核對任務歸屬** | 甲專案管理者可以改乙專案任務的名稱、類型、問卷、設備、檢測工具設定，還能把自己寫進乙專案任務的指派人——接著就能讀寫乙專案那份問卷的答案（第 6 節） | 同上 | `app/flow_control/service/job_service.py:266`（`update_job` 起點） | **中** | 總表第 63 項；CM-2039 已修 | 工具 3:0 |
| W2-3 | **任務證明清單不問你是不是專案成員** | 同客戶內任何人，拿到別專案的任務編號，就看得到那張任務上傳的檔名、檔案編號、連結、問卷快照編號 | ① 同客戶內任何登入帳號 ② 知道任務編號 | `app/flow_engine/service/job_evidence_service.py:46`（`get_job_evidences_by_job_execution_uid` 起點） | **中** | 總表第 7 項（部分修）；修正分支 CM-2035 已修 | 工具 3:0 |
| W2-4 | **任務清單不問你是不是專案成員**（修正分支上也還沒補） | 同客戶內任何人，只要知道一個專案編號，就能把那個專案每個查核項目底下的任務全列出來：任務編號、指派人、問卷、檢測工具設定——正是 W2-1～3 要用的「任務編號」的來源 | ① 同客戶內任何有 `project` 模組的登入帳號 ② 知道專案編號（查核項目編號是公開的標準編號，猜得到） | `app/flow_control/service/job_service.py:244`（`list_jobs` 起點） | **中** | **總表未登記**（第 63 項只講詳情，不含清單） | 工具 3:0 |
| W2-5 | **看任務詳情不問你是誰** | 同客戶內任何人拿任務編號就看得到那張任務的完整設定 | ① 同客戶內任何登入帳號 ② 知道任務編號 | `app/flow_control/service/job_service.py:140`（`get_job` 起點） | **低**（面板從中調降：只讀一張任務、要先知道編號） | 總表第 63 項本身；CM-2039 已修 | 工具 3:0 |

**另有一條是我讀程式補的、沒經過投票**：W2-2 寫進去的那一列指派，會讓 W1 看過的問卷守門判錯專案（第 6 節）。這是 W1 與 W2 的接縫，也就是卡片擔心的「責任真空」。

---

## 4. 每條發現的詳述

### 4.1 W2-1、W2-2、W2-5 規劃頁的「任務」網址只認網址上的專案、不認任務本身屬於誰

**白話說明**

規劃頁上每張任務的網址長這樣：`/grc/project/<專案編號>/job/<任務編號>`。看、改、刪都走這個網址。

在目前分支 `feature/review` 上：

| 動作 | 檢查了什麼 | 沒檢查什麼 |
|---|---|---|
| 看（`get_job`） | 只有登入＋買了 `project` 模組 | 連「你是不是這個專案的人」都沒問，**網址上的專案編號完全沒用到** |
| 改（`update_job`） | 你是不是**網址上那個專案**的管理者 | 這張任務是不是那個專案的 |
| 刪（`delete_job`） | 同上，外加網址上那個專案是否還在規劃期 | 同上 |

資料庫的隔離規則（RLS）只擋「不同客戶」，**同一個客戶底下的不同專案之間不擋**。所以「甲專案管理者＋乙專案任務編號」這個組合，在每一層都會被放行。

**攻擊情境（刪）**

小王是甲專案的管理者，在同一個客戶裡另有乙專案他不在裡面。他從任務清單（W2-4）拿到乙專案某張問卷任務的編號，送出「刪除」，網址填甲專案的編號＋乙專案的任務編號。系統確認「小王是甲專案管理者、甲專案在規劃期」，放行；然後照任務編號把乙專案那張任務硬刪。刪除時會連帶清掉 `task_survey`、問卷快照與已填的答案——**這條刪除路徑不會檢查快照有沒有人填過**（「改任務時移除問卷」那條路會檢查，有答案就拒絕；刪除這條沒有）。乙專案若已離開規劃期（稽核進行中），這道檢查也擋不住，因為它查的是甲專案的輪次。

**攻擊情境（改）**：見第 6 節，後果比刪更隱蔽。

**為什麼是中不是高**：要先是同一客戶內某個專案的管理者（不是隨便一個帳號），而且要拿到別專案的任務編號。不跨客戶。

**在哪裡**

| 位置 | 是什麼 |
|---|---|
| `app/flow_control/service/job_service.py:140` | `get_job`：該補「是不是網址專案的參與者」＋「任務是否屬於網址專案」 |
| `app/flow_control/service/job_service.py:211` | `delete_job`：該補「任務是否屬於網址專案」；`project_uid` 目前還是選填（`=None`），不傳就連管理者檢查都跳過 |
| `app/flow_control/service/job_service.py:266` | `update_job`：該補「任務是否屬於網址專案」 |
| `api/flow_control/routes/job_route.py:88` | 詳情網址收了 `project_uid` 卻沒傳給 service |

**修正分支的狀態**：`fix/security-b1` 的 commit `8e33568d2`（CM-2039，總表第 63 項）三支都補了——新增 `_assert_job_belongs_to_project()`，反查任務實際所屬專案與網址比對，對不上一律回「查無」；`delete_job` 的 `project_uid` 改必填；`get_job` 補參與者檢查。**我開修正分支的程式碼逐行核對過，修法正確。** 本棒不另計，只列出來讓首腦知道目前分支上仍然開著。

**要注意的一點**：修正分支判斷「任務屬於哪個專案」是看 `task_assignees` 表的第一列。我在 DEV 唯讀查過（2026-09-23 22:49）：41,519 張使用者任務裡，**36,851 張沒有任何指派列**（大多是流程自動建的）。這些任務在修正分支上會一律回「查無」——安全方向是對的（擋下而非放行），但規劃頁若要看或改一張還沒指派人的任務，會被誤擋。這不是漏洞，是修正卡驗收時要測的情境。

---

### 4.2 W2-3 任務證明清單：寫的那半有守、讀的那半沒守

**白話說明**

每張任務底下可以放「證明」：上傳的檔案、貼的連結、掛的問卷。`job_evidence_service.py` 裡五支方法，**新增、修改、刪除三支都會先問「你是不是這個任務所屬專案的成員」，讀清單和讀單筆兩支不問**——典型的「讀的那半沒守」。

**會怎樣**：同客戶內不在乙專案的人，拿到乙專案任務編號後，可以讀到乙專案那張任務的證明清單：檔名、儲存位置、檔案編號（可接著下載）、校驗碼、連結、問卷快照編號。

**為什麼是中**：外洩的是稽核證據的中繼資料與下載用的編號；要先拿到任務編號、不跨客戶。

**在哪裡**：`app/flow_engine/service/job_evidence_service.py:46`（`get_job_evidences_by_job_execution_uid`）、`:67`（`get_job_evidences_uid`）。該補的就是新增那支在 `:87` 呼叫的同一道 `assert_project_participant`。

**卡片問的「第 48 行那條還在不在」**：還在，就是這一條。FR-088 H2 報的位置是同一支方法，掃描後的 +13／-3 改動沒有碰到它。**修正分支 CM-2035（commit `e2c32258d`，總表第 7 項）已補，兩支讀取都接上了。** 本棒不另計。

---

### 4.3 W2-4 任務清單不問你是不是專案成員（總表未登記、修正分支也沒補）

**白話說明**

規劃頁點開一個查核項目，會列出底下所有任務。這支網址是 `/grc/project/<專案編號>/.../assessment-object/<查核目標編號>/jobs/list`。它只檢查登入＋買了 `project` 模組，`list_jobs` 本身**不問你是不是這個專案的人**。

查核目標編號不是隨機產生的，是標準條文的固定編號（例如 `AC.L1-3.1.2_obj.1`），所以只要知道一個專案編號，就能一個一個條文列下去。

**會怎樣**：同客戶內任何有 `project` 模組的人，列出別專案所有任務的編號、名稱、指派人、掛的問卷、檢測工具設定（帳密有遮，見第 5 節）。拿到的任務編號可以直接拿去打 W2-1～3。

**為什麼是中不是低**：它是其他幾條的入場券，一次就能拿到整個專案的任務。

**在哪裡**：`app/flow_control/service/job_service.py:244`（`list_jobs` 起點）。修法是開頭補一道 `assert_project_participant`（同一檔案已經 import 的那支），專案編號改必填。

**總表對照**：第 63 項（M20-3）講的是「任務**詳情**收了專案編號卻沒用」，範圍是 GET／PUT／DELETE 三支；CM-2039 也只修了那三支。**清單這支兩邊都沒涵蓋**，修正分支上 `list_jobs` 跟目前分支一字不差（我用 `git show` 核對過）。**建議登記為新項。**

---

## 5. 卡片點名要追的問題：逐條結論

**① `survey_handler.py` 的 `_reconcile_task_surveys`：前端送來的問卷編號，有沒有檢查是同一家客戶的？**

**跨客戶打不到，由資料庫隔離規則擋住；同客戶內不需要擋。**

- 前端送來的問卷編號**不會直接**寫進關聯表。它先進 `_replace_survey_snapshots`（`survey_handler.py:159`），由套件的 `create_snapshot` 用這個編號去讀原問卷。讀的時候走的是 `cm_app` 這個資料庫帳號，`surveys` 表的讀取規則是「只能讀自己客戶的」（DEV 唯讀查過 `surveys_select` 規則）。別家客戶的問卷編號讀不到，會拋「問卷不存在」。
- `_reconcile_task_surveys`（`:39`）寫進 `task_survey` 的問卷編號，來自**上一步剛建好的快照**，不是前端原始輸入。前端的問卷清單在這支只用來對應「要不要審核」這個開關。
- 同一客戶內，問卷設計庫本來就是全公司共用（不屬於哪個專案），任何專案都可以引用，所以不需要專案層的檢查。
- **FR-088 H4 當時的疑慮「只驗證存在、沒看到客戶歸屬檢查」**：程式碼裡確實沒有寫檢查，但擋在資料庫那一層。**不成立，下次不用重查。**
- 這整段的守門在呼叫它的 `create_job` / `update_job`（`_require_manager`）。問題不在這支，在呼叫端沒核對任務歸屬（W2-1、W2-2）。

**② `job_dto.py` 的敏感參數遮罩：每一條把任務回給前端的路，都有經過嗎？**

**有。**檢測工具的參數會從兩條路出去，兩條都有遮：

| 出口 | 遮罩位置 |
|---|---|
| 規劃頁任務（`FlowControlJobDto`） | `job_dto.py:209`（工具層 `params`）、`:155`、`:171`（每台分派列） |
| SSP 控制項實作頁 | `app/oscal/service/ssp_control_implementation_service.py:588`、`:626`、`:657`；未遮的暫存欄位 `_raw_params` 在 `:664` 輸出前移除 |

我另外 grep 過 `tool_params` / `params` 在 `app/` 與 `api/` 的所有出口，沒有第三條。**不成立。**（卡片提到「總表第 85 項：檢測工具帳密可被解密外送」——對照總表，第 85 項其實是問卷署名那條；帳密解密外送是**第 4 項**（測試連線把帳密送到指定主機）與**第 70 項**（派工時多存一份明文）。那兩條都是解密那端的問題，不是這裡的遮罩，本棒不重報。）

**③ `job_evidence_service.py:48`（FR-088 H2）那條還在不在？**

還在，見 W2-3。修正分支 CM-2035 已修。**不重報成新發現。**

**④ `job_service.py` 的讀取入口 `get_job`、`list_jobs` 有沒有同等檢查？**

都沒有。`get_job` 見 W2-5（修正分支已修），`list_jobs` 見 W2-4（修正分支也沒修）。**「讀的那半沒守」在這支檔案裡完全成立。**

**⑤ `job_batch_complete_query.py`：批次完成時查問卷狀態，有沒有綁定「這個任務」？**

**有。**`get_job_completion_readiness`（`:108`）依呼叫端給的任務編號清單查 `task_surveys.task_id`，不會查到清單外的任務。這支查詢本身不做權限判斷，也不該做——守門在呼叫它的 `JobBatchCompleteService.batch_complete`（範圍外）：入口先驗「是不是網址專案的參與者」，每張任務最後再經過 `complete_job` 裡的「是不是那張任務所屬專案的參與者」（`workflow_execution_service.py:660`）。**不成立。**

順帶記一筆：批次完成的「你是不是管理者」是用**網址上的專案**算的，但每張任務可以來自別的專案。如果某人是甲專案管理者、只是乙專案的檢視者，他送甲專案網址＋乙專案任務，就能完成乙專案不是指派給他的任務（`complete_job` 只問「是不是成員」）。這跟總表 M10「單筆完成時任何參與者都能完成別人的任務」是同一個病，**不另計**，但修 M10 那條時要記得批次這條入口一起改。

---

## 6. W1 看到的守門零件，在 W2 這條路上有沒有被用到

W1 報告第 6 節列了主專案交給問卷套件的守門零件。逐樣對照 W2 這條路：

| W1 的零件 | 在 W2 這條路上 | 說明 |
|---|---|---|
| 登入檢查 `auth_required` | ✅ 有 | 任務網址都掛了 `@jwt_required()` |
| 商務授權 `license_guard` | ⚠️ 換了一個 | 任務網址掛的是 `require_license("project")`，**不是 `survey`**。沒買問卷模組的客戶，照樣能在規劃頁把問卷掛上任務、建出 `task_survey`。跟 W1-3 同一類問題 |
| 能力點 `capability_required` | ❌ 沒用 | 任務建改刪用的是專案角色，不看能力點。這是設計，不算缺 |
| 專案角色守門 `project_role_guard` | ⚠️ **沒經過零件，直接呼叫** | `job_service.py` 直接 import `common.authz.project.assert_project_manager`，沒有走 W1 那個 adapter。實作相同所以結果一樣，但**判的是網址上的專案，不是任務所屬的專案**（W2-1、W2-2） |
| 任務編號換算 `task_uid_resolver` | ❌ 沒用 | W2 直接用任務的內部編號，不經換算。換算只給套件查詢用 |
| **填答歸屬反查（`task_assignees`）** | 🔴 **W2 會寫它，而 W1 的守門信任它** | 見下 |
| 即時共編連線身分 | 不相關 | W2 沒有即時連線 |

**最關鍵的接縫——W2 可以偽造 W1 守門依賴的那份資料**

W1 查過：問卷套件判斷「你能不能讀／填／管理這份任務問卷」（`task_survey_guard.py`），是先從 `task_assignees` 表**隨便挑一列**，看它記的專案編號，再問你在那個專案是什麼角色。**這張表記的專案編號，問卷套件完全相信。**

而 W2-2 那條路（`update_job` 帶 `assignees`），寫進 `task_assignees` 的專案編號是**網址上的專案**，不是任務真正所屬的專案（`infra/flow_control/repository/flow_control_job_repo_impl.py:589`，指派那段用網址的 `project_uid` 反查專案編號）。

串起來：

1. 小王是甲專案管理者。乙專案有一張問卷任務，還沒指派任何人（DEV 上大多數任務都沒有指派列，見 4.1）。
2. 小王送「修改任務」，網址填甲專案＋乙專案任務編號，指派人填自己。系統只驗「小王是甲專案管理者」→ 放行，寫進一列：`任務＝乙專案那張、專案＝甲、使用者＝小王`。
3. 現在問卷套件判斷這張任務的問卷時，查到的專案是**甲**。小王在甲是管理者，也是這張任務的被指派人 → **讀、填、改答案、匯入答案、還原歷史版本全部放行**。
4. 同一份資料也會讓流程引擎把乙專案那條流程判成屬於甲專案（`workflow_execution_service.py:149` 同樣挑一列指派），小王因此也能在乙專案那條流程上新增／修改證明、完成任務。

這條路**只有在「任務還沒有指派列」時才完整**；已有指派列的任務，挑到哪一列要看資料庫回傳順序（DEV 目前沒有同一任務混兩個專案的資料，查過 0 筆）。

**這就是卡片擔心的責任真空**：W1 那邊的守門寫得沒錯、W2 這邊的寫入也各自看起來合理，但 W2 可以寫出一份讓 W1 判錯的資料。

**修正分支的狀態**：CM-2039 在 `update_job` 最前面先核對「任務屬於網址專案」，對不上就回「查無」，第 2 步就擋掉了；沒有指派列的任務在修正分支上一律回「查無」，所以也寫不進去。**這條接縫在修正分支上已封住，前提是 CM-2039 要合。**

**但體質上還留一個坑**：`task_assignees` 同時當兩種用途——「誰被指派」和「任務屬於哪個專案」。後者是守門依據，卻存在一張會被前端操作寫入的表裡，而且讀的時候只挑一列。建議首腦考慮讓「任務屬於哪個專案」改成從流程關係推（任務 → 流程 → 專案），不要從指派表推（第 9 節）。

---

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

**「這五條存在嗎」——高。**三個獨立檢查員對每條各投一票，**15 票全數投出、全部 3:0 成立**；W2-5 從中調降為低（三票裡兩票評低）。關鍵事實我另外開檔核對過：

- 三支任務方法與網址層的程式碼、修正分支的 diff（`git diff feature/review fix/security-b1`）逐行比對。
- 資料庫隔離規則是 DEV **唯讀**查的（`pg_policies`，2026-09-23 22:49）：`surveys`、`job_executions` 只分客戶；`task_assignees` 的寫入規則只驗「專案存在」。
- 主專案連資料庫用的是 `cm_app`（受隔離規則約束）。

**「第 6 節的接縫」——未經三人面板投票、runner 自行開檔核對。**每一步都有對應程式碼，但**沒有實際打網址重現**。第 4 步（流程引擎也被騙）是依程式碼推的。

**「只有這幾條嗎」——不保證。**

1. 最快的掃描檔位（effort low）：一輪研究員加一輪投票，沒有威脅建模與廣度掃描。
2. 研究員有追到範圍外的網址層、repo 層與套件，但範圍外的檔案不是逐支讀。
3. 本機載入的是修正分支 worktree 的套件（`jedi-wt-fix-security`），主專案則是 `feature/review`。
4. 全程沒有打任何網址、沒有寫資料庫。

---

## 8. 執行概況（數字，給工程師看）

| 項目 | 數值 |
|---|---|
| 掃描範圍 | 6 檔 / 1,522 行 |
| 基準 commit | `081b61cc812a`（工作區 dirty，有平行 session 改動） |
| 檔位 | effort low，focus 生產程式碼（含密鑰專項） |
| 原始候選 | 5 條，去重後 5 條 |
| 投票 | 三個檢查員 × 5 條 = 15 票，全數投出 |
| 票型 | W2-1（F1）、W2-2（F2）、W2-3（F3）、W2-4（F4）3:0 評中；W2-5（F5）3:0 成立，從中調降為低 |
| 驗證章 | **verified**（`CLAUDE-SECURITY-REVISION-081b61cc812a-dirty.json`） |
| 工具 run ID | `wf_3dc14845-314` |
| 耗時 | 約 27 分鐘（17 個 agent，零失敗） |
| 工具產出原始報告 | `CLAUDE-SECURITY-20260923-142057/`（未入版控） |
| 密鑰專項 | 無新發現 |

---

## 9. 待首腦裁決

1. **W2-4（任務清單）要不要登記成新項**（建議要：總表第 63 項與 CM-2039 都只涵蓋詳情三支；它是其他幾條的入場券，修正分支上也還開著）。修法一行：`list_jobs` 開頭補 `assert_project_participant`。
2. **W2-1、W2-2、W2-5 → 第 63 項；W2-3 → 第 7 項**，都已在修正分支修好，不另計。要確認的是**合併時程**：目前分支上這四條全開著，而且 W2-2 會打穿 W1 的問卷守門（第 6 節）。
3. **CM-2039 的驗收要補一個情境**：規劃頁看／改一張**還沒有指派人**的任務。修正分支用 `task_assignees` 判歸屬，DEV 上 89% 的任務沒有指派列，會被誤擋成「查無」。
4. **「任務屬於哪個專案」的判斷依據**要不要改成走流程關係、不走指派表（第 6 節末段）。這是體質問題，影響問卷守門、流程引擎守門、CM-2039 三處，建議開一張分析卡而不是直接修。
5. **規劃頁掛問卷只驗 `project` 模組、不驗 `survey` 模組**（第 6 節表格）：併進 W1-3 一起裁。
6. **批次完成的管理者判斷用網址專案**（第 5 節⑤）：修 M10 單筆完成那條時一起改，不另計。

---

沿革見 `FR-115` LOG 與 git log。
