# FR-187.D2 資安掃描：主系統「推送 Agent 升級與升級紀錄」

## 一句話結論

掃到 **3 個低風險問題**，沒有高風險。三條都要先**控制一台已經註冊的 Agent**（或是持有更新權限的子公司管理員）才打得到。最值得修的是第 1 條：一個客戶的 Agent 可以讓**另一個客戶**的 Agent 升級卡住或被暫停。

## 這一棒在檢查什麼

主系統下載好 Agent 新版之後，怎麼推到各台 Agent：

- 更新中心勾選幾台推送
- Agent 主控台自己按「請更新這一台」
- Agent 收到升級指令後下載安裝包
- Agent 上傳升級紀錄，以及誰能下載這份紀錄

推送等於「讓遠端機器以高權限換掉自己的程式」，所以重點看誰能推、推給誰、推哪一版。

範圍是 7 支檔案、共 1181 行，見文末執行概況。

## 總覽

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 | 嚴重度 |
|---|---|---|---|---|---|---|
| 1 | 甲客戶的 Agent 自己要求更新時，會被排進乙客戶那次推送的同一批 | 乙客戶其餘 Agent 的升級永遠卡住，或被自動暫停。資安修正版可能因此推不出去 | 控制甲客戶一台 Agent；乙客戶同一版的推送還有後面的批次在等 | `app/product_update/service/agent_rollout_service.py:483` | 批次編號改在「看得到全部客戶」的身分下計算，或分批時把客戶別一起當分組條件 | 低 |
| 2 | Agent 上傳升級紀錄時，沒有限制能傳幾次 | 每換一個工單編號就多存一份 10 MB 的檔案，最後把主機硬碟塞滿、服務停擺；存進去的紀錄刪不掉 | 控制一台 Agent | `app/product_update/service/agent_update_event_handlers.py:243`（由 `api/remote_agent/routes/agent_update_log_route.py:26` 呼叫） | 只收「這台真的被派過的工單」的紀錄，並加每台次數上限與總容量上限 | 低 |
| 3 | Agent 主控台按「立即檢查」時，會拿到全機共用的上次檢查結果 | 任何一台 Agent 都看得到「哪個管理員帳號剛按過檢查」，以及所有客戶裡最舊的 Agent 版本 | 持有任一台 Agent 的通行證 | `core/plugins/_agent_console_update.py:57` | 回給 Agent 的內容另做一份精簡版，拿掉 `by`、`agent_base`／`current`、`host_version` | 低 |

為什麼三條都是「低」：每一條都要先控制一台 Agent，而且影響只到「延誤升級、塞硬碟、洩漏一個帳號名稱」，不能拿到資料、也不能執行程式。

## 每條詳述

### 1. 甲客戶的 Agent 會干擾乙客戶的分批升級

**白話說明。** 推送採「先推 1 台，成功了才推其餘」，靠批次編號決定誰等誰。Agent 主控台按「請更新這一台」時，程式用這台 Agent 所屬客戶的身分去查「這一版目前排到第幾批」。因為有 RLS（就是「每個客戶只能看自己資料」的資料庫隔離機制），它只看得到自己客戶的列，所以以為自己是第 2 批。可是每分鐘的排程用系統身分推進批次，看到的是全部客戶的列，按批次編號分組。結果甲客戶這台跟乙客戶的「先推的那 1 台」被算成同一批。甲客戶這台只要回報失敗，排程就把乙客戶第 3 批全部暫停；如果它一直不回報，乙客戶第 3 批就永遠等下去。

**在哪裡。**
- `agent_rollout_service.py:483`（`_rebatch` 算 `base`）
- `agent_rollout_service.py:448-466`（`_advance_group` 依 batch 分組）
- `core/plugins/_agent_console_update.py:90`（`tenant_context(agent.tenant_id)`）

**怎麼修。** 二選一：
- `_rebatch` 的 `base` 改在系統身分下計算
- `_advance_group` 的分組條件加上 `tenant_id`，或每次推送一個獨立 id

**首腦核對。** 我開檔追過：`request_update` 確實在 `tenant_context` 內呼叫 `push`／`reapply`，`tick` → `_advance` 是系統身分。成立。

### 2. 升級紀錄上傳沒有數量上限

**白話說明。** Agent 每次升級完會上傳一份壓縮的紀錄檔（每份最多 10 MB）。工單編號是 Agent 自己帶的，主系統只檢查格式，不檢查「這張工單是不是真的派給過這台」。被控制的 Agent 每次換一個新編號，主系統就多存一份檔案、多一筆紀錄，而且紀錄表禁止刪除。

**在哪裡。**
- `agent_update_event_handlers.py:235-247`（`_save_log`：同一張工單只擋重傳，不擋新編號）
- 呼叫端：`api/remote_agent/routes/agent_update_log_route.py:26`

這支檔案不在本棒範圍內，因為範圍內的上傳端點會走到它才列入。

**怎麼修。**
- `_save_log` 先確認該 job_id 已經有這台 Agent 的升級事件列，沒有就回 404，不要新建
- 加每台每小時的上傳次數上限，以及每個客戶的附件總容量上限

**首腦核對。** 我開檔看過 `_save_log`：`self._row(...)` 遇到沒看過的編號會直接建新列。成立。

### 3. 「立即檢查」回給 Agent 的內容太多

**白話說明。** Agent 主控台按「立即檢查」時，如果 5 分鐘內有人查過，就直接回上次的結果。這份結果是主系統更新中心用的，只拿掉了簽章原文，下列欄位都還在：
- `by`：上次按檢查的管理員帳號
- `host_version`：主系統版本
- `agent_base`：所有客戶裡最舊的 Agent 版本

在更新中心，要有更新中心的讀取權限才看得到這份資料；在 Agent 這條路上，任何一台 Agent 都看得到。

**在哪裡。**
- `core/plugins/_agent_console_update.py:57`
- `app/product_update/service/agent_release_service.py:484-494`（`public_check` 只拿掉 `signed_manifest`）

**怎麼修。** 在 `check_for_agent` 回一份給 Agent 專用的欄位：status、latest、compatible、notes、at。

**首腦核對。** 我開過 `public_check`，確認只拿掉 `signed_manifest`。成立。

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

- **推送對象跨子公司會不會被擋**（`agent_rollout_service.py:_push`）：**不成立**。`get_remote_agent_by_uid` 在登入者身分（受 RLS）下查，別的客戶的 uid 查不到，直接列入 skipped。推送端點 `ProductUpdateAgentPushRoute` 有掛 `@jwt_required()` 加 `@require_capability(CAP_UPDATE)`（`api/product_update/routes/product_update_route.py:258-259`），也就是有檢查這個人有沒有更新權限。另外有發現：雖然推不到別人的 Agent，但批次編號會互相干擾，見第 1 條。
- **Agent 主控台能不能要求別台更新或降版**（`_agent_console_update.py`）：**不成立**。哪一台只認通行證（`current_agent().agent_uid`），不收 body 帶的編號。版本由主系統手上的包決定，Agent 不能指定。`is_lagging` 只推比目標舊的台，不會降版。同版重套要主系統開關打開，而且更新站上沒有更新的版。
- **升級指令內容，以及下載端點會不會給待核准或停用的 Agent**（`_agent_update.py`）：**不成立**。心跳交給 Agent 的只有 `target_version`、`bundle_sha256`、`size`，沒有下載網址。下載端點有掛 `agent_pass_required`，路由檔註明撤銷、停用、待核准一律擋（`api/remote_agent/routes/agent_update_bundle_route.py:3,17`）。`_tenant_of` 也會排除 revoked。之後的 `_dispatchable_bundle` 要求這台要有「已下發」的推送列，下發前只放 active 的台（`_take_woken`、`_push` 都檢查 `status == active`）。
- **Agent 身分的端點是不是每支都掛 `agent_pass_required`**：**成立**。`agent_update_request_route.py:25,35,45` 三支、`agent_update_log_route.py:17` 一支，全都有掛。
- **升級紀錄附件下載時是不是強制下載**（`_agent_update_log_ownership.py`）：**成立**，會強制下載。檔名由系統產生，固定是 `agent-update-<編號>.tar.gz`；`.tar.gz` 不在可直接開啟的副檔名清單（pdf／html／svg／圖片）內。jedi-file-upload 的 `decide_preview` 對清單外的副檔名一律設成附件下載加 octet-stream，所以瀏覽器不會直接開啟。讀取要有 `system.product-update.read` 權限，寫入與刪除一律拒絕。
- **回畫面的欄位有沒有帶 Agent 自報的錯誤字串、有沒有截長度**（`agent_rollout_views.py`）：**部分成立**。`error` 欄位確實是 Agent 自報的原文，但存進去之前已經在 `agent_update_event_handlers.py:116` 用 `_text(..., _ERROR_MAX=500)` 截到 500 字。這份資料用 JSON 回給前端，顯示時是否會被當成網頁內容執行，要看前端怎麼渲染，本棒沒有掃前端。

## 可信度

- **這三條存在嗎？** 存在。三個獨立的檢查員各從「打不打得到」「影響多大」「有沒有其他防線」三個角度投票，三條都是 3 比 0 成立。首腦也逐條開檔核對過。
- **只有這三條嗎？** 不能保證。這次用最省的模式：只派 1 位研究員讀完整範圍，另派 1 位專找寫死在程式裡的密碼。這種模式適合快速篩一輪，不是逐行窮舉。另外，前端怎麼顯示 Agent 自報的錯誤字串，不在本棒範圍內。

全部結論都是讀程式碼得出的，沒有執行程式，也沒有實際攻擊驗證。

## 執行概況

| 項目 | 值 |
|---|---|
| 工具 | Claude Code `claude-security` plugin 0.12.0，effort low，著重攻擊面 |
| run ID | `wf_2d63cdaa-128` |
| 掃描的版本 | `eee99445e`，working tree 乾淨 |
| 範圍 | 7 檔／1181 行 |
| 研究員 | 派 2 位、回 2 位 |
| 候選發現 | 3 條 |
| 投票 | 三個檢查員對 3 條各投一票，9 票全數投出，沒有漏投 |
| stamp `verification.status` | `verified`（`reason_kind` 為空） |
| 耗時 | 約 45 分鐘 |
| 原始產物 | `docs/features/security-scan-consolidated/runs/be/CLAUDE-SECURITY-20261108-180500/` |
