---
title: D3-2 檢查結果：任務與工具綁定（jedi-detection）
---

# D3-2 檢查結果：任務與工具綁定（jedi-detection）

> 檢查日期 2026-09-18｜對應卡片 CM-1875（D3 第 2 小棒）｜檢查範圍 5 個檔案 1,328 行

## 🔴 一句話結論

**找到 1 條中風險：換工具時「掃描範圍」不會重新檢查，可以讓系統把一整個超大網段逐台展開，把後端伺服器的記憶體吃爆、服務倒掉。** 要先是某個專案的管理者，送兩次修改再按執行才打得到，所以不急；修法很小——換工具時拿「實際會生效的值」重驗一次，再幫展開那支加一道硬上限。另外查證了卡片重點⑥的另外三項**確認沒問題**：密碼加密的時機是對的（綁定與 BPMN 兩份同源、不會留明文副本）、留空沿用不會把密碼寫到別人那列、掃描範圍只有專案管理者能改。

## 這一棒在檢查什麼

客戶在一張稽核任務上指定「要用哪個檢測工具、掃哪些機器、用什麼帳密登入」。這份設定存下來之後，按「執行」時會被拆成工單送給裝在客戶機房的代理程式。

這一棒看的是**存設定的那一段**，兩件事：

1. **帳密什麼時候加密、加密得夠不夠早**。掃描設定裡混著非機敏欄位（逾時秒數）與機敏欄位（被掃網站的登入密碼），兩者同住一個 JSON 欄位。如果加密做得太晚、或只做了其中一條寫入路徑，密碼就會以明文留在資料庫或畫面上。
2. **掃描範圍是誰填的、有沒有檢查**。「掃哪些機器」這欄使用者可以填 `192.168.50.1-50` 或 `10.0.0.0/8` 這種寫法，系統要把它展開成一台一台的位址。填得太大就是一顆炸彈——問題是檢查有沒有真的擋在每一條路上。

掃描目標 `/Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-detection`，revision `955e40964baa`（branch `feature/review`，工作區乾淨），mode `scan`，effort `low`，範圍 5 個檔案：綁定處理器 `detection_job_binding_handler.py`（659 行）、任務層敏感參數加解密 `detection_secret_params.py`（154 行）、掃描目標語法與展開 `scan_target_spec.py`（307 行）、分派參數工具 `detection_assignment_params.py`（171 行）、綁定表 model `job_execution_detection_tool.py`（37 行），共 1,328 行。

## Coverage

`low` 強度：一位研究員讀完這 5 個檔案就提報候選，未做元件盤點、未做威脅建模、未跑額外密鑰專項掃描，`completenessCheckOutcome` 為 `not-applicable`（低強度本來就不跑盤點）。驗證跑了 **1 輪**，2 個候選去重後仍是 2 個，沒有候選遺失、沒有嚴重度被降低、沒有候選被略過。研究員為了追資料流另外讀了範圍外的幾處（主專案的任務序列化器與 job service、`BaseRepositoryImpl` 的更新語意、編排服務的展開與派工、jedi-file-upload 的下載守門與 RLS），那些只當佐證、沒有納入稽核。

研究員沒有回報「哪些檔案沒讀完」的自述（`coverage.research` 為 null），所以工具端沒有做讀取完整度的交叉檢查。

工具沒有實際執行任何程式碼：沒跑測試、沒發請求、沒示範攻擊，所有判斷都是讀原始碼推出來的。唯一的例外是首腦核對時直接呼叫 `scan_target_spec` 這支純函式確認數字（`count_spec('10.0.0.0/8')` 回 16,777,214），那是讀取性質的驗算、不改任何狀態。

**工具碰到了卡片重點⑥的「掃描目標驗證」那一半，「加密時機」那一半完全沒提出候選，由首腦回頭開檔查證補上**，詳見下方「卡片重點逐項人工查證」段。

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - F1（換工具時掃描範圍不重驗）＝M03 第 11 條，✅ 已修（CM-2058）。

## 掃到什麼：總覽

| 編號 | 這是什麼問題 | 出事會怎樣 | 要先有什麼 | 在哪裡 | 嚴重度 |
|---|---|---|---|---|---|
| F1 | 換工具時掃描範圍不重驗，超大網段留在原地被逐台展開 | 後端工人記憶體被吃爆，API 服務變慢或倒掉，過程中還握著一個沒關的資料庫交易 | 專案管理者身分 ＋ 兩次修改 ＋ 按執行 | `detection_job_binding_handler.py:202` | MEDIUM |

被駁回 1 條（內部欄位沒過濾就寫進派工參數），三位檢查員一致認為拿不到好處，理由見下方。

## Findings

### F1 — 換工具時掃描範圍不重新檢查，超大網段會留在原地被逐台展開（MEDIUM，confidence medium）

**這是什麼問題。** 「掃哪些機器」這一欄有台數上限（預設 256 台），但**上限只檢查「這次送上來的值」**。如果這次的修改沒有帶這一欄，程式就當作沒東西要檢查、直接跳過；而底層更新又有一條規則是「沒送的欄位不覆蓋」，於是上次存進去的那個超大網段原封不動留著，工具卻已經換成受上限管的那一支了。等到按下執行，派工端才真的去展開這個網段——那裡沒有上限（那支程式的註解自己就寫著「這裡刻意不擋」）。

還有一個前提讓這件事打得成：**不是每個工具都受台數上限管**。OpenVAS 這類工具自己就吃網段寫法、不需要逐台登入，所以刻意豁免；OpenSCAP、Nmap、CINC Auditor 這幾支要逐台 SSH 登入的才受管。攻擊者就是利用這個豁免把大網段先存進去。

**出事會怎樣。** 一個 `/8` 網段展開後是 **16,777,214 台**（首腦實跑 `count_spec` 確認的數字）。展開的動作會在記憶體裡生出一千六百多萬個字串、一個去重用的集合，最後再把它們串成一條逗號分隔的長字串——而且整段過程都在一個請求的資料庫交易裡面。結果是那個 API 工人的記憶體被吃光、行程變慢或直接被系統砍掉，同時那個沒結束的交易還一直佔著資料庫連線。其他使用者這段期間會覺得系統卡住或連不上。

**要先有什麼才打得到。**
- 攻擊者得是**某個專案的管理者**（能登入、且在該專案有 manager 角色）。改任務設定這條路有守門：`compliance-manager-be/app/flow_control/service/job_service.py:235` 的 `_require_manager`。
- 要送**兩次**修改：第一次綁一支豁免的工具（OpenVAS）並把掃描範圍填成 `10.0.0.0/8`；第二次只換工具（換成 OpenSCAP）、**不帶參數**。
- 之後要**按下執行**，才會走到展開那一步。

三個條件都是「一個內部人員刻意做」才成立，不是外面的人打一個網址就中——所以是中風險不是高風險。

**在哪裡。**
- 缺口本體：`jedi-detection/jedi_detection/app/service/detection_job_binding_handler.py:201-204` —— 台數檢查只吃 `tool.get("params")`，也就是這次送來的值
- 跳過檢查的那行：同檔 `:331-333` —— `values = [v for v in raw_values if v]`，全是 None 就直接 `return`，後面的上限檢查一行都沒跑
- 沒送就不覆蓋的規則：`jedi-common/jedi_common/session/database/repository/base_repository_impl.py:424` —— `value is not None` 這個條件讓 `tool_params=None` 被跳過，舊值留著
- 參數預設值來源：`compliance-manager-be/api/flow_control/serializers/job.py:256` —— `params = fields.Raw(allow_none=True, load_default=None)`，沒帶就是 None
- 豁免表：`jedi-detection/jedi_detection/common/scan_target_spec.py:60-67` —— `SSH_ITERATED_TARGET_FIELDS`，openvas 不在裡面
- 真正展開的地方：`jedi-detection/jedi_detection/app/service/detection_orchestration_service.py:555` —— `expand_spec(raw)`，上游函式的 docstring（`:535-537`）明說「真的走到這裡還超量仍照展開」

**另一個落點（首腦補查，工具沒提）。** 同一個缺口在**分派列**那條路也成立，而且更直接：`_replace_detection_tool_agent_assignments`（`:492`）開頭的 `if assignments is None ... return`（`:536`）表示「沒帶分派清單＝不動既有設定」，於是 `:570` 那道同名的台數檢查根本不會跑，既有列裡的 `hosts` 就這樣帶著舊值跟到新工具底下。修法要**兩條路一起補**，只補其中一條會留下另一半。

**怎麼修。** 兩層，都要做：

1. **寫入端**：在 `detection_job_binding_handler.py:201` 附近，把餵給檢查的值從「這次送來的」改成「實際會生效的」——`tool.get("params")` 取不到時回頭拿 `existing.tool_params`。判斷條件是**工具有沒有換**（`detection_tool_id` 與 `existing.detection_tool_id` 不同），換了就一定要用生效值重驗一次。分派列那條（`:570`）同樣處理：工具換了就把既有列的 `hosts` 一併拉出來重驗。
2. **展開端**：在 `detection_orchestration_service._expand_scan_target_fields`（`:551` 的迴圈裡）呼叫 `expand_spec` 之前先呼叫 `scan_target_spec.count_spec(raw)`，超過一個硬上限就記 error 並跳過該欄位（或直接讓這張工單失敗），不要展開。`count_spec` 是純算術、不產生清單，成本幾乎是零（這正是它當初被寫出來的理由，見 `scan_target_spec.py:168-170` 的 docstring）。這一層是給**存量資料**的保險——就算寫入端補好了，資料庫裡已經存著的超大網段還是打得到。

⚠️ 注意展開端的現行設計是**刻意**不擋的（docstring 寫「寧可跑一張大工單，也不要讓一個已經存在的任務突然變成執行不了」）。所以那道硬上限要訂得比使用上限寬很多（例如 65,536），只用來擋「明顯是炸彈」的輸入，不要讓正常的大範圍掃描被誤擋——否則會推翻原本的設計判斷。

**驗證。** 3/3 三位檢查員確認成立（可達性、影響、既有防護三個角度），嚴重度維持 MEDIUM。三位各自獨立把鏈子追完：序列化器的預設值 → 檢查被跳過 → 底層不覆蓋 → 展開無上限，四個環節都對得上，且都確認中間沒有任何一處做過補救。

**為什麼是 MEDIUM 不是 HIGH：** 要先是專案管理者，還要分兩次送修改、再按執行；打不到資料（不會外洩也不會竄改），只會讓服務變慢或倒掉。

**首腦核對註記。** 屬實，逐環開檔核對過：`:201-204` 確實只吃這次的值、`:331-333` 確實提早 return、`base_repository_impl.py:424` 的 `value is not None` 確實會跳過 None、`SSH_ITERATED_TARGET_FIELDS` 確實不含 openvas、`_expand_scan_target_fields` 確實無上限。另外實跑 `scan_target_spec` 確認 `10.0.0.0/8` 語法檢查會通過、展開台數是 16,777,214、openvas 回空 tuple（不受管）、openscap 回 `('hosts',)`（受管）——**攻擊路徑的每一環都對得上**。**與 D1／D3-1 不重複**：D1 查的是憑證存放與守門，D3-1 查的是結果回收信不信代理程式，都沒碰到綁定寫入這一段。淨新增 1 條，登記跨 arc 總表。

## 卡片重點逐項人工查證

卡片重點⑥有兩半：「任務綁定的加密時機」與「掃描目標驗證」。工具只碰到後者（就是 F1），前者完全沒提候選，首腦開檔補查。

### ⑥-1 加密時機：哪些欄位進信封、會不會漏掉某條寫入路徑 — ✅ 沒問題

**設計本身是對的。** 密碼不是整欄加密，而是包成一個「自我描述的信封」：`{"__enc__": "fernet", "value": "<密文>"}`。這樣做的理由寫在 `detection_secret_params.py` 的模組說明裡，而且理由站得住——密文存進 JSON 之後跟明文一樣都是字串，**光看值分不出有沒有加密**；如果靠「查工具定義哪些欄位是機敏的」來決定要不要解密，那工具定義一改版，舊任務就會解錯（把明文當密文解會炸、把密文當明文送出去會讓代理程式拿密文去登入）。改成信封之後，值自己會說明自己加密與否，工具定義怎麼改都不影響既有資料。

**兩條寫入路徑都有加密，沒有漏的。** 任務層一條（`:147-159`）、分派列一條（`:412-433`），兩處都呼叫同一支 `encrypt_secret_params`。

**加密的時機擋在對的位置**——`:139` 的 `_encrypt_tool_secret_params` 是在 `tool` dict 剛進來、還沒分岔去寫 BPMN XML 之前就做的。這點很關鍵，該函式的 docstring（`:128-133`）自己點名了：同一個 `tool` dict 會同時被拿去寫綁定表**和**寫進 BPMN 流程定義的 XML，如果只在綁定那端加密，BPMN XML 裡就會留一份明文密碼副本，而那份還會被規劃頁與 BPMN 編輯器讀出來——**等於加密了卻照樣外洩**。現行寫法是在分岔前就加密，兩份拿到的都是密文。**判定：時機正確。**

**「留空＝沿用原值」不會把密碼寫到別人那列。** 前端對已設定的密碼欄位是留空的（不會把密文送回來），所以後端要從既有資料沿用。危險在於「沿用誰的」——分派列有很多列，拿錯列就會把 A 列的密碼寫進 B 列。核對 `:415-417`：沿用來源是用 `uid` 逐列對位建的表（`previous_by_uid`），沒有 uid 的列視為新增、本來就沒有前值。**判定：對位正確。**

**三處剝除都在。** 加密只解決資料庫，畫面與稽核記錄要另外處理：`strip_secret_params` 的做法是**整個欄位不出現**而不是遮成 `****`——理由（`:120-124`）也站得住：遮罩仍要把值取出來走過序列化，任何一處忘了套就是明文外洩，而且遮罩後的密文照樣會落進 `api_logs.response`。判定依據是「值是不是信封」而不是查工具定義，所以連拿不到定義的讀取路徑（control-tree 的 raw SQL）也剝得掉。**判定：沒問題。**

**兩個容錯行為的方向是對的**（出錯時偏向安全）：`crypto` 沒注入時會記 warning 而不是靜默落明文（`:79-83`）；解密失敗時**剝除該欄位**而不是把密文丟給代理程式（`:130-135`），因為送密文過去會讓它拿密文當密碼去登入，症狀是「掃描跑完但沒進到登入後頁面」，比「缺欄位」難查得多——缺欄位至少會被連接器明確回報。

### ⑥-2 掃描目標驗證：接受什麼寫法 — ⚠️ 語法檢查沒問題，台數上限有缺口（就是 F1）

**語法檢查本身寫得不錯，三個細節值得記。**

- **主機名與 IPv6 原樣放行**（`scan_target_spec.py:93-99`）。只有長得像「四段數字」的東西才套 IPv4 規則。這是必要的，因為掃描目標欄一直都允許填主機名，不能因為新增了 IP 語法就把它們擋掉。
- **「想寫範圍但寫錯」會明確報錯，不會靜默放行**（`:107-137`）。`192.168.1.1-192.168.2.5`（跨網段範圍，目前不支援）長得像一個含連字號的主機名，如果讓它走放行那條路，會被當成一台叫這個名字的主機送出去，DNS 解析失敗變成一筆「主機不存在」——**使用者以為掃了一整段、其實零台**。現在會明確報錯告訴他要改寫法。這是靜默失敗的反面教材，寫得對。
- **範圍顛倒不自動對調**（`:127-129`）。`192.168.50.100-50` 這種會報錯而不是幫他調過來，理由是「自動對調等於猜使用者的意思，猜錯就掃了一整段不該掃的機器」。

**誰能改掃描目標：只有專案管理者。** 三個入口（建立任務 `:134`、更新任務 `:179`、改分派 `:235`）都先呼叫 `_require_manager`（`compliance-manager-be/app/flow_control/service/job_service.py:94`）。這是 FR-048 軸③的資源域守門，走 app service 層、不是 route 層 decorator，符合規範。**判定：權限這一層沒問題。**

**前端送來的內部欄位會被剝掉。** `scan_targets` 裡 `_` 開頭的欄位是後端自己維護的狀態（例如源碼包的參照），前端看不到也不該送。`:556-561` 會先把前端送來的內部欄位一律丟棄，再從既有資料逐列保留原本的——**剝除與保留是一組動作**，少了後者的話使用者每次按儲存都會把源碼包參照清掉。**判定：處理正確**（這也正是下面被駁回那條候選的爭點，見後）。

**缺口就是台數上限那一項** —— 見 F1。

### 附帶查證：內部欄位沒過濾就寫進派工參數 — ✅ 不成立（面板 0:3 全否，首腦同意）

研究員提報：綁定寫入那條路（`detection_job_binding_handler.py:213`）**沒有**做上面說的「剝掉 `_` 開頭內部欄位」那一步（分派列那條路 `:558` 有做），所以前端可以塞 `_source_file`、`_profile` 這種內部欄位進去，一路流到代理程式讀取檔案時用的允許清單。

三位檢查員一致否決，理由一致且首腦複核同意：

- **寫入是真的**，`:213` 確實少了那道剝除，注入的欄位也確實會存活到派工參數裡。
- **但拿不到任何好處**：能做這件事的人本來就是專案管理者，而管理者原本就有那個檔案的存取權——**攻擊者做完跟做之前的權限一模一樣**。
- **下載端點的守門沒被影響**：`/api/1.0/file/download/<uid>` 的守門與這個欄位無關。
- **抓檔跑在該代理程式自己的租戶脈絡下**：`agent_file_access_service.py:127-130` 會從代理程式那筆資料推出租戶身分，讀取時被資料庫的租戶隔離（RLS，就是「每個客戶只能看自己資料」的機制）擋住，跨租戶拿不到東西。

**判定：不是漏洞，不計為發現。** 不過既然兩條寫入路徑對同一件事的處理不一致（分派列剝、綁定不剝），**建議在修 F1 時順手把 `:213` 也補上那道剝除**——一致性本身有價值，而且「兩條路不一樣」這件事本身就是下一個人踩坑的來源。列為體質改善建議。

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

分兩層講：

**「報出來的這條存在嗎」——可信度高。** F1 的每一環首腦都開檔核對過，而且實跑純函式確認了數字（`/8` 是 16,777,214 台、openvas 不受管、openscap 受管）。三位檢查員從三個不同角度獨立追同一條鏈，三票全過。另外首腦補查時還找到同一缺口的第二個落點（分派列那條路），這增加而非削弱了對這條發現的信心——但也代表**修法不能只補一處**。

**「只有這條嗎」——可信度中等，有三個已知限制。**
1. **強度是 `low`**，只有一位研究員讀一遍，不是多位分工交叉讀。低強度的設計目的是快速篩，不是窮盡。
2. **這 5 個檔案的下游不在範圍內**。F1 的後半段（真正展開的那一步）在編排服務裡，那支檔案屬於 D3-4a／D3-4b 兩小棒，本棒沒掃。同理，若編排服務在參數落地時另有問題，本棒看不到。
3. **加密那一半的「沒問題」是人工定向查證，不是面板投票的結果**——工具對這一半一條候選都沒提。這幾項結論沒有三票背書，可信度低於 F1 那條。首腦讀的是設計意圖與實際程式碼是否一致，若有人刻意繞過這套信封機制另寫一條路（例如直接寫 SQL 更新那個 JSON 欄位），本次查證看不到。

`verification.status` 為 **`verified`**：三位檢查員對 2 條候選各投一票，**6 票全數投出，沒有漏投**，票數由工具自己的程式碼統計、不是任何 agent 自報。

## 執行概況

| 項目 | 數字 |
|---|---|
| run ID | `wf_e2650a07-12c` |
| 掃描 commit | `955e40964baa3cfc0e8766078db7d65075d3c50b`（工作區乾淨） |
| 範圍 | 5 檔 1,328 行 |
| 強度 | `low`（一位研究員 ＋ 三票面板） |
| 研究員 | 派 1 位、回 1 位、**零重試** |
| 面板 | 2 條候選 × 3 位檢查員 ＝ 6 票，全數投出 |
| 總耗時 | 約 140 分鐘（21:29 啟動 → 23:49 報告產出） |
| 候選 → 成立 | 2 → 1 |
| 驗證輪數 | 1 |
| 驗證章 | `verified` |
| 工具原始產物 | `jedi-detection/CLAUDE-SECURITY-20260918-132920/`（不入版控） |

**這一棒是「每棒總行數 ≤ 2,000」判準的第二次實證。** 1,328 行、零重試跑完。另外這棒是**刻意與另一支掃描（jedi-evidence-classification E1）平行跑的**——決策者要驗「並行」是不是死因之一。結果是**平行沒有害死它**，這與第 6 次診斷的結論一致：死因是研究員單次思考超過 180 秒被判停滯，跟並行無關。不過樣本只有一次，「一次只跑一棒」的紀律沒有因此解除，後續仍照舊。
