# FR-177 B8 掃描報告：主機端升級腳本（bash）

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

## 🔴 一句話結論

**沒有急件，工具與人工都沒找到成立的漏洞。** 工具的研究員讀完三支腳本，沒有提出任何候選問題，所以三人檢查面板沒有東西可投（0 票）——這代表「沒東西可投」，**不等於保證乾淨**。人工照「重點看什麼」逐條追了一遍：首腦最擔心的「新映像的 bash 在驗章前就以 root 跑了」，在**自動升級（升級單）**那條路**不成立**——驗章在前，source 新映像的腳本在後。另外記下 3 條觀察（不是漏洞，是加固建議或範圍外的提醒），見下方。

## 這一棒在檢查什麼

Agent 線上更新的**主機那一半**：容器裡的 Agent 把更新包和「升級單」（一個 JSON 檔，寫要升到哪一版、包名、指紋）放進共用目錄後，主機上以 root 跑的三支 bash 腳本接手：

- `scripts/install/lib/upgrade_order.sh`（417 行）：讀升級單、驗格式、驗包的指紋（sha256）、解包、載入映像，再用**現行版**的執行檔驗新版程式樹的簽章（`verify-tree`）。
- `scripts/install/lib/upgrade_runner.sh`（557 行）：升級本體，分步驟執行（預檢 → 處理執行中工作 → 備份並換設定 → 切換 → 健康檢查 → 清理），任何一步失敗就自動退回舊版。
- `scripts/install/lib/upgrade_guard.sh`（73 行）：升級前查有沒有工作正在跑。

共 3 檔、1047 行。為了查證呼叫順序，另外翻了範圍外的 `deploy/install.sh`、`scripts/install/install.sh`、`scripts/install/lib/progress.sh`、`deploy/docker-compose.yml`，**只為查證，不當發現**。

## 掃到什麼：總覽表

| # | 這是什麼 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼處理 | 嚴重度 |
|---|---|---|---|---|---|---|
| — | 工具沒有報任何問題 | — | — | — | — | — |
| 觀察 1 | **手動**以 compose 升級（`sudo guidant-agent-ctl upgrade <包>`）時，只比對包裡自帶清單的 sha256，就從新映像裡 cat 出 `upgrade_runner.sh` 以 root source 執行，前面沒有 `verify-tree` 驗章 | 拿到偽造交付包的維運人員一執行，偽包的 bash 就以 root 跑 | 維運人員自己拿到並親手執行一個被換掉的交付包 | `deploy/install.sh:2361-2363`（範圍外）、`upgrade_runner.sh:47-55` | 開卡評估：手動路徑也先用現行版 `verify-tree` 驗新映像，再 source 新映像的腳本 | 範圍外觀察，未經面板 |
| 觀察 2 | `_upr_source` 起容器 cat 檔時沒帶 `--network none`（`upgrade_order.sh:49` 的 `_uo_cat` 有帶） | 只是加固上不一致；cat 本身不連網，實際影響很小 | — | `upgrade_runner.sh:50` | 補上 `--network none` 與 `_uo_cat` 一致 | 加固建議 |
| 觀察 3 | 「有工作在跑就不升級」的判斷，依據是容器自己寫的檔；容器被控時可以讓自動升級一直被擋 | 自動更新永遠「略過」（拖住更新，不能拿 root） | 容器已經被攻陷 | `upgrade_guard.sh:13-24`、`upgrade_order.sh:282-285` | 設計上可接受；可補「連續略過 N 次就回報主系統」 | 接受的風險 |

## 每條詳述

工具沒有報發現，這裡沒有 F 條目。三條觀察都寫在上面的總覽表；觀察 1 的時序細節見下一段第 1 列。

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

| 重點 | 人工結果 | 為什麼 |
|---|---|---|
| 🔴 `upgrade_runner.sh:50` 的 `_upr_source`（從新映像 cat 出腳本、直接 source）是在驗章前還是驗章後執行 | **自動升級路徑：不成立（驗章在前）。手動路徑：見觀察 1** | 時序：① `upgrade_order.sh:57-61` `_uo_load`：`AGENT_IMAGE_TAG` 取 `.env` 的**現行**映像，source 的是現行版的 runner。② `:282` `_uo_precheck` 呼叫 `_upr_source local_admin.sh／upgrade_guard.sh`，這時 `AGENT_IMAGE_TAG` 仍是現行映像。③ `:337-361` 解包、`:393-416` 用現行映像（`$old_id`）跑 `verify-tree` 驗新版程式樹，再 `cmp` 確認包內的 `install.sh`／`docker-compose.yml` 與簽章樹裡的那份**完全相同**。④ 這些都過了才在 `:196` 執行包內的 `install.sh upgrade`；它的 `load_image` 把 `AGENT_IMAGE_TAG` 改成新映像，然後才 source 新映像的 runner（`deploy/install.sh:2363`），之後 `:195-196`、`:302`、`:434`、`:472` 的 `_upr_source` 都讀新映像。`$1` 一律是程式裡寫死的檔名（`local_admin.sh`、`upgrade_guard.sh`、`progress.sh`、`upgrade_order.sh`），沒有外部輸入。所以 A3「新版程式在驗章前一行都沒執行」的結論，在升級單這條路上成立 |
| `_uo_precheck` 的 target／bundle／sha 格式驗證之後，進了哪些指令、JSON 怎麼解析 | 不成立 | 用 `sed` 解析（`:144`），值不能含 `"`，也不會跨行。`target` 要符合 `^[0-9][0-9A-Za-z.+-]{0,63}$`，`bundle` 必須正好等於 `guidant-agent-compose-${target}.tar.gz`，`sha` 必須是 64 個十六進位字元，`job_id` 也有嚴格的 regex（`:30`、`:175`）。之後的展開都有加引號，沒有 `eval`，拼進 docker 參數的也只有過了 regex 的值 |
| `$old_id` 怎麼取得；`cmp` 之後實際執行的是哪一份 | 不成立 | `$old_id` 來自主機 `.env` 的 `AGENT_IMAGE`（`:394`）。`.env` 在主機上、檔案 0600，容器只能唯讀掛載升級目錄（`deploy/docker-compose.yml:238-239`，可寫的只有 `agent-order`），所以要改 `.env` 得先有主機 root。實際執行的是**包內**那份 `install.sh`，但它已和簽章樹裡的那份 `cmp` 過、完全相同 |
| `rm -rf` 的變數會不會為空；`.env` 寫回的權限 | 不成立（低度加固空間） | `UPR_BACKUP_DIR` 固定是 `${UPGRADE_DIR}/prev`；`VERSION_DIR` 是 `${VERSIONS_DIR}/${VERIFIED_VERSION}`，由宿主設好；`rm -rf` 一律加引號，而且沒有 `"$X/"` 這種寫法。`.env` 寫回在 `( umask 077; sed … > .env.new )` 裡做（`:240-242`），從一開始就是 0600，**沒有 A1 M1 那種先以預設權限落地的問題**。可加的保險：`rm -rf "${VERSION_DIR:?}"` |
| `UPG_LIMIT_*`、`UPG_TRIGGER_*`、`SUDO_USER`、`--rollback` | 不成立 | 這些變數只能由啟動 root 腳本的那一方設定（systemd unit 或 sudo 的人），容器設不到。`UPG_TRIGGER_*` 只寫進進度檔，而且會經過 `progress__str` 遮罩、跳脫。退回（`:343-365`）只用主機上的備份與進度檔，不載入新程式碼，所以不需要再驗章 |
| `upgrade_guard.sh:16` 的 `cat /data/db/$1` | 不成立（DoS 見觀察 3） | `$1` 固定是 `running-tasks.tsv`（`:8`），不是外部輸入，不可能是 `../`。讀到的內容只被計數和用 printf 顯示，沒有拿去執行 |
| 有沒有 `set -euo pipefail`；錯誤時進度檔寫什麼、誰能寫 | 部分（設計上如此） | 三支都是 lib，本身沒有 set；宿主腳本有：`deploy/install.sh:108` 用 `set -Eeuo pipefail`，`scripts/install/install.sh:99` 用 `set -uo pipefail`（**沒有 `-e`**，靠逐行 `|| fail`）。runner 刻意不靠 `set -e`（`:82` 的註解有說明）。進度檔在主機的升級目錄，容器只能唯讀，所以只有主機 root 寫得了 |

## 可信度兩層

- **「報出來的存在嗎」**：沒有報出任何東西，這一層不適用。三條觀察是人工判讀，沒經過面板。
- **「只有這些嗎」**：不能保證。這次是 low effort，一位研究員讀一遍，沒有候選就不會進投票；人工只照「重點看什麼」逐條查證，不是逐行完整審查。

## 執行概況（照 stamp 抄）

- run ID：`wf_a162e8d3-b87`；revision `63334f464215`（工作樹乾淨）；effort low；scope 3 檔
- `verification.status`：**verified**；`reason_kind`：null
- `candidates`：0（**沒東西可投，不等於乾淨**）；`panel_votes`：0
- `researchers_dispatched`：1；`researchers_returned`：1
- 歸檔：`docs/security-scan/FR-177/runs/CLAUDE-SECURITY-20261105-174554-B8/`
