FR-177.B2 資安掃描:主控台「掃描、網診、檔案、工具」路由

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

卡片 CM-3517|母卡 CM-3507|掃描日 2026-11-05|revision 749d3b42529f|run ID wf_af582674-52c|歸檔 runs/CLAUDE-SECURITY-20261105-165639/

§1

一句話結論

找到 2 個中度(MEDIUM)問題,沒有高風險。兩個都是同一個根本原因:主控台「手動掃描」把使用者填的參數幾乎原樣交給掃描工具,只擋了 _ 開頭的參數;其中有兩個欄位(InSpec 的 profile、SonarQube 的 repo_url)不在授權清冊(allowed_targets,「Agent 准連哪些主機」的名單)的檢查範圍內。

§2

這棒檢查什麼

主控台上四組會讓 Agent「對外動手」的路由:

檔案 做什麼 要什麼權限(scope)
guidant_agent/console/api/scan_routes.py 手動發掃描、取消、上傳源碼 run-local-scan
guidant_agent/console/api/netdiag_routes.py 網路診斷、下載/預覽報告 net-diag(下載要 download-files)
guidant_agent/console/api/files.py 證據檔列表、下載、預覽 files/download-files
guidant_agent/console/api/tools.py 工具帳密設定、測試連線 configure

術語:scope=主控台登入票上的權限名,路由沒有對應 scope 就進不去。清冊=主系統下發的授權目標名單。面板=工具裡三個獨立檢查員,分別從「打得到嗎/影響多大/有沒有防線」三個角度投票,2 票以上同意才留。

§3

總覽表

# 嚴重度 一句話 誰打得到 急不急
M1 中 手動掃描可指定任意 InSpec profile,Agent 會在自己身上執行那段程式 有「手動掃描」權限的主控台使用者 下一版修
M2 中 手動掃描的 SonarQube repo_url 不過清冊,可叫 Agent 連清冊外的內網主機 同上 下一版修

兩條的修法可以一起做:手動掃描的參數改成「每個工具只收白名單欄位」,不再採用「只擋 _ 開頭的參數」這種黑名單做法。

§4

每條詳述

M1 — 手動掃描可指定任意 InSpec/GCB profile(CWE-94 程式碼注入)

  • 白話:InSpec(實際執行的是 cinc-auditor)的「profile」是一包 Ruby 程式,會在 Agent 自己的機器上執行。正常情況下 profile 應該是主系統下發、存在本機快取的那幾份(參數 _profile);但手動掃描允許使用者自己填 params.profile,而且填什麼都照用。
  • 出事會怎樣:有手動掃描權限的人可以讓 Agent 以服務帳號身分跑任意 Ruby 程式。那個帳號能讀工具帳密檔(Fernet 金鑰加上加密後的受檢主機 SSH/WinRM 密碼),等於拿到客戶所有受檢主機的帳密。如果 profile 是網址,Agent 抓 profile 時連的主機也不受清冊管。
  • 要先有什麼才打得到:主控台登入且持有 run-local-scan(離線操作者也算);清冊與操作者名單已同步;本機已設好 inspec 或 gcb 帳密;清冊裡至少有一台目標。
  • 在哪裡:guidant_agent/console/api/scan_routes.py:159(_build_params 只擋 _ 開頭的鍵)→ guidant_agent/scan/app/local_runs.py:114(只查清冊鍵)→ guidant_agent/scan/adapters/connectors/inspec.py:872-878(沒有 _profile 時直接回傳 params.profile)。
  • 人工核對:我開檔逐段確認過。inspec.py:874-878 的註解寫著「手填路徑(P6):原樣取用」,可見這是刻意保留的舊行為;但手動掃描這個入口讓「手填」變成主控台使用者也能用。工具原本判 HIGH,面板三票一致降成 MEDIUM,理由是要先有已授權的操作者身分。
  • 怎麼修:手動掃描不收手填 profile,只能從 profile_sync.local_choices 選主系統下發的 profile;或者每支工具列出使用者可設定的參數白名單,其他一律丟掉。

M2 — SonarQube repo_url 不過清冊(CWE-918 伺服器端請求偽造)

  • 白話:SonarQube 的「scan 模式」會讓 Agent 先 git clone 使用者填的 repo_url 再分析。這個網址只檢查開頭是不是 http/https,沒有比對清冊。
  • 出事會怎樣:可以叫 Agent 從客戶內網對任何 HTTP(S) 主機發連線,包括清冊外的內部服務,違反 SR-02.11「Agent 只連清冊內或主系統下發的位址」。抓回來的內容會被分析並推到客戶的 SonarQube。
  • 要先有什麼才打得到:run-local-scan 權限、本機已設好 SonarQube 帳密、Agent 上有 git。
  • 在哪裡:guidant_agent/console/api/scan_routes.py:159 → guidant_agent/sync/app/allowed_targets.py:37-40(TARGET_PARAM_KEYS/URL_PARAM_KEYS 都沒有 repo_url)→ guidant_agent/scan/adapters/connectors/sonarqube.py:435-443(只驗 scheme)。後兩個檔在範圍外,只為了查證而讀,不當成本棒的發現。
  • 怎麼修:把 repo_url 加進 URL_PARAM_KEYS,讓它解析出的主機也要在清冊內;或者手動掃描只開放 upload 模式。
§5

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

卡上重點 人工結果 為什麼
TARGET_PARAM 表漏工具(sonarqube server_url、zap context) 部分成立 → 見 M2 scan_routes.py:37-38 的表只決定 targets[] 欄要放進哪個參數;真正擋人的是 local_runs.start 用 allowed_targets.iter_targets 檢查,看的鍵是 hosts/targets/host/ips/target_url/urls/login_url。sonarqube 的 repo_url 不在裡面(M2)。server_url/base_url 是工具自己的服務位址,註解說刻意不比對(屬帳密設定,configure 權限),不另報。zap 的 context 不是連線目標。
extra_args/options 原樣進 connector 變成指令注入 路由層成立(無過濾)→ 見 M1 路由層只擋 _ 開頭的鍵,其他鍵全部原樣交下去;profile 就是實例(M1)。connector 有沒有把其他欄位拼進 argv 屬第三期範圍。
netdiag /estimate、/runs 的 target 過清冊 不成立 /runs 在 :138 由 netdiag.check 同步檢查清冊,排到才跑時 netdiag.run 會再比一次。/estimate 只計算不連線。
每 session 目標上限、能不能開多個 session 繞過 不成立(附註) 上限 SESSION_MAX_TARGETS = 20(netdiag/domain/limits.py:34),而且是算在每個 session 上的,開新 session 確實能重置計數。但每個目標本來就要在清冊內,另外還有全域的每分鐘輪數上限(limits.ROUNDS),繞過上限也連不到清冊外的主機,所以不構成問題。
報告下載用 download-files、預覽用 net-diag 不成立 兩者是同一份檔,但預覽走 send_customer_file(inline=True),只能 inline 顯示白名單內的型別,其他回 415、不給附件(files.py:151-154)。設計就是「能看不能拿走檔案」,跟證據檔的 files 對 download-files 同型。
_base_name 拼 target ip 進檔名、uid 沒驗格式 不成立 target.ip 是 netdiag.check 驗過的 IP,只可能有數字、.、:;檔名只是存檔名,不拿來拼路徑。uid 只拿來查 local_runs 表(ORM 參數化),查不到就 404。
files.py 列表過濾、uid 歸屬、header 注入 不成立 列表與單檔查詢都只取 storage_scope='customer'(customer_files_repo.py:53);下載、預覽共用 _owned_blob,先查歸屬才取檔。下載檔名由 Flask send_file 處理,X-Filename 經過 quote(file_protect.py:97),不會造成 header 注入。
tools.py GET 回密文?測試連線收整組設定? 不成立 GET 走 secret_form.masked,祕密欄只回 {"set": true}(secret_form.py:116)。測試連線只用已存的帳密(:115),body 只收 target_host 與 credential_group;target_host 先過清冊(:90),run_probe 內部又再查一次。跟 A7 LDAP 不同型,不會把密碼送到任意位址。
讀寫同 scope、body 大小限制 不成立(附註) scan 和 netdiag 都掛了 body_limit.guard(64KB/1KB)。tools 的 PUT 讀寫同用 configure,這是設計(沒有獨立的唯讀設定 scope);tools/files 沒有掛 blueprint 層級的 body 上限,但用的是 console_bp,是否有全域上限屬 A6 範圍,不重報。
§6

可信度兩層

  • 「這些存在嗎」:兩條都經面板 3/3 確認,我也開檔追到 connector 看過,可信。
  • 「只有這些嗎」:effort low 只派一位研究員,是快速掃描,不是逐行窮舉;connector 本體(第三期)與 allowed_targets 本體(B6a)不在本棒範圍。
§7

執行概況(照 stamp 抄)

CLAUDE-SECURITY-REVISION-749d3b42529f.json 的 verification:

  • status: verified,reason_kind: null
  • candidates: 2,panel_votes: 6
  • researchers_dispatched: 1,researchers_returned: 1
  • 面板完整跑完;F1 被面板從 HIGH 降為 MEDIUM。