# FR-177 B4 掃描報告：掃描任務執行、工具帳密保管與交付

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

## 🔴 一句話結論

**沒有急件。** 找到 1 個中風險問題：主控台「檢測工具」頁可以只改工具位址、密碼欄留空，Agent 就會把原本存著的客戶工具密碼（SonarQube token、OpenVAS 密碼、ZAP API key）送到新位址。要打得到，得先有主控台 `configure` 權限的登入。三個獨立檢查員投票 3 比 0 確認成立。這一條建議排進近期修正，因為它違反了我們自己訂的 SR-06.3（「位址改了，祕密要重填」），而 LDAP 那頁已經擋了。

## 這一棒在檢查什麼

Agent 是裝在客戶自己機器上的程式。這一塊是：

- 主系統派來的掃描任務怎麼被接下來、交給哪支「connector」（接檢測工具的轉接程式）跑、結果與報告怎麼回傳；
- 掃描工具要用的帳密（客戶的 SonarQube token、SSH／WinRM 帳密）怎麼加密存在本機；
- 主系統送來的「交付帳密」任務怎麼寫進去，跑掃描時怎麼解出來交給 connector。

這裡保管的是**客戶自己機器的密碼**，漏出去就是客戶受檢主機被拿走。

範圍 14 檔（`guidant_agent/scan/app/` 的任務執行與帳密 7 支、`scan/adapters/credential_store.py`、`scan/api/`、`scan/ports/` 兩支）。

## 掃到什麼：總覽表

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 | 嚴重度 |
|---|---|---|---|---|---|---|
| F1 | 主控台改檢測工具的位址時，密碼欄留空就沿用舊密碼，沒有要求重填 | 能進主控台「檢測工具」頁但看不到密碼的人，把位址改成自己的機器、按「測試連線」，就拿到客戶工具的密碼或 token | ①主控台登入且有 `configure` 權限 ②來源在主控台允許的網段內 ③該工具已存過帳密 ④Agent 連得到攻擊者的機器 | `guidant_agent/console/api/tools.py:66`（觸發點）；`guidant_agent/scan/app/secret_form.py:139`、`:146`（沿用舊值的地方） | 存工具帳密時把位址類欄位（`base_url`、`port`）當 `pinned_keys` 傳進 `secret_form.save` | 中 |

**為什麼是「中」不是「高」：** 陌生人打不到，要先有主控台 `configure` 權限的登入。但設計上這種人「只能看到已設定、看不到密碼」，這條等於繞過那道牆。

## 每條發現的詳述

### F1 — 改工具位址時沿用舊密碼，密碼會被送到新位址

**白話：** 主控台的表單為了不把密碼顯示在畫面上，規定「密碼欄留空＝沿用已存的密碼」。`secret_form.merged` 有一道保護：「位址改了，密碼就要重填」（SR-06.3），但前提是呼叫端傳入 `pinned_keys`（哪些欄位算位址）。LDAP 那頁有傳，檢測工具這頁沒傳，所以保護從來沒有生效。

**出事會怎樣：** 攻擊者送 `PUT /console/api/tools/sonarqube`，內容 `{"fields": {"base_url": "https://攻擊者的機器"}}`，token 留空。接著按 `POST /console/api/tools/sonarqube/test`，Agent 帶著 `Authorization: Bearer <存著的 token>` 去連攻擊者的機器。OpenVAS（帳號密碼）、ZAP（API key）一樣。之後主系統派這支工具的掃描，也都會把密碼送過去。另外 `base_url` 刻意不在授權清冊（`allowed_targets`）的比對範圍內，所以清冊也擋不到。

**要先有什麼才打得到：** 主控台（8444 埠）登入、有 `configure` 權限、來源在允許網段內；該工具已存過帳密；Agent 連得到攻擊者的機器。

**在哪裡：**
- `guidant_agent/console/api/tools.py:66`：`secret_form.save(code, schema, raw)`，沒帶 `pinned_keys`（這檔在範圍外，是查證觸發點用，問題本體在範圍內的 `secret_form.py`）；
- `guidant_agent/scan/app/secret_form.py:139`：`moved` 永遠是 False；
- `guidant_agent/scan/app/secret_form.py:146`：把舊密碼補進新位址的這筆設定。

**怎麼修：** 工具路由存檔時傳 `pinned_keys`（固定 `base_url`、`port`，或由工具 schema 標出位址類欄位）。更穩的做法：`merged` 改成預設擋，只要任何非密碼欄位改了，就要求重填密碼，呼叫端明說才放行。補一支回歸測試：改 `base_url`、token 留空 → 400。

**runner 開檔核對：** 成立。`tools.py:62-68` 的 `put_tool` 只傳了三個位置參數，沒有 `pinned_keys`；`secret_form.py:127-147` 的邏輯與描述一致。

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

| 卡上的點 | 人工結果 | 為什麼 |
|---|---|---|
| `credential_store` 金鑰與密文同目錄，第一次產生金鑰時的權限 | **不成立（設計如此）** | 金鑰檔用 `os.open(..., O_EXCL, 0o600)` 建，目錄 `makedirs(mode=0o700)`，讀時權限不是 600 會記 warning（`platform/secret_store.py:94-107`，B7 範圍，只為查證）。同目錄的風險是已知的設計取捨：只防「拿到密文檔但沒拿到金鑰」，整台被複製就兩個都拿到。不另報。 |
| `put` 空值保留舊值 + `validate` 的 default，會不會送空值把密碼清成預設 | **不成立** | `validate` 第 89 行的 default 只套在**非祕密欄**（`not field.get("secret")`）。祕密欄空值在 `put` 第 73 行被略過、第 76-78 行補回舊值，所以空值是「不改」，不會清成預設。真正的問題是反過來：空值沿用舊值的時候沒檢查位址有沒有變，就是 F1。 |
| `credential_handover.store` 帳密有沒有進 log | **不成立** | `audit(...)` 只帶 `tool=code, method="handover"`；rejected 的 log（第 46 行）帶的是 `problem`，來自 `validate` 的錯誤訊息，內容只有欄名與原因（例如「X 不是這個表單的欄位」「要填數字」），不含值。 |
| `_clear` 能不能清掉所有工具帳密（讓掃描功能停擺） | **成立但屬設計內，不報** | `clear=true` 的單可以清掉列出的工具（`_`開頭的保留代碼除外）。但它的來源只有兩個：心跳派工、8443 資料面 `DELETE /detection/credentials`，都要主系統簽的 JWT。主系統被打穿本來就能做更多事，這屬於主系統的權限，不是 Agent 的漏洞。 |
| `_run_one` 解出來的帳密交給 connector 的方式 | **不成立** | `credential_resolver.resolve` 回的 dict 是 `get_connector(...)` 的第三個參數，跟 `params` 分開，沒有塞進 `params`。connector port 介面沒有寫明「不准進命令列」，這條規範在 `.claude/rules/security/secrets.md`（SR-06.4）與 connector 檔頭；各 connector 怎麼交給外部程式不在本棒範圍。 |
| 任務失敗時 `sink.fail` 的錯誤訊息會不會帶帳密 | **不成立（有遮）** | 回主系統的 `error_message` 一律走 `safe_error_text(e)`：先把網址裡的 `user:pass@` 換成 `***@`，再過 `jedi_masking`，截 500 字。**但注意：** 第 138 行 `logger.error("task %s 執行失敗: %s", task_uid, e)` 是原文進 log，要靠 log 管線的 `SecretMaskingFilter` 遮；網址形式的 `user:pass@` 那個過濾器認不認得，屬 log 管線（不在本棒），建議由掃 log 管線的那棒順便確認。 |
| `_get_stream`／`_download` 的 `dest_path`、`max_bytes`、下載目錄 | **不成立** | `dest_path` 由 connector 決定，目前唯一的呼叫者 `sbom_scan.py:248-251` 用 `os.path.basename` 取檔名、落在本次 workspace 的 `input/` 下，不會跳出目錄；`max_bytes` 由工具限額（`upload_max_mb`）算出傳入。這兩支只是轉給 sync 的 `CloudChannel`。 |
| `_upload_reports` 上傳報告前有沒有過遮罩 | **沒有遮，但不報** | 報告內容原樣上傳到本機 blob。掃描報告本來就是證據，裡面的主機帳號是證據的一部分；遮掉反而毀證據。報告只存本機 blob，下載走主控台與資料面守門。 |
| `running_tasks`：同一 task_uid 重複派會怎樣 | **成立但影響低，不報** | `register` 用新的 Event 直接覆蓋舊的，同 uid 第二次派會並行跑兩次；先跑完的那個 `unregister` 會把登記清掉，另一個就取消不到了。前提是主系統重複派同一張單（要有 JWT），屬可靠性問題，不是資安漏洞。 |
| `request_cancel` 誰都能取消嗎 | **不成立** | 資料面 `/detection/cancel` 要主系統 JWT；主控台的取消 `scan_routes.py:220` 掛 `@require_session(SCOPE)`，只取消 `local_runs` 的手動執行。沒有比對 `started_by`，同 scope 的操作者可以取消彼此的手動掃描，這是同權限內的操作，不報。 |
| `credential_route.py`、`detection_route.py` 掛在哪、守門是什麼 | **不成立** | `scan/api/__init__.py` 把 `/detection/probe`、`/detection/cancel`、`/detection/credentials` 掛在主 app（8443 資料面），守門是 `sync/api/data_plane_auth.py` 的 app 層 `before_request`（mTLS + JWT 綁 op、jti 只能用一次），不在公開清單內。`probe` 收工具 id／code、帳密、params；`cancel` 只收 task_uid。 |
| `secret_form._coerce` 型別強轉讓 schema 檢查失效 | **不成立** | boolean 欄只收真正的 `bool`，字串 `"true"` 會被退（「只能是是／否」）；number 欄明確擋 `bool`；select 欄比對允許清單。`store_call` 只接 `CredentialStoreCorrupted`，回固定訊息 `STORE_UNREADABLE`，不帶 Fernet 細節。 |

## 可信度兩層

- **「報出來的這條存在嗎」**：可信。三個獨立檢查員從可達性、影響、防禦三個角度投票，3 比 0 成立，runner 也開檔核對過。
- **「只有這一條嗎」**：不能保證。這是 `low` 強度：只有一個研究員讀全範圍，沒有威脅模型和廣度掃。上面的「重點看什麼」逐條人工查過，補了一層，但掃描結果本來就每次不同。

## 執行概況

- run ID：`wf_90202827-ce1`
- 原始產物：`docs/security-scan/FR-177/runs/CLAUDE-SECURITY-20261105-165640/`
- 掃描 commit：`749d3b4`（working tree 乾淨）
- 強度：low；範圍 14 檔；耗時約 16 分鐘
- stamp `verification`：`status: verified`、`reason_kind: null`、`candidates: 1`、`panel_votes: 3`、`researchers_dispatched: 1`、`researchers_returned: 1`
- 面板有跑完。工具只讀程式碼，沒有執行程式或實際打過。
