檢查日期 2026-09-19|對應卡片 CM-1875(D3 第 4 小棒前半)|檢查範圍 1 個檔案 2,498 行(本棒歸屬前 1,151 行)
找到 1 條新的中風險:解密後的帳密被原封不動寫進工單資料表,而且這份副本根本沒人用。 系統把客戶的 SSH 密碼、掃描器管理員 token 解密之後,一邊交給代理程式、一邊又存了一份明文到 agent_tasks.params 這張表——代理程式拿到的其實是另一份(心跳組裝時當場重解的),存下來這份從頭到尾沒有任何程式讀過。這張表沒有任何清理或保存期限,所以那份明文會一直躺著。修法是刪掉那一行加一支清資料的 migration,不影響代理程式(已逐環核對過)。另外工具還報了兩條,都是前面棒次已經記過的:list_executions 缺參與者檢查=重複第 88 項、換工具繞過台數上限=重複第 105 項,本棒不重複計數。卡片重點①「誰能按」工具沒直接查,首腦人工核對九個寫入端點全部有守門、無缺口。
「執行檢測」這件事的中控台就是這支檔案。客戶在畫面上按「開始掃描」「取消」「重跑這一台」「刪掉這筆失敗紀錄」,每一個按鈕背後都是這支檔案裡的一個方法。
這一棒看的是按下去之後發生什麼,兩件事:
掃描目標 /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 兩小棒分掃,D3-4a 負責前 1,151 行(派工/取消/刪除),1,152 行之後歸 D3-4b。但工具的低強度模式是整檔一次讀完、不吃行號範圍,所以它報出來的東西可能落在後半段——本棒依卡片分界處理:落在前半的計入本棒,落在後半的標註清楚、留給 D3-4b 那棒判斷。
low 強度:一位研究員讀完這個檔案就提報候選,未做元件盤點、未做威脅建模、未跑額外密鑰專項掃描,completenessCheckOutcome 為 not-applicable(低強度本來就不跑盤點)。驗證跑了 1 輪,3 個候選去重後仍是 3 個,三條全數通過,沒有候選遺失、沒有嚴重度被降低、沒有候選被略過。
研究員為了追資料流另外讀了範圍外的幾處(主專案的路由層與心跳組裝、代理程式端的工單執行器、jedi-remote-agent 的工單 model、SQL migration、綁定處理器),那些只當佐證、沒有納入稽核。研究員沒有回報「哪些檔案沒讀完」的自述(coverage.research 為 null),所以工具端沒有做讀取完整度的交叉檢查。
工具沒有實際執行任何程式碼:沒跑測試、沒發請求、沒示範攻擊,所有判斷都是讀原始碼推出來的。首腦核對階段同樣只做讀取(開檔、grep),沒有改動任何檔案、沒有連任何資料庫。
工具碰到了卡片重點②「憑證解密後流向」,重點①「誰能按」一條候選都沒提,由首腦回頭開檔逐支核對補上,詳見下方「卡片重點逐項人工查證」段。
現況(2026-10-01):本棒各條後來的處理結果如下(過程紀錄保留,不改)。
- F1(解密帳密另存明文進工單表)=M03 第 9 條,✅ 已修(FR-114.4-3)。
- F2(查執行歷史缺成員檢查)=M03 第 12 條,✅ 已修(CM-2040)。
- F3(換工具範圍不重驗)=M03 第 11 條,✅ 已修(CM-2058)。
| 編號 | 這是什麼問題 | 出事會怎樣 | 要先有什麼 | 在哪裡 | 嚴重度 | 計數 |
|---|---|---|---|---|---|---|
| F1 | 解密後的帳密被寫進工單資料表存著,而且沒人讀它 | 拿到資料庫備份/唯讀帳號的人,直接得到客戶正式機器的可用帳密 | 有人按過一次執行 + 之後取得資料庫讀取權 | detection_orchestration_service.py:480 |
MEDIUM | 本棒新增 |
| F2 | 查執行歷史沒檢查是不是專案成員 | 同租戶任何人看得到別專案的掃描歷史與報告檔編號,再拿編號下載完整報告 | 一個有效登入身分 + 知道別專案的任務編號 | detection_orchestration_service.py:1667 |
MEDIUM | 重複第 88 項 |
| F3 | 換工具時掃描範圍不重驗,超大網段被逐台展開 | 後端記憶體被吃爆,API 服務變慢或倒掉 | 專案管理者身分 + 兩次修改 + 按執行 | detection_orchestration_service.py:555 |
MEDIUM | 重複第 105 項 |
淨新增 1 條(F1),登記跨 arc 總表。F2 落在後半段(1,667 行)本不屬本棒,且已是第 88 項;F3 的落點雖在前半(555 行),但缺口本體與修法在 D3-2 已完整記為第 105 項,本棒只是從另一端又看到同一件事。
這是什麼問題。 客戶要掃描自己的機器,系統得有登入用的帳密——SSH 的 root 密碼、Windows 遠端管理的帳密、OpenVAS/ZAP/SonarQube 的管理員 token。這些東西存進資料庫時是加密的,這是產品刻意做的保護(負責這件事的元件在 domain/ports.py 的註解裡自己寫著「缺了就是明文落庫」)。
按下「開始掃描」時,系統必須把它解開才能用。問題出在解開之後多做了一步::480 這一行把解出來的明文原封不動塞進派工參數,而派工參數整包會被寫進 agent_tasks 這張表的 params 欄位。那個欄位是普通的 JSON 欄位,沒有再加密。
於是同一份帳密有兩種存在形式:tenant_detection_tool_configs 裡那份是密文(受保護),agent_tasks.params 裡這份是明文(不受保護)。加密這道防線等於被繞過了。
最關鍵的一點:存下來這份沒有任何程式讀過。 這是首腦核對時查出來的,比工具原本講的更乾淨——詳見下方「修法成立性核對」。
出事會怎樣。 任何拿得到資料庫讀取權的人,一句 SQL 就得到客戶正式機器的可用帳密:
SELECT params->'_credentials' FROM compliance.agent_tasks;「拿得到讀取權」的情境比想像中多:資料庫備份檔、pg_dump 出來的檔案被複製到筆電、給報表用的唯讀帳號、唯讀的複本資料庫、或是別處的一個唯讀型 SQL 注入漏洞。這些情境本來都只該洩漏業務資料,現在額外附送一整份可以直接登入客戶機房的帳密。
而且這份明文會一直留著。agent_tasks 這張表沒有任何清理機制(見下方查證),所以每一次執行掃描就多留一份,累積下去。
要先有什麼才打得到。
compliance.agent_tasks 的讀取權:資料庫帳號、備份、dump 檔、複本,或唯讀型 SQL 注入。第二個條件是「額外還要發生一件事」,所以是中風險不是高風險——但它的性質是把別的事故放大:原本只是「備份檔外流」,現在變成「備份檔外流+客戶機房被登入」。
在哪裡。
jedi-detection/jedi_detection/app/service/detection_orchestration_service.py:480 —— params["_credentials"] = creds:1397 _resolve_credentials() —— 解密 tenant_detection_tool_configs.credentials_encrypted:488-491 create_task(params=params)compliance-manager-be/scripts/sql/packages/jedi_remote_agent/001-remote-agent-tables.sql:80 —— params JSONB NOT NULL DEFAULT '{}'::jsonb,沒有加密包裝修法成立性核對(首腦補查,這是本條能不能修的關鍵)。
工具的建議是「刪掉 :480,因為心跳組裝時會另外現場注入帳密,存這份是多餘的」。這句話屬實,而且實際情況比它講的更乾淨。逐環查證如下:
代理程式領單時帳密確實另有來源。 代理程式定期「心跳」問雲端有沒有新工單,組工單內容的是主專案的 infra/remote_agent/adapter/detection_task_payload_provider.py。它的 build_pending_payloads()(:48)組出來的每張工單長這樣:
{
"uid": ...,
"detection_tool_id": ...,
"detection_tool_code": ...,
"params": self._inject_source_file_limits(params),
"credentials": self._resolve_tool_credentials(t.detection_tool_id, tenant_id), # ← :68
}credentials 是頂層獨立的鍵,由 _resolve_tool_credentials()(:109)當場去查 tenant_detection_tool_configs 重新解密產生——完全不看 params 裡有什麼。
代理程式讀的是哪一個鍵。 查代理程式本體(evidence-agent repo)core/task_executor.py:101:
connector = get_connector(
task.get("detection_tool_id"), params, task.get("credentials") or {}, ...
)它讀的是 task["credentials"],也就是心跳當場解的那份。全 repo grep _credentials 的結果:代理程式端沒有任何一處讀取這個鍵,只有三處註解提到它(都是在講「_ 開頭是雲端注入的內部欄位」這個命名慣例)。
雲端這邊也沒人讀。 三個 repo 一起 grep,params["_credentials"] 的讀取端掛零:唯一的寫入在 :480,其餘全是註解或 docstring 提及。反而有一處是專門把它剝掉的——_visible_scan_params()(:2271)在回 API 之前會把所有 _ 開頭的欄位濾掉,註解寫明理由是「_credentials:租戶層憑證明文,派工時塞入」。換句話說,程式自己知道這裡有明文、自己在出口做了補救,但補救的是 API 回應,資料庫裡那份沒有被處理。
同一份檔案裡已經有正確的做法可以對照。 _resolve_source_file() 的註解(:796-800)在講另一個欄位(限額)為什麼不存進 JSON 時寫道:「改由心跳組裝時當場注入(與 _credentials 同時機)」——設計者本人已經認定帳密是「心跳當場注入」那一類,:480 這行存進去的是與該設計矛盾的殘留。
結論:刪掉 :480 之後,代理程式照樣拿得到帳密,修法成立。
agent_tasks 有沒有清理或保存期限 —— 沒有(首腦查證)。
查了兩處:
core/scheduler.py):全系統共九支定期工作,分別是完整性抽查、Drive 同步、webhook 續約、框架解析暫存清理、授權到期狀態機、防竄改同步、檢測執行逾時收斂、任務綁定孤兒清理、日誌分區維護。其中沒有任何一支碰 agent_tasks。名字最接近的 detection_execution_timeout 收斂的是 detection_executions(執行紀錄)的狀態,不刪工單、也不清參數。app/、common/、core/ 全域 grep agent_task 配上清理類關鍵字(purge/cleanup/delete/retention/expire),零命中。所以那份明文沒有時限,只會累積。這讓 F1 的嚴重度撐在 MEDIUM 而不是更低——若有 30 天保存期限,風險窗口至少有界。
怎麼修。 兩步,兩步都要做:
拔掉源頭:刪掉 :480 的 params["_credentials"] = creds。這一行之後 creds 在 _dispatch_one 裡就沒有其他用途了,連帶可以把參數傳遞鏈上多餘的 creds 一併收掉(start_execution 仍要保留 _resolve_credentials 的呼叫——:219-226 用它做「沒設帳密就擋下來」的前置檢查,那個檢查要留)。
清存量:寫一支 migration 把既有資料裡的這個欄位拿掉。這一步不能省——光改程式只讓「以後不再新增」,資料庫裡已經躺著的那些明文原封不動。
UPDATE compliance.agent_tasks
SET params = params - '_credentials'
WHERE params ? '_credentials';套用規範照 sql-migration skill:cmmgr 帳號、--single-transaction、收尾寫 schema_migrations。先只套 DEV,STG/POC 等決策者放行(環境異動鐵律)。
⚠️ 修完要實測一次完整掃描。這條路徑的驗證不能只看程式碼——_dispatch_one 改完之後,要實際派一張需要帳密的工單(例如 OpenSCAP)、讓代理程式領走、確認它真的登入成功。理由是本條的修法建立在「代理程式讀的是另一份」這個判斷上,判斷雖然逐環核對過,但沒有實跑驗證;掃描能不能登入成功是這個判斷唯一的硬證據。
驗證。 3/3 三位檢查員確認成立(可達性、影響、既有防護三個角度),嚴重度維持 MEDIUM,沒有被降級。三位各自獨立追同一條鏈:解密 → :480 塞入 → create_task → 落進沒加密的 JSON 欄位,並各自確認了中間沒有任何一處做過遮蔽或剝除。
首腦核對註記。 屬實,逐環開檔核對過::480 確實把明文塞進去、:1397 確實是解密來源、:488-491 確實寫進資料庫、SQL 定義確實是普通 JSONB 沒有加密。工具聲稱的「心跳會另外現場注入」經三個 repo 交叉核對確認屬實(見上),且進一步查出代理程式端沒有任何一處讀 _credentials——這比工具的說法更強,代表刪除的風險比工具評估的還低。agent_tasks 無清理機制亦經排程清單與程式碼雙重查證確認。
這是什麼問題。 同一個檔案裡有九支方法會對「某個任務」做事,其中八支都會先問一句「按的人是不是這個專案的成員」,只有查執行歷史那支沒問。它只檢查了租戶(也就是「是不是同一家客戶」),所以同一家客戶底下、不同專案的人,拿著別的專案的任務編號就查得到。
出事會怎樣。 查得到的內容包括對方專案掃了哪些內網網段、用什麼工具、失敗訊息,以及報告檔的編號。報告檔編號等同下載憑證——下載端點只驗「有沒有登入」不驗「這份報告是不是你的」,所以拿到編號就能下載對方專案的完整弱點掃描報告。
在哪裡。 detection_orchestration_service.py:1667 list_executions。
計數處理。 這條是第 88 項,前面棒次已經記過,本棒不重複計入。另外它落在 1,152 行之後,依 D3-4a/D3-4b 的分界本來就不屬本棒範圍——工具因為整檔一次讀而撞到。D3-4b 那棒若再撞到,同樣標重複即可。
驗證。 3/3 檢查員確認成立。本棒首腦核對時順帶確認了「八支有、一支沒有」這個對比屬實(見下方重點①的逐支清單),與第 88 項的既有記載一致,沒有新增資訊。
這是什麼問題。 「掃哪些機器」這欄有台數上限,但上限只在存檔時檢查、而且只檢查「這次送上來的值」。先綁一個不受上限管的工具把 10.0.0.0/8 存進去,再只換工具不帶參數,那個超大網段就留在原地跟到新工具底下。按下執行時,派工端會把它逐台展開成 1,670 萬個位址,後端記憶體被吃爆。
在哪裡。 展開那一端在 detection_orchestration_service.py:555(本棒範圍內),寫入端的缺口在 detection_job_binding_handler.py:201-204(D3-2 範圍)。
計數處理。 這條是 第 105 項,D3-2 那棒已經從寫入端完整記過,連修法(寫入端重驗 + 展開端硬上限)與「展開端刻意不擋是設計判斷、硬上限要訂寬」的注意事項都寫清楚了。本棒只是從展開端這一側又看到同一件事,不重複計入,也沒有新增資訊。
驗證。 3/3 檢查員確認成立,confidence 記為 medium(攻擊路徑要兩步操作,是讀程式碼推出來的、沒有實跑)。
卡片重點有兩項:①「誰能按」②「憑證解密後流向」。工具碰到了②(就是 F1),①一條候選都沒提,首腦開檔補查。
本棒範圍的七支方法(加上範圍外的兩支一併核對)逐支開檔確認,全部都有專案成員檢查,沒有缺口:
| 方法 | 行號 | 成員檢查 | 額外守門 |
|---|---|---|---|
start_execution 開始掃描 |
:196 |
✅ | 任務狀態須為處理中、無執行中群組、帳密須已設定 |
cancel_execution 取消 |
:871 |
✅ | — |
rerun_assignment 重跑單台 |
:925 |
✅ | — |
cancel_assignment 取消單台 |
:1007 |
✅ | — |
cancel_group 整組取消 |
:1032 |
✅ | — |
delete_execution 刪單筆 |
:1097 |
✅ | 只准刪 failed;跨租戶回 404 不回 403 |
delete_group 整組刪 |
:1127 |
✅ | 全組皆 failed 才准;空群組也擋 |
start_assignment_now 立即開始(範圍外) |
:1470 |
✅ | — |
list_executions 查歷史(範圍外) |
:1667 |
❌ | 缺口=F2/第 88 項 |
守門的實際判定邏輯。 assert_project_participant 走的是 FR-048 軸③(資源域守門,在 app service 層而非 route decorator,符合規範)。判定鏈是:任務 → 流程執行 → 反查專案 → 查使用者在該專案的角色;查無角色就擋(fail-closed)。任一角色都放行(manager/auditor/reviewer/viewer),所以它擋的是「不是這個專案的人」,不是「角色不夠大」。
路由層另有一道授權鎖。 這些端點在路由層都掛了 @require_license("plugin"),也就是客戶得買了檢測模組才能用。這是產品授權層,不是權限層——同一租戶內所有人的授權狀態相同,所以它擋不了「同租戶跨專案」,F2 的缺口不會因為有這道鎖而被補上。
兩個設計細節值得記。
delete_execution :1090-1095、_require_group)。註解寫明理由是「避免成為探測管道」——回 403 等於告訴對方「這個編號是存在的」,攻擊者可以用它逐一試出有效編號。這個處理是對的。判定:重點①這一項沒有問題,唯一的缺口是已記在案的第 88 項。
完整的流向追蹤:
解密只發生在一處,_resolve_credentials()(:1397)。它的設計有兩點值得記:
tenant_config_id 欄位實務上一律是 NULL(寫入端從沒寫過它),所以主要靠 (租戶, 工具) 反查——那個組合有唯一約束,定位得了,且歷史 NULL 資料自動自癒、不必補資料 migration。判定:設計合理。:219-226)。註解的理由站得住:空帳密派下去代理程式必炸,而且錯在雲端卻只能從代理程式的 log 看到——提前擋並講清楚「要去設定工具憑證」,比讓它炸在遠端好查得多。零帳密工具有例外處理(否則一個合法狀態會永遠開始不了),且往嚴的方向失敗(查不到工具一律當作需要帳密)。判定:方向正確。流向有三條,兩條正確、一條是缺口:
_visible_scan_params()(:2271)把所有 _ 開頭的欄位濾掉,且單筆與批次兩條讀取路徑共用同一份剝除實作——註解自己寫明「兩份剝除實作就是『這裡忘了剝』的溫床(憑證明文會落進 api_logs.response)」。這個共用是對的。agent_tasks.params:❌ 缺口,就是 F1。第 2 點的剝除只保護 API 出口,保護不到資料庫本身。一個容易誤判的地方要講清楚。 同一個欄位裡還有另一種機敏資料——「任務層敏感參數」(例如被掃網站的登入密碼),那一類在資料庫裡是密文,因為它在綁定寫入時就被包成加密信封了(D3-2 查證過),心跳組裝時才解。所以 agent_tasks.params 裡面是混的:任務層那些是密文(安全),_credentials 這個鍵是明文(就是 F1)。看到「這欄位裡有密文」不代表整欄安全,兩者要分開看。
分兩層講:
「報出來的這條存在嗎」——可信度高。 F1 的每一環首腦都開檔核對過,而且修法的成立性做了三個 repo 的交叉查證(雲端組裝端、代理程式讀取端、既有設計註解),三邊結論一致。三位檢查員從三個不同角度獨立追同一條鏈,三票全過。
「只有這條嗎」——可信度中等,有四個已知限制。
low,只有一位研究員讀一遍,不是多位分工交叉讀。低強度的設計目的是快速篩,不是窮盡。verification.status 為 verified:三位檢查員對 3 條候選各投一票,9 票全數投出,沒有漏投,票數由工具自己的程式碼統計、不是任何 agent 自報。
| 項目 | 數字 |
|---|---|
| run ID | wf_3487f8c6-0ca |
| 掃描 commit | 955e40964baa3cfc0e8766078db7d65075d3c50b(工作區有其他未 commit 改動,與本棒無關) |
| 範圍 | 1 檔 2,498 行(本棒歸屬前 1,151 行) |
| 強度 | low(一位研究員 + 三票面板) |
| 研究員 | 派 1 位、回 1 位、零重試 |
| 面板 | 3 條候選 × 3 位檢查員 = 9 票,全數投出 |
| 總耗時 | 約 102 分鐘(04:17 啟動 → 05:59 報告產出) |
| 候選 → 成立 | 3 → 3(零駁回) |
| 淨新增 | 1 條(F2=重複第 88 項、F3=重複第 105 項) |
| 驗證輪數 | 1 |
| 驗證章 | verified |
| 工具原始產物 | jedi-detection/CLAUDE-SECURITY-20260919-041735/(不入版控) |
三個觀察,留給後續切棒參考。