# FR-187.A2 掃描報告：設定下發與工具帳密

## 🔴 一句話結論

找到 1 個問題（中度）：**管理員在主系統替 Agent 設定的檢測工具帳密（受檢主機的 SSH 密碼、金鑰、工具 token），以明文永久留在資料庫的 `agent_requests` 表裡**，任何拿得到資料庫或備份的人都能一次撈走全部客戶的受檢主機密碼。要修，但不需要今天停機修：攻擊者得先拿到資料庫讀取權或備份檔。

## 這一棒在檢查什麼

主系統怎麼把 13 類設定（掃描範圍、LDAP、離線登入政策等）組好送給各家客戶的 Agent、設定改了要通知哪幾台、主系統怎麼把工具帳密交給 Agent、Agent 主控台的離線操作者名單怎麼管。重點是「會不會把 A 客戶的設定或密碼送到 B 客戶的 Agent」與「密碼有沒有落地」。

## 掃到什麼：總覽

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 | 嚴重度 |
|---|---|---|---|---|---|---|
| F1 | 工具帳密被原樣存進資料庫的請求表，Agent 拿完也不清掉 | 拿到資料庫、備份檔或 E2E 用的資料庫快照的人，能看到所有客戶、所有受檢主機的明文密碼與私鑰 | ① 曾有管理員在主系統設過工具帳密 ② 攻擊者能讀資料庫或其備份（網頁端沒有找到能讀這張表的 API） | `core/plugins/_agent_tool_credentials.py:75` → `app/remote_agent/service/agent_request_service.py:205` | 帳密加密後才寫進 payload、Agent 領走時才解；或請求一結束（done／failed／expired）就把 payload 清空，並加定期清舊列 | 中（MEDIUM） |

為什麼是中不是高：網頁端打不到，要先拿到資料庫層的讀取權。但一旦打到，影響是全部客戶的受檢主機帳密，所以修正優先序不低。

## 每條發現詳述

### F1 工具帳密明文留在 `agent_requests.payload`

**白話**：管理員在「Agent 工具帳密」頁輸入密碼後，主系統把它塞進一張「主系統要 Agent 做事」的請求表（`compliance.agent_requests`），Agent 下次長輪詢（Agent 主動來問「有事嗎」的連線）時把這列拿走、存到自己那邊、回報完成。問題是：這一列標成完成後，**密碼欄位還在，而且是明文**，沒有任何程式會去清。檔頭註解寫「帳密只在記憶體過一手、不寫進資料表」，實際行為和註解相反。

**出事會怎樣**：資料庫備份外流、維運人員有唯讀帳號、報表用的副本庫、E2E 從 POC 拉下來的資料庫快照——任何一種都能用一行 `SELECT payload FROM compliance.agent_requests WHERE kind='credentials_set'` 拿到全部明文密碼，跨所有客戶（這是直接查資料庫，不受「每個客戶只能看自己資料」的 RLS 隔離擋）。違反 `.claude/rules/security/secrets.md`「解密後的帳密只留記憶體、不寫回任何表」(SR-06.4)。

**要先有什麼**：至少有一次透過主系統設工具帳密；攻擊者有資料庫層讀取權。

**在哪裡**：
- `core/plugins/_agent_tool_credentials.py:75` `set_tool_credentials()` 把 `fields` 交給 `credential_client.put`
- `infra/remote_agent/agent_credential_client.py:33` 組成 `{"tool", "fields"}` 呼叫 `ask`
- `app/remote_agent/service/agent_request_service.py:205` `_new_row()` `payload=dict(payload or {})` 原樣寫入，`:155` `_insert` commit

**怎麼修**：二擇一（建議 A）：
- A. 在 `_new_row` 前對 `credentials_set` 的 `fields` 用 Fernet（`infra/cloud_integration/crypto/fernet_crypto.py`）加密；長輪詢交出（`infra/remote_agent/command_sources_impl.py:35` 組 `Command` 時）才解密放進回應 body。
- B. 最低限度：`credentials_set` 列在變成 taken／done／failed／expired 的同一個更新裡把 `payload` 設成 `{"tool": ...}`（拿掉 `fields`），並為 `agent_requests` 加保留期清除。既有已落地的列要一支一次性清理 migration。
- 補一條測試：請求結束後資料庫裡查不到送出的密碼字串。

**小弟核對**：已自己開檔追過整條路徑（`ask` → `_ask` → `_new_row` → `_insert`），確認成立；`finish`／`mark_handed_out` 類只改狀態不動 payload（三位檢查員也各自確認）。

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

1. **各類 `section(agent)` 有沒有拿全站設定送給子租戶**：本棒範圍內的四類（allowed_targets／allowed_target_names／agent_name／trust_anchors）都用 `agent.tenant_id` 或該台自己的欄位；trust_anchors 是全站公鑰，本來就該全發，**不成立**。LDAP、離線登入天數、錯誤追蹤三類的實作在範圍外檔案（`_agent_ldap_config.py` 等），**本棒沒讀、不能下結論**；`_agent_config_notify.py:41-50` 註解寫「LDAP 存在 ROOT，影響所有 Agent」，代表設計上就是全站一份 LDAP 帳密發給所有租戶的 Agent——若 SaaS 情境多家客戶共用一台主系統，這就是跨客戶發 AD 密碼。落地版單一客戶無影響，**建議首腦另開一棒掃 `_agent_ldap_config.py`**。
2. **版本 HMAC 空鑰匙**：`agent_config_service.py:36-37` 沒有 app context 或沒設時會用空字串當鑰匙。但 `config/config.py:24` 以 `_secrets["JWT_SECRET_KEY"]` 取值，缺了啟動就 KeyError，正式環境走不到空鑰匙；`pull` 走 HTTP 請求一定有 app context。**不成立**（剩餘風險：JWT_SECRET 設成空字串的部署錯誤，不在程式能防的範圍）。
3. **`notify` 的 `None`＝全部租戶**：`_GLOBAL_KINDS`（tool_schemas／vulndb／trust_anchors／ldap_config／file_protect）與 bulk 寫入、查不出租戶時標 `None`。`notify` 只寫一列 `config_changed` 叫 Agent 來拉，**內容不含設定值**，Agent 來拉時仍以自己租戶身分組裝；多通知只是多拉一次。且 `get_remote_agents()` 受寫入者身分的 RLS 限制。**不成立**（洩漏面在 LDAP 本身是全站一份，見第 1 條）。
4. **工具帳密有沒有落進 `agent_requests.payload`**：**成立**，即 F1。
5. **`agent_credentials_route.py` 守門與回傳內容**：檔頭說由 `admin_required` 依 `_ADMIN_CAPABILITIES` 套上（登記在範圍外的 `core/plugins/remote_agent.py`，未展開核對）；清單回傳（`core/plugins/_agent_keys.py:89-100`）只有 uid／名稱／租戶／狀態／到期日／最近結果，**不含通行證或憑證本體**；換證 payload 是空的、私鑰在 Agent 端產生。**不成立**。
6. **離線操作者能不能加別家租戶的人**：`_agent_offline_operators.py:46-55` 以呼叫者的 `tenant_id` 加 `member_tenant_id` 條件查使用者，查不到就 `None`；別家租戶的人查不到。**不成立**。（附帶：`set_console_allow_cidr` 用 `verify_remote_agent_is_exist` 依 RLS 擋他租戶 Agent。）
7. **直推 8443 有沒有驗 Agent 憑證**：`agent_command_service.py:165-168` 預設關，開關要 `is True` 才開。開了之後連線由套件 `AgentDataPlaneClient.command` 負責，驗證邏輯在 `jedi-remote-agent` 套件、範圍外，**本棒不能下結論**；FR-177／FR-077 掃過套件端，參照 `docs/features/security-scan-consolidated/README.md`。

## 這份結果可信到什麼程度

- **「這 1 條存在嗎」**：存在。三位獨立檢查員（分別從「打不打得到」「影響多大」「有沒有別的防線」三個角度看）三票全數判定成立，小弟也自己開檔追過。
- **「只有這 1 條嗎」**：不能保證。low 檔是單一研究員讀完 9 檔，不是逐元件窮舉；範圍外的 LDAP／錯誤追蹤設定組裝與套件端直推驗證沒看（見上第 1、7 條）。掃描結果每次跑都可能略有差異。

## 執行概況

| 項目 | 值 |
|---|---|
| run ID | `wf_4a13cf35-087` |
| 原始目錄 | `CLAUDE-SECURITY-20261108-171710`（歸檔於 `docs/features/security-scan-consolidated/runs/be/`） |
| 掃描版本 | `31345fbe0`，工作目錄乾淨 |
| 範圍 | 9 檔，effort low，focus attack-surface |
| verification.status | `verified`（reason_kind：無） |
| candidates | 1（去重後 1） |
| panel_votes | 3（三位檢查員對 1 條各投一票，3 票全數投出，無漏投） |
| researchers_dispatched / returned | 2 / 2（一位讀全範圍、一位專查寫死的密碼） |
| 耗時 | 約 18 分鐘 |

全部結論都是讀程式碼得出，沒有執行任何程式或攻擊驗證。
