# FR-177.A3：Agent「線上更新鏈」資安掃描報告

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

## 1. 🔴 一句話結論

**工具沒找到問題，我人工查了七個重點也沒找到會被打穿的洞。** 更新包要裝上去，得先過三道鎖，而且都是主機上信得過的那一側在鎖：比對主系統給的指紋、用「現在已裝那一版」的程式驗新版的簽章、禁止裝回舊版。另外記下兩個小觀察，都不急，見第 4 節。

## 2. 這一棒在檢查什麼

客戶機上的 Agent 收到主系統「更新到某一版」的命令後，會先下載更新包、確認包沒被調換，再交給主機上的安裝腳本安裝，最後回報結果。主控台上也有「請主系統更新」和「離線上傳更新包」兩個按鈕。這條鏈如果被騙過，等於能在客戶的每一台 Agent 上執行任意程式，所以是價值最高的攻擊面。範圍是五支檔，約 1,235 行：

- `guidant_agent/sync/app/update_sync.py`：下載、驗證、寫「升級單」
- `guidant_agent/sync/app/update_events.py`：回報進度與結果
- `guidant_agent/sync/app/update_log_upload.py`：上傳主機的升級紀錄
- `guidant_agent/console/app/update_console.py`：離線上傳包的檢查
- `guidant_agent/console/api/update_routes.py`：主控台更新頁的網址入口

## 3. 掃到什麼：總覽表

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 急不急 |
|---|---|---|---|---|---|
| — | 工具：**沒有發現** | — | — | — | — |
| 觀察 1 | 主系統直推的更新命令裡，指紋沒有另外附簽章 | 單靠這點裝不上惡意包：主機還會再驗一次新版簽章 | 要先拿到主系統的通行證，**還要**偽造原廠簽章 | `update_sync.py:118-127` | 不急，是設計上的取捨 |
| 觀察 2 | 結果回報只看主機寫的進度檔 | 能改主機檔的人可以讓主系統以為「升級成功」 | 已經拿到主機的 root（最高權限）或容器內的寫入權 | `update_sync.py:151-180` | 不急：到這個程度，對方本來就能做更多事 |

（觀察不算正式發現，只是記錄。需要時另開卡處理。）

## 4. 每條「重點看什麼」的人工查證

**① 主系統送來的指紋（sha256，就是檔案的「數位指紋」）有沒有簽章？**
人工查過，**不成立（沒有打得穿的洞），但有一點值得記下（觀察 1）**。`update_sync.py:118-127` 的 `_parse_update` 只檢查格式，指紋本身沒有另外簽章。資料面的入口要過 JWT（主系統簽發的短效通行證），驗簽、綁這台機器、而且一張只能用一次。真正擋住惡意包的是主機端：`scripts/install/lib/upgrade_order.sh:406-411` 會用**現在已經裝好的那一版**的程式（`verify-tree`）去驗新映像的原廠簽章，再比對簽章裡的版號（`:412`）。所以就算有人拿到通行證、配上自己的包和指紋，也會在驗章那一步被擋下。
怎麼修（選做）：如果要多一層保險，可以讓主系統把 `bundle_sha256` 包進 Ed25519 信封（原廠簽名的格式）再送，Agent 端用離線上傳已經在用的 `verify_payload` 驗。這是加深防線，不是補洞。

**② 下載完才驗，還是先改成正式檔名再驗？**
人工查過，**不成立**。`update_sync.py:588-596` 下載時寫進 `.part` 暫存檔，驗完大小和指紋後才 `os.replace` 成正式檔名，驗失敗就刪掉。中斷後續傳的話，續傳接起來之後還是會**對整個檔重算一次**指紋，不會只驗後半段。主機 `upgrade_order.sh:344` 移走包之後又驗一次指紋。

**③ 能不能降級（把有洞的舊版裝回去）？**
人工查過，**不成立**。Agent 端擋同版（`update_sync.py:375`），但沒擋更舊的版本。不過主機端的 `upgrade_order.sh:270-275` 一律擋降版：目標不比現行版新就拒絕。只有在主機 `.env` 開了 `AGENT_UPDATE_ALLOW_SAME_VERSION=1` 時，才放行「同版」重裝。離線上傳另外有 `is_newer` 擋（`update_console.py:205`）。

**④ 更新包從哪裡下載？**
人工查過，**不成立**。網址寫死在程式裡（`update_sync.py:46` `BUNDLE_PATH`），命令裡沒有網址欄位。連線走 `cloud_channel`，底層的 `cloud_http` 一律用 `cloud_tls_verify`（連線時會檢查對方真的是主系統），而主系統的位址只收 https。

**⑤ 主機與容器的交界：容器內能寫的升級單，主機用 root（最高權限）讀了會怎樣？把 `bundle` 欄位改成指到別的檔呢？**
人工查過，**不成立**。主機 `upgrade_order.sh:175、267` 會檢查 `job_id` 的格式，並要求 `bundle` 必須**完全等於** `guidant-agent-compose-<版號>.tar.gz`，指向別的路徑會被拒絕。包移走時也確認它不是捷徑檔（`! -L`）。主機不會直接執行包裡的程式，會先驗章（見①），還會比對包裡的 `install.sh` 和 `docker-compose.yml` 跟簽章內的版本完全相同（`:413-415`）。

**⑥ 主控台觸發更新要什麼權限？能不能指定版本和指紋？**
人工查過，**不成立**。四支 route 都掛 `@require_session("update")`（`update_routes.py:55、62、70、115`）。「請主系統更新」只帶操作者名字，**不能指定版本或指紋**（`update_sync.py:672`），由主系統決定。離線上傳則必須通過原廠簽章驗證（`update_console.py:174-187`，`verify_payload`），包的名稱也用簽章裡的版號組出來，不信包內的檔名；解壓走共用的安全解壓 `archive_safety`。上傳的 log 是主機打包的升級紀錄，Agent 端只做大小（10MB）、格式和「不是捷徑檔」的檢查（`update_log_upload.py:30-51`），內容由主機腳本決定，不在這次範圍內。

**⑦ 回報結果能不能被偽造成「已更新成功」？**
人工查過，**有條件成立，但不算洞（觀察 2）**。`update_sync.py:151-180` 的 `_order_outcome` 看到進度檔的 `job_id` 對得上、`status` 是 `succeeded`，就回報成功。進度檔在容器內是唯讀的，由主機的 root 寫入。要偽造，得先有主機的 root，或者已經控制容器內這支程式。到那一步，攻擊者早就能做更大的事。另外還有一條路：如果「現在跑的版本等於目標版本」，也會直接算成功（`:174-176`），這個版本號來自正在執行的程式本身。

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

- **「這些存在嗎」**：工具沒有提出任何發現，所以沒有東西需要確認。上面兩個觀察是我開檔確認過的，行號都已核對。
- **「只有這些嗎」**：**不能保證**。這次用的是 low 檔，只有一位研究員掃整個範圍，研究員沒提出候選，所以三個獨立的檢查員**沒有東西可投票**。stamp 上寫「verified」，意思是「沒有東西可驗」，不是「有人確認過很乾淨」。另外，主機端腳本 `upgrade_order.sh` 不在掃描範圍內，第 4 節只是為了查證才去讀，沒有做完整審查。建議之後另開一棒，專門掃主機端的升級腳本。

## 6. 執行概況

| 項目 | 值 |
|---|---|
| workflow run ID | `wf_702592dc-a0a` |
| 掃描版本 | `bab2aa31f00d3bf9b2e34c2a6dab88d1885b227f`（工作區乾淨） |
| effort／focus | low／attack-surface（只看攻擊者碰得到的正式程式） |
| verification.status | `verified` |
| reason_kind | `null` |
| candidates（候選數） | 0 |
| panel_votes（三位檢查員投票數） | 0（沒有候選可投） |
| researchers_dispatched／returned | 2／2（一位全範圍，一位專找寫死的密碼） |
| 耗時 | 約 10 分鐘 |
| 歸檔 | `runs/CLAUDE-SECURITY-20261105-152759/` |
