---
title: U7 檢查結果：任務入口與批次（任務增刪改查、Excel 批次匯入匯出、批次完成、我的任務）
---

# U7 檢查結果：任務入口與批次

> 檢查日期 2026-09-25｜對應卡片 CM-2158（母卡 CM-2152）｜檢查範圍 7 個檔案 1,908 行｜工具 run ID `wf_14dcf3f9-ca2`｜掃描版本 `159a2fff`（`feature/review`）

## 🔴 一句話結論

**決策者點名的「批次完成把呼叫者當管理員」，寫在程式裡的那條預設走不到；真正的洞在隔壁：批次完成拿「網址上的專案」判斷你是不是管理員，卻不核對勾選的任務屬不屬於那個專案。** 這跟本批其他五個入口是同一個病：**只認網址專案，不認任務歸屬**。

- **批次完成（卡片最重要的一條）**：`is_manager = True` 這個預設值，只在「守門零件沒接上」或「網址沒帶專案編號」時才會生效。實際上零件有接，網址也一定帶專案編號，所以**這條預設在正式路徑上走不到**（第 59 項當年寫的「待核對」，核對結果是不成立）。**但我手做實測（未經三人面板）發現另一個洞**：你是專案 A 的管理員、又是專案 B 的旁觀者（viewer）時，網址填 A、勾選 B 的任務，系統就用「你是 A 的管理員」直接跳過「這張任務是不是指派給你」的逐筆檢查。DEV 上真的有這種身分組合的帳號。**目前分支上這招換不到新能力**，因為第 59 項的洞本來就讓 viewer 單筆完成任務；等修正分支（CM-2147）合回來，內層守門會擋住它。所以我建議**不另計、併進第 59 項那張修正卡的驗收項**。
- **批次匯入（卡片第二條，「任務編號誰給的」）**：任務編號是呼叫者自己填在 Excel 或 JSON 裡的。「確認」那一步只查「這個任務存不存在」，不查「是不是這個計畫的」（**第 156 項**，工具 F3 這次再度 3:0 命中）。前兩步「上傳驗證」與「重新驗證」**完全沒有身分檢查**（**第 158 項**）。
- **🟠 新發現（中風險，runner 實測、未經面板）**：「上傳驗證」那一步零守門，而且整份 Excel 直接展開成記憶體裡的物件。**實測 9.8MB 的特製 Excel 檔，開檔花了 22 秒、記憶體衝到 1.46GB**。同一家公司任何有專案模組的登入帳號都能打，連送幾份就能把服務撐爆。這是第 130／176 項「壓縮炸彈」的**第五個入口**，之前沒被記錄過。
- **工具報 8 條全部 3:0 通過**：6 條中風險都是既有項目重現（第 45／155／156／157 項），**淨新增 2 條低**（匯出任務 Excel 沒過濾公式、批次完成通知信沒跳脫 HTML）。
- **「我的任務」與儀表板**：查詢條件永遠是「登入者本人」，是從登入憑證取的，不是從請求內容取的。資料庫檢視表也開了隔離保護。**不成立**。

**帶著前棒的形狀來看**：U5／U6 的病是「背景工作用系統身分跑、沒核對專案歸屬」。U7 沒有背景工作，但**同一個問題換了個入口出現**：每個入口都只問「你是不是網址那個專案的人」，沒人問「你要動的這張任務是不是那個專案的」。

## 這一棒在檢查什麼

「任務」是稽核流程裡最小的工作單位（例如「上傳防火牆設定截圖」），掛在某個專案的某個控制項底下。這 7 支程式是任務的所有對外入口：

| 檔案 | 行數 | 角色 |
|------|-----:|------|
| `app/flow_control/service/job_import_service.py` | 470 | 規劃頁「匯出／匯入任務 Excel」四步：匯出、上傳驗證、重新驗證、確認 |
| `api/flow_control/serializers/job.py` | 417 | 任務 API 的輸入輸出格式 |
| `api/flow_control/routes/job_route.py` | 259 | 任務的看、新增、修改、刪除、清單、「我的任務」 |
| `infra/readmodel/audit/flow_control_dashboard_repo_impl.py` | 254 | 首頁儀表板統計 |
| `infra/readmodel/tasks/my_grc_jobs_query.py` | 198 | 「我的任務」清單查詢 |
| `app/flow_control/service/job_batch_complete_service.py` | 193 | 「批次完成任務」＋完成後的彙總通知信 |
| `app/readmodel/service/my_jobs_app_service.py` | 117 | 「我的任務」服務層 |

要回答的核心問題：**任務編號是誰給的？系統有沒有核對這個編號屬於呼叫者有權限的專案？**

## 掃到什麼（總覽）

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度＋為什麼 | 來源 |
|---|---|---|---|---|---|---|
| U7-1 | 匯入任務 Excel 的「上傳驗證」零守門，而且整份檔案展開進記憶體（壓縮炸彈） | 一份 10MB 以內的檔就能吃掉 1.5GB 記憶體、卡住處理程序 20 秒以上；連送幾份，整個產品下線 | 同一家公司任何有「專案」模組的登入帳號（修正分支上要是任一專案成員） | `app/flow_control/service/job_import_service.py:203`（開檔前量「解壓後大小」，或改用 `read_only=True` 逐列讀）；入口守門見第 158 項 | 🟠 中：與第 130 項同級，門檻低、後果是全站停擺 | runner 實測（未經三人面板） |
| U7-2 | 匯出任務 Excel 時，任務名、部門名、設備名原樣寫進儲存格 | 有人把任務名改成 `=HYPERLINK(...)`，別人匯出後打開，點一下就把同表資料送到外部網址 | 能改任務名（專案管理者）或部門、設備名稱的人；受害者用試算表軟體打開 | `app/flow_control/service/job_import_service.py:158-167`（`=`、`+`、`-`、`@` 開頭的值前面加單引號，或強制存成文字） | 🟢 低：要受害者打開並點擊；CM-2189 修了範本那三支，這支沒修 | 工具 F7，面板 3:0 |
| U7-3 | 批次完成的彙總通知信把「操作者暱稱」原樣塞進 HTML 信件 | 把暱稱改成一段釣魚連結，批次完成一張任務，專案所有管理者和審閱者都會收到一封「系統寄的」、帶著那個連結的信 | 任一能批次完成至少一張任務的專案參與者（暱稱本人可以隨意改） | `app/flow_control/service/job_batch_complete_service.py:168-174`（塞進 HTML 前先跳脫）；同一型還有 `workflow_execution_service.py:1219`、`project_service.py:776` 兩處，要一起修 | 🟢 低：只能插內容、不能執行程式；但信是系統名義寄出，釣魚可信度高 | 工具 F8，面板 3:0 |
| U7-4 | 批次完成用「網址專案」的角色決定要不要逐筆核對指派，但不核對任務屬於網址專案 | A 的管理員兼 B 的 viewer，可以批次完成 B 裡不是指派給他的任務 | 同時是 A 的管理員和 B 的參與者（任何角色） | `app/flow_control/service/job_batch_complete_service.py:58-62`（逐筆用任務自己的專案查角色，或先核對任務屬於網址專案） | 🟢 低：目前分支上等同第 59 項（viewer 本來就能單筆完成）；修正分支的內層守門會擋 → **建議不另計、併第 59 項** | runner 實測（未經三人面板） |
| — | 修改、刪除、查看單筆任務只認網址專案（工具 F1／F2／F5） | 見第 45 項 | — | — | 中 | **既有第 45 項重現，不另計** |
| — | 批次匯入「確認」可改別專案任務（工具 F3） | 見第 156 項 | — | — | 中 | **既有第 156 項重現，不另計** |
| — | 匯出任務 Excel 的專案與計畫可以不一致（工具 F4） | 見第 157 項 | — | — | 中（總表記低） | **既有第 157 項重現，不另計**；這次面板 3:0 評中，總表當時 2:1 評低 |
| — | 規劃頁任務清單不問是不是專案成員（工具 F6） | 見第 155 項 | — | — | 中 | **既有第 155 項重現，不另計** |

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - U7-1（匯入 Excel 壓縮炸彈）＝總表第 198 項，✅ 已修（CM-2203，commit `eb4ff79cd`／`040c3c827`，1.21.0 出貨）。
> - U7-2（匯出 Excel 公式）＝總表第 199 項，✅ 已修（CM-2213，commit `a5804a990`／`bde8f9d16`，1.21.0 出貨）。
> - U7-3（暱稱塞進通知信）＝總表第 200 項，✅ 已修（CM-2213，同上 commit，1.21.0 出貨）。
> - U7-4（批次完成用網址專案角色）：併總表第 59 項（viewer 不可完成任務，該項 ✅ 已修）；U7-4 那條「逐筆用任務自己專案查角色」是否一併落地，查不到單獨記錄，保留待核。

## 卡片點名的疑點逐條回答

### 1. 🔴 `job_batch_complete_service.py:50-56`「預設把呼叫者當管理員」——預設走不到；真正的缺口在第 58～62 行

**先回答卡片的問題：不成立。** 程式一開始設 `is_manager = True`，只有三個條件同時滿足才會去查真正的角色：守門零件有接、專案服務有接、網址有帶專案編號。我逐一核對：

- 兩個零件在 `di_containers/flow_control/flow_control_containers.py:579-585` 都有接。
- 網址是 `/project/<project_uid>/jobs/batch-complete`（套件 `jedi-task-platform` 的 `routing.py:68`），專案編號是網址的一段，不可能是空的。
- 唯一的呼叫點是套件的 `job_batch_complete_route.py:31-38`，永遠把 `project_uid` 傳進來。

所以「預設是管理員」那條路在正式環境走不到。它的問題在寫法：**「零件沒接就放行」**。將來有人改接線或加第二個呼叫點，就會默默變成全部放行。建議改成沒接就擋（修正分支上 `job_import_service.py` 已經這樣做）。

**真正的缺口（U7-4，runner 實測）：** 第 58 行用「網址上的專案」查你的角色。只要你在那個專案是管理員，第 62 行就把「逐筆核對指派」整段跳過。但勾選的任務編號是呼叫者自己填的，**沒有任何一行核對它屬不屬於網址上的專案**。

- **DEV 唯讀查詢（2026-09-25 14:00 前後）**：租戶 102 的 user 17 在專案 323 是管理員、在專案 642 是 viewer。642 有一張進行中的任務 `70bba021…` 指派給 user 19，不是 17。
- **手做實測**：照 DEV 這組真實身分寫單元測試，直接呼叫批次完成服務，網址填 323、勾選 642 的任務：逐筆指派核對**完全沒被呼叫**，直接進入完成任務，回傳「成功 1 筆」。對照組把網址老實填 642：回 `NO_PERMISSION`。
- **為什麼目前換不到新能力**：真正完成任務的 `complete_job`（`workflow_execution_service.py:660`）會再查一次「你是不是任務所屬專案的參與者」，任何角色都過。這就是**第 59 項**：viewer 本來就能從單筆完成的網址完成別人的任務。所以在目前分支，這招能做到的事，單筆入口早就做得到。
- **修正分支會怎樣**：`fix/security-b1` 的 CM-2147 把 `complete_job` 內層改成「只有被指派人或管理員能完成」（`app/flow_engine/service/job_operator_guard.py`，拿任務自己的專案判斷）。合回來以後，這招會被內層擋下，錯誤會變成那一筆的失敗原因。**批次那層的過濾仍然是假的，只是內層兜住了。**
- **建議**：不另計，併進第 59 項那張修正卡，驗收項加一條「A 管理員＋B viewer，網址填 A、勾 B 的任務，要被擋」。修法是逐筆用任務自己的專案查角色，不要用網址的。

### 2. `job_import_service.py`「匯入三步只有最後一步有守門」——成立（第 158 項），而且多一個炸彈入口

**任務編號誰給的**：「上傳驗證」從 Excel 的 A 欄讀，「重新驗證」和「確認」從前端送來的 JSON 讀，**全部是呼叫者給的**。

| 步驟 | 目前分支的守門 | 前兩步能不能讀到別專案的資料 |
|---|---|---|
| 匯出 `:104` | 查網址專案成員，但計畫編號不核對（第 157 項） | 能，整張任務表 |
| 上傳驗證 `:198` | **零守門**（第 158 項） | 能拿錯誤訊息試探：「任務 X 不屬於此稽核計畫」可以判斷 X 在不在計畫 Y 裡；「使用者不存在：abc」可以列舉帳號、部門、設備名稱 |
| 重新驗證 `:315` | **零守門**（第 158 項） | 同上，而且不用上傳檔案，改 JSON 就能大量試 |
| 確認 `:398` | 查網址專案管理員，但白名單只查「任務存在」（第 156 項） | 不是讀，是直接改別專案的任務 |

前兩步不會直接吐出別專案的任務內容，但**能確認某個編號存不存在、屬不屬於某計畫**，這正是第 156 項攻擊需要的材料。

**新增（U7-1）**：「上傳驗證」在零守門的狀態下，第 203 行 `load_workbook(file, data_only=True)` 把整份檔案展開成記憶體物件。唯一的大小檢查（`:194`，10MB）量的是**壓縮後**的大小。實測如下：

| 項目 | 數值 |
|---|---|
| 特製檔（表頭正確，過得了格式檢查，底下 33 萬列同值） | 壓縮後 9.8MB、解開 95MB |
| 照第 203 行同一寫法開檔 | **22.4 秒、記憶體高峰 1,455MB** |

一個請求就佔住一個處理程序超過 20 秒、吃掉將近 1.5GB。預設四個處理程序同時被打，就是將近 6GB。落地版單一容器、沒設記憶體上限（第 130 項已查）。**修正分支已把守門搬到開檔前**（要是專案成員才能上傳），門檻變高，但開檔方式沒變，炸彈本身還在。

### 3. `my_grc_jobs_query.py`、`flow_control_dashboard_repo_impl.py`：WHERE 有沒有限定「是我的」——不成立

- **「我的任務」**：`my_grc_jobs_query.py:113-115` 第一個條件永遠是 `user_id == user_id`，而這個 `user_id` 在 `job_route.py:246` 取自 `get_user_context().id`（登入憑證），請求內容改不到。`project_uid`、`round_uid` 等篩選只會在自己的任務裡再縮小。另一支 `get_user_task_queue_by_sp` 也一樣（`flow_engine_route.py:184`）。
- **資料庫這層**：`public.vw_user_job_queue` 開了 `security_invoker=true`（DEV 唯讀查詢確認），代表查詢時會套用底下各張表的客戶隔離規則，不會用建立者的權限繞過。
- **輸入安全**：關鍵字用 `ilike` 加參數綁定，沒有字串拼接 SQL；排序欄位走白名單（`:174-182`），不在名單裡的直接忽略；每頁筆數有上限（`jedi_common` 的 `PagerSchema`，`MAX_PAGE_SIZE`）。
- **儀表板**：可見專案 = 自己是擁有者或任一層參與者的專案（`:56-67`、`:110-121`），`user_id` 同樣來自登入憑證。套件的儀表板路由把 `is_admin` 寫死成 `False`（`dashboard_route.py:36`），連管理員都只看自己參與的專案。原生 SQL（`:188-214`、`:247-253`）全部用參數綁定，而且只在「可見專案」清單裡查。

### 4. `job_route.py`：有沒有哪支方法沒走到 `_require_manager`——有兩支

| 方法 | 守門 | 結論 |
|---|---|---|
| 清單 `POST .../jobs/list`（`:52`） | 無 | **漏**（第 155 項，工具 F6） |
| 查看 `GET .../job/<uid>`（`:82`） | 無，`project_uid` 收了但沒用 | **漏**（第 45 項，工具 F5） |
| 修改 `PUT`（`:104`） | 網址專案管理員 | 有守，但不核對任務屬於該專案（第 45 項，工具 F1） |
| 刪除 `DELETE`（`:147`） | 網址專案管理員＋規劃期 | 同上（第 45 項，工具 F2） |
| 新增 `POST .../jobs`（`:183`） | 網址專案管理員 | **不成立**：新增的 SQL 本身就用網址專案去找評估項目（`flow_control_job_repo_impl.py:677-697`，`WHERE p.uid = :puid`），找不到就不建，沒辦法把任務建進別的專案 |
| 我的任務（`:231`） | 只看自己 | 不成立（見上一條） |

修正分支（CM-2039、CM-2191）已補上查看、修改、刪除、清單四支，**目前分支上都還開著**。

### 5. 「有人守了一半」共通疑點逐條對照

| 型態 | 本棒有沒有 |
|---|---|
| 只驗你是誰、沒驗資料是不是你的 | **有**，本批主病：六個入口都只認網址專案 |
| 列表有查、單筆沒查（或反過來） | **有**：修改、刪除有查，查看、清單沒查 |
| 守門條件用 `or` 串、其中一個永遠成立 | 無 |
| 「查不到」和「沒權限」混成同一個回應 | 批次完成：不存在是一句話、沒權限是 `NO_PERMISSION`，可以分辨任務存不存在；但任務編號本來就能從清單或匯出拿到，不另立 |
| 背景或系統身分假設「呼叫者一定是自己人」 | 本棒範圍沒有背景工作；通知信用執行緒寄，但不查資料庫 |
| 防重放或計次只在單一程序有效 | 無 |
| 註解寫「刻意不檢查」、上一層也沒檢查 | **有**：`job_import_service.py:418` 註解寫「防止未經驗證的 confirm」，實際白名單不限計畫（第 156 項）；批次完成的註解說「context 不齊就跳過檢查」（U7-4） |
| 「零件沒接就放行」 | **有**：批次完成 `:52`、匯出 `:108`、`_require_manager`（`job_service.py:133`）三處都是「沒接就不守」；修正分支只把匯入匯出改成擋 |

## 工具報的逐條

工具交回 9 條候選，三人面板 27 票全投，8 條 3:0 通過、1 條 0:3 被否決。

| 工具編號 | 說什麼 | 面板 | 對到總表 |
|---|---|---|---|
| F1 | 修改任務只核網址專案的管理員身分，不核任務歸屬 | 3:0 中 | 第 45 項 |
| F2 | 刪除任務同上，連「輪次凍結」檢查也查錯專案 | 3:0 中 | 第 45 項（追記①已記） |
| F3 | 批次匯入確認能改全公司任何任務 | 3:0 中 | 第 156 項 |
| F4 | 匯出任務 Excel 核一個專案、匯出另一個計畫 | 3:0 中 | 第 157 項（總表評低，這次面板評中） |
| F5 | 查看單筆任務零守門 | 3:0 中（一票評低） | 第 45 項 |
| F6 | 任務清單零守門 | 3:0 中 | 第 155 項 |
| F7 | 匯出任務 Excel 沒過濾公式 | 3:0 低 | **新**＝U7-2 |
| F8 | 批次完成通知信沒跳脫 HTML | 3:0 低 | **新**＝U7-3 |
| F9 | 批次完成把原始例外訊息回給前端 | **0:3 否決** | — |

**F9 為什麼被否決、我怎麼看**：面板三票一致認為，寫資料庫的錯誤不會走到這一行，所以吐不出 SQL 語句。我的實測也證實：`except Exception` 會把 `str(e)` 原樣放進回應（我在測試裡丟一個假的資料庫錯誤，回應就帶著 `[SQL: ...]`）。**機制存在，但面板認為正常路徑上觸發不到**，我同意不立項。修第 59 項時可以順手把 `:117` 改成固定錯誤碼。

**F7 補充**：CM-2189（`895db0ef8`，修正分支）修了範本下載那三支，**沒修這支**。我在修正分支查過 `job_import_service.py`，沒有任何公式中和。

**F8 補充**：暱稱本人可以隨意改（見第 150 項）。同一型寫法在範圍外還有兩處：`workflow_execution_service.py:1219`（每日待辦通知，塞收件人自己的暱稱，傷害較小）和 `project_service.py:776`（專案通知）。修的時候三處一起做。

## 可信度分兩層

- **「這幾條存在嗎」**：工具那 8 條都經過三人面板完整投票（27 票全投、零漏投），驗證章 `verified`。卡片點名的四條是我逐支開檔回答的；U7-1 和 U7-4 是我手做實測，**未經三人面板投票**。
- **「只有這幾條嗎」**：範圍 7 支我逐支通讀完。範圍外我讀了追下去需要的檔：`job_service.py`、`job_batch_complete_query.py`、`job_import_lookup_query.py`、`workflow_execution_service.py` 的完成任務段、`common/authz/workflow.py`、`project.py`，以及套件 `jedi-task-platform` 的四支路由。**打折處**：
  - 實測都是單元測試層直接呼叫服務，沒有起後端打真的 HTTP 請求（本機後端沒在跑）。
  - 炸彈實測是在本機用同一行 `load_workbook` 寫法開檔，沒有經過 gunicorn，也沒量多個處理程序並發的情形。
  - DEV 查詢是唯讀的，時間點 2026-09-25 14:00 前後；平行修正線可能改變資料。

## 執行概況

| 項目 | 數值 |
|---|---|
| run ID | `wf_14dcf3f9-ca2` |
| 報告目錄 | `CLAUDE-SECURITY-20260925-053454/`（不入版控） |
| 驗證章 | `verified`（stamp `CLAUDE-SECURITY-REVISION-159a2fff977c-dirty.json`；dirty 是平行線未 commit 的改動，不在範圍內） |
| 掃描參數 | `--effort low`、focus attack-surface、範圍 7 檔 |
| 研究員 | 2 派 2 回（主掃 1＋密鑰掃 1，密鑰零發現），**零失敗、零重試** |
| 候選／投票 | 9 候選、27 票全投；8 條 3:0、1 條 0:3 |
| agent 總數 | 29 個，0 錯誤 |
| 耗時 | 約 60 分鐘 |
| 淨新增 | 中 1（U7-1 炸彈，runner）＋低 2（U7-2、U7-3，面板）；U7-4 建議併第 59 項不另計 |
