# FR-177.B7 掃描報告：祕密保管、診斷包、維運與稽核、裝置指紋（CM-3523）

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

## 一句話結論

工具找到 **1 個中度（MEDIUM）問題**，三位驗證員一致成立：容器形態升級時，主機用 root 去跑「新映像裡的 `cat`」把升級腳本讀出來執行，而新映像的作業系統層沒有被簽章涵蓋——控制了 Agent 容器（或更新管道）的人可以藉此拿到客戶主機 root。人工逐條查證「重點看什麼」另記 3 個低度觀察（不算正式發現，建議擇期處理）。

## 這一棒檢查什麼

Agent 平台層 11 個檔（約 1388 行）：Fernet 祕密保管（`secret_store.py`）、給原廠的診斷包（`support_bundle.py`）、重啟與本機維運端點（`ops.py`、`local_ops.py`）、維運 CLI（`local_state_cli.py`）、稽核寫入與舊檔匯入（`audit_log.py`、`audit_pipeline.py`、`audit_import.py`）、`device_uuid` 算法（`fingerprint.py`、`host_identity.py`）、升級驗章 CLI（`agent_tree_verify.py`）。

## 總覽表

| # | 嚴重度 | 一句話 | 急不急 |
|---|---|---|---|
| F1 | 中 | 升級時主機以 root 執行新映像內未驗章的 `cat`，可被換掉而拿到主機 root | 下一個升級相關版本前修 |
| 人工 O1 | 低 | 金鑰檔權限不對只記 warning、照用 | 可排進一般改善 |
| 人工 O2 | 低 | 診斷包 `_SECRET_NAME` 沒涵蓋 `DSN`／`URL` 內嵌密碼的 env（目前清單內只有 `DATABASE_URL`，已涵蓋） | 新增 env 時注意 |
| 人工 O3 | 低 | `device_uuid` 兩來源都讀不到時退化成固定值（只 warning，不拒啟） | 靠雲端撞號偵測守門，可接受 |

> 術語：**root**＝主機最高權限帳號；**映像（image）**＝docker 容器的整包檔案系統；**驗章**＝用原廠公鑰確認檔案沒被改過；**uid 1000**＝容器內 Agent 跑的一般帳號。

## F1：升級時主機以 root 執行新映像內未驗章的程式

- **白話**：升級時，主機先叫舊版 Agent 驗新版「agent 目錄」的簽章，這一步沒問題。但驗完後，主機用 `docker run --entrypoint cat <新映像>` 把新版的 `upgrade_runner.sh` 讀出來，用 root 的 bash 直接執行。`cat` 這支程式本身、映像的 PATH 設定都不在簽章範圍內，攻擊者可以塞一支假 `cat`，讓它印出任意腳本。
- **出事會怎樣**：攻擊者在客戶主機上拿到 root，整台機器（含其他服務）失守。這也違反了設計的承諾：「舊版驗過新版之前，主機不跑新版任何一行」。
- **要先有什麼才打得到**：①能把升級單與升級包寫進主機的 `agent-order/` 目錄，也就是已控制 Agent 容器，或控制主系統的更新推送；②手上有一份原廠真簽章、版號較新的 agent 樹（或主機設了 `AGENT_UPDATE_ALLOW_SAME_VERSION=1`）；③compose 安裝形態，且裝了升級單的 systemd path unit。門檻不低，所以評中度；但它正好是「容器被打穿後能不能逃到主機」的那道牆。
- **在哪裡**：`deploy/install.sh:2363`（`run_upgrade` 內的 `source <(docker run --rm --entrypoint cat "$AGENT_IMAGE_TAG" ...)`）；驗章範圍見 `guidant_agent/platform/agent_tree_verify.py:22-29`（只驗 manifest 列的檔）。`install.sh` 在範圍外，只為追蹤資料流而讀。
- **怎麼修（方向，不在本卡修）**：主機端不執行未驗章映像裡的任何東西。改從已驗過的 staging 樹讀 `install-lib/upgrade_runner.sh`；或 build 時把映像 ID／config digest 一起簽進去，主機先 `docker image inspect` 比對再用。

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

| 項目 | 結果 | 為什麼 |
|---|---|---|
| `fingerprint.py`＋`host_identity.py`：兩來源都讀不到退到什麼 | **成立（低，O3）** | `fingerprint.py:58-66`：以空字串參與雜湊，退化成固定值 `sha256("|")`，只記 warning。舊版退到 MAC 的邏輯已在 D11 刪除，不會漂移。冒充風險由雲端註冊端的撞號偵測守門（檔頭說明）。 |
| 路徑能不能被環境變數換成任意檔 | 不成立（有界） | `config/config.py:183-184` 讀 `HOST_PRODUCT_UUID_PATH`／`HOST_MACHINE_ID_PATH`。但能改 Agent 環境變數的人本來就是主機 root／能改 `.env` 的人，而且註冊還要一次性 token，換路徑不會增加新能力。 |
| 容器內 machine-id 是掛主機的還是容器自己的 | 不成立 | `deploy/docker-compose.yml:203-204` 唯讀掛宿主的 `/etc/machine-id` 與 `host-id/`（product_uuid 由 install.sh 複製成 0440），重建容器不會漂移。 |
| `support_bundle.redacted_settings` 的 `_SECRET_NAME` | 部分成立（低，O2） | `support_bundle.py:35` 抓 `SECRET|PASS|TOKEN|KEY|CREDENTIAL|DATABASE_URL`；白名單只有 `AGENT_CERT_DIR`。目前前綴清單內含密碼的 URL 只有 `DATABASE_URL`，已整欄遮成 `***`；其餘值也過 `mask_text`。未來新增 `*_DSN`／`*_URL` 且內嵌密碼的 env 時要補進正則。 |
| `_journal_tail` 有沒有過 `mask_text` | 不成立 | `support_bundle.py:171`：log 不論來自 journald 或記憶體，每一行都過 `mask_text`。 |
| `recent_diagnostics` 含內網拓撲要不要出去 | 設計取捨，非漏洞 | 診斷包本來就是給原廠支援看網路問題的，內容過 `_masked_json`；由客戶自己下載、自己決定給不給。 |
| 排除清單三項，`build` 有沒有讀到 `allowed_targets`／`secrets/` | 不成立 | `build`（`support_bundle.py:149-175`）只寫 manifest／log／env 設定／儀表板／最近診斷五段，不讀資料目錄的任何檔。 |
| `secret_store._read_key(create=True)` 建檔權限 | 不成立 | `secret_store.py:98`：`os.open(..., O_CREAT|O_EXCL, 0o600)`，建立時就是 0600，沒有「先 open 再 chmod」的空窗；目錄 0700。 |
| `:107` 權限不對只 warning | **成立（低，O1）** | `secret_store.py:106-107`：權限不是 600 時只記 log，照讀照用。建議改成拒用或自動收緊。 |
| 金鑰與密文同目錄 | 設計取捨 | 防的是：「只拿到密文檔」、備份外流到沒有金鑰的地方、其他非 root 帳號（0600／0700）。不防的是：整個資料目錄被複製，或主機 root 被拿下。這點與 `.claude/rules/security/secrets.md` 的決定一致（不從 mTLS 私鑰派生）。 |
| `ops.request_restart` 的 `note` 長度與遮罩 | 不成立 | `ops.py:157` 截到 200 字；`ops.py:164` 的 log 不帶 note；log 全走 `audit_pipeline` 的 `SecretMaskingFilter`。 |
| `local_ops` loopback 端點各自能做什麼 | 不成立 | 只有兩支：`revoke-admin-sessions`（登出本機管理員）與 `cancel-running`（取消工作），都是「收緊」動作，沒有改設定、讀祕密或觸發更新的端點。而且除了 loopback，還要帶 root 才讀得到的 0600 權杖（`local_ops.py:58-61`），所以容器內同 pod 的行程不夠打到。 |
| `local_state_cli` 子命令有沒有印祕密 | 不成立 | 六個子命令（`local_state_cli.py:134-141`）都沒印密碼；`set-admin-password` 只從 stdin 讀。守門＝能執行這支 CLI 且能寫 DB＝root（資料目錄權限），符合設計。 |
| `audit()` kwargs 有沒有帶密碼／token | 不成立 | 範圍內的呼叫只帶 `sub`／`first_time`／`method`／`reason`／各表刪除筆數。 |
| `audit_import` 會不會覆蓋既有紀錄 | 不成立 | `audit_import.py:154-175`：只 append 新列，先查 `AGENT_LEGACY_AUDIT_IMPORTED` 防重複匯入，舊檔改名不刪。 |
| `agent_tree_verify` 路徑參數有無限制、版號從哪來 | 部分成立（併入 F1） | 能驗任意目錄是無害的（只讀、只回結果）；版號來自已驗章的 manifest（`agent_tree_verify.py:33`），可信。問題在「驗完之後主機跑了什麼」，即 F1。 |

## 可信度兩層

- **「報出來的存在嗎」**：F1 經三位驗證員（可達性、影響、防禦三個角度）3/3 確認，且人工開 `deploy/install.sh:2363` 核對過程式碼確實如此。
- **「只有這些嗎」**：low effort 只派 1 位研究員，沒有威脅建模與廣度掃描；人工逐條補查了「重點看什麼」。不能宣稱範圍內沒有其他問題，只能說這些重點都查過了。

## 執行概況（照 stamp 抄）

- Run ID：`wf_68fd2842-89a`；歸檔：`docs/security-scan/FR-177/runs/CLAUDE-SECURITY-20261105-174556/`
- Revision `63334f4`（工作樹乾淨），effort low，範圍 11 檔，耗時約 49 分鐘
- `verification.status`: **verified**；`reason_kind`: null；`candidates`: 1；`panel_votes`: 3；`researchers_dispatched`: 1；`researchers_returned`: 1
- 驗證面板完整跑完。整個過程只讀程式碼，沒有執行任何程式或實際攻擊。
