# FR-187 D1 掃描報告：Agent 新版的下載與驗章

## 🔴 一句話結論

**不急，沒有會讓惡意 Agent 包混進來的洞。** 只找到 1 個低風險問題（F1）：任何一台已註冊的 Agent 按「檢查更新」時，主系統會把「上一次是誰按檢查」連同全機最舊的 Agent 版號一起回給它，所以別的客戶的管理員帳號名稱，或別家 Agent 的編號，會被看到。三個獨立檢查員投票 3 比 0 判定成立，我也自己開檔核對過。下載鏈本身（驗章、核 sha256、限定下載主機）照卡片「重點看什麼」逐條人工查過，都守得住。

## 這一棒在檢查什麼

主系統替 Agent 去原廠更新站查新版、下載 Agent 包，再推給各台 Agent。這一棒看下載這一段，有五步：

1. 問更新站有沒有新版；
2. 拿下載網址；
3. 用本機存的原廠印章，驗發布清單的簽章；
4. 核對下載檔的 sha256 指紋和大小；
5. 落檔。

主系統自己的更新包也走同一支驗章程式（`package_verifier.py`）。

範圍 4 檔，約 1096 行。為了確認 F1 是從哪條路打得到，翻了範圍外的 `core/plugins/_agent_console_update.py` 與 `config/config.py`，只為查證，不另列發現。

## 掃到什麼：總覽表

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 | 嚴重度 |
|---|---|---|---|---|---|---|
| F1 | Agent 按「檢查更新」拿回的結果，帶著「上次是誰按的」和全機最舊的 Agent 版號 | 別的客戶看得到總部管理員的登入帳號名稱（可以拿來猜密碼）、另一家客戶 Agent 的編號，以及全機最舊的 Agent 版號（可以挑舊版的洞打） | ① 手上有一台已註冊、還在用的 Agent ② 多個客戶共用一台主系統而且彼此不互信（SaaS，或總部底下的子公司） ③ 5 分鐘內剛好有別人按過檢查 | `app/product_update/service/agent_release_service.py:488`（`public_check`）；入口 `core/plugins/_agent_console_update.py:56-58` | 給 Agent 的回應另外做一份「只放這幾欄」的白名單（status、latest、compatible、reason、reason_text、at），`by`、`agent_base`、`host_version`、`error` 一律不給 | 低 |

**為什麼是低不是中：** 漏出去的只是帳號名稱和版號，沒有密碼、金鑰，也改不了任何東西。而且要有多個不互信的客戶共用一台主系統才有意義，第一版的落地版通常只有一家客戶。

## 每條發現的詳述

### F1：Agent 檢查更新時，看得到別人的帳號名稱與全機版號

**白話：** 主系統的「上一次 Agent 檢查結果」整台機器只存一份，畫面跟各台 Agent 共用。這份紀錄裡有 `by`（誰按的）、`agent_base`（全機最舊的 Agent 版號）、`host_version`、`error`。拿給 Agent 時用的是 `public_check`，它只拿掉 `signed_manifest`（簽章清單原文）這一欄，其他欄位全部照給。這種「黑名單」寫法（只列出不准給的，其餘照給）會漏東西。

**出事會怎樣：** B 客戶的 Agent 呼叫 `POST /agents/update/check`。如果 5 分鐘內有總部管理員在更新中心按過檢查，回應的 `last_check.by` 就是那位管理員的登入帳號；如果是 A 客戶的 Agent 主控台按的，看到的就是 `agent-console:<A 客戶的 Agent 編號>`。

**要先有什麼才打得到：**
- 有一張有效的 Agent 通行證，加上 DPoP 金鑰（每台 Agent 自己的簽名金鑰，用來證明「這個請求真的是這台發的」）；
- 多個客戶共用一台主系統，而且彼此不互信；
- 漏的內容取決於「上一次是誰按的」。

**在哪裡：**
- `app/product_update/service/agent_release_service.py:488`：`out = {k: v for k, v in record.items() if k != "signed_manifest"}`；
- `app/product_update/service/agent_release_service.py:197`：紀錄寫入 `by`、`host_version`、`agent_base`；
- `core/plugins/_agent_console_update.py:56-58`：`check_for_agent` 把 `public_last_check()` 或 `check()` 的結果原樣回給 Agent。

**怎麼修：** 在 `check_for_agent` 回傳前，用白名單只挑 Agent 需要的欄位（status、latest、compatible、reason、reason_text、at）。也可以把 `public_check` 本身改成白名單，`by` 只在管理員總覽另外加上。

**runner 開檔核對：** **成立。** 上面三個位置都開過，跟描述一致。

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

1. **`package_verifier.py:116-170`：印章編號、印樣目錄、清單型別、預覽版比版號**
   - 印章編號（kid）只接受 16 位小寫十六進位（`_KID_RE`，`:34`、`:118`），所以 `../` 這類路徑擋得掉；印樣檔排除捷徑檔（symlink），大小上限 4KB；只從本機印樣目錄讀。
   - 驗章之後還比對 `payload_type`（清單型別）和 `kid`（`:166-168`），拿主系統的清單頂替 Agent 清單會被拒，反過來也一樣。
   - `compare_versions`（`:98-110`）照 semver 比，同版號時預覽版排在正式版前面，所以 `1.2.0-beta` 會被當成比 `1.2.0` 舊，拿它來降版會被擋。降版這件事本來就要原廠私鑰簽過的清單才做得到。
   - 結論：**不成立**。

2. **`agent_release_service.py:127-146` `bundle()` 只比大小、不重算 sha256**
   - 推給 Agent 時帶的 sha256 是 `manifest.sha256`（`:146`），來自驗過章的清單，不是讀本機檔重算的。
   - 落地之後就算有人把檔換成一樣大小的，Agent 下載完會拿簽過的 sha256 去核，核不過就不裝（docstring `:130` 這樣寫；Agent 那一側不在範圍內，沒有開檔核對）。
   - 要換那個檔，得先能寫主系統的資料目錄，那等於已經拿下主機了。
   - 結論：**不成立**（前提是 Agent 那一側真的有核，建議 Agent 掃描卡順便確認）。

3. **`:243-260` `download`：續傳的 `.part` 檔被預先放一份**
   - `package_downloader.download` 從 `.part` 現有的大小接著下載（`:220`）。下載完 `install_verified` 會對整個檔案核大小和 sha256，核過才改名成正式檔（`:242`）。
   - 預先放進去的內容會讓最後的 sha256 對不上，結果是下載失敗，不會放行。核 sha256 在落檔之前。
   - 能預先放檔的人，本來就有主機的寫入權限。
   - 結論：**不成立**。

4. **`update_site_client.py:59-80`：限 https、不跟轉址、下載主機白名單、`_dev_loopback`**
   - `_post` 帶 `follow_redirects=False`（`:79`），所以更新站叫它轉去別的網址也不會跟。
   - 下載網址要精確等於 `DOWNLOAD_HOSTS`（`:28`、`:153`），網址裡帶帳號密碼也拒收（`:61`）。
   - **注意一點：** `ENV_LABEL` 取自環境變數 `ENV`，而**預設值就是 `DEVELOP_PREMISE`**（`config/config.py:95`），被 `_DEV_ENV_LABELS` 當成開發機。所以正式機的 `ENV` 如果沒設，就會放寬成「可以用 http 連本機（127.0.0.1／localhost／::1）」。
   - 影響有限：只放寬「本機回環位址」，外面的主機照樣要 https。下載網址照樣要在白名單裡。清單照樣要驗章，包照樣要核 sha256，偽造的包還是裝不進去。
   - 要打得到，攻擊者得在主系統主機上起一個假的更新站，還得把 `UPDATE_SITE_URL` 指過去，這兩件事都要先拿下主機。
   - 我沒找到出貨範本裡寫 `ENV=` 的地方（只有 `.env.sample` 有）；正式安裝 stack 實際設什麼，這一棒沒有查證。
   - 結論：**本身不成立**，但「預設值等於開發值」是出錯就預設放行的寫法，建議首腦判斷要不要開一張小卡，把預設改成正式值。

5. **`same_version.py`：`PRODUCT_UPDATE_ALLOW_SAME_VERSION=1`**
   - 這個開關只放寬「同版本可以重裝」（`package_verifier.py:224` 的 `allow_same`）。簽章、清單型別、主版號、sha256 照樣全驗，降版和跨主版號照樣擋。
   - 本機清單要排除捷徑檔（`same_version.py:41`）。
   - 客戶自己加了這個開關，最多就是把同一個原廠版本再裝一次。主機端 `upgrade_order.sh` 也要另外開同名開關才會真的重裝。
   - 結論：**不成立**。

6. **送給更新站的身分（授權照全文、機器指紋）有沒有進 log**
   - `update_site_client.py` 的 log 只記路徑、HTTP 狀態碼、錯誤種類，還有「沒有身分票」這句提醒（`:113`）。授權照全文、指紋、身分票都沒有寫進 log。
   - 連線失敗只記例外的類別名（`type(exc).__name__`，`:82`），不會帶出網址或請求內容。
   - 結論：**不成立**。

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

- **「這 1 條存在嗎？」** 可信度高。三個獨立檢查員分別從三個角度看（打不打得到、出事多大、有沒有別的防線），3 比 0 判定成立，我也自己開檔核對過。
- **「只有這 1 條嗎？」** 不能保證。這次是低強度掃描：一位研究員讀全範圍，另一位專查有沒有寫死的密碼。沒有跑測試、沒有實際打過，結論全部來自讀程式碼。「重點看什麼」六項我逐條人工補查過，只多找到第 4 項「ENV 預設是開發值」這個加固建議，本身不算洞。

## 執行概況

| 項目 | 值 |
|---|---|
| run ID | `wf_08cfcc94-7fa` |
| 掃描版本 | `eee99445e72a74662da483920d61ecfad81209ad`（工作目錄乾淨） |
| 強度與範圍 | low，4 檔 |
| verification.status | `verified`（reason_kind 為 null） |
| candidates（候選問題） | 1（去重後 1） |
| panel_votes（檢查員投票） | 3：三個檢查員對 1 條各投一票，全部投出 |
| researchers_dispatched / returned（派出／回來的研究員） | 2 / 2（一位讀全範圍、一位專查寫死的密碼） |
| 耗時 | 約 37 分鐘，5 個 agent，約 53 萬 token |
| 原始產物 | `docs/features/security-scan-consolidated/runs/be/CLAUDE-SECURITY-20261108-180451/` |
