# FR-177 B3 掃描報告：主控台聚合層（總覽、心跳欄位、線上預覽判定、body 上限）

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

## 🔴 一句話結論

**沒有急件。** 工具找到 1 個低風險問題：總覽頁的「正在執行」清單會把正在掃的主機 IP 與設備名稱給**任何登入的人**看，繞過了「要有掃描或除錯權限才看得到目標清單」那道門。三個獨立檢查員 3 比 0 確認成立。另外人工照「重點看什麼」逐條查證時，多記下 1 條**範圍外**的觀察（登入端點 `/console/session` 沒掛 body 上限），請首腦判斷要不要另開卡。可以排進一般修正排程。

## 這一棒在檢查什麼

Agent 主控台（8444 埠，人用瀏覽器操作的那一面）API 背後的聚合邏輯，`guidant_agent/console/app/` 共 7 檔：

- `overview.py`：總覽頁把各模組狀態湊成一頁；
- `heartbeat.py`：主控台回報給主系統的狀態欄位（`agent_status` 事件）；
- `inline_policy.py`：「線上預覽」判斷哪些證據檔可以直接在瀏覽器開；
- `body_limit.py`：請求 body（瀏覽器送上來的資料本體）的大小上限；
- `view_audit.py`：「看頁」類稽核的去重；
- `targets.py`、`update_console.py`：只為接縫帶入（`update_console.py` 第一期 A3 已掃，不重報）。

## 掃到什麼：總覽表

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 | 嚴重度 |
|---|---|---|---|---|---|---|
| F1 | 總覽的「正在執行」清單不看權限，一律帶出掃描目標 IP 與設備名稱 | 只有「看檔案」權限的人，持續重新整理總覽就能拼出客戶內網有哪些主機在被掃、叫什麼名字、用什麼工具 | ①已登入主控台（任何權限都行）②連得到 8444（在白名單網段內）③當下剛好有掃描或除錯在跑 | `guidant_agent/console/app/overview.py:64`（呼叫 `_running()`，函式本體在 `:270`） | `_running()` 也吃 `include_targets`，沒權限時拿掉 `targets` 與 `target_names` 兩欄 | 低 |
| 人工-1（範圍外） | 登入端點 `/console/session` 不在 `body_limit.guard` 保護內，用 `request.get_json` 整包讀 | 沒登入的人送超大 body，Agent 會把它整包讀進記憶體，幾個併發可能拖慢或吃光記憶體 | ①連得到 8444（在白名單網段內）——**不用登入** | `guidant_agent/console/api/__init__.py:96`（範圍外，只為查證） | 對 `console_bp` 掛 `body_limit.guard`，或該路由先 `read_limited` 再解 JSON | 低（待首腦確認） |

**為什麼是「低」：** F1 不能改東西、不能拿帳密，只看得到「當下正在跑的那幾台」，而且要先登入。人工-1 前面有 CIDR 白名單（只放特定網段連進來），只影響可用性，不洩漏資料。

## 每條發現的詳述

### F1 — 總覽「正在執行」清單繞過目標清單的權限門

**白話：** 總覽 API（`GET /console/api/overview`）只要求「有登入」，不要求特定權限。程式裡本來就有一道門：主機／網段清單算內網拓撲，只有帶 `run-local-scan` 或 `net-diag` 權限的人才看得到（`_targets_view`，`overview.py:246-252`）。但同一頁的 `running`（正在執行）欄位走另一個函式 `_running()`，它沒拿到這個權限旗標，每筆任務都原樣帶出 `targets`（目標 IP）與 `target_names`（授權清冊裡的設備名稱，來自 `local_runs.display_fields`，`guidant_agent/scan/app/local_runs.py:259-265`）。

**出事會怎樣：** 主系統只發給某人「看檔案」權限，他照理看不到內網有哪些主機。但他只要開著總覽頁（前端本來就會定期刷新），主系統派掃描、或別的操作者手動跑掃描／網路除錯時，就能看到目標 IP、設備名稱、用的工具、誰發動的。時間一長就拼得出一張相當完整的內網受檢主機表。同樣的資料在 `/api/scan/targets`、`/api/netdiag/targets` 與總覽自己的 `info.allowed_targets.host_list` 都會擋掉這個人。

**要先有什麼才打得到：** 已登入（任何權限）、來源 IP 在 8444 白名單內、Agent 已同步而且當下有任務在跑或在排隊。

**在哪裡：**
- `guidant_agent/console/app/overview.py:63-64`：`include_targets` 只交給 `_info()`，`_running()` 沒拿到；
- `guidant_agent/console/app/overview.py:284-288`（主系統派的任務）、`:292-296`（手動任務）：原樣帶 `targets` 與 `display_fields`；
- 呼叫端 `guidant_agent/console/api/pages.py:130-136`：只掛 `@require_session()`，`include_targets` 在這裡算好傳進來（範圍外，只為查證）。

**怎麼修：** `_running(include_targets)`，旗標為 False 時拿掉 `targets` 與 `target_names`（工具、狀態、筆數可以留）。在 `test_overview_target_list_needs_scan_or_netdiag` 旁補一支測試，斷言 `files`／`configure` 權限的 session 看不到 `running[*].targets`。

**runner 開檔核對：** 成立。`overview.py:63-64` 與 `:270-297` 我都開過，`_running()` 沒有任何權限參數。`display_fields` 我也追到 `local_runs.py:259-265`，確認帶的是清冊設備名。（本條是 LOW，卡片沒要求逐條核對，但仍照做。）

### 人工-1（範圍外）— 登入端點沒掛 body 上限

**白話：** `body_limit.guard` 只掛在 `scan_bp`、`netdiag_bp`、`ops_bp`、`local_auth_bp` 四支 blueprint 上（blueprint 是 Flask 把一群路由打包在一起的單位）。`console_bp` 沒掛，它底下的 `POST /console/session`（拿主系統票或一次性登入碼換 session 的登入入口）直接 `request.get_json(silent=True)`。專案也沒設 `MAX_CONTENT_LENGTH`，所以 werkzeug 會把整個 body 讀進記憶體再解析。這支在登入**之前**就會執行，違反 `endpoints.md`「身分檢查之前只做便宜、有上限的事（SR-04.1）」。

**出事會怎樣：** 白名單網段內任何一台機器不用登入，就能送幾百 MB 的 body，幾個併發就能讓 Agent 吃光記憶體，或者讓主控台卡住。

**要先有什麼才打得到：** 只要連得到 8444。

**在哪裡：** `guidant_agent/console/api/__init__.py:91-96`。同一支 blueprint 的 `PUT /api/tools/<code>`（`tools.py:62`，經 `secret_form.raw_fields` → `request.get_json`）、`POST /api/tools/<code>/test`（`tools.py:118`）也沒有上限，不過這兩支要先有 `configure` 權限。

**怎麼修：** `console_bp` 掛 `body_limit.guard`（`/session` 的上限給幾 KB 即可），讀 body 的地方改用 `body_limit.json_body()`。

**註記：** 這條不在本棒 scope（`console/api` 不在 `console/app` 內），工具沒報，是 runner 照「`guard` 掛在哪些 blueprint、有沒有漏掛」追出來的。**沒經過面板投票**，請首腦決定要不要另開卡。

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

1. **`inline_policy.py` 線上預覽判定**
   - **SVG：** 不收。`_MAGIC`（`:18-24`）沒有 SVG；SVG 是以 `<` 開頭的文字，`_text_mime`（`:59-60`）看到 `<` 開頭直接回 None，所以不會被當成文字 inline。`html_preview`（`:93`）要求檔名結尾是 `.html`／`.htm`，所以 `.svg` 也走不到 HTML 那條。最後呼叫端 `files.py:151-154` 回 415，**不會變成附件**。
   - **檔頭繞過：** `_magic_mime` 只讀前 64 bytes（`_SNIFF_BYTES`，`:25`）比對特徵碼。前面塞 `%PDF-`、後面放 HTML 的檔，會以 `application/pdf` inline 回去，再加上 `nosniff`，瀏覽器會用 PDF 檢視器開，不會當 HTML 執行。PNG／JPEG／GIF／WebP 同理，瀏覽器以圖片解碼，不會執行裡面的 HTML。
   - **CSP sandbox：** `:85` 的字串是 `sandbox; default-src 'none'; ...`，**沒有** `allow-scripts`，也**沒有** `allow-same-origin`，所以腳本不執行，也讀不到主控台的 cookie。`files.py:160` 用 `headers.update` 覆蓋，不會被全域的 `setdefault` 蓋掉。
   - **nosniff：** `RESPONSE_HEADERS`（`:31`）有 `X-Content-Type-Options: nosniff` 與 `Cache-Control: no-store`，`apply_headers` 每個預覽回應都會套上。
   - **文字類：** 內容不是以 `<` 開頭、但中間夾 HTML 的，會以 `text/plain` 加 `nosniff` 回去，瀏覽器不會執行。
   - 結論：**不成立**。

2. **`body_limit.py` `read_limited`**
   - **chunked（分段傳輸，沒有 Content-Length 標頭）：** `request.content_length` 是 None，`(None or 0) > max` 為假，接著 `stream.read(max_bytes + 1)` 最多只讀上限加 1 個 byte，超過就 413。chunked 也擋得到，跟檔頭註解說的一致。
   - **漏掛：** 已掛的有 `scan_bp`、`netdiag_bp`、`ops_bp`（`ldap_routes` 也掛在 `ops_bp` 上）、`local_auth_bp`；`runs_routes`、`update_routes` 在路由內自己呼叫 `read_limited`。**`console_bp` 漏掛**，詳見人工-1。`keys_bp` 的 `POST /renew` 不讀 body。
   - **直傳 blob：** 離線更新包走 `update_routes.py:100` 的 werkzeug `max_content_length` 串流，屬第一期 A3 範圍；8444 上傳源碼包在 files 模組（A8 範圍），不重報。
   - 結論：`read_limited` 本身**不成立**；漏掛**成立**（人工-1，範圍外）。

3. **`heartbeat.py` 送回主系統的欄位**
   - 釐清一點：`fields()` 不是放在心跳裡，是 `agent_status` 事件（檔頭 `:1-4`，決策者 2026-11-01「心跳不帶訊息」）。
   - 送出去的欄位：`credential_store: True`、`console_allow_cidr_installed`（安裝時的網段底線）、ops／LDAP 來源／改名的狀態、`sync_state`、`local_admin_set` 與 `local_admin_password_set_at`（只有「有沒有設」和時間，沒有雜湊）、`configured_tools`（`credential_store.status()`，沒有欄位值），以及 storage 統計（型別、健康與否、檔數、用量、剩餘磁碟，**沒有 endpoint**）。
   - **本機密碼狀態：** 有出去，這是刻意設計（`:138` D13：無人值守升級不問密碼，主系統靠這欄提醒）。只出去「有沒有設」和時間，不含任何祕密。
   - **網段基線：** 有出去（`allow_cidr.py:105-109`）。理由寫在程式裡：主系統要存「收緊值」之前，得先驗它跟底線的交集不是空的。資料送給同一客戶自己的主系統，而且主系統本來就能下發 CIDR。對照記憶「新增資料出口先查同一份資料在既有端點的守門」：同一份底線在總覽的 `info.console_allow_cidr`（`overview.py:262`）也回給任何登入的人，兩邊一致，沒有比既有出口開得更大。
   - 結論：**不成立**（屬設計決定）。底線網段在主控台對任何登入者都看得到這點，與 F1 同性質但等級更低（底線是給維運看的設定、不是受檢主機清單），只記不報。

4. **`overview.py` `build_overview`**
   - `include_targets`：呼叫端 `pages.py:134`，session 帶 `run-local-scan` 或 `net-diag` 其中一個才是 True，只影響 `info.allowed_targets` 的清單。**`running` 沒受這道門管**，就是 F1。
   - `include_tools`：要 `configure` 權限，內容是 `credential_store.status()`（只有已設定與測試結果，不含帳密值）。
   - **儲存 endpoint：** `storage` 欄（`:49`，來自 `storage_stats.storage_health`，`storage_stats.py:79-89`）會回 MinIO／SeaweedFS 的 endpoint（host:port）或本機 `BASE_DIR`，對任何登入者都回，但不含 access key／secret。程式註解明說「不回端點帳密，只回 host 給人辨識」，屬設計取捨。`cloud_endpoint`、`base_url` 同理。
   - 結論：除 F1 外**不成立**。

5. **`view_audit.py` `audit_once`**
   - **去重 key：** `(類別, 對象)`，例如 `("page", "overview")`、`("run", uid)`、`("preview", uid)`，存在**各自 session 物件**的 `audit_marks` dict 上（`session.py:40`），不落地。
   - **記憶體：** 每個 session 每個對象最多一筆（同一個 key 是覆寫，不會累加）。總量上限是「登入中的 session 數 × 這個 session 看過的不同對象數」，session 被登出或過期回收時一起清掉。要讓它長大，得先登入，再逐一看大量不同的檔或紀錄，每筆只是一個 tuple 加一個 float。唯一的放大因素是 session 沒有絕對壽命上限（已登記在 A6 F1）。
   - **並行：** 讀改寫有 `threading.Lock`（`:25`、`:30`）保護。
   - 結論：**不成立**。

6. **`targets.py`／`update_console.py`**：`targets.py` 只是把授權清冊組成固定形狀，權限門掛在呼叫端；`update_console.py` 第一期 A3 已掃，零發現。本棒不重報，工具也沒報這兩支。

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

- **「F1 存在嗎？」** 可信度高。三個獨立檢查員從三個角度投票（打不打得到、出事多大、有沒有別的防線擋住），結果 3 比 0 成立，我也自己開檔核對過。人工-1 是我讀程式碼查證的，沒經過面板。
- **「只有這些嗎？」** 不能保證。這次是低強度掃描，只派一位研究員讀這 7 檔，另一位做寫死帳密的掃描（兩位都有回來）。沒有跑測試、沒有實際攻擊過，結論全部來自讀程式碼。「重點看什麼」六項我逐條人工補查過。範圍外的檔案（`console/api/*`、`local_runs.py`、`storage_stats.py`、`allow_cidr.py`、`session.py`）只為追呼叫端才看。

## 執行概況

| 項目 | 值 |
|---|---|
| run ID | `wf_12d39046-180` |
| 掃描版本 | `749d3b42529fe63dc2b07d866e7fe9bed3e16951`（工作目錄乾淨，`dirty: false`） |
| 強度與範圍 | low，`guidant_agent/console/app` 7 檔，focus attack-surface |
| verification.status | `verified`（reason_kind 為 null） |
| candidates（候選問題） | 1（去重後 1） |
| panel_votes（檢查員投票） | 3：三個檢查員對 1 條各投一票，全部投出 |
| researchers_dispatched / returned（派出／回來的研究員） | 2 / 2 |
| 耗時 | 約 19 分鐘（1137 秒），5 個 agent，約 29.6 萬 token |
| 原始產物 | `docs/security-scan/FR-177/runs/CLAUDE-SECURITY-20261105-165639-B3/`（原目錄名與 B2 同秒撞名，加 `-B3` 後綴區分） |
