# FR-177 C3 掃描報告：Nmap connector＋SSH 主機金鑰策略

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

## 一句話結論

**沒有要處理的漏洞。** 工具只提出 1 個候選問題，三位獨立檢查員投票 1 比 2 否決，所以報告是 0 條。我另外把「重點看什麼」七條逐一開檔核對，也沒有找到能打得進去的洞，只有幾個「防線只有一層」的加固建議（列在最後，不算漏洞）。

## 這一棒在檢查什麼

Agent 用 SSH（paramiko 套件）登入客戶指定的一台「執行主機」，在那台機器上跑 `nmap` 掃描其他目標，再把報告用 SFTP 拉回來。這支 connector 是**在客戶機器上用字串跑遠端指令**，`shlex.quote`（把一段文字包成 shell 不會拆開的形式）是 shell 層唯一的防線。另一支 `ssh_host_keys.py` 處理 SSH 主機金鑰：第一次連線時記住對方的金鑰，之後金鑰不符就拒絕連線（TOFU，trust on first use，首次信任）。

範圍：`guidant_agent/scan/adapters/connectors/nmap.py`、`guidant_agent/scan/adapters/connectors/ssh_host_keys.py`，共 2 檔、約 813 行。為了查證，我翻了範圍外的 `guidant_agent/sync/app/allowed_targets.py`、`scan/app/task_executor.py:74`、`probe_runner.py:51`、`local_runs.py:114`，**這些檔只為查證而讀，本身不當作發現**。

## 掃到什麼：總覽表

| # | 這是什麼問題 | 面板結果 | 結論 |
|---|---|---|---|
| C1（候選） | SSH 主機金鑰是用「使用者打的主機字串」原樣記的，同一個 IPv6 位址換一種寫法（例如 `2001:db8:0::5` 和 `2001:db8::5`）會被當成新主機，重新走一次首次信任 | 1 成立（LOW）／2 否決 | **不成立，不報** |

## 候選 C1 為什麼被否決（白話）

**機制是真的：** `nmap.py:240` 把原始字串交給 paramiko，paramiko 用字串完全比對找已記住的金鑰；找不到就走 `ssh_host_keys.py:56`，記下新金鑰並送出帳密。授權清冊（`allowed_targets.py:261`）在比對時會把 IP 正規化，但不會把正規化後的值傳回來。

**為什麼不算漏洞：** 主機字串只有「派工的人」能決定：主系統派下來的任務，或是有 `run-local-scan` 權限的主控台使用者。能這樣派工的人，本來就能把掃描指到清冊裡任何**還沒連過**的 IP，直接拿到首次信任，不需要繞換寫法這一圈。純粹在網路上攔截的人又決定不了寫法。所以換寫法並沒有讓誰拿到比原本更多的東西。另外這只對 IPv6 有效：IPv4 的變體寫法會被 `ipaddress` 拒絕，裸主機名也被清冊擋掉（`allowed_targets.py:265`）。

**可當加固：** 在建 SSH 連線前，用 `ipaddress` 把 host 正規化，記金鑰和查金鑰都用正規化後的值。openscap 也共用這段程式，要一起改。

## 重點逐條人工查證

| # | 重點 | 人工查過 | 為什麼 |
|---|---|---|---|
| 1 | `:535-552` 遠端指令字串，`scan_flag`／`port_range`／`targets` 有沒有 quote；target 能不能夾帶 nmap 選項（例如 `-iL /etc/passwd`、`--script`） | **不成立（有加固建議）** | `scan_flag` 不 quote，但值只會來自 `_SCAN_TYPE_FLAGS` 白名單（`:268`，查不到就 raise），不會是使用者原本填的值。`port_range` 先過 `^[0-9,\-\s]+$`（`:158`）再 quote。targets 逐一 quote，shell 層是安全的。**選項夾帶：** 程式裡沒有 `--` 分隔，也沒有 target 字元白名單，但 `targets` 在派工的三個入口（`task_executor.py:74`、`probe_runner.py:51`、`local_runs.py:114`）都要過清冊。清冊會把每個值解析成 IP／CIDR／末段範圍，解析不出來就得到空集合，`is_allowed` 回 False（`allowed_targets.py:186`）。`-iL`、`--script` 都解析不成 IP，會被拒。實際拼出的指令長這樣：`sudo /usr/bin/nmap -sS -oX '/home/u/tmp.Ab12.xml' -p '22,80' 10.0.0.5 10.0.1.0/24`（單引號是 shlex.quote 加的；遇到純 IP 這種安全字元時，quote 不會加引號）。**加固：** connector 本身不做第二道過濾，完全依賴清冊。建議在 targets 前面加 `--`，或者 `-` 開頭就拒。 |
| 2 | `:549` `sudo` 由誰決定、密碼怎麼送；非 syn 也能配 sudo 嗎 | **不成立** | `use_sudo` 來自工具帳密設定（`:199-200`），預設 False。不送 sudo 密碼，只靠遠端 sudoers 設 `NOPASSWD: /usr/bin/nmap`，probe 用 `sudo -n` 確認（`:721`）。connect／version 掃描配 sudo 也放行，但這只是多拿 nmap 本身的 root 權限，指令內容還是被前一條鎖住。 |
| 3 | `:231` `_connect`、`:210` 私鑰暫存檔；`look_for_keys`／`allow_agent` | **不成立** | 暫存檔優先放 `/dev/shm`（`:216`），用 `mkstemp` 建立再 chmod 0600，`finally` 一定刪除，包含 connect 丟例外的路徑（context manager 包住 `client.connect`）。`look_for_keys=False, allow_agent=False` 都已關閉（`:242`），不會拿 Agent 自己的金鑰去試。 |
| 4 | `ssh_host_keys.py:46-60` TOFU 取捨 | **刻意設計，不是漏洞** | 金鑰記在 `資料目錄/ssh_known_hosts`，存檔後 chmod 0600（`:59`），寫檔有鎖。取捨：**某個 IP 第一次連線時如果被攻擊者攔截，Agent 會一直信任攻擊者的機器**，帳密也會送給它。這是 SR-03.4 接受的風險：第一次連線前，管理員要拿 probe 回傳的指紋（`nmap.py:696`）和 `ssh-keygen -lf` 對照。金鑰不符時的訊息（`:81-84`）只包含主機、兩個指紋和處理方式，不洩漏帳密。 |
| 5 | `:297-339` 輸出上限；`:599` 遠端報告留存 | **加固建議（不算漏洞）** | stdout／stderr 和 SFTP 拉回的 XML（`:576` `f.read()`）**都沒有大小上限**，60 MB 上限（`:457`）是在全部讀進記憶體**之後**才檢查。前提是執行主機已經被攻陷、而且故意送超大輸出，最壞情況是 Agent 記憶體被吃光、行程掛掉（阻斷服務，不會外洩資料）。建議邊收邊計量，超過就斷開。遠端報告放在登入者家目錄（`mktemp` 預設權限 0600），`finally` 會 `rm -f`。刪除失敗時檔案會留下，但只有該帳號和 root 讀得到。 |
| 6 | `:625-635` `hosts`（跳板）與 `targets` 都過清冊嗎 | **不成立** | `TARGET_PARAM_KEYS = ("hosts", "targets", "host", "ips")`（`allowed_targets.py:37`），三個入口的 `check(params)` 兩個欄位都會檢查，所以跳板主機也必須在清冊內。 |
| 7 | `:676-692` probe 的 `raw_host` 與錯誤訊息 | **不成立（輕微）** | `raw_host` 是 `params.host`，也在清冊欄位裡。遠端只跑固定字串 `command -v '/usr/bin/nmap'`、`--version`、`sudo -n ... --version`，沒有使用者輸入。錯誤訊息會帶 paramiko 例外原文（例如 `Connection refused`），但能探測的只有清冊內的主機，而這些主機使用者本來就有權連，算不上內網資訊外洩。 |

## 可信度兩層

- **工具層：** 1 個研究員回來了，1 個候選問題，三位檢查員（可達性、影響、防線三個角度）都投了票，1 比 2 否決。stamp 是 `verified`。「沒東西過關」不等於絕對乾淨：low effort 只跑一個研究員，這是快速初篩。
- **人工層：** 七條重點逐條開檔核對，追到清冊和三個派工入口，結論見上表。全部是讀程式得出的，沒有執行任何程式或攻擊。

## 執行概況（照 stamp 抄）

- run ID：`wf_de9cde3e-611`；scan_id `41e54ea3-c0fc-47aa-8a16-b5d54dc4d8bc`
- revision `3d5aa2afda96a6d7fe52b3c04db27f88346ed059`，branch `ws-CM-3507-CM-3527`，dirty false
- effort low、scope 2 檔、耗時 1672 秒
- verification：status **verified**、candidates 1、candidates_deduped 1、panel_votes 3、panel_reviewed_findings 0、researchers 1／1、reason null
- 研究員沒有被砍（journal `failed` 0）
- 歸檔：`docs/security-scan/FR-177/runs/CLAUDE-SECURITY-20261106-032315/`
