# FR-177 C2 掃描報告：CINC Auditor／InSpec connector

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

## 🟡 一句話結論

**有一條新問題要排修。** 工具找到 2 個問題。F1（高）就是總表已登記的 #223（使用者手填檢測規則包 profile → Agent 執行任意 Ruby），本棒**不重報**，只當作再次確認。**新的是 F2（中）**：cinc-auditor 用 SSH 連 Linux 目標主機時，從不檢查對方的「主機指紋」（host key，SSH 用來確認「我連到的真的是那台機器」的身分證）。所以內網裡有人冒充目標主機的 IP，Agent 照樣把稽核帳號的密碼交給他。同一支程式裡，OpenSCAP 和 nmap 兩個 connector 都有做這個檢查，只有 InSpec 漏了。兩條都經三個獨立檢查員投票 3 比 0 成立，我也自己開檔核對過。

## 這一棒在檢查什麼

- Agent 用 cinc-auditor（開源版 InSpec 合規檢測引擎）以 SSH 或 WinRM（Windows 遠端管理協定）連到客戶的目標主機，跑檢測規則包（profile）；
- 重點是 connector 拿到參數之後：哪些欄位會進到外部指令、SSH／WinRM 帳密怎麼交給 cinc、暫存的設定檔放哪裡、用完有沒有刪、錯誤訊息會不會漏出密碼。

範圍只有 `guidant_agent/scan/adapters/connectors/inspec.py` 一個檔，約 1,050 行。為了查證，翻過範圍外的 `openscap.py`、`nmap.py`、`ssh_host_keys.py`，以及 cinc 內附的 train-core（SSH 連線函式庫）原始碼。這些檔只為查證而讀，本身不當作發現。

## 掃到什麼：總覽表

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 | 嚴重度 |
|---|---|---|---|---|---|---|
| F1 | 手填 profile 原樣交給 `cinc-auditor exec` | Agent 主機被執行任意程式 | 主控台 `run-local-scan` 權限 | `inspec.py:875-878`、`:913` | 已登記 #223，本棒不重報 | 高（已登記） |
| F2 | SSH 連線不驗主機指紋 | 冒充目標主機的人拿到稽核帳號密碼，或回傳假的檢測結果 | 攻擊者在客戶內網、能搶到目標主機的 IP（例如 ARP 欺騙：在區網裡謊稱自己是那個 IP） | `inspec.py:482-488` `_build_config` | 比照 OpenSCAP／nmap，接 `ssh_host_keys.py` 首次信任後釘住指紋，config 帶 `verify_host_key` | 中 |

## 每條詳述

### F1（高，已登記 #223，不重報）

`_resolve_profile`（`:872-878`）在沒有 `_profile`（主系統同步的庫內參照）時，直接回傳 `params.profile`，這個值原樣成為 `exec` 後面的參數（`:913`）。中間沒有任何驗證，前面也沒有 `--`。如果值以 `-` 開頭，還會被 cinc 當成選項。這裡跟 B5 F1／總表 #223 是同一個問題，連修法都一樣，所以不另外登記。

### F2（中）SSH 不驗主機指紋

- **白話**：SSH 第一次連線時，正常的程式要記住對方的指紋，下次指紋不對就拒絕連線。這支 connector 交給 cinc 的設定檔（`_build_config`，`:482`）只寫了 host／user／port／password／key_files／sudo，沒有寫 `verify_host_key`。cinc 內附的 train-core 預設 `verify_host_key=false`，換算到 net-ssh 就是「:never（永不檢查）」，known_hosts（記錄已知指紋的檔）還被指到 `/dev/null`。所以 cinc 連到哪台都照單全收。
- **出事會怎樣**：
  - 認證方式是「密碼」時，密碼直接送給冒充者。這組帳號是全租戶共用、可以 sudo 的稽核帳號（`:318` 註解），拿到一組就等於拿到每台受檢主機。
  - 認證方式是「私鑰」時，私鑰偷不走，但冒充者可以回傳假的指令輸出，讓證據裡出現「全部通過」的假結果。對稽核產品來說，這等於證據被偽造。
- **要先有什麼**：攻擊者已經在客戶內網，而且能搶到目標主機的 IP 或 DNS（ARP 欺騙、搶走一個沒人用的 IP、竄改 DNS）。要偷密碼，還得是密碼認證（預設是私鑰）。
- **在哪裡**：`guidant_agent/scan/adapters/connectors/inspec.py:482-488`。probe 的 `:640` 與 exec 的 `:912-931` 都只帶 `--config` 和 `-t`，沒有任何指紋相關參數。`ssh_host_keys.py` 只被 `openscap.py`、`nmap.py` import，`inspec.py` 沒有用到（我用 grep 確認過）。
- **怎麼修**：比照 OpenSCAP。用 `ssh_host_keys.py` 做首次信任（TOFU：第一次連線時記住指紋），每次執行時產生一份暫存的 known_hosts，config 裡帶 `verify_host_key: "always"`（或 `accept_new`）並指定 `user_known_hosts_file`。指紋不符時要報成白話的「主機身分改變」。修是另外開卡的事。
- **WinRM 的對應情況**：`winrm_self_signed=True`，或關掉 SSL 走 HTTP 5985 時，WinRM 也一樣不驗對方身分。不過這兩種狀況都是帳密設定裡管理員「明示選的」，預設值是 `False`（`:424`、`:435`），屬於設定選擇，不另外登記。

## 重點逐條人工查證

| 卡上重點 | 人工查過：結論 | 為什麼 |
|---|---|---|
| `target_uri` 怎麼組，`host` 含 `@ : ? #` 會不會改變 URI 解析；port 範圍 | **不成立**（URI 那部分） | `-t` 的值固定是 `ssh://<_CRED_SET_NAME>` 或 `winrm://<_CRED_SET_NAME>`（`:507`），只是指到 config 裡那組設定的名字。host 是寫進 JSON 的 `host` 欄位（`:482`），根本不進 URI，所以沒有拼字串、也沒有 URI 解析可以被改。port 走 `_resolve_port`（`:438-441`）的 `int()`，沒有檢查範圍，但值來自租戶工具設定，不是任務參數，最壞只是連不上，不算資安問題。目標主機要經授權清冊放行，是呼叫端（B5 已查）的事。 |
| `--config` 裡的 JSON 放了密碼，檔案權限、放在哪、刪不刪 | **成立為良好作法**（不是問題） | `_secure_temp_file`（`:453-473`）優先寫到 `/dev/shm`（記憶體檔案系統），`mkstemp` 建檔再明確 `chmod 0600`（只有擁有者能讀寫），`finally` 一定會刪。私鑰另外寫一個暫存檔，config 裡只放它的路徑，兩層 context manager 巢狀包住（`:508-516`），例外或取消時也會刪。只有一個小地方：如果 `os.fdopen` 寫入途中失敗，fd 會由 fdopen 收掉，檔案仍然會被 finally 刪除。sudo 沒有另外的密碼欄位，只有一個 `sudo` 布林值（`:488`），所以 sudo 是沿用同一個帳號的權限。 |
| `:646` subprocess 繼承整包 `os.environ` | **成立，低風險，不另外登記** | `_ruby_env`（`:767`）把 `os.environ` 整包複製給 cinc。Agent 的環境變數裡可能有 `AGENT_*` 這類設定，cinc 和它載入的 profile Ruby 都讀得到。但能控制 profile 的人本來就能執行任意程式碼（#223），所以這不會開出新的攻擊面。與 C1 第 3 點同一類，建議一起修：只傳 PATH／HOME／LANG／RUBYOPT 這類白名單。 |
| transport 只收 ssh／winrm；`winrm_self_signed` | **transport 不成立；self_signed 屬設定選擇** | `:342-348` 遇到未知值就 raise。`self_signed` 和 `ssl` 都來自 `self.credentials`（租戶層工具帳密設定，由管理員設定），預設都是 `False`。 |
| `:238` 錯誤分類，stderr 回給使用者前有沒有遮密碼 | **不成立（密碼不會出現在 URI 裡）** | 密碼只放在 config 檔裡，`-t` 是 `ssh://<名字>`，不會出現 `user:pass@host` 這種形式。`_classify_failure` 只回傳 host:port、帳號名稱和原始輸出的最後 500 字（`:540`）。train 的錯誤訊息不會印 config 裡的密碼，所以這 500 字只會帶到主機與帳號名稱。帳號名稱外露不算機密。 |
| 庫內參照 `profile_cache` 回傳的目錄，進 exec 前有沒有再驗證；lockfile 旗標 | **不成立** | `_profile` 存在時交給 `profile_source().resolve_profile_source`（`:882`）處理，它會對帳 sha256 並安全解壓（在 sync 模組，屬於範圍外，A／B 棒已查），型別不對就 raise，不會偷偷改用手填路徑。connector 這邊沒有再檢查一次目錄是否存在，但如果目錄不存在，cinc 會報「could not fetch」並被歸類為本機失敗（`:285`），沒有資安影響。不寫 lockfile 的旗標是 `--no-create-lockfile`（`:929`）。 |
| `_terminate`／`_run_with_cancel`：Ruby fork 殺不殺得乾淨；遠端 session 怎麼收 | **成立，低風險，不另外登記** | `_terminate` 只對 Popen 那一個 pid 送 SIGTERM，等寬限期後再送 SIGKILL（`:699-708`），沒有開 process group（`start_new_session`），所以 cinc 自己 fork 出來的孫行程不會被一起殺掉，可能留下孤兒行程。遠端的 SSH／WinRM session 只能靠 SIGTERM 讓 cinc 自己關，被 SIGKILL 的話，就要等目標主機那邊逾時才會收掉。這是可用性和資源問題，不是資安漏洞。與 C1 第 6 點同一類。 |

## 可信度兩層

1. **工具層**：stamp `verification.status = verified`。2 個候選問題全部經過三視角面板（可達性／影響／防禦）各投 3 票，兩條都 3:0。
2. **人工層**：F2 我自己開了 `inspec.py:476-498`、`:500-516`、`:640`、`:912-931`，並用 grep 確認 `ssh_host_keys` 只有 openscap、nmap 有用到。train-core 預設值這一點是採信檢查員讀到的版本（3.16.3／3.16.5 的 `ssh.rb:65`），我自己沒有重讀 gem 原始碼。F1 對照 `:872-878`、`:913`，與 #223 相同。

## 執行概況（照 stamp 抄）

- run ID：`wf_a5bb8ac0-ee7`；原始報告目錄 `CLAUDE-SECURITY-20261106-032312/`，歸檔到 `runs/CLAUDE-SECURITY-20261106-032312/`
- 版本：`3d5aa2afda96`；effort low；範圍 1 檔
- verification：status `verified`、candidates 2、panel_votes 6、panel_quorum_findings 2、unreviewed_candidate_sites 0、incomplete_panel_candidates 0、researchers 1/1、reason 無
- 研究員被砍的次數：0（journal 裡沒有 `failed`）；整趟約 25 分鐘
