# 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/`

## 一句話結論

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

## 這棒檢查什麼

主控台上四組會讓 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 票以上同意才留。

## 總覽表

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

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

## 每條詳述

### 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 模式。

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

| 卡上重點 | 人工結果 | 為什麼 |
|---|---|---|
| `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 範圍，不重報。 |

## 可信度兩層

- **「這些存在嗎」**：兩條都經面板 3/3 確認，我也開檔追到 connector 看過，可信。
- **「只有這些嗎」**：effort low 只派一位研究員，是快速掃描，不是逐行窮舉；connector 本體（第三期）與 `allowed_targets` 本體（B6a）不在本棒範圍。

## 執行概況（照 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。
