# FR-177 C5 掃描報告：OpenSCAP＋OpenVAS connector

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

## 一句話結論

**找到 1 個問題，最嚴重是「中」**：Agent 連客戶的 OpenVAS（GVMd）時**完全不驗對方的 TLS 憑證**，同網段能做中間人的人可以拿走 OpenVAS 帳密，或回一份假的弱點掃描報告讓 Agent 當證據上傳。三位檢查員 3:0 通過。OpenSCAP 這支沒有工具報出的問題；「重點看什麼」逐條人工查過，另外列了三個加固建議（不算漏洞）。

## 這一棒在檢查什麼

- **OpenSCAP**：Agent 用 SSH 登入客戶主機，在那台機器上跑 `oscap xccdf eval`（系統組態合規檢測，可帶 sudo），把 HTML 報告用 SFTP 拉回。遠端指令是**字串**，`shlex.quote`（把一段文字包成 shell 不會拆開的形式）是 shell 層唯一的防線。
- **OpenVAS**：Agent 透過 GMP（Greenbone 的 XML 協定，走 TLS）叫客戶自備的 OpenVAS 建目標、建任務、開始掃描，最後抓 PDF 報告。

範圍：`guidant_agent/scan/adapters/connectors/openscap.py`、`guidant_agent/scan/adapters/connectors/openvas.py`，共 2 檔、約 880 行。為了查證，我翻了範圍外的 python-gvm 27.5.0 套件 `gvm/xml.py`（XML 怎麼組），**只為查證而讀，本身不當作發現**。

## 掃到什麼：總覽表

| # | 這是什麼問題 | 嚴重度 | 面板 |
|---|---|---|---|
| F1 | OpenVAS 連線不驗 TLS 憑證，帳密送給誰都行 | 中 | 3:0 通過 |

已對照主系統 `security-scan-consolidated/README.md`：#232 講的是「主控台改位址沿用舊密碼」，#235 講的是「InSpec 不驗 SSH 主機指紋」，**都不是 F1**，所以 F1 是新問題。

## F1：OpenVAS 連線不驗 TLS 憑證

**白話：** TLS 憑證是伺服器的身分證。Agent 連 OpenVAS 時沒有檢查這張身分證，誰冒充 OpenVAS 都會被當成真的，Agent 照樣把帳號密碼交出去。

**出事會怎樣：**
1. 攻擊者拿到 OpenVAS 帳號密碼，可以登入客戶真正的弱點掃描器：看到所有目標的弱點清單，或自己建任務掃別台。
2. 攻擊者假扮 OpenVAS，回一份「沒有弱點」的假 PDF 和 XML 報告，Agent 會當成正式稽核證據上傳，「發現 N 項」也是假的。

**要先有什麼：**
- 客戶設定了 OpenVAS 工具，而且有人跑測試連線或掃描。
- 攻擊者在 Agent 和 OpenVAS 之間的網路上，能做中間人（例如同網段用 ARP 欺騙搶 IP），或控制 `base_url` 那個主機名的 DNS。

**在哪裡：** `openvas.py:82` `_gmp`：

```python
connection=TLSConnection(hostname=host, port=port, timeout=_CONNECT_TIMEOUT_SEC),
```

我自己開了已安裝的 python-gvm 27.5.0 `gvm/connections/_tls.py:77-81` 核對：沒給 `cafile`／`certfile`／`keyfile` 時，它建的連線設定是 `check_hostname = False`、`verify_mode = ssl.CERT_NONE`，也就是完全不驗。接著 `:90`（probe）和 `:136`（run）呼叫 `gmp.authenticate(username, password)`，把帳密送出去。repo 的守衛 `test_no_cert_none` 只掃我們自己的程式碼，所以抓不到第三方套件預設關掉驗證的情況，違反 `.claude/rules/security/external-calls-and-tools.md` 的「TLS 不得關驗證」（SR-03.2）。

**怎麼修：** 給連線一個信任來源。可以在工具設定裡放客戶 GVMd 的 CA 檔，或在第一次測試連線時記下伺服器憑證指紋、之後不符就拒絕（跟 SSH 的 `ssh_host_keys` 同一套做法），沒有信任來源或指紋不符就不連。注意 python-gvm 就算三個檔都給了，還是會關掉主機名檢查，所以要自己包一層會驗證的連線，或在 `authenticate` 之前先比對對方憑證指紋。守衛也要擴充，抓「第三方連線類別預設不驗」這種寫法。

## 重點逐條人工查證

| # | 重點 | 人工查過 | 為什麼 |
|---|---|---|---|
| 1 | `:375-376` oscap 指令的三個值；`_resolve_content_path` 在遠端用什麼指令解析 `content_path`；`profile='-x'` 會不會被當選項 | **shell 層不成立；選項夾帶有一個加固點** | `_resolve_content_path`（`:229`）**不在遠端跑任何吃 `content_path` 的指令**：結尾是 `.xml` 就直接用，否則只跑寫死的 `cat /etc/os-release`（`:219`）再拼檔名。實際拼出的指令長這樣：`sudo /usr/bin/oscap xccdf eval --profile xccdf_org.ssgproject.content_profile_cis --report /home/audit/tmp.Ab12Cd.html /usr/share/xml/scap/ssg/content/ssg-ubuntu2204-ds.xml`（值裡沒有特殊字元時，`shlex.quote` 不會加引號）。`profile='-x'` 時，`--profile` 一定要帶一個值，所以 `-x` 會被吃成 profile 名稱，不會變成選項。**加固點：** `content_path` 是最後一個參數，前面沒有 `--` 分隔，也沒有檢查 `-` 開頭。如果填 `--results=/某路徑/x.xml`，結尾是 `.xml` 會直接採用，oscap 可能把它解析成 `--results` 選項（以 root 身分寫結果檔）。我推論這種情況因為少了 content 參數，oscap 會先報錯，所以沒有實際寫檔，但**沒有實機驗證**。只有派工的人（主系統，或有本機掃描權限的主控台使用者）填得到這個值。建議拒絕 `-` 開頭的 `content_path`／`profile`，或在 content 前面加 `--`。 |
| 2 | `:19` sudo 怎麼決定、密碼怎麼送；`_read_remote_os_release`、`_detect_content_os_mismatch` 跑什麼指令 | **不成立** | `use_sudo` 來自工具帳密設定（`:163-164`），預設 True。**不送 sudo 密碼**，靠受檢主機 sudoers 設 `NOPASSWD: /usr/bin/oscap`。`_read_remote_os_release` 只跑寫死的 `cat /etc/os-release`；`_detect_content_os_mismatch`（`:253`）只在 Agent 本機比對檔名，沒有另外跑遠端指令，也不吃使用者值。 |
| 3 | `:174-197` 私鑰暫存檔、`_connect`、`BadHostKeyException` | **不成立** | 暫存檔優先放 `/dev/shm`，用 `mkstemp` 建立再 chmod 0600，`finally` 一定刪除，`client.connect` 被 context manager 包住（`:213-214`），連線丟例外也會刪。`look_for_keys=False, allow_agent=False`（`:208`）。主機指紋走 `new_verified_client`（`:204`），`:356`／`:578` 不符就拒絕（C3 已掃過的首次信任策略）。 |
| 4 | `:445` `rm -f`、`:589` `command -v`；`remote_report` 能不能被預先放 symlink | **不成立** | `remote_report` 由遠端 `mktemp -p "$HOME"`（`:369`）建立，檔名隨機、檔案已存在且屬登入者；家目錄一般其他使用者寫不進去，搶不到 symlink。`rm -f` 和 `command -v` 都有 quote，值來自 mktemp 輸出或常數。`:602` `test -s` 也有 quote。 |
| 5 | `openvas.py:125-148` `hosts`／`port_list_id`／`scan_config_id`／`scanner_id` 有沒有 XML 跳脫；挑任意 scan config 合理嗎 | **不成立** | python-gvm 用 lxml 組 XML（`gvm/xml.py:101-102` `SubElement` + `.text =`、`:112` `.set()`），lxml 會自動跳脫 `<`／`&`，不能注入元素。挑 scan config 是功能本身（掃多深是派工者的選擇），派工者本來就能決定掃哪些目標。 |
| 6 | `:101` `_task_uid` 進任務名稱；OpenVAS 帳密來源與 `verify`；報告上傳前遮不遮 | **前兩項：見 F1；遮罩：不成立** | `_task_uid` 截前 8 碼拼名稱，經 lxml 跳脫，無害。帳密來自心跳下發的解密 credentials，**TLS 不驗＝F1**。弱點報告就是要交的稽核證據，不遮是設計如此。 |
| 7 | 兩支的 `timeout_sec` 上限；Agent 逾時放棄後 OpenVAS 那邊的任務會不會一直跑 | **成立（可靠性，不算資安漏洞）** | 兩支都直接 `int(params["timeout_sec"])`，**沒有上限**（openscap `:528`、openvas `:132`）。OpenVAS 逾時時（`:258`）只 raise `TimeoutError`，**沒呼叫 `stop_task`**（只有取消路徑 `:242` 會停），所以 OpenVAS 那邊會繼續掃，佔客戶資源，target／task 也不會刪。建議逾時時也 `stop_task`，並對 `timeout_sec` 設上限（例如不超過主體 4 小時）。 |

## 可信度兩層

- **工具層：** stamp `verification.status = verified`。1 個候選、3 票全投、3:0 通過，研究員 1/1 回來。
- **人工層：** F1 我自己開套件原始碼核對過，成立。重點 7 條逐條開檔，結果寫在上表。重點 1 的「`content_path` 夾帶 oscap 選項」是推論，沒有實機驗證。

## 執行概況

| 項目 | 值 |
|---|---|
| run ID | `wf_0d847800-830` |
| 掃描版本 | `6d1e6ca`（branch `ws-CM-3507-CM-3529`，tree 乾淨） |
| effort | low（1 位研究員＋三視角面板） |
| 研究員 | 派出 1、回來 1，沒有被砍（journal `failed` 0） |
| verification | `verified`；candidates 1、panel_votes 3、panel_quorum_findings 1、reason 無 |
| 耗時 | 約 17 分鐘（1,037 秒） |
| 歸檔 | `runs/CLAUDE-SECURITY-20261106-035701-C5/`（原目錄名與 C7 同秒撞名，加 `-C5` 後綴區分） |

所有結論都是讀程式碼得出的，沒有執行程式碼，也沒有實際打過攻擊。
