# FR-177.A2 資安掃描：資料面 8443 與主系統下達的指令（CM-3509）

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

## 一句話結論

找到 **1 個中風險問題**：內網任何人只要對 8443 開一條連線、什麼都不送，就能讓 Agent 的資料面整個卡住（主系統派工、重啟、升級、取檔全部停擺），直到那條連線斷掉或服務重啟。它**不會**洩漏資料，也**不會**讓人冒用主系統下指令。「重點看什麼」七條逐條人工查過，其中守門殼、JWT 驗證、各指令端點、信任錨更新都沒有發現能繞過的路。

## 這棒檢查什麼

主系統會主動連到 Agent 的 8443 埠（資料面）下指令，包括派任務、通知設定變更、換證、重啟、升級。這一棒要回答三個問題：Agent 怎麼確認「這真的是主系統」、哪些路徑不必驗證、Agent 信任哪一台主系統。

範圍共 11 個檔：
- `guidant_agent/sync/api/` 底下的 8 個檔（守門、伺服器、5 支 `cmd_*.py`、`__init__.py`）
- `guidant_agent/sync/domain/agent_auth.py`
- `guidant_agent/sync/app/cloud_trust.py`
- `guidant_agent/sync/app/task_queue.py`

## 總覽表

| 編號 | 嚴重度 | 白話 | 要先有什麼 | 急不急 |
|---|---|---|---|---|
| F1 | 中 | 開一條空連線就能讓 8443 停止服務 | 能連到 8443 這個埠（客戶內網通常都能） | 建議排進下一輪修；只能讓服務停擺，不能拿資料 |

## 每條詳述

### F1：開一條 TCP 連線不完成 TLS 交握，就能凍住整個 8443

- **白話**：TLS（加密連線）在正式傳資料前，雙方要先「握手」交換憑證。現在 8443 用的是 werkzeug（Flask 內建的小型伺服器），它在**唯一一條負責接新連線的執行緒**裡做這次握手，而且沒有設逾時。只要有人連上來卻不握手，這條執行緒就一直等，後面所有連線都排在門外進不來。
- **出事會怎樣**：主系統送來的請求一律收不到，包括取證據檔、派工（`/tasks`）、重啟（`/restart`）、升級（`/update`）、設定通知（`/config/notify`）、換證（`/credentials/renew`）、檢測端點。這段期間主系統會以為 Agent 失聯。資料不會外洩，也改不了任何東西。
- **要先有什麼才打得到**：
  - Agent 已註冊、`AGENT_AUTH_MODE=full`（正式出貨的預設），也就是 8443 有在聽。
  - 攻擊者能對 8443 開 TCP 連線。這個埠綁在 `0.0.0.0`，客戶內網任何一台機器通常都連得到。**不需要憑證，也不需要帳密。**
  - mTLS（雙向憑證驗證，主系統也要出示憑證）在這裡擋不住，因為驗對方憑證的動作本身就發生在那次被卡住的握手裡。
- **在哪裡**：`guidant_agent/sync/api/data_plane_server.py:129-131`，`start_data_plane_tls` 呼叫 `make_server(host="0.0.0.0", ..., threaded=True, ssl_context=context)`。`threaded=True` 只代表「握手完成之後」才分給新的執行緒，握手本身仍在接連線的那一條。
- **人工核對**：開檔確認過。整支程式沒有呼叫 `settimeout`／`setdefaulttimeout`，`build_server_ssl_context`（同檔 70-90 行）也沒有設任何逾時。三位檢查員都看了 werkzeug 與 Python ssl 的原始碼，得到同一個結論。這項結論是讀程式碼判斷的，沒有實際打過。
- **怎麼修**（另開卡）：把握手移出「接連線」那條執行緒。做法一：接進來的 socket 用 `do_handshake_on_connect=False` 包，改到各連線自己的執行緒裡、在逾時之內做握手。做法二：換成每條連線各自握手、並設有逾時的正式伺服器（例如 cheroot／waitress）。另外再加每條連線的讀取逾時，免得握手後慢慢送請求一樣能霸佔執行緒。

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

1. **`data_plane_auth.py:46-47` `_skips_jwt`、loopback 判定**：**不成立（目前）**。不必驗證的路徑只有 `/health`、`/agent-info` 兩條，而且必須完全相同（`/health/x` 仍要驗）。`/local-ops/*` 要來源是 loopback（本機 127.0.0.1）才跳過驗證。我搜過全 repo，沒有 `ProxyFix`，也沒有讀 `X-Forwarded-For`，所以 `remote_addr` 是 TCP 真實來源，外部從 8443 連進來不會是 127.0.0.1。**風險提醒**：容器部署時，如果 8000 埠被 docker 轉發，或前面放了在本機跑的反向代理，來源會變成 loopback 或 docker 閘道位址。依檔頭說明，`/local-ops` 另有「只有 root 讀得到的權杖」這第二道門；這部分在範圍外，沒有細看。
2. **`data_plane_auth.py:79-92` `before_request` 掛載、mode 不是 full**：**守門成立；mode=none 是刻意設計**。守門用 `app.before_request` 掛在整個 app 上，所以之後才註冊的 blueprint（Flask 的路由群組）一樣會經過，沒有前綴清單會漏掉。5 支指令 blueprint 由 `register_command_blueprints` 掛在同一個 app 上。`mode` 不是 `full` 時**完全不驗**（第 90-91 行），也就是 demo 模式下 8443 等於全開。不過 8443 只有在憑證齊全、已註冊時才會起來；正式出貨要確保不會用 `none` 跑。
3. **`agent_auth.py:221-238` `verify_data_plane_jwt`**：**成立，沒有漏洞**。JWT（主系統簽發的短效通行證）會驗 RS256 簽章、`iss`、`aud`（必須是本機 `agent_uid`）、`bound_fp`（必須等於本機指紋），並且用 `require` 強制 `exp`、`iss`、`aud`、`op`、`jti` 都要存在，**沒有 `exp` 的 token 不會通過**。重放（同一張票打兩次）由 `TicketReplayGuard.consume` 擋（`data_plane_auth.py:71-75`）。`op` 必須等於「方法 路徑」，簽給某支端點的票不能拿去打另一支。重放紀錄只存在記憶體，重啟後就清空；但 token 只活 60 秒，可以接受。
4. **五支 `cmd_*.py`**：**不成立**。
   - `cmd_credentials.py`：body 刻意是空的，只是叫 Agent 自己在背景換憑證（私鑰在本機產生），**不收工具帳密**，所以沒有「改完存哪」的問題。
   - `cmd_config.py`：body 只寫「哪幾類設定變了」，Agent 收到後再自己去主系統拉設定（`config_pull`），通知本身改不了任何 key。能不能透過拉回來的設定改到 `allow_cidr` 這類信任邊界，屬於拉取端，不在本棒範圍。
   - `cmd_restart.py`：每次都要一張新的、綁 `POST /restart` 的 JWT，而且重複請求與開機沒多久就請求都會被拒（`too_soon` 回 409）。只有主系統能觸發，不構成 DoS（阻斷服務攻擊）。
   - `cmd_tasks.py`、`cmd_update.py`：見第 6 條；`/update` 的版號、sha、大小形狀由 `update_sync.trigger` 驗。
5. **`cloud_trust.py`**：**不成立（從 8443 改不掉信任錨）**。對主系統的 https 信任錨來自安裝時的 `CLOUD_CA_FILE`（TOFU，首次使用時確認憑證，概念同 SSH 第一次連線），只讀 env，8443 沒有任何端點能改它。另外非 https 的主系統位址一律拒絕。JWT 驗章公鑰的更新（`apply_trust_anchors`）只會經由**心跳設定通道**套用，而且先驗形狀再落檔；8443 的 `/config/notify` 只能讓 Agent 去拉，拉的對象仍是那台經 TLS 釘住的主系統，所以做不到「自己換自己的老闆」。前提是主系統本身沒被打穿。
6. **`task_queue.py`**：**部分成立（低風險，未列入發現）**。`cmd_tasks._parse` 只驗 `task_uid`（1～64 字）和「`payload` 是物件」，payload 裡的內容（目標主機、帳密）**進佇列前沒有逐欄驗 schema**，原封交給 scan 的 `dispatch_tasks`。但送得進來的一定是持有主系統簽章 JWT 的人，而目標還會在 scan 端再比對授權清冊（`allowed_targets`，範圍外）。佇列**只存在記憶體**（`queue.Queue`），沒有落地檔，所以沒有檔案權限的問題。帳密不會進 log（只記 uid）。
7. **`data_plane_server.py` TLS 設定**：**成立，設定正確**。`build_server_ssl_context`（70-90 行）的最低版本是 TLS 1.2，`verify_mode = CERT_REQUIRED`（要求主系統出示內部 CA 簽的客戶端憑證，也就是 mTLS），憑證三件套來自註冊流程。唯一的問題就是 F1 的握手逾時。

## 可信度兩層

- **「這些存在嗎」**：F1 由三位獨立檢查員（分別看「打不打得到」「影響多大」「有沒有防線」）一致確認（3/3），我也自己開檔核對過，可信度高。全程是讀程式碼判斷，沒有執行程式、也沒有實際打過。
- **「只有這些嗎」**：本輪是 low effort，由一位研究員讀完整個範圍，另外再跑一輪找寫死在程式裡的密碼／金鑰，兩位都有回報，加上上面七條人工查證。範圍很小（11 檔），覆蓋算完整，但掃描每次結果會有些不同，不能保證沒有其他問題。範圍外的 `config_pull`、`allowed_targets`、`local_ops`、`update_sync` 沒有看。

## 執行概況（照 stamp 抄）

- Run ID：`wf_67cc4f3e-bd2`；原 run 目錄 `CLAUDE-SECURITY-20261105-155939`，已歸檔到 `docs/security-scan/FR-177/runs/CLAUDE-SECURITY-20261105-155939/`
- 掃描版本：`3813abd05232`（工作目錄乾淨），effort `low`，focus `attack-surface`
- `verification.status`: `verified`；`reason_kind`: 無
- `candidates`: 1；`panel_votes`: 3（三位檢查員對 1 條各投 1 票，3 票全投出、全數成立）
- `researchers_dispatched`: 2；`researchers_returned`: 2
- 面板完整跑完；耗時約 21 分鐘
