本卡屬 FR-195(Agent 資安修正),第 2 批 F12。修 FR-186 掃描的 C2 F1(3/3 票),嚴重度:中。
Agent 連 Linux 主機(SSH)時,第一次連線會記住對方的指紋,之後指紋不符就拒絕,帳密不會送出去——跟遠端桌面第一次連線問「要不要信任這台」是同一種做法,客戶不用匯入任何憑證。連 Windows 主機(WinRM)這條一直沒有做:就算客戶開了 HTTPS,Agent 也不確認對方是不是原本那台,同網段的人冒充目標主機就能拿到客戶的 Windows 稽核管理員帳號。
這張卡把 WinRM 補成跟 SSH 一樣:開了 HTTPS 的,第一次連線記住對方憑證的指紋,之後不符就拒絕、帳密不送出。客戶什麼都不用貼。
沒開 HTTPS(走明文 5985)的主機沒有憑證可以記,照舊可以掃,不擋;只在測試連線結果與執行紀錄多一句提示「這台走未加密連線,建議在主機上啟用 WinRM HTTPS」。
guidant_agent/scan/adapters/connectors/inspec.py:471-488 _prepared_invocation:SSH 目標在把帳密交給 cinc-auditor 之前,先呼叫 inspec_host_keys.pin_or_raise(target.host, target.port) 釘指紋(:480),結果存在 self.host_key_fingerprint。WinRM 目標沒有對應的步驟。guidant_agent/scan/adapters/connectors/inspec_config.py:37-42 build_config:WinRM 只寫 ssl 與 self_signed。self_signed=True 會讓底層完全不驗憑證。train-winrm-0.4.3/lib/train-winrm/transport.rb:69 有 option :ssl_peer_fingerprint,:173 會把它交給底層連線(ssl_peer_fingerprint: opts[:ssl_peer_fingerprint])。所以只要在設定檔的 winrm 區塊多寫一個 ssl_peer_fingerprint 鍵,檢測工具就會自己比對,不用改工具。 這個鍵的值是對方憑證的 SHA-1 指紋(十六進位、不含冒號)——這是底層 winrm 套件的規格,不是我們選的。guidant_agent/scan/adapters/connectors/ssh_host_keys.py(存在資料目錄的 ssh_known_hosts)。WinRM 的憑證指紋格式不同,不要硬塞進那個檔。inspec.py 現在 1,010 行,已超過 800 行上限,體積守衛釘在 1033(test/test_file_and_function_size.py:20)。新邏輯一律放新檔,inspec.py 只加呼叫。InspecConnector.probe(63)與 _scan_one_host(91)在函式行數白名單上,不能變長。guidant_agent/scan/adapters/connectors/winrm_cert_pins.py(新檔)
guidant_agent/scan/adapters/connectors/inspec.py
guidant_agent/scan/adapters/connectors/inspec_config.py
.claude/rules/security/external-calls-and-tools.md
test/test_inspec_connector.py
guidant_agent/scan/adapters/connectors/winrm_cert_pins.py,三樣東西:<資料目錄>/winrm_cert_pins.json,內容 {"<host>:<port>": "<sha1 十六進位>"}。資料目錄用 config.config.data_dir()(同 ssh_host_keys.py:38 的寫法)。讀寫用同檔一把 threading.Lock;寫檔用 guidant_agent.shared.secure_file.write_atomic(path, text, mode=0o600)(既有函式)。🔴 模組層級不准有可變全域(守衛 test_no_module_globals.py):鎖的放法照 ssh_host_keys.py:27-36 的 KnownHostsFile 與 _lock(),開工先讀那一段照做。fetch_fingerprint(host, port, timeout) -> str——ssl.get_server_certificate((host, port), timeout=timeout) 取回 PEM(Agent 裡已有同樣用法:guidant_agent/sync/app/enroll_service.py:132),ssl.PEM_cert_to_DER_cert 轉 DER,hashlib.sha1(der).hexdigest() 回小寫十六進位。這一步只做 TLS 握手、不送任何帳密。pin_or_raise(host, port) -> str:取指紋;檔裡沒有這台就記下、回指紋;有而且相同就回指紋;有但不同就 raise ValueError,訊息寫清楚「這台 Windows 主機的憑證與第一次連線時記下的不同(可能是主機重灌、換憑證,或有人冒充)。確認是同一台後,請刪除 Agent 資料目錄 winrm_cert_pins.json 裡這台的那一行再重試」。連不上就 raise 同一種「連不上」例外讓既有失敗分類處理——直接沿用 inspec_host_keys.HostUnreachable(inspec_host_keys.py:55)。逾時用 15 秒(同 inspec_host_keys._PIN_TIMEOUT_SEC)。inspec.py:478-483(在 _prepared_invocation 裡)現在是 if target.transport == _TRANSPORT_SSH: 釘指紋。加一個 elif:WinRM 而且 target.ssl 為真時,呼叫 winrm_cert_pins.pin_or_raise(target.host, target.port),回傳值一樣存進 self.host_key_fingerprint(測試連線的回應本來就會帶這個欄位給畫面,見 guidant_agent/scan/app/probe_runner.py:83-84),並把指紋傳給 inspec_config.build_config。連不上時照 :481-482 的寫法轉成 ValueError(self._classify_failure(...))。:486 呼叫 self._build_config(target, key_path, ssh_config) 的地方多傳指紋。這支函式現在 18 行、不在函式行數白名單上,加這幾行沒問題;要顧的是整支檔的行數:inspec.py 淨增的行數要 ≤ 23(1,010 → 上限 1,033),這張卡大約淨增 6~8 行,夠用。改完跑體積守衛確認綠,不用改白名單數字。inspec_config.py build_config:多收一個參數 winrm_fingerprint: str | None = None。WinRM 分支(:37-42)改成:ssl 為真而且有指紋時,寫 entry["ssl_peer_fingerprint"] = winrm_fingerprint,並且不要寫 self_signed(有了指紋就不需要「關掉驗證」這個開關;釘指紋對自簽憑證一樣有效,這正是它比「貼 CA」好用的地方)。ssl 為假時照舊只寫 ssl: False。