# FR-177.A8 掃描報告：Agent 證據檔（blob API、瀏覽器直傳 8444、儲存設定、下載加密）

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

> 卡片：CM-3515（母卡 CM-3507）｜範圍 `guidant_agent/files`（19 檔）｜掃描日 2026-11-05｜程式版本 `3813abd`

## 1. 🔴 一句話結論

**這一塊沒有掃到需要修的資安問題。** 工具只提出一條疑點：「沒設定的話 /blob 不用登入就能下載」。三個獨立的檢查員都投票否決，理由是兩種出貨安裝方式都會把登入檢查打開。卡片「重點看什麼」列的 7 項我逐項開檔看過，有 2 個小地方值得記一筆，都**不需要現在修**（見第 4 節）。

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

證據檔是客戶最敏感的資料（掃描報告、設定檔、系統清冊）。它進出 Agent 有四條路：

- 主系統把檔存進來、拿出去（blob API，走 8443 埠）
- 使用者從瀏覽器直接上傳到 Agent（8444 埠），憑主系統簽發的一次性「上傳票」
- 檔案存放位置的設定：本機磁碟，或客戶自己的物件儲存（SeaweedFS／MinIO，一種放檔案的伺服器）
- 下載時加密打包

這一棒看的是這四條路有沒有沒鎖好的門。

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

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 | 結論 |
|---|---|---|---|---|---|---|
| 工具 C1 | 程式內建的預設值是「不檢查登入」，萬一沒設定，誰都能下載或刪除證據檔 | 拿得到 Agent 網址的人可以下載、刪除證據檔 | 安裝時沒寫 `AGENT_AUTH_MODE=full`，而且埠對外開放。兩種出貨安裝都不會這樣 | `config/config.py:166`、`guidant_agent/sync/api/data_plane_auth.py:90-91` | 不用修（可另議：把程式內建的預設值改成 `full`） | ❌ 三票否決 |
| 人工 M1 | 8444 上傳失敗時，回應裡「允許哪個網站讀這個回應」照抄請求者自己的網站 | 別的網站只讀得到錯誤代碼，讀不到檔案、也拿不到登入狀態 | 沒有實質可利用的路 | `guidant_agent/files/api/browser_plane.py:145` | 可不修；想收緊就改成只在驗票成功後回 | 備註，不算問題 |
| 人工 M2 | 擋「同一張上傳票用兩次」的紀錄只放在記憶體 | Agent 重啟後，還沒過期（5 分鐘內）的票可以再用一次 | 要先偷到一張有效票，而且剛好碰上 Agent 重啟 | `guidant_agent/sync/domain/agent_auth.py:261-276`（`browser_plane.py:134` 使用） | 可不修（程式註解已寫明這是刻意接受的風險） | 備註，已知且接受 |

## 4. 每條詳述

### 工具 C1：程式預設值是「不檢查登入」（三個檢查員否決）

- **白話**：設定檔沒寫 `AGENT_AUTH_MODE` 時，程式退回 `none`，也就是不檢查主系統簽發的登入憑證。這時 `/blob/<uid>` 的下載與刪除誰都能打。
- **為什麼否決**：原生安裝腳本 `scripts/install/install.sh:1125` 會寫入 `AGENT_AUTH_MODE=full`，而且把不加密的那個埠只綁在本機（127.0.0.1，外面連不到）。docker 交付包同樣會打開檢查。所以「預設就開著門」的情況實際出貨時不存在。三個檢查員各從「打不打得到」「後果多大」「有沒有其他防線」看，三票都判否決。
- **首腦參考**：要的話可以另開卡，把 `config/config.py:166` 的程式內建預設值改成 `full`（出錯時預設擋下來）。這是加一層保險，不是修漏洞。

### 人工 M1：8444 回錯誤時的跨網站讀取設定（備註）

- `browser_plane.py:145`：驗票成功時，回應只允許票上記的那個網站讀取。驗票前（瀏覽器的事前詢問、驗票失敗），則照抄請求者帶來的 `Origin`（請求從哪個網站發出的）。
- 影響很小：回應沒有開放帶 cookie 讀取，而且沒有票就上傳不了任何東西。別的網站最多讀到「票無效」的錯誤代碼。

### 人工 M2：防重放紀錄只在記憶體（備註）

- 「重放」就是把同一張票拿來用第二次。`TicketReplayGuard` 用一個記憶體裡的 dict 記用過的票編號。上傳票只活 5 分鐘，程式註解（`agent_auth.py:262`）明說「重啟後的窗口可接受」。Agent 是單一行程，不存在多個工作程序紀錄不同步的問題。

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

1. **`_verify_ticket`／防重放／CORS 退路**（`browser_plane.py:106-149`）：票會驗簽章、綁 `agent_uid` 與 `scan_uid`，同一張票不能用第二次（`agent_auth.py:282-303`）。瀏覽器帶的來源網站和票上的不同就拒絕（`:302`）。防重放紀錄只在記憶體，見 M2。CORS（瀏覽器的「允許別的網站讀回應」機制）的退路見 M1。**不成立**（只留備註）。
2. **檔名與落地路徑、大小上限、內容類型**（`browser_plane.py:66-103`）：檔名先取最後一段、再用 `secure_filename` 消毒，`..` 和斜線都會被拿掉。`scan_uid` 先用正規式驗過才拼進路徑（`:168`）。檔案先寫進暫存檔 `.upload-<jti>.part`，超過上限就拋錯，`except` 會把暫存檔刪掉（`:100-101`）。上傳的檔沒有限制格式，但它是源碼包，只給掃描工具讀，**不會被主控台當網頁打開**，所以沒有 XSS（存進去的內容被當程式執行）的風險。**不成立**。
3. **`blob_route.py` 三處 `send_file`**：`uid` 是拿去資料庫查，不是直接拼成路徑。本機儲存讀檔前會確認真實路徑在 `BASE_DIR` 底下（`_read_bytes` `:28-40`）。PDF 預覽用 LibreOffice 轉檔，只轉白名單裡的 office 副檔名（`:25`）。這幾支都在 8443，必須帶主系統簽的登入憑證（見 C1）。主控台的預覽另有 `inline_policy`（讀檔頭判斷格式），不在本棒範圍。**不成立**。
4. **儲存設定**（`storage_settings.py`）：主控台只能選「系統內建 SeaweedFS」（`FIELDS` `:36-39`；`verify` `:118` 其他型別一律拒）。所以主控台**不能填任意網址**，沒有「騙 Agent 去連攻擊者指定的網址」（SSRF）的空間。自訂端點只能用本機指令 `configure-storage`。金鑰存在加密的 `SecretFieldStore` 裡。`storage_trust.py` 只是把 CA 憑證**追加**進信任清單，沒有關閉憑證檢查的寫法。**不成立**。
5. **下載加密**（`file_protect.py`）：用 `jedi-file-protect`，註解寫明是 AES-256 zip，不是可以破解的舊式 ZipCrypto。密碼由前端產生（19 字元），只放在這一次請求的 header 裡。兩處稽核（`:47`、`:59`）只記檔名與 `encrypted`，**沒有記密碼**。保護開啟卻沒帶密碼時回 400，不會偷偷給沒加密的檔。**不成立**（加密演算法本身在 jedi-file-protect 套件內，不在本棒範圍）。
6. **8444 掛了什麼**（`files/__init__.py`、`browser_plane.py`）：8444 是獨立的 app，只掛 `/scan-input/<scan_uid>` 上傳、根目錄轉址、主控台（`registry.mount_browser_plane`）。`/blob` 只由 `register` 掛在主 app（8443）。**沒有 blob 下載掛在 8444**。**不成立**。
7. **SQL**（`customer_files_repo.py`、`migrations.py`）：全部用 SQLAlchemy 的查詢語法，沒有自己拼 SQL 字串。關鍵字搜尋有 `autoescape`；排序欄位用白名單 `SORTS` 對照。查詢都限定 `storage_scope='customer'`。Agent 只服務一個客戶，不需要依客戶過濾。**不成立**。

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

- **「這些存在嗎」**：工具那條疑點經三個獨立檢查員投票，三票否決。人工兩條備註都附了行號，可以照著開檔核對。
- **「只有這些嗎」**：這次用低強度掃描（low），一個研究員看完整個範圍，另一個專門找寫死在程式裡的密碼，兩個都完成了。低強度不會做逐元件的深挖，所以**不能宣稱這一塊完全乾淨**，只能說「工具與人工都沒找到」。jedi-file-upload、jedi-file-protect 套件內部，以及 sync、console 的實作都不在範圍內。

## 7. 執行概況（照 stamp 抄）

| 項目 | 值 |
|---|---|
| run ID | `wf_7fd381a8-b9d` |
| 掃描版本 | `3813abd05232`（branch `ws-CM-3507-CM-3515`，沒有未提交的改動） |
| effort | low；聚焦 attack-surface（只看攻擊者摸得到的正式程式，測試與產生的程式碼當背景） |
| verification.status | `verified` |
| reason_kind | null |
| candidates | 1（一條疑點進投票，不是 0，所以這次「verified」代表真的投過票） |
| panel_votes | 3（三個檢查員對 1 條各投一票，3 票全數投出，全判否決） |
| researchers_dispatched／returned | 2／2 |
| 耗時 | 約 22 分鐘（1362 秒） |
| 歸檔 | `docs/security-scan/FR-177/runs/CLAUDE-SECURITY-20261105-155941/` |
