# FR-187 D3 掃描報告：更新中心主系統端（頁面 API、離線上傳、診斷包）

> 掃描當下版本：commit `1c9be8d84`（branch `ws-CM-3711-CM-3720`）。行號以這個版本為準。

## 🔴 一句話結論

**沒有急件，但有一條要排進修正。** 這次找到 2 個問題，兩條都是同一個缺口：落地版裡，更新中心沒有檢查操作的人是不是「總部」。

- **F1（中）**：子公司管理員可以下載**整台機器**的診斷包，裡面是所有公司最近 24 小時的操作紀錄。如果只看「升級紀錄」，只有讀取權限的人也拿得到。
- **F2（低）**：子公司管理員可以替整台機器立即升級或排程升級，也可以關掉自動檢查更新。

兩條都經過三個獨立檢查員投票，結果都是 3 比 0 成立。我也自己開檔核對過。

## 這一棒在檢查什麼

更新中心頁面背後的 API：查目前版本與更新紀錄、檢查新版、下載新版、離線分塊上傳更新包、立即或排程更新、取消與重試、產生並下載診斷包、下載升級紀錄，以及這次新加的 Agent 區（查 Agent 新版、下載、推送給各台 Agent）。

這些動作影響的是**整台機器**，不是某一家公司。落地版也可以開子公司（子租戶），所以重點是「子公司的管理員是不是碰得到整台機器的東西」。

範圍：4 個檔、約 1135 行。為了查證，我翻了範圍外的 `core/host_capabilities.py:214-215`、`upgrade_order.py:424-463`、`product_update_flow.py` 的上傳段、`common/util/chunked_upload.py`、`resources/roles/shipped-roles.json`。這些檔只是拿來查證，本身不算發現。

## 掃到什麼：總覽表

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 | 嚴重度 |
|---|---|---|---|---|---|---|
| F1 | 診斷包和升級紀錄包裝的是整台機器的資料，但只要有更新中心的權限就能下載，沒有檢查是不是總部 | 子公司管理員拿得到總部和其他子公司最近 24 小時的操作紀錄：帳號、來源 IP、網址、訊息、遮罩後的請求內容 | ① 落地版 ② 機器上開了子公司 ③ 攻擊者是子公司管理員（出廠角色預設就有這兩個權限）；或者只有「更新中心讀取」權限，就能拿到升級紀錄 | `app/product_update/service/product_update_app_service.py:228-235`（`diag_file`、`upgrade_log_file`）；判斷在 `:104-107` | 這兩支在落地版改呼叫 `require_tenant_hq()`（「只有總部可以」的共用檢查）；`upgrade_log_file` 不能只靠讀取權限就放行 | **中** |
| F2 | 更新中心的所有寫入動作，在落地版都沒有檢查是不是總部 | 子公司管理員可以讓整台機器馬上升級，造成所有公司一起停機；也可以排程升級、關掉每日自動檢查 | ① 落地版 ② 攻擊者是子公司管理員 | `app/product_update/service/product_update_app_service.py:104-107`（`_assert_may_write`） | `_assert_may_write` 在落地版也呼叫 `require_tenant_hq()` | 低 |

**為什麼 F1 是中、不是高**：要先滿足三個條件：客戶在落地版上開了子公司、攻擊者本身就是那家子公司的管理員、拿到的資料已經遮過密碼。外部的人打不到，拿到的也是紀錄，不是帳密。

**為什麼 F2 是低**：升級只能升到原廠簽過章的新版，換不成惡意程式，最壞就是挑時間讓整台停機。

## 每條發現的詳述

### F1：子公司管理員下載得到整台機器的診斷包與升級紀錄

**白話**：診斷包是主機上的 `guidantai diag` 產生的。它在「系統身分」下執行，會跳過「每家公司只能看自己資料」的資料庫隔離機制（RLS），所以包裡是**全部公司**的 API 紀錄、系統紀錄、Agent 紀錄，再加上 docker 紀錄和遮過的設定檔。「支援頁」也能匯出同一種診斷資料，但那條路只給呼叫者自己公司的資料；更新中心這條路沒有。

`_assert_may_write()` 只在 SaaS 時要求平台管理員，落地版直接放行。更新中心的兩個權限（`system.product-update.read`／`.update`）也沒有標「只限平台」（`is_platform`）。出廠的「系統管理員」角色是萬用字元 `*.*.*`，子公司的管理員因此會自動拿到這兩個權限。

**出事會怎樣**：子公司管理員先按「產生診斷包」（`POST /system/product-update/diag`），再下載（`GET /system/product-update/diag/<diag_id>`），就拿到總部和其他子公司的操作紀錄。只有讀取權限的人，可以從狀態 API 拿到 job_id，再打 `GET /system/product-update/upgrade-log/<job_id>` 下載升級紀錄包。

**要先有什麼才打得到**：落地版、開了子公司、子公司管理員（或者擁有更新中心讀取權限的人）。

**在哪裡**：
- `app/product_update/service/product_update_app_service.py:104-107`：`_assert_may_write` 只在非落地版擋
- `app/product_update/service/product_update_app_service.py:228-230`：`diag_file`
- `app/product_update/service/product_update_app_service.py:232-235`：`upgrade_log_file`（route 只掛讀取權限，見 `api/product_update/routes/product_update_route.py:205`）
- `core/host_capabilities.py:214-215`：兩個權限都沒有 `is_platform`

**怎麼修**：在 `diag_file`、`request_diag`、`upgrade_log_file` 開頭，落地版加 `require_tenant_hq()`（`common/authz/tenant_hq.py`）；或者比照支援頁匯出，改用 `require_log_viewer('platform.support.export')` 再加總部檢查。另一個做法是讓這條路產生的診斷包只含呼叫者自己公司的資料。

**小弟開檔核對**：**成立。** `_assert_may_write` 的判斷、兩個權限沒有 `is_platform`、出廠角色是 `*.*.*`，三點都開檔看過，和描述一致。

### F2：子公司管理員可以操作整台機器的升級

**白話**：更新中心的每個寫入動作（檢查新版、自動檢查開關、下載、離線上傳、立即或排程更新、取消、重試、Agent 推送）都只過 `_assert_may_write`，這一關在落地版什麼都不擋。系統裡其他「全站共用設定」，例如登入規則、LDAP、日誌轉送，都有過 `require_tenant_hq()`，只有更新中心漏掉。

**出事會怎樣**：子公司管理員送 `POST /system/product-update/apply`、`mode=now`，整台機器馬上升級，所有公司在升級期間都用不了。他也可以排程更新、關掉每日自動檢查（`PUT /auto-check`）。有一位檢查員指出，「取消總部排好的升級」這一點說得有點誇大，但「自己發起整台升級」這一點成立。

**要先有什麼才打得到**：落地版、子公司管理員。

**在哪裡**：`app/product_update/service/product_update_app_service.py:104-107`

**怎麼修**：`_assert_may_write` 改成：SaaS 照舊要求平台管理員，落地版加呼叫 `require_tenant_hq()`（平台管理員在那支函式裡本來就會放行）。F1 和 F2 可以合成一張修正卡。

**小弟開檔核對**：**成立**（同 F1 的核對）。

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

1. **子公司拿到 `system.product-update.update` 能不能升級整台**：**成立**，就是 F2。`core/host_capabilities.py:214-215` 兩個權限都沒有 `is_platform`，也沒有「只限總部」的宣告。出廠角色 `*.*.*` 在子公司只排除 `is_platform` 的權限，所以子公司管理員一定有這個權限。
2. **離線上傳**：**不成立（沒問題）。** `upload_id` 要符合 32 碼十六進位格式（`common/util/chunked_upload.py:24,55`）。`index` 在路由宣告成整數，而且會檢查 `0 <= index < total_chunks`（`:97`）。每塊最多 50MB，邊讀邊算，超過就中止（`:109`）。總大小最多 8GB，塊數也有上限（`product_update_flow.py:581-584`），開始上傳前會先檢查磁碟空間，而且同一時間有下載或更新在跑就回 409。驗章的順序也對：單一離線檔是先讀檔內的清單、驗過簽章，才把包抽出來（`product_update_flow.py:674-683`）；舊的兩檔送法是清單一開始就要驗過才收包（`:592-598`）。小缺口：上傳中的檔案不受「同一時間只能一個」的限制，同一個人可以開很多個上傳把磁碟塞滿，但能這樣做的人本來就有更新權限，所以不列為發現。
3. **`diag_file`／`upgrade_log_file`**：路徑本身沒問題。id 要符合 job_id 格式，會跳過符號連結，也會檢查是不是一般檔案（`upgrade_order.py:426-431`）。權限面有問題，就是 F1。
4. **`ProductUpdateNoticeRoute` 只驗登入**：**沒問題。** 回傳的只有 `scheduled_at`、`version` 和固定的提醒分鐘數（`product_update_app_service.py:245-246`）。
5. **`package_downloader.py` Agent 包路徑**：**沒問題。** `agent_bundle_dir` 先用 `parse_version` 檢查版號格式，不符就丟例外（`package_downloader.py:87-89`），檔名用的也是同一個版號。
6. **新加的 Agent 方法有沒有漏 `@transaction` 或權限檢查**：Agent 寫入方法都呼叫了 `_assert_may_write`（`:264-311`），下載完自動推送的 `push_after_download` 也有（`:328`）。這些方法沒有 `@transaction`，檔案裡的註解說明是刻意的：流程交給下一層各自包交易，不是漏掉。權限檢查本身的缺口和 F2 是同一件事。

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

- **「這兩條存在嗎」**：可信度高。三個檢查員分別從「打不打得到」「影響多大」「有沒有其他防線」三個角度投票，2 條各 3 票，6 票都投出、都成立。我也開檔核對過。
- **「只有這兩條嗎」**：不能保證。low 檔只派一個研究員讀完整個範圍，再加一個專門找寫死密碼的研究員。掃描每次結果不一定一樣，沒報出來不代表一定沒有。範圍外的 `product_update_flow.py`、`upgrade_order.py`、`agent_rollout_service` 只讀了查證需要的那幾段，沒有完整掃。
- 程式碼都沒有實際執行，所有結論都是讀程式碼推出來的。

## 執行概況（工程師看）

| 項目 | 值 |
|---|---|
| run ID | `wf_78b66801-823` |
| 報告目錄 | `CLAUDE-SECURITY-20261108-191322/` |
| verification.status | `verified` |
| reason_kind | null |
| candidates（候選發現） | 2 |
| panel_votes（檢查員投票數） | 6（三個檢查員 × 2 條，沒有漏投） |
| researchers_dispatched／returned | 2／2 |
| 耗時 | 約 44 分鐘 |
| effort／focus | low／attack-surface |

歸檔：`docs/features/security-scan-consolidated/runs/be/CLAUDE-SECURITY-20261108-191322/`
