# FR-177 C8 掃描報告：網路診斷的執行、判讀、儀表板、報告與限制

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

## 🔴 一句話結論

**沒有急件，最嚴重是低風險。** 工具只找到 1 個問題（F1，低），三個獨立檢查員 3 比 0 確認：代理伺服器（proxy）的設定如果沒寫 `http://` 開頭，帳號密碼就不會被遮掉，會原樣出現在儀表板、診斷報告和支援診斷包裡。首腦標的「重點看什麼」7 條我逐條開檔看過：報告的 XSS（網頁被塞腳本）不成立，因為輸出一律 `html.escape`，而且報告根本不存 `.html`；限制面有兩件設計面的事值得記（目標數限制只算「每個登入 session」，重新登入就歸零；清冊如果收了 127.0.0.1，就能拿 Agent 打自己），都不升級成發現。

## 這一棒在檢查什麼

`guidant_agent/netdiag/app`、`guidant_agent/netdiag/domain`、`guidant_agent/netdiag/__init__.py`，共 10 檔、1289 行。這是網路診斷的上層：收到主控台請求後挑哪些探測、估要跑多久、依序跑、把結果判讀成「哪裡壞了」、組本機網路儀表板、產 JSON／純文字／PDF 報告，以及每分鐘輪數、每 session 目標數、連線間隔這些限制。

為了查證，翻了範圍外的 `console/api/netdiag_routes.py`（呼叫端）、`sync/app/allowed_targets.py`（清冊）、`netdiag/adapters/probes/cloud.py`（儀表板代理資訊流向）、`platform/support_bundle.py`。**這些只為查證，不當發現。**

## 掃到什麼：總覽表

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 | 嚴重度 |
|---|---|---|---|---|---|---|
| F1 | 代理網址沒寫 scheme（`user:pass@proxy:3128`）時，「遮帳密」函式把原字串連密碼整段吐回 | 只有 `net-diag` 權限的主控台使用者看得到公司代理帳密；帳密也寫進報告檔、支援診斷包，被帶出客戶環境 | 管理員把代理設成不帶 `http://` 的寫法（httpx、curl 都接受這種寫法，所以真的會有人這樣設） | `dashboard.py:105-114`（`_strip_userinfo`，`:112` 回原字串） | 沒有 `://` 就先補 `http://` 再解析；解不出主機名就回固定字樣，絕不回原字串；順便接住 `parts.port` 會丟的 `ValueError` | 低 |

## 每條發現的詳述

### F1 — 代理帳密在「沒寫 http://」時不會被遮

**白話：** Agent 對外連主系統可以經過公司的代理伺服器，代理位址從環境變數 `HTTPS_PROXY`／`HTTP_PROXY` 讀，常夾帳密。儀表板要顯示「有沒有設代理」，所以 `proxy_info()`（`dashboard.py:117-124`）把每個值交給 `_strip_userinfo` 遮掉帳密。這個函式用 Python 的 `urlsplit` 拆網址，拆不出主機名就直接 `return url[:200]`（`:111-112`），也就是原字串。

我自己跑過：`urlsplit("user:pass@proxy:3128")` 得到 scheme=`user`、netloc 空、hostname=`None`，所以整串（含密碼）原樣回傳。

**出事會怎樣：** 這個值會出現在三個地方：
1. `GET /console/api/netdiag/dashboard` 回應的 `info.proxy.https_proxy`，只要有 `net-diag` scope 就看得到。
2. 主系統連線診斷（cloud 探測）的「proxy」那一步（`adapters/probes/cloud.py:140` 把 `proxy_info()` 整包塞進結果），因此寫進存證的 JSON／txt／PDF 報告。
3. 支援診斷包的儀表板快照（`platform/support_bundle.py` 呼叫 `collect_dashboard`），這包會寄給廠商。

**要先有什麼：** 管理員把代理設成不帶 scheme 又含帳密的寫法；攻擊者要有 `net-diag` 主控台 session、能下載報告，或拿到支援診斷包。

**在哪裡：** `guidant_agent/netdiag/app/dashboard.py:105-114`。

**怎麼修：** 見總覽表。重點是「解析失敗就當作不安全」，回固定字樣而不是原文。

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

| 首腦標的重點 | 人工查過 |
|---|---|
| `report.py`：HTML 報告有沒有跳脫、是不是 inline 的 `.html`、subprocess 跑什麼 | **XSS 不成立。** `to_html`（`:91-112`）每個插值都過 `e = html.escape`，包含 raw 原始輸出（`:99`）、摘要、判讀文字、目標主機名。而且 HTML 只是 PDF 轉檔的中間檔，放在暫存資料夾、轉完就刪；`store`（`:140-161`）只存 `json`／`txt`／`pdf` 三種，**沒有存 `.html`**，主控台預覽（`netdiag_routes.py:207-210` `inline=True`）拿到的不會是 HTML。subprocess 是 LibreOffice 轉 PDF（`:128-130`）：參數陣列、經 `run_cmd`、60 秒逾時、獨立 profile 目錄、`HOME` 指向暫存資料夾，沒有使用者值進參數（檔名固定 `report.html`）。**另記（低、不升級）：** HTML 交給 LibreOffice 轉檔，若內容含 `<img src="http://...">` 可能讓 soffice 對外抓圖；但所有內容都 escape 過，塞不出標籤，所以不成立。 |
| `dashboard.py`、`runner.py`：怎麼排探測、並行幾個、逾時殘留子行程；dashboard 跑什麼指令 | **不成立。** `runner.run`（`:170-206`）拿 `run_lock` 全域一把鎖，一次只跑一台、依固定順序、總逾時 120 秒，超時的項目標 `skipped`。dashboard 的指令全是固定參數陣列（`ip -j addr/route/neigh`、`ss -Hltn`、`timedatectl`、`nmcli`、`networkctl`、`resolvectl`、`ethtool`），每支 3 秒逾時；唯二帶變數的：`ethtool <網卡名>`（網卡名先過 `^[A-Za-z0-9_.:@-]{1,15}$`，來自本機 `ip` 輸出）與 `ip neigh show <閘道IP>`（來自 `/proc/net/route` 解成 IPv4），都不是使用者值。逾時後子行程怎麼收是 `shared/timeouts.py` 的 `run_cmd` 的事，在範圍外，這次沒有追。 |
| `limits.py`：限制存記憶體嗎、多 session 能不能疊加繞過、estimate 會不會變免費 DNS 查詢器 | **estimate 不成立：** `domain/limits.estimate`（`:75-95`）是純計算，只看勾選與埠數，不碰目標、不解析 DNS。**限制在記憶體：成立（設計面，記錄不升級）。** `RoundLimiter` 是模組內記憶體（註解寫「重啟即清可接受」），重啟就歸零。**多 session 疊加：成立（設計面，記錄不升級）。** 目標數上限 20 存在 `sess.diag_targets`（`netdiag_routes.py:143`），是「每個登入 session」各 20；登出再登入或開多個 session 就能各打 20 台。不過目標一律要在雲端下發的授權清冊內（`runner._address_target` `:124`），加上每台每分鐘 3 輪、連線間隔 1 秒、全域一次一台，能掃的範圍和速度都被清冊與全域鎖封頂，所以不升級。門檻寫死在 `domain/limits.py`、請求改不了，這點成立的是好事。 |
| `verdict.py`：會不會把 raw 原文放進判讀訊息 | **部分成立、但無害。** 判讀文字會帶探測摘要的欄位（例如 `tls.verify_message`、`dns.rcode`、HTTP 狀態碼、埠號，`:121`、`:171`、`:197`），不帶 raw 整段。這些值進 HTML 前一律 escape（見第 1 條），進 JSON／txt 則是純文字，沒有被執行的地方。 |
| `ports.py`、`__init__.py`：報告存檔的檔名、歸屬、下載 scope | **不成立。** 檔名 `netdiag-<IP 或 cloud>-<時間>`（`netdiag_routes.py:117-119`），IP 已經過 `ipaddress` 正規化、`:` 換 `-`，沒有路徑字元。歸屬 `uploader=started_by`＝登入者 `sub`（`:151`）。下載走 `download-files` scope（`:202-204`），預覽走 `net-diag`。`ports.py` 每次現從容器拿、不存模組變數，符合註解。 |
| `domain/` 與目標：除了固定目標，能不能把 Agent 自己（127.0.0.1、8443/8444）當目標 | **程式本身不擋 loopback：成立（設計面，記錄不升級）。** `_address_target`（`runner.py:115-126`）只擋 multicast、`0.0.0.0`、廣播位址，**不擋 `127.0.0.1`／`::1`**，唯一的牆是授權清冊 `is_allowed`。清冊由主系統下發，一般不會含 loopback，所以實務上打不到；但主系統被打穿或設錯時，Agent 就能對自己的 8443／8444 做 port、http 探測。建議之後加一道「address 類目標一律拒 loopback」，與清冊無關。固定目標（主系統、閘道、DNS）的位址都由 Agent 自己算、請求只指定「哪一類」；DNS 類可以帶 `ip`，但必須是本機 resolv.conf 列出的伺服器之一（`:146-148`）。 |
| `report.py` 與代理資訊 | 工具的 F1 就從這條延伸出來，見上。 |

## 這份結果可信到什麼程度

- **工具這一層：** low 強度，1 個研究員讀整個範圍，加 1 個找寫死帳密的研究員，2 派 2 回。1 個候選問題，三個檢查員 3 比 0 確認（兩票 LOW、一票 MEDIUM，所以從 MEDIUM 降成 LOW）。stamp 是 `verified`。全部都是讀程式碼得出的結論，沒有執行 Agent。
- **人工這一層：** F1 我自己用 `urlsplit` 跑過一次，確認回傳原字串；三個流向（儀表板、cloud 探測、支援診斷包）都 grep 到呼叫點。「重點」7 條逐條開檔看過。`run_cmd` 逾時後怎麼收子行程，在範圍外，沒有追。

## 執行概況

| 項目 | 值 |
|---|---|
| run ID | `wf_1a8fedab-fcd` |
| 掃描版本 | `6d1e6ca3836c389d0b26b8cb8dd1f870f9172ac8`（工作區乾淨） |
| 強度與範圍 | low，10 檔，focus=attack-surface |
| verification.status | `verified`（reason_kind 為 null） |
| candidates（候選問題） | 1（去重後 1） |
| panel_votes（檢查員投票） | 3，全部投出 |
| researchers_dispatched / returned（派出／回來的研究員） | 2 / 2 |
| 耗時 | 約 34 分鐘，5 個 agent，約 29 萬 token |
| 原始產物 | `docs/security-scan/FR-177/runs/CLAUDE-SECURITY-20261106-035657/` |
