---
title: D3-4b 檢查結果：編排服務後半 — 回收／狀態／排程（jedi-detection）
---

# D3-4b 檢查結果：編排服務後半 — 回收／狀態／排程（jedi-detection）

> 檢查日期 2026-09-19｜對應卡片 CM-1875（D3 第 4 小棒後半，D3 收尾）｜檢查範圍 1 個檔案 2,498 行（本棒歸屬第 1,152 行～尾，約 1,350 行）

## 🔴 一句話結論

**本棒範圍內沒有新問題。** 工具報了三條，**三條全是前面棒次已經記過的舊帳**：查執行歷史缺參與者檢查＝第 88 項、換工具繞過台數上限＝第 105 項、解密帳密明文落工單表＝第 107 項（D3-4a 上一棒才記的），**淨新增 0 條，跨 arc 總表不加號**。卡片三個重點工具一條候選都沒提，由首腦逐項開檔查證，**三項都沒問題**：逾時排程用的是具名的系統身分、不是借用某個使用者；執行歷史與通知信兩條出口都走同一份剝除實作，帳密不會漏出去；代理程式回報與使用者按取消撞在一起的競態，三個地方都有擋。另外對工具報的第 107 項做了一次**方向修正**——它這次主張的缺口位置（`:196` 守門放行太寬）與 D3-4a 判定的「九支端點守門全在」**不衝突**，兩者是同一道守門的兩面，而且守門放行太寬這件事**本身就是跨 arc 總表第 59 項**、決策者 2026-09-13 已裁過，不是新發現。

## 這一棒在檢查什麼

D3-4a 看的是「按下去之後派工怎麼出去」，這一棒看的是**後半段：結果怎麼回來、狀態怎麼收口、沒人回報的怎麼辦**。

三件事：

1. **逾時排程用什麼身分做事**。掃描派出去之後代理程式可能失聯，系統有一支每 15 分鐘跑一次的背景工作，把「超過時限又沒回報」的執行判為失敗。這支工作沒有人在操作、沒有登入身分，但它要跨所有客戶改資料——**它用什麼身分繞過客戶隔離規則，是這棒最該問的**。
2. **執行歷史列表會不會把帳密帶出去**。掃描用的帳密在派工時被存進工單參數（就是 D3-4a 記的第 107 項），而執行歷史要把「這次掃了什麼參數」顯示給使用者看——**讀的是同一個欄位**，如果剝除做得不乾淨，明文帳密就會經 API 出去、還會落進 API 日誌。
3. **代理程式回報與使用者按取消撞在一起會怎樣**。使用者按下取消的同時，代理程式正好回報掃描成功——如果沒擋，已取消的紀錄會被復活成成功，證據也照轉。

掃描目標 `/Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-detection`，revision `955e40964baa`，mode `scan`，effort `low`，範圍是編排服務 `detection_orchestration_service.py` 這一個檔案（2,498 行）。

**行號分界說明**：這支檔案由 D3-4a／D3-4b 兩小棒分掃，本棒負責第 1,152 行之後。但工具的低強度模式是**整檔一次讀完、不吃行號範圍**——D3-4a 那棒已經記下這個機制落差，這一棒完全坐實：工具報的三條裡有兩條落在前半段（`:196`、`:555`），只有一條在後半（`:1667`），**沒有任何一條是本棒範圍的新東西**。

## Coverage

`low` 強度：一位研究員讀完這個檔案就提報候選，未做元件盤點、未做威脅建模，`completenessCheckOutcome` 為 `not-applicable`（低強度本來就不跑盤點）。驗證跑了 **1 輪**，3 個候選去重後仍是 3 個，**三條全數通過**，沒有候選遺失、沒有嚴重度被降低、沒有候選被略過。

研究員為了確認可達性另外讀了範圍外的幾處（主專案的路由層、`common/authz/workflow.py` 守門政策、角色列舉定義、專案序列化器的預設角色、代理程式端的工單 model），那些只當佐證、沒有納入稽核。研究員沒有回報「哪些檔案沒讀完」的自述（`coverage.research` 為 null），所以工具端沒有做讀取完整度的交叉檢查。

工具沒有實際執行任何程式碼：沒跑測試、沒發請求、沒示範攻擊，所有判斷都是讀原始碼推出來的。首腦核對階段同樣只做讀取（開檔、grep），沒有改動任何檔案、沒有連任何資料庫。

**工具對卡片三個重點（逾時身分／列表夾帶帳密／回呼競態）一條候選都沒提**，全部由首腦回頭開檔查證，詳見下方「卡片重點逐項人工查證」段。

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - F1（重複第 107 項）＝M03 第 9 條，✅ 已修（FR-114.4-3）。
> - F2（改狀態只擋成員、純瀏覽角色也過，重複第 59 項與第 88 項）＝M03 第 14 條，✅ 已修（FR-114.1-2）；列表缺守門同 M03 第 12 條，✅ 已修（CM-2040）。
> - F3（重複第 105 項）＝M03 第 11 條，✅ 已修（CM-2058）。

## 掃到什麼：總覽

| 編號 | 這是什麼問題 | 在哪裡 | 嚴重度 | 計數 |
|---|---|---|---|---|
| F1 | 解密後的帳密被寫進工單資料表存著 | `detection_orchestration_service.py:480` | MEDIUM | **重複第 107 項**（D3-4a 上一棒記的） |
| F2 | 改狀態的端點只擋「是不是專案成員」，純瀏覽角色也過得去 | `detection_orchestration_service.py:196` | MEDIUM | **重複第 59 項**（守門政策）＋ 第 88 項（列表缺守門） |
| F3 | 換工具時掃描範圍不重驗，超大網段被逐台展開 | `detection_orchestration_service.py:555` | MEDIUM | **重複第 105 項** |

**淨新增 0 條。** 三條全部落在已記在案的範圍內，跨 arc 總表不加號。

## Findings

### F1 — 解密後的帳密被寫進工單資料表存著（MEDIUM，confidence high）**重複第 107 項，不計新發現**

**這是什麼問題。** 按下「開始掃描」時系統把加密存放的帳密解開，一邊交給代理程式、一邊又把明文存了一份進 `agent_tasks.params`（`:480`）。那個欄位沒有再加密，所以拿到資料庫備份或唯讀帳號的人，一句 SQL 就得到客戶正式機房的 SSH 密碼與掃描器管理員 token。

**計數處理。** 這條是**第 107 項**，上一棒（D3-4a）才剛完整記過——含修法（刪掉那一行 ＋ 補一支清存量的 migration）、三個 repo 交叉核對確認刪掉不影響代理程式、以及「這張表沒有任何清理機制」的查證。落點 `:480` 在前半段，依分界本來就不屬本棒。**本棒沒有新增任何資訊。**

**驗證。** 3/3 檢查員確認成立，與上一棒的票數一致。

### F2 — 改狀態的端點只擋「是不是專案成員」，純瀏覽角色也過得去（MEDIUM，confidence medium）**重複第 59／88 項，不計新發現**

**這是什麼問題。** 工具這次挑的角度與 D3-4a 不同：D3-4a 問的是「每支端點有沒有呼叫守門」（答案是九支有八支有），這次問的是「**守門本身放得夠不夠緊**」。答案是：那道守門只檢查「你是不是這個專案的人」，**任何角色都放行**，包含產品定義為「純瀏覽」的 viewer。所以一個只該看報告的外部顧問，可以按下「開始掃描」對客戶正式機器發動掃描、可以反覆取消再重派、也可以把失敗的執行紀錄刪掉。

**為什麼這不是新發現。** 守門函式 `common/authz/workflow.py` 的檔頭註解自己寫著：

> 守門政策（per 2026-06-05 user 決策）：任一角色（含 viewer / member）的專案參與者皆通過，只擋非參與者。

也就是說這是**刻意的政策設定，不是漏寫**。而「這個政策與 viewer 的產品定義（純瀏覽、沒有待辦任務）互相矛盾」這件事，**跨 arc 總表第 59 項已經完整記過**（來源 FR-088 H4 F6），而且**決策者 2026-09-13 已經裁定**：viewer 不可以完成或退回任務，只能留言；修法方向是「呼叫者必須是任務負責人或專案管理者」。

這次工具是從檢測模組這一側又看到同一道守門。**它帶來的唯一新資訊是：第 59 項的修法範圍比原本記的更大**——原本記的是「完成任務／退回任務」兩支，現在確認**檢測模組的八支寫狀態端點吃的是同一道守門**，修法若只改流程模組那兩支，檢測這八支仍然對 viewer 敞開。

**在哪裡。** 守門政策在 `common/authz/workflow.py:47-50`；檢測模組吃這道守門的八支在 `detection_orchestration_service.py` 的 `:196`（開始掃描）、`:871`（取消）、`:925`（重跑單台）、`:1007`（取消單台）、`:1032`（整組取消）、`:1097`（刪單筆）、`:1127`（整組刪）、`:1470`（立即開始）。`list_executions`（`:1667`）則是連守門都沒呼叫，那是第 88 項。

**計數處理。** 不計新發現。**但要回寫兩處**：第 59 項的影響面補上「檢測模組八支寫狀態端點同吃這道守門」，讓修法時不會漏掉。

**驗證。** 3/3 檢查員確認成立。首腦開檔核對：守門鏈（任務 → 流程執行 → 反查專案 → 查角色 → 查無角色才擋）屬實，`role is None` 才擋、任何角色都過屬實，路由層只有 `@require_license("plugin")` 這道授權鎖（管的是「有沒有買這個功能」、不是權限）屬實。

### F3 — 換工具時掃描範圍不重驗，超大網段被逐台展開（MEDIUM，confidence medium）**重複第 105 項，不計新發現**

**這是什麼問題。** 「掃哪些機器」這欄的台數上限只在存檔時檢查、且只檢查這次送上來的值。先綁一個不受上限管的工具把 `10.0.0.0/8` 存進去，再只換工具不帶參數，舊範圍就跟到新工具底下；按執行時派工端逐台展開成 1,670 萬個位址，把後端記憶體吃爆。

**計數處理。** 這條是**第 105 項**，D3-2 那棒已從寫入端完整記過（含兩個入口的修法與「展開端硬上限要訂寬」的注意事項），D3-4a 也從展開端撞過一次。**本棒第三次撞到同一件事，沒有新增資訊。**

**驗證。** 3/3 檢查員確認成立。

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

卡片三個重點工具一條候選都沒提，首腦開檔補查。

### ⑤ 逾時排程用什麼身分 — ✅ 沒問題（工具未報，人工查證）

**問題意識。** 這支背景工作要跨所有客戶改資料，但它沒有登入身分。系統的客戶隔離是資料庫層做的（每次查詢前注入「你是誰、能看哪些客戶」），沒有身分就查不到東西、或是更糟——**借用某個真實使用者的身分**，那會讓稽核紀錄上的經手人變成無辜的人，而且那個人能看的範圍決定了這支工作能收斂的範圍。

**查證結果：用的是具名的系統身分，不是借用使用者。** `core/scheduler.py:398`：

```python
with system_context("detection_execution_timeout"):
    result = service.converge_timed_out_executions()
```

`system_context` 是專門給背景工作用的顯式宣告，繞過客戶隔離規則掃全部客戶，而且**括號裡的字串就是宣告「我是誰」**——`detection_execution_timeout`，看得出是哪一支工作做的。同檔註解寫明理由：「要掃全部租戶，故以 `system_context()` 顯式宣告繞 RLS」。

**寫資料時留的經手人也是具名的。** 收斂時寫進執行紀錄與工單的操作者是 `_TIMEOUT_ACTOR = "detection-timeout@system"`（`:105`），不是某個真人帳號、也不是空白。稽核紀錄上看得出「這筆是被系統判逾時的」，與「使用者自己取消」區分得開。

**判定：這一項沒有問題，而且是正面案例。** FR-094 那批「背景排程具名身分」的同題檢查裡，這支是做對的那型——顯式宣告、名字說得出是誰、寫入者可追溯。

**順帶查到兩個設計細節值得記（都不是問題）：**

- **排程單不會被誤殺。** `_is_timed_out()`（`:1213`）特別處理了一個陷阱：排在三天後才開跑的單子，它的「開始時間」記的是**被發動的時間**而非開掃時間，直接拿時限判會把還沒開始跑的排程單殺掉。程式改看工單的排定時間，**領單時間都還沒到就不算逾時**。
- **工單也一併判失敗。** 收斂執行紀錄時同步把工單標失敗（`:1191-1194`），註解理由是「留著待領的工單，代理程式之後若復活會把它領走、跑一份已經被判失敗的掃描，結果回來對不上任何活著的紀錄」。這是對的。

### ⑦ 執行歷史列表會不會夾帶帳密 — ✅ 沒問題（工具未報，人工查證）

**問題意識。** 帳密明文就存在工單參數裡（第 107 項），而執行歷史要把「這次掃了什麼參數」顯示出來，**讀的是同一個欄位**。剝除若漏掉，明文會經 API 出去，還會被記進 API 日誌（那是另一份永久保存的明文副本）。

**查證結果：剝得乾淨，而且只有一份剝除實作。**

列表路徑 `list_executions`（`:1649`）取參數走 `_resolve_task_fields_batch`（`:1937`），它與單筆版 `_resolve_scan_params`（`:1927`）**共用同一支** `_visible_scan_params()`（`:2270`）。那支做兩件事：

1. **所有 `_` 開頭的鍵整個丟掉**——`_credentials`（租戶層帳密明文）就是被這條規則剝掉的。
2. **任務層敏感參數整個 key 不出現**（走 `strip_secret_params`）——這類在資料庫裡本來就是密文，但密文一樣不該出 API。

被剝掉之後有兩個鍵**刻意放回**不帶底線的版本：源碼包（只放 `{uid, file_name}`，技術細節不放）與掃描目標的原始寫法（讓列表能顯示「使用者原本寫的是哪一段」而不是 123 行 IP 牆）。兩個放回的內容都與帳密無關。

**通知信那條出口也走同一份。** `_build_group_notify_rows`（`:2059`）取掃描目標時同樣呼叫 `_visible_scan_params()`；下游的 `_fmt_params()`（`:2401`）註解寫明「傳進來的必須已經剝除過，此處不再過濾——**只有一個剝除來源，避免出現『這裡忘了剝』的第二條路徑**」。

**這裡有個值得記的設計理由。** `_resolve_task_fields_batch` 的註解自己寫著：「兩份剝除實作就是『這裡忘了剝』的溫床（憑證明文會落進 `api_logs.response`）」——**程式作者知道這個風險、也知道防法是「只留一份」**。這是對的做法，而且它正好是 F1／第 107 項的反證：程式在出口擋得很嚴，**擋不到的是資料庫裡那份**。

**判定：這一項沒有問題。** 兩條出口（API 列表、通知信）共用同一份剝除，沒有第二條繞過路徑。

### ④ 代理程式回報與取消撞在一起的競態 — ✅ 沒問題（工具未報，人工查證）

**問題意識。** 掃描是非同步的：使用者按取消的那一刻，代理程式可能正在回報結果。如果沒擋，已取消的紀錄會被寫回成功、證據照轉、通知照發，使用者會看到「我明明取消了它卻成功了」。

**查證結果：三個地方都有擋，而且擋法各自對應不同的撞法。**

| 撞法 | 擋在哪 | 怎麼擋 |
|---|---|---|
| 取消之後代理程式才 ack 領單 | `on_task_claimed`（`:1432`） | **只在「排程中」時才轉執行中**，不無條件寫。註解寫明理由：無條件寫會把已取消的筆復活成執行中 |
| 取消之後代理程式回報成功 | `on_scan_succeeded`（`:1503`） | 先看狀態是不是 `cancelled`，是就**不轉證據、不通知、不觸發自動完成**，直接返回 |
| 取消之後代理程式回報失敗 | `on_scan_failed`（`:1542`） | 同上，**不覆寫已取消的紀錄** |

**還有第四個競態，擋法不同。** 好幾台同時掃完時，每一台回報都會觸發「這組是不是全部結束了」的收口檢查——如果沒擋，收口的三件事（彙總狀態、自動完成任務、彙總通知）會被做好幾次，使用者收到 N 封信。`_close_group_if_terminal`（`:1562`）用的是**資料庫排隊鎖**：

> 必須與該筆終態寫入在同一交易內——先 `SELECT ... FOR UPDATE` 鎖 group 列，再算彙總。N 筆併發回報時後到者會等鎖，看到的是前者已提交的終態，故收口三件事只會由「讓彙總離開 running 的那一筆」執行一次。

這個擋法是對的：用資料庫的鎖而不是程式裡的旗標，跨多個後端工人也成立。

**另有一層冪等防護。** 自動完成任務那支（`_auto_complete_job_if_needed`，`:1622`）會先看任務是不是還在處理中，不是就跳過不報錯。註解的理由是：人工先把任務完成了、之後某組重跑又成功，收口會再次呼叫完成，而那支對已完成的任務會回 409、且回報端不吞例外 → **代理程式會收到 5xx 而重試**。這個防護擋的是「錯誤傳導到代理程式」，不只是資料正確性。

**判定：這一項沒有問題。** 四種競態各有對應的擋法，而且擋的位置與理由在註解裡都寫清楚了。

**留一條體質改善建議（不是缺口）。** `on_task_claimed` 第一行 `getattr(agent_task, "uid", None)` 拿不到 uid 時會帶著 `None` 往下查，查不到才走警告返回——**D3-3 那棒已經記過同一條**，這裡再次看到。拿不到 uid 應該直接返回，省一次無謂查詢。不是安全缺口，兩棒都建議、可以順手改。

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

分兩層講：

**「報出來的這三條存在嗎」——可信度高，但三條都是舊帳。** 三條的每一環首腦都開檔核對過，三位檢查員各自獨立追鏈、九票全過。問題不在準確度，在**新鮮度**：工具在同一個檔案裡第三次重新發現同一批已知問題。

**「範圍內真的沒有新東西嗎」——可信度中等，有四個已知限制。**

1. **強度是 `low`**，一位研究員讀一遍，設計目的是快速篩不是窮盡。
2. **工具沒有吃行號範圍，本棒完全坐實這個落差。** 三條報出來的有兩條在前半段——工具讀的是整檔 2,498 行，它報什麼是它自己挑的，「後半 1,350 行被讀得多仔細」沒有任何獨立保證。D3-4a 那棒已經提出這個疑慮，**本棒的結果就是證據**：切棒只切了首腦核對這一端，研究深度那一端沒切。
3. **三個卡片重點的「沒問題」是人工定向查證，不是面板投票的結果。** 工具一條候選都沒提，結論是首腦逐項開檔比對出來的，沒有三票背書。查的是「程式怎麼寫」，若有人用別的路徑繞過（例如直接對資料庫下 SQL、或從代理程式端偽造回報），本次查證看不到。
4. **競態那一項沒有實跑驗證。** 「取消與回報撞在一起會被擋下來」是讀程式碼推出來的，邏輯鏈完整但沒有真的製造競態測試。

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

## 執行概況

| 項目 | 數字 |
|---|---|
| run ID | `wf_f7f37e86-053` |
| 掃描 commit | `955e40964baa3cfc0e8766078db7d65075d3c50b`（工作區有其他未 commit 改動，與本棒無關） |
| 範圍 | 1 檔 2,498 行（本棒歸屬第 1,152 行～尾，約 1,350 行） |
| 強度 | `low`（一位研究員 ＋ 三票面板） |
| 研究員 | 派 1 位、回 1 位、**零重試** |
| 面板 | 3 條候選 × 3 位檢查員 ＝ 9 票，全數投出 |
| 總耗時 | 約 76 分鐘（06:40 啟動 → 07:56 報告產出） |
| 候選 → 成立 | 3 → 3（零駁回） |
| 淨新增 | **0 條**（三條分別重複第 107／59＋88／105 項） |
| 驗證輪數 | 1 |
| 驗證章 | `verified` |
| 工具原始產物 | `jedi-detection/CLAUDE-SECURITY-20260919-064027/`（不入版控） |

**三個觀察，留給後續切棒參考。**

1. **同一個檔案掃第二次，淨新增掉到 0。** D3-4a 掃這個檔得到 1 條新的，D3-4b 再掃一次得到 0 條——**三條報出來的有兩條 D3-4a 也報過**（F1＝那棒剛記的第 107 項、F3＝那棒也撞過的第 105 項）。這說明低強度整檔掃對同一個檔案的第二次是**高度重疊的**，不是互補的。若後續還有「同檔分兩棒」的情況，第二棒的價值主要在人工查證那一端，不在工具產出。
2. **零重試，76 分鐘，比 D3-4a 的 102 分鐘更短。** 同一個檔案、同樣單獨跑，差別可能只是候選的複雜度。兩次都沒觸發停滯判定，2,500 行這個量級一位研究員確實扛得住。
3. **工具第一次挑到「守門政策放得太寬」這個角度。** 前幾棒工具挑的都是「有沒有呼叫守門」，這次挑的是「守門本身鬆不鬆」——雖然結論是舊帳，但這個角度是新的，而且**帶來了第 59 項影響面的擴大**（檢測模組八支端點同吃那道守門）。這是本棒唯一的實質產出。
