FR-177 B6a 掃描報告:目標清冊、結果回報 outbox、事件送出

⚠️ 歷史紀錄:本報告描述的問題已於 2026-11-07 全部修正並合回 Agent 主線(修正卡 CM-3559~3564、3573~3576、3584,驗收紀錄見母卡 CM-3507)。文中的檔名與行號是掃描當下(commit bab2aa3)的版本,修正後已變動,請勿拿來定位現行程式。

§1

🔴 一句話結論

沒有急件,但有兩個「清冊這道門關不緊」的中風險問題,建議排進近期修正。 找到 2 個中風險問題,都是「Agent 能被拿去連清冊外的主機」:一個是有些工具的參數欄位根本沒被清冊檢查(F1),一個是主機名先查一次 DNS 比對、工具連線時又自己查一次,兩次結果可以不同(F2)。兩條都經三個獨立的檢查員投票,3 比 0 確認成立。outbox 與事件送出沒有發現問題。

§2

這一棒在檢查什麼

  • 允許目標清冊(allowed_targets):主系統下發「這台 Agent 只能掃哪些 IP/網段」,Agent 每次連線前都要比對。這是「主系統被打穿時,Agent 不會被拿去掃客戶內網任意主機」的最後一道門。
  • outbox(離線續傳佇列):主系統連不上時,掃描結果和事件先存在 Agent 本機的 SQLite(單檔資料庫),連上後再補送。
  • 事件送出(event_sender):Agent 通知主系統的唯一管道。
§3

掃到什麼:總覽表

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 怎麼修 嚴重度
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 中

為什麼是「中」不是「高」: 兩條都要攻擊者先拿到「能發動掃描」的身分(主控台帳號加對應權限,或主系統本身被打穿),陌生人碰不到。但清冊的設計目的正是防「主系統被打穿」這種情況,所以門沒關緊仍值得修。

§4

每條發現的詳述

F1 — 清冊只看固定欄位名,SonarQube 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),沒有別的防線。

F2 — 主機名查兩次 DNS,比對和實際連線用的 IP 可以不同

白話: 網址目標寫的是主機名(例如 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 回的是原字串。三個檢查員裡有一位(看「打不打得到」那一位)評為低,另外兩位評為中,最終維持中。

§5

「重點看什麼」逐條人工查證

  1. _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)。略過只會讓清冊變小,不會變大,方向是安全的。不成立。
  2. iter_targets/_resolve/_target_networks(:217-263)

    • DNS rebinding:成立,即 F2。
    • 多個 IP:_resolve 回全部的 IP,is_allowed 用 all(...)(:186),全部都在清冊內才過,一個不在就拒。不成立。
    • 帶 port 的目標:網址走 urlsplit().hostname,port 會被去掉,只比主機;hosts 欄位裡的 10.0.0.1:22 用 ip_network 解析會失敗,結果是拒絕。不成立(清冊不管 port 是既定設計)。
    • 帶 scheme 的目標:只有 URL_PARAM_KEYS 那幾欄會補 http:// 再解析;hosts 欄填 https://host,因為有 :// 也會被當網址解析主機名。不成立。
    • CIDR 當目標:_network_allowed 用 subnet_of(:177),整個網段都要被清冊某一段包含,不是有交集就過。末段範圍 10.0.0.1-50 會先拆成幾個網段再逐一比。不成立。
    • IPv6 寫法:[::1] 會去掉括號,ip_network 會正規化寫法,zone id(%eth0)只在解析結果裡去除。IPv4 對應 IPv6 位址(::ffff:10.0.0.1)會被當成 IPv6,不會對到 IPv4 清冊,結果是拒絕,方向安全。不成立。
    • 裸主機名(不是網址):hosts 欄填主機名時,ip_network 失敗,is_url 為假,回空清單,is_allowed 回 False。一律拒,不成立。
  3. apply_names/_clean_names(:134-152、:203-214)

    • 名稱對照表只拿來畫面顯示(local_runs.py:261 讀 names)。_target_networks/is_allowed 完全不讀 _names,名稱對不到任何連線行為;數量和長度都有上限(10,000 筆、255 字)。不成立。
  4. outbox(outbox.py+outbox_repo.py)

    • 存在 SQLite 的 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(),或設單筆上限。工具沒報。
    • 重送有 backoff:1、2、4…分鐘,最長 1 小時(:74-76),沒有「失敗幾次就放棄」,會一直重試直到主系統回定論碼(_TASK_SETTLED 等)。是刻意設計,不算資安問題。
    • 送成功(或主系統回定論碼)就刪本機副本(_settle,:310-312)。不成立。
    • 遮罩:outbox 存的是原樣 payload,沒有另外遮罩。內容是要送給主系統的結果本身,遮罩應在產生結果時做(屬 scan 模組,範圍外)。SQLite 檔在資料目錄。人工查過:本範圍不成立;資料目錄權限屬其他棒的範圍,不在此判斷。
  5. event_sender.send_event(:39-68)

    • log 有沒有印 payload:直送失敗只印 kind、HTTP 碼和例外類別名(type(e).__name__,:55-59);入列失敗那條(:73)有 exc_info=True 會印堆疊,堆疊裡是 DB 例外,不含 body 內容。不成立。
    • kind 白名單:build() 建 AgentEvent 時,jedi_agent_protocol/events.py:53-55 的 __post_init__ 會拒絕不在 EVENT_KINDS(六種)裡的 kind,outbox 的 enqueue 也只收已知 kind。程式內任何模組都能呼叫 send_event,但只能發這六種,而且是 Agent 自己的程式碼,不是外部輸入。不成立。
  6. ports/target_authorizer.py

    • Protocol 的 docstring 有寫「沒收過清冊一律 False」「沒收過清冊或任一不過 → TargetNotAuthorized」,但 Protocol 只是型別介面,規定靠實作自律,沒有強制。唯一的實作 AllowedTargets 有做到(:184-185、:191-192)。人工查過:不成立:目前只有一個實作,而且做對了;未來新增實作時要靠測試守住。
§6

這份結果可信到什麼程度

  • 「這 2 條存在嗎?」 可信度高。三個獨立的檢查員從三個角度投票(打不打得到、出事多大、有沒有別的防線擋住),兩條都 3 比 0 成立,我也自己開檔核對過。
  • 「只有這 2 條嗎?」 不能保證。這次是低強度掃描,只派一位研究員讀這 5 個檔,沒有跑測試、沒有實際打過,結論全部來自讀程式碼。「重點看什麼」六項我逐條人工補查過,另外記下三項低風險建議:萬用網段沒擋、舊清冊可回放、task_result/event 沒有磁碟上限。工具沒報這三項,也沒經過檢查員投票,屬人工判斷。範圍外的檔案(connectors、local_runs.py、probe_runner.py、cert_files.py、jedi_agent_protocol/events.py)只為追呼叫端才看,不當發現。
§7

執行概況

項目 值
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/