⚠️ 歷史紀錄:本報告描述的問題已於 2026-11-07 全部修正並合回 Agent 主線(修正卡 CM-3559~3564、3573~3576、3584,驗收紀錄見母卡 CM-3507)。文中的檔名與行號是掃描當下(commit
bab2aa3)的版本,修正後已變動,請勿拿來定位現行程式。
沒有急件,但有兩個「清冊這道門關不緊」的中風險問題,建議排進近期修正。 找到 2 個中風險問題,都是「Agent 能被拿去連清冊外的主機」:一個是有些工具的參數欄位根本沒被清冊檢查(F1),一個是主機名先查一次 DNS 比對、工具連線時又自己查一次,兩次結果可以不同(F2)。兩條都經三個獨立的檢查員投票,3 比 0 確認成立。outbox 與事件送出沒有發現問題。
allowed_targets):主系統下發「這台 Agent 只能掃哪些 IP/網段」,Agent 每次連線前都要比對。這是「主系統被打穿時,Agent 不會被拿去掃客戶內網任意主機」的最後一道門。event_sender):Agent 通知主系統的唯一管道。| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 | 嚴重度 |
|---|---|---|---|---|---|---|
| F1 | 清冊只檢查固定幾個參數名(hosts、targets、target_url…);SonarQube 的 repo_url、InSpec 的 profile 不在名單,填任何網址都不會被擋 |
Agent 會對清冊外的內網或外網主機發 HTTP 請求(git clone),可被當成跳板探測內網 |
①主控台有「執行本機掃描」權限的帳號,或主系統被打穿 ②有設定 SonarQube(且 Agent 裝了 git)或 InSpec | guidant_agent/sync/app/allowed_targets.py:37-40 |
改成預設拒絕:每個 connector 自己宣告「哪些參數會拿去連線」,沒宣告的 connector 或欄位一律拒;短期先把 repo_url、profile 加進清單 |
中 |
| F2 | 網址目標是主機名時,Agent 查一次 DNS 比對清冊,ZAP 實際連線時又自己查一次 DNS,兩次可以拿到不同 IP(DNS rebinding,DNS 換綁攻擊) | ZAP 主動掃描(會送攻擊 payload)打到清冊外的內網網站 | ①能發動 ZAP 掃描 ②攻擊者掌控一個 DNS 網域 ③有設定 ZAP | guidant_agent/sync/app/allowed_targets.py:263(解析在 :230-238) |
網址目標只收 IP 寫法;要收主機名,就要求主系統在清冊裡列出主機名本身,不信 Agent 自己查到的 DNS | 中 |
為什麼是「中」不是「高」: 兩條都要攻擊者先拿到「能發動掃描」的身分(主控台帳號加對應權限,或主系統本身被打穿),陌生人碰不到。但清冊的設計目的正是防「主系統被打穿」這種情況,所以門沒關緊仍值得修。
repo_url/InSpec profile 沒被檢查白話: 清冊比對(check())只會檢查 iter_targets() 挑出來的目標。iter_targets() 只看七個固定的參數名:hosts、targets、host、ips、target_url、urls、login_url。SonarQube 從 repo_url 拿原始碼網址,然後 git clone 下來,只檢查開頭是不是 http/https;InSpec 把 profile 原樣交給 cinc-auditor exec,這個工具也吃遠端網址。檔頭的註解自己就寫了「新 connector 若用別的鍵名,必須加進來,否則那一欄完全不受比對」,也就是說這道門要靠人記得補。
出事會怎樣: 填 repo_url=http://10.20.0.7:8080/,Agent 就會去連這台清冊外的主機。git 的錯誤訊息會回到畫面,等於提供了「這個 IP/port 有沒有開」的探測工具。
要先有什麼才打得到: 主控台帳號有執行本機掃描的權限,或主系統被打穿後派任務;Agent 上設定了 SonarQube 並裝了 git(profile 那條則要有 cinc-auditor)。
在哪裡:
guidant_agent/sync/app/allowed_targets.py:37、:40:兩份固定名單;guidant_agent/sync/app/allowed_targets.py:217-227:iter_targets;guidant_agent/scan/adapters/connectors/sonarqube.py:435(讀 repo_url)、:838(git clone),以及 guidant_agent/scan/adapters/connectors/inspec.py:875(profile)。怎麼修: 根本解法是改成預設拒絕:每個 connector 宣告自己哪些參數會拿去連線,執行前沒有宣告的就拒。短期先把 repo_url 加進 URL_PARAM_KEYS,git 加 -c http.followRedirects=false,避免比對的主機和實際連的主機因為重新導向而不同。
runner 開檔核對: 成立。allowed_targets.py:37-40 兩份名單沒有 repo_url/profile。grep 過 sonarqube.py:435、:838,確認讀的是 params.repo_url,clone 前沒有再過清冊。三個呼叫入口 local_runs.py:114、probe_runner.py:51、task_executor(檢查員引用 :74)都只呼叫 check(params),沒有別的防線。
白話: 網址目標寫的是主機名(例如 http://web.example)時,_resolve 會用 getaddrinfo 查一次 DNS,確認每個 IP 都在清冊內才放行,但只留下「過/不過」的結果。ZAP connector 交給 ZAP 的還是原本的主機名網址,ZAP 連線時會自己再查一次 DNS。
出事會怎樣: 攻擊者的 DNS 伺服器第一次回清冊內的 IP(TTL 0,不快取),第二次回 10.9.9.9。ZAP 的主動掃描 payload 就打到清冊外的內網網站。如果 ZAP 主機用的 DNS 跟 Agent 不同(例如內外部 DNS 回不同答案),不需要搶時機也會發生。
要先有什麼才打得到: 能發動 ZAP 掃描、掌控一個兩邊都查得到的 DNS 網域、Agent 有設定 ZAP。
在哪裡: guidant_agent/sync/app/allowed_targets.py:263(主機名交給 _resolve)、:230-238(只查一次,不綁定連線);範圍外,只為查證:guidant_agent/scan/adapters/connectors/zap.py:179-184、:460、:482(把原主機名交給 ZAP)。
怎麼修: 網址目標只收清冊內的 IP 寫法;一定要支援主機名,就由主系統在清冊裡明列主機名,或把網址改寫成比對過的 IP(再帶 Host header)後才交給 ZAP。
runner 開檔核對: 成立。_target_networks 只回網段清單,沒有把解析結果往下傳;zap.py 的 _resolve_target_url 回的是原字串。三個檢查員裡有一位(看「打不打得到」那一位)評為低,另外兩位評為中,最終維持中。
_apply/apply 內容驗證(allowed_targets.py:94-131)
ip_network(..., strict=False) 會接受 0.0.0.0/0 和 ::/0,Agent 不擋萬用網段。主系統下發這個,就等於全部放行。不過清冊內容本來就由主系統決定,主系統能下發 0.0.0.0/0,也就能直接列出任何單一 IP,所以 Agent 擋萬用網段只是多一層防呆,擋不住被打穿的主系統。人工查過:成立,但屬縱深防禦建議(低):可在 Agent 端拒絕前綴長度太短的網段(例如 IPv4 小於 /8)。工具沒報。apply 只比對「版本跟目前是否相同」(:126),沒有比大小,所以舊的清冊再送一次會被套用。version 沒給時用內容 hash 算(:300-304),本來就沒有先後可言。能回放舊清冊的只有設定通道,而設定通道的守門是 A2 那一棒的範圍。人工查過:成立,屬設計限制(低):要防回放,需要主系統給單調遞增的版號、Agent 拒絕較舊的版號。_persist 落地檔權限:open(tmp, "w") 沒指定權限,跟著 umask 走。但檔案放在憑證目錄,cert_files.py:90 建目錄時用 0o700,其他帳號進不去;內容也只是 IP 清單,不是秘密。人工查過:不成立。:121-123),單筆看不懂就略過該筆(:104、:109)。略過只會讓清冊變小,不會變大,方向是安全的。不成立。iter_targets/_resolve/_target_networks(:217-263)
_resolve 回全部的 IP,is_allowed 用 all(...)(:186),全部都在清冊內才過,一個不在就拒。不成立。urlsplit().hostname,port 會被去掉,只比主機;hosts 欄位裡的 10.0.0.1:22 用 ip_network 解析會失敗,結果是拒絕。不成立(清冊不管 port 是既定設計)。URL_PARAM_KEYS 那幾欄會補 http:// 再解析;hosts 欄填 https://host,因為有 :// 也會被當網址解析主機名。不成立。_network_allowed 用 subnet_of(:177),整個網段都要被清冊某一段包含,不是有交集就過。末段範圍 10.0.0.1-50 會先拆成幾個網段再逐一比。不成立。[::1] 會去掉括號,ip_network 會正規化寫法,zone id(%eth0)只在解析結果裡去除。IPv4 對應 IPv6 位址(::ffff:10.0.0.1)會被當成 IPv6,不會對到 IPv4 清冊,結果是拒絕,方向安全。不成立。hosts 欄填主機名時,ip_network 失敗,is_url 為假,回空清單,is_allowed 回 False。一律拒,不成立。apply_names/_clean_names(:134-152、:203-214)
local_runs.py:261 讀 names)。_target_networks/is_allowed 完全不讀 _names,名稱對不到任何連線行為;數量和長度都有上限(10,000 筆、255 字)。不成立。outbox(outbox.py+outbox_repo.py)
outbox 表(outbox_repo.py),不是散落的檔案;SQL 全部用參數綁定,沒有字串拼接 SQL。MAX_ROWS = 10_000(outbox.py:49)。滿了先丟 audit,再丟 local_run/source_scan,task_result 和 event 永不丟,必要時超過上限也收(:101)。也就是說,主系統斷線很久、任務又多,這兩類可以無上限長大。單筆大小沒有上限,task_result 裡有完整掃描報告。另外 audit 在磁碟低於門檻時不入列(:87),其他類沒有這個檢查。人工查過:成立,屬可用性風險(低):斷線一週、每天跑很多任務時可能塞滿磁碟。建議 task_result/event 也看 disk_space.is_low(),或設單筆上限。工具沒報。:74-76),沒有「失敗幾次就放棄」,會一直重試直到主系統回定論碼(_TASK_SETTLED 等)。是刻意設計,不算資安問題。_settle,:310-312)。不成立。event_sender.send_event(:39-68)
kind、HTTP 碼和例外類別名(type(e).__name__,:55-59);入列失敗那條(:73)有 exc_info=True 會印堆疊,堆疊裡是 DB 例外,不含 body 內容。不成立。build() 建 AgentEvent 時,jedi_agent_protocol/events.py:53-55 的 __post_init__ 會拒絕不在 EVENT_KINDS(六種)裡的 kind,outbox 的 enqueue 也只收已知 kind。程式內任何模組都能呼叫 send_event,但只能發這六種,而且是 Agent 自己的程式碼,不是外部輸入。不成立。ports/target_authorizer.py
AllowedTargets 有做到(:184-185、:191-192)。人工查過:不成立:目前只有一個實作,而且做對了;未來新增實作時要靠測試守住。local_runs.py、probe_runner.py、cert_files.py、jedi_agent_protocol/events.py)只為追呼叫端才看,不當發現。| 項目 | 值 |
|---|---|
| run ID | wf_bb4d13b2-4dc |
| 掃描版本 | 63334f464215c18fa89c334b360a86f31679c1f8(工作目錄乾淨,沒有未提交的改動) |
| 強度與範圍 | low,5 檔 |
| verification.status | verified(reason_kind 為 null) |
| candidates(候選問題) | 2(去重後 2) |
| panel_votes(檢查員投票) | 6:三個檢查員對 2 條各投一票,全部投出,沒有漏投 |
| researchers_dispatched / returned(派出/回來的研究員) | 1 / 1 |
| 耗時 | 約 34 分鐘,7 個 agent,約 48.3 萬 token |
| 原始產物 | docs/security-scan/FR-177/runs/CLAUDE-SECURITY-20261105-174554/ |