D3-4b 檢查結果:編排服務後半 — 回收/狀態/排程(jedi-detection)

D3-4b 檢查結果:編排服務後半 — 回收/狀態/排程(jedi-detection)

檢查日期 2026-09-19|對應卡片 CM-1875(D3 第 4 小棒後半,D3 收尾)|檢查範圍 1 個檔案 2,498 行(本棒歸屬第 1,152 行~尾,約 1,350 行)

§1

🔴 一句話結論

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

§2

這一棒在檢查什麼

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),沒有任何一條是本棒範圍的新東西。

§3

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)。
§4

掃到什麼:總覽

編號 這是什麼問題 在哪裡 嚴重度 計數
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 總表不加號。

§5

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 檢查員確認成立。

§6

卡片重點逐項人工查證

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

⑤ 逾時排程用什麼身分 — ✅ 沒問題(工具未報,人工查證)

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

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

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 應該直接返回,省一次無謂查詢。不是安全缺口,兩棒都建議、可以順手改。

§7

這份結果可信到什麼程度

分兩層講:

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

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

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

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

§8

執行概況

項目 數字
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 項影響面的擴大(檢測模組八支端點同吃那道守門)。這是本棒唯一的實質產出。