檢查日期 2026-09-19|對應卡片 CM-1875(D3 第 3 小棒)|檢查範圍 11 個檔案 707 行
這一棒範圍內沒有找到新問題。 工具唯一報出來的那條落在範圍外的檔案(編排服務),而且就是跨 arc 總表第 88 項那條已知的舊帳——同一個缺口、同一個修法,只是這次拿到了更新的行號,不計為新發現。卡片指定要看的三件事(狀態機會不會被兩個人同時改壞、查詢條件空了會不會回整個資料表、錯誤訊息會不會洩漏內部資訊)工具一條候選都沒提,由首腦逐項開檔查證,三項都沒問題:狀態收口有資料庫層的排隊鎖擋住競態、查詢入口全部都帶條件所以踩不到「回全表」那個坑、錯誤碼只有兩個而且都只回固定英文短句。
客戶按下「執行檢測」之後,系統會把工作拆成一張張工單發給裝在客戶機房的代理程式。每張工單跑完會回報成功或失敗,一次執行底下可能有好幾台機器同時在跑,全部跑完才算這次執行結束。
這一棒看的是記錄這些狀態的那一層,三件事:
掃描目標 /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 行。
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 項,只更新行號 |
本棒範圍內工具零產出。 卡片重點④的三項另見人工查證段,結論皆為沒問題。
先講結論:這條不是新的。 它與跨 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」,修的時候兩個都會看到。
卡片重點④列了三件事,工具一條候選都沒提,以下三項全部由首腦開檔查證。這三項的結論沒有三位檢查員的票背書,可信度低於上面那條,詳見「這份結果可信到什麼程度」。
擔心的是什麼。 一次執行底下有 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 連空群組都擋掉)。分工清楚、兩端沒有落空。
判定:沒問題。 補一句限制:這是讀程式碼推出來的,沒有實際起兩個並行請求去打。真正的併發測試不在本棒範圍,也不是這個掃描工具會做的事。
老問題是什麼。 共用的資料存取底層(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 筆)。繞過隔離是必要且範圍受控的,判定合理。
兩支錯誤碼檔全部讀完,共 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 項,而且落在本棒範圍外,所以本棒對「有沒有新問題」這個問題的答案是「沒有」。
「本棒範圍內真的乾淨嗎」——可信度中等,有四個已知限制。
low,只有一位研究員讀一遍,不是多位分工交叉讀。低強度的設計目的是快速篩,不是窮盡。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 項的修法落點會再被撞到一次,預期還是重複,不要重複計數。