---
title: D3-3 檢查結果：執行狀態機與存取層（jedi-detection）
---

# D3-3 檢查結果：執行狀態機與存取層（jedi-detection）

> 檢查日期 2026-09-19｜對應卡片 CM-1875（D3 第 3 小棒）｜檢查範圍 11 個檔案 707 行

## 🔴 一句話結論

**這一棒範圍內沒有找到新問題。** 工具唯一報出來的那條**落在範圍外的檔案**（編排服務），而且**就是跨 arc 總表第 88 項那條已知的舊帳**——同一個缺口、同一個修法，只是這次拿到了更新的行號，**不計為新發現**。卡片指定要看的三件事（狀態機會不會被兩個人同時改壞、查詢條件空了會不會回整個資料表、錯誤訊息會不會洩漏內部資訊）工具一條候選都沒提，**由首腦逐項開檔查證，三項都沒問題**：狀態收口有資料庫層的排隊鎖擋住競態、查詢入口全部都帶條件所以踩不到「回全表」那個坑、錯誤碼只有兩個而且都只回固定英文短句。

## 這一棒在檢查什麼

客戶按下「執行檢測」之後，系統會把工作拆成一張張工單發給裝在客戶機房的代理程式。每張工單跑完會回報成功或失敗，**一次執行底下可能有好幾台機器同時在跑**，全部跑完才算這次執行結束。

這一棒看的是**記錄這些狀態的那一層**，三件事：

1. **好幾台同時回報時，會不會把狀態算錯**。四台機器可能在同一秒回報完成，如果每一台都各自去算「整組好了沒」，就可能四台都認為自己是最後一台，於是「這次執行結束」的後續動作（自動完成任務、發通知）被做了四次；或者反過來，四台互相覆蓋，最後誰也沒收口，整組永遠卡在執行中。
2. **查詢條件如果空了，會不會把整個資料表都撈出來**。這是跨 arc 總表**第 70 項**那個底層老問題：共用的資料存取底層規則是「查詢欄位有值才加條件、沒值就不加」，所以只要上層不小心傳了空值下來，那句查詢就變成「把整張表撈回來」，而且**不會報錯**——已經在三個不同套件放大成全庫外洩過。這一棒要確認檢測執行這兩張表有沒有踩到同一個坑。
3. **錯誤訊息會不會把內部資訊吐給外面**。錯誤碼如果把資料庫欄位名、內部路徑或別人的資料夾帶在訊息裡回給前端，等於免費送攻擊者一張地圖。

掃描目標 `/Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-detection`，revision `955e40964baa`（branch `feature/review`，工作區乾淨），mode `scan`，effort `low`，範圍 11 個檔案：執行歷史 domain service（184 行）、群組 domain service（119 行）、群組彙總狀態計算（57 行）、兩支查詢條件物件（19＋15 行）、兩支資料存取實作（83＋69 行）、兩支資料表定義（56＋46 行）、兩支錯誤碼（9＋50 行），共 707 行。

## Coverage

`low` 強度：一位研究員讀完這 11 個檔案就提報候選，未做元件盤點、未做威脅建模、未跑額外密鑰專項掃描，`completenessCheckOutcome` 為 `not-applicable`（低強度＋限定範圍本來就不跑盤點）。驗證跑了 **1 輪**，1 個候選去重後仍是 1 個，沒有候選遺失、沒有嚴重度被降低、沒有候選被略過。研究員為了追資料流另外讀了範圍外的幾處（編排服務、API 路由、主專案的守門實作、jedi-file-upload 的下載端點），那些只當佐證、沒有納入稽核。

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

工具沒有實際執行任何程式碼：沒跑測試、沒發請求、沒示範攻擊，所有判斷都是讀原始碼推出來的。首腦核對時另外開了範圍外的幾個檔案對照（`base_repository_impl.py` 的條件組裝規則、排程器的身分設定、RLS 政策 SQL、jedi-remote-agent 的工單狀態機），同樣只是讀取、不改任何狀態。

**🔴 工具唯一那條候選越界了**：F1 指向 `detection_orchestration_service.py:1667`，**不在本棒 11 檔之內**（那支檔案屬 D3-4a／D3-4b 兩小棒）。研究員是從本棒的資料存取層往上追呼叫端追出去的，方向合理，但產物不算本棒的發現。**而且它與跨 arc 總表第 88 項是同一條**，詳見下一段。

**卡片重點④的三項工具完全沒提候選，由首腦開檔查證補上**，詳見「卡片重點逐項人工查證」段。

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - F1（查執行紀錄缺參與者檢查，重複第 88 項）＝M03 第 12 條，✅ 已修（CM-2040）。

## 掃到什麼：總覽

| 編號 | 這是什麼問題 | 出事會怎樣 | 要先有什麼 | 在哪裡 | 嚴重度 | 計不計新發現 |
|---|---|---|---|---|---|---|
| F1 | 查掃描執行紀錄少了「你是不是這個專案的人」這道檢查 | 同一家客戶內任何成員知道任務編號，就讀得到別人專案的掃描歷史與報告檔 | 該客戶內的有效登入帳號 ＋ 一個任務編號 | `detection_orchestration_service.py:1667` | MEDIUM | ❌ **越界＋重複第 88 項**，只更新行號 |

**本棒範圍內工具零產出。** 卡片重點④的三項另見人工查證段，結論皆為沒問題。

## Findings

### F1 — 查執行紀錄漏了參與者檢查（MEDIUM，confidence high）— ⚠️ 越界、重複第 88 項、不計新發現

**先講結論：這條不是新的。** 它與跨 arc 問題總表**第 88 項**是同一個缺口、同一個修法（總表第 88 項的來源是 FR-108 D1 的 F4＋F5，由決策者指示併為一條）。本次只是從另一個方向（資料存取層往上追呼叫端）再次撞到它，**並且落點不在本棒的 11 個檔案裡**——`detection_orchestration_service.py` 屬 D3-4a／D3-4b 的範圍。**所以不計為新發現，總表不加號，只做一件事：把行號更新成這次核對過的值。**

**這是什麼問題。** 「查某張檢測任務的執行紀錄」這個功能，只檢查了「你是不是這家客戶的人」，**沒有檢查「你是不是這個專案的成員」**。同一支服務裡另外八個一樣吃任務編號的功能（開始執行、取消、重跑、取消單組、取消整組、刪除單筆、刪除整組、立即開始）**每一個都先做了參與者檢查**，只有查紀錄這一支漏掉——它甚至沒有去查這張任務屬於哪個專案。

**出事會怎樣。** 同一家客戶裡的任何成員，只要知道一個任務編號，就讀得到那個專案的完整掃描歷史：掃了哪些內部機器與網段、用什麼掃描設定、哪一台代理程式執行的、用了什麼工具、失敗訊息、摘要、是誰發動的（帳號與暱稱），以及**掃描報告的檔案編號**。拿到檔案編號之後還能接著去打下載端點，把那份原始的弱點掃描報告整份載下來——**等於拿到別的部門的資安體檢報告**。

**要先有什麼才打得到。**
- 攻擊者要有**同一家客戶內的有效登入帳號**（跨客戶打不到：查詢本身帶了客戶條件，資料庫層也有「每個客戶只能看自己資料」的隔離規則 `detection_executions_tenant_isolation` 擋著）。
- 該客戶的授權要包含檢測模組（路由上掛著授權檢查）。
- 要**知道一個任務編號**——那是一組隨機碼，猜不出來，但會出現在轉寄的任務連結、通知信、或其他把任務編號攤給全客戶看的頁面上。

三個條件裡前兩個內部人員天生就有，第三個是「知道就有、不知道就沒有」，所以評中風險不評高。

**在哪裡。**
- 缺口本體：`jedi-detection/jedi_detection/app/service/detection_orchestration_service.py:1667` —— `list_executions` 直接打 `list_by_job_execution_ordered(job_uid, tenant_id=tenant_id)`，前面沒有解析任務、也沒有參與者檢查
- 入口：`jedi-detection/jedi_detection/api/routes/detection_tool_route.py:339-346` —— `GET /api/1.0/detection-tools/jobs/<job_uid>/executions`，裝飾器只管「有沒有登入」與「授權有沒有含這個模組」，不管「這筆資料是不是他的」
- 八個做對的鄰居：同檔 `:196`／`:871`／`:925`／`:1007`／`:1032`／`:1097`／`:1127`／`:1470` —— 每一處都呼叫 `self._wf_svc.assert_project_participant(...)`
- 那道檢查的真正實作：`compliance-manager-be/common/authz/workflow.py:44-49` —— 確實會把非參與者擋成 403，不是空殼
- 查詢本身（**本棒範圍內**）：`jedi_detection/infra/detection_execution/repository/detection_execution_repo_impl.py:26-38` —— 只以任務編號與客戶編號過濾，**這一層這樣寫是對的**（資料存取層本來就不該知道專案權限），問題在上層沒擋

**怎麼修。** 照同一支服務裡其他八處已經在用的寫法，在 `:1667` 那句查詢之前補兩行：先用 `self._task_lookup.get_task(job_uid)` 把任務查出來（查不到回 404，不要回空清單——回空清單等於告訴對方「這個編號不存在」還是「你沒權限」分不出來，但更重要的是行為要跟其他八處一致），再呼叫 `self._wf_svc.assert_project_participant(job.workflow_execution_id)`。

**另外建議加一道自動守門**：寫一支測試，斷言「這支服務裡每一個對外開放、吃任務編號的方法，都必須呼叫過參與者檢查」。這個缺口的成因就是「加新的讀取功能時忘了抄那一行」，靠人記得抄防不住第二次——**九個方法裡漏一個就是這次的實況**。

**驗證。** 3/3 三位檢查員確認成立（可達性、影響、既有防護三個角度），嚴重度維持 MEDIUM。三位各自獨立把鏈子追完：路由掛載 → 服務層無守門 → 資料存取層只有客戶條件 → 主專案的守門實作確實有效，四個環節都對得上。

**首腦核對註記。** **重複第 88 項，不計新發現。** 座標開檔核對屬實：`:1667` 確實沒有守門、八個鄰居確實都有、路由確實只掛了登入與授權兩道。**與第 88 項的差異只有行號**——總表第 88 項記的是 `detection_orchestration_service.py:1649`（D1 掃 `3ddd721` 當時的行號），本次在 `955e409` 上核對到的實際查詢行是 `:1667`（`:1649` 是該方法的起始行）。**總表的第 88 項行號建議補註「方法起始 :1649／查詢落點 :1667」**，修的時候兩個都會看到。

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

卡片重點④列了三件事，**工具一條候選都沒提**，以下三項全部由首腦開檔查證。**這三項的結論沒有三位檢查員的票背書**，可信度低於上面那條，詳見「這份結果可信到什麼程度」。

### ④-1 狀態機競態：好幾台同時回報會不會算錯 — ✅ 沒問題（工具未報，人工查證）

**擔心的是什麼。** 一次執行底下有 N 台機器在跑。每一台跑完都會去問「整組好了沒」，如果答案是「好了」就要做三件收尾的事：把整組狀態寫下來、自動把稽核任務標成完成、發一封通知信。**這三件事只能做一次**——做四次就是四封通知信、任務被重複標記；做零次就是整組永遠卡在執行中，使用者下次按執行還會被擋（系統不准同一張任務有兩組同時在跑）。

**實際的寫法擋住了。** 收尾那段（`detection_orchestration_service.py:1562` 的 `_close_group_if_terminal`）的第一個動作就是**跟資料庫要一把排隊鎖**——`lock_for_close()`（`detection_execution_group_repo_impl.py:47-56`，用的是 `SELECT ... FOR UPDATE`）。白話講就是「這一列同一時間只准一個人碰，第二個人得排隊等」。**關鍵在於它跟「寫入這台機器的最終狀態」是在同一個資料庫交易裡**，所以排在後面的那台拿到鎖的時候，前面那台的結果**已經確定寫進去了**，它算彙總時看得到——於是只有「讓整組真正跑完的那一台」會算出終態並執行收尾，其餘的算出來仍是「執行中」就直接返回。

**彙總規則本身也查了，沒有漏洞**（`detection_group_status.py:22-57`）：規則是「只要有任何一台還沒落地就算執行中」，而且**未知的狀態值一律當成還沒落地**。這個預設方向是對的——出錯時偏向「不收口」而不是「誤判成功」，因為誤判成功會讓稽核任務被自動標成完成，那是錯的方向。空清單也回「執行中」（群組剛建好、還沒有任何執行列的那一瞬間不該被判成已結束）。

**還查了一個更隱晦的競態，也擋住了。** 單組重跑時要判斷「這個群組是不是已經收口了、需不需要撥回執行中」。程式沒有用稍早查出來的那份群組資料去判斷，而是**重新去拿排隊鎖再讀一次**（`:968`）。原因寫在 `:964-967` 的註解裡，而且理由成立：稍早那份是交易早期讀的、沒帶鎖，收口可能在那之後才提交，用舊快照判斷會得到「還在跑、不用撥回」的錯誤結論，結果是一筆執行中的紀錄掛在一個已結束的群組底下——畫面顯示已結束、掃描其實還在跑。**這一處寫對了。**

**兩個「不做狀態守門」是刻意的分工，不是漏洞。** `write_cancelled`（`detection_execution_domain_service.py:143`）與 `delete`（`:171`）都在註解裡寫明「前置條件由上層驗、本層不做」。追上去核對，上層確實有驗：取消前會先確認該筆真的在跑，刪除前會確認只有失敗的才能刪（`:1129-1140` 連空群組都擋掉）。**分工清楚、兩端沒有落空。**

**判定：沒問題。** 補一句限制：這是讀程式碼推出來的，**沒有實際起兩個並行請求去打**。真正的併發測試不在本棒範圍，也不是這個掃描工具會做的事。

### ④-2 空條件回全表（總表第 70 項的老問題）：檢測執行這兩張表有沒有踩到 — ✅ 沒問題（工具未報，人工查證）

**老問題是什麼。** 共用的資料存取底層（`jedi-common` 的 `base_repository_impl.py:511-512`）規則是 `if value is not None` 才加條件。**所以查詢條件物件如果整個都是空值，那句 SQL 就沒有任何 WHERE，等於「把整張表撈回來」**——而且不會報錯、不會變慢到被發現，只會安靜地回一大包。這個底層行為已經連續三次在不同套件放大成全庫外洩（總表第 60／61／68 項），所以第 70 項把它登記成根因。

**這兩張表的查詢條件物件本身寫法是對的**（`detection_execution_query_entity.py`、`detection_execution_group_query_entity.py`）：每個欄位都是 `Optional[X] = None`，而且 `to_dict()` 會把空值濾掉，**兩支的 docstring 都明寫「防幽靈 WHERE」**——寫的人知道這個坑。但光是「知道」不夠，真正決定安不安全的是**上層有沒有可能傳空的下來**，所以逐個入口查。

**七個會走到這個底層的入口，逐個核對，沒有一個能被外面弄成空條件：**

| 入口 | 帶什麼條件 | 空值有沒有可能 |
|---|---|---|
| `get_one(uid)` | `uid` | 呼叫端都是先拿到 uid 才呼叫 |
| `get_by_agent_task_uid()` | `agent_task_uid` | **唯一有疑慮的一支**，見下 |
| `get_running_by_job_execution_uid()` | 任務編號 ＋ `status="running"` | **`status` 是寫死的常數**，就算任務編號是空的也還有一個條件在 |
| `list_by_job_execution()` | 任務編號 | 來自路徑參數，路由層不可能給空 |
| `get_one(uid)`（群組） | `uid` | 同上 |
| `get_running_by_job_execution()`（群組） | 走 repo 自訂方法，**條件直接寫死在 SQL 裡**（`repo_impl:36-40`） | 不吃條件物件，不受影響 |
| `list_by_job_execution()`（群組） | 任務編號 ＋ **`is_delete=False`**（寫死） | 同樣有第二個寫死的條件兜底 |

**那支唯一有疑慮的追到底了，打不到。** `get_by_agent_task_uid` 在 `:1444` 被呼叫時寫的是 `getattr(agent_task, "uid", None)`——**看起來就是「拿不到就傳 None」**，如果真傳了 None 進去，條件物件就全空，底層會回整張 `detection_executions`（跨客戶，因為那一層沒有客戶條件）。實際追呼叫鏈：這支只從 jedi-remote-agent 的 `agent_task_service.py:105` 被呼叫，而它拿到的 `task` 來自上一行的 `mark_dispatched(uid, ...)`；再追進去，`mark_dispatched` → `update_status` 的第一個動作是 `verify_task_is_exist(uid)`，**查不到就直接拋 404、根本走不到後面**。所以傳進來的物件一定有 uid，`getattr` 的那個 `None` 預設值永遠不會生效。

**判定：沒問題，但這是「靠上游擋住」不是「自己擋住」。** 那個 `getattr(..., None)` 是防禦性寫法，可是它防的方向反了——**拿不到 uid 時應該直接返回，而不是帶著 None 往下查**。目前安全完全依賴「上游一定先驗存在」這個外部保證，哪天有人加了第二個呼叫端而沒有先驗，這裡就會從「安全」變成「回整張表」，而且**不會有任何錯誤訊息**。**體質改善建議（不計為發現，因為現在打不到）**：在 `on_task_claimed` 開頭加一句「uid 為空就記 log 並返回」，或在 `get_by_agent_task_uid` 裡擋掉空值。一行的事。

**另外，就算真的漏出去，還有第二層擋著。** 這兩張表都開了「每個客戶只能看自己資料」的資料庫隔離（`scripts/sql/packages/jedi_detection/002-detection-rls-grants.sql:24-25`、政策在 `:34-42`），所以即使 WHERE 被清空，一般使用者的連線也只撈得到自己客戶的資料。**唯一例外是逾時收斂那支背景排程**——它刻意用系統身分繞過這層隔離（`core/scheduler.py:399` 的 `system_context`，註解寫明「要掃全部租戶」）。核對過它的查詢（`list_stale_running`，`repo_impl:66-83`）：條件是「狀態還沒落地 ＋ 開始時間早於某個時間點」、寫死在 repo 裡、不吃外部參數，而且**每輪有筆數上限**（預設 200 筆）。**繞過隔離是必要且範圍受控的，判定合理。**

### ④-3 錯誤碼洩漏：錯誤訊息會不會吐出內部資訊 — ✅ 沒問題（工具未報，人工查證）

**兩支錯誤碼檔全部讀完，共 10 個碼。**

**執行狀態機自己的只有兩個**（`detection_execution_error_code.py`）：「Detection execution not found」與「Detection execution group not found」。**都是固定的英文短句，沒有任何插值**——不帶編號、不帶欄位名、不帶路徑。就算攻擊者拿它來試編號，回來的也只有「找不到」三個字。

**更值得記的是「找不到」與「不是你的」回同一個碼。** `_require_group`（`:1235-1245`）的註解寫明「查無或跨租戶一律 404，不區分，避免成為探測管道」——**這是對的**：如果跨客戶回 403、不存在回 404，攻擊者就能靠回應碼的差別一個一個試出「哪些編號是真的存在」，等於把一張清單送給他。現在兩種情況長得一模一樣，試不出東西。

**另一支（`detection_job_binding_error_code.py`）的八個碼是從主專案逐字複製過來的**，全部是給使用者看的中文提示（「掃描目標格式不正確」「Agent 分派列有必填欄位未填」之類），**同樣沒有插值、沒有內部資訊**。檔頭那段說明也查證過並且成立：套件不得 import 主專案（守衛測試會擋），而整份主專案的枚舉屬別的疆界、不該整包拖進來，所以只複製這 8 個。**值被凍結是刻意的**——前端的多語系檔靠這些字串比對，改了值不會報錯，只會讓整批錯誤訊息靜默掉回英文，而且有契約測試兩邊各自焊死。

**還注意到一個寫對的細節**：`GRC_DETECTION_TOOL_UID_INVALID` 旁邊註明「不可改回 GRC_400105，那個號被主專案的另一個功能佔用，會撞號」。**錯誤碼號不可回收再利用**這個紀律有寫進去。

**判定：沒問題。** 限制同上：這是讀原始碼，沒有實際打 API 去看回應長什麼樣，**如果有某個地方把例外的原始訊息直接塞進回應**（例如把資料庫錯誤原文回給前端），那不在這兩支錯誤碼檔裡、本棒看不到。

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

分兩層講：

**「報出來的這條存在嗎」——可信度高，但它不是新的。** F1 的每一環首腦都開檔核對過，三位檢查員從三個不同角度獨立追同一條鏈、三票全過。**但它重複總表第 88 項，而且落在本棒範圍外**，所以本棒對「有沒有新問題」這個問題的答案是「沒有」。

**「本棒範圍內真的乾淨嗎」——可信度中等，有四個已知限制。**
1. **強度是 `low`**，只有一位研究員讀一遍，不是多位分工交叉讀。低強度的設計目的是快速篩，不是窮盡。
2. **研究員的注意力被帶出去了**。它唯一的產出落在範圍外的編排服務——這代表它在這 11 個檔案裡沒找到可報的東西，於是往上追呼叫端。**好的一面是「往上追」本來就是對的方向；壞的一面是這 11 檔本身可能因此讀得比較淺。**
3. **卡片重點④三項的「沒問題」是人工定向查證，不是面板投票的結果**。這三項結論沒有三票背書，可信度低於 F1 那條。首腦讀的是程式碼與註解宣稱的意圖是否一致，**沒有實際起並行請求測競態、沒有實際打 API 看錯誤回應**。
4. **這 11 個檔案的上游不在範圍內**。狀態機的正確性有一半取決於編排服務怎麼呼叫它（收口在同一交易內、取消前先驗狀態、刪除前先驗前置），那支檔案屬 D3-4a／D3-4b，本棒沒掃。**如果編排服務那邊沒照約定呼叫，本棒看不到。**

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

## 執行概況

| 項目 | 數字 |
|---|---|
| run ID | `wf_0d92c667-51b` |
| 掃描 commit | `955e40964baa3cfc0e8766078db7d65075d3c50b`（工作區乾淨） |
| 範圍 | 11 檔 707 行 |
| 強度 | `low`（一位研究員 ＋ 三票面板） |
| 研究員 | 派 1 位、回 1 位、**零重試** |
| 面板 | 1 條候選 × 3 位檢查員 ＝ 3 票，全數投出 |
| 總耗時 | 約 94 分鐘 |
| 候選 → 成立 | 1 → 1（但**越界＋重複第 88 項，不計新發現**） |
| 本棒淨新增 | **0 條** |
| 驗證輪數 | 1 |
| 驗證章 | `verified` |
| 工具原始產物 | `jedi-detection/CLAUDE-SECURITY-20260919-021651/`（不入版控） |

**這一棒是「每棒總行數 ≤ 2,000」判準的第三次實證**（D3-1 859 行、D3-2 1,328 行、本棒 707 行，三棒都零重試）。本棒**單獨跑、沒有並行**。

**給下一棒的一句話**：工具把注意力追到 `detection_orchestration_service.py` 去了，而那支正是 D3-4a／D3-4b 的範圍——**那兩棒開跑時，第 88 項的修法落點會再被撞到一次，預期還是重複，不要重複計數。**
