# FR-187.B 掃描報告：Agent 回報事件（狀態、日誌、升級進度、主控台稽核）

## 1. 🔴 一句話結論

找到 3 個問題，**都是低風險**，而且都要先控制一台已註冊的 Agent 才打得到。最值得修的是第 3 條：被控制的 Agent 可以冒用主系統任何一個真實帳號的名字寫稽核紀錄。

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

Agent 裝在客戶機器上，會透過 `POST /agents/events` 回報自己的狀態、錯誤日誌、升級進度與結果，以及主控台上有人做了什麼（寫進主系統稽核）。這一棒假設 Agent 已經被人控制，檢查主系統收下這些回報時信了多少：能不能改到不該改的欄位、冒用別人名義、謊報升級成功、或把主系統灌爆。

範圍 10 個檔、約 870 行（清單見卡片 CM-3715）。

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

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 | 嚴重度 |
|---|---|---|---|---|---|---|
| 1 | Agent 送的錯誤日誌，「訊息」如果不是文字（例如一個陣列），就不會被截到 8KB 上限，整包原樣存進資料庫 | 資料庫被塞大、日誌查詢頁與診斷包匯出讀到很大的列時吃記憶體變慢 | 控制一台已註冊的 Agent（拿得到它的通行證與簽章金鑰） | `domain/support/service/agent_log_domain_service.py:59-67` | 先轉成文字再截長度 | 低 |
| 2 | 事件去重用的「發生時間」與「工單編號」是 Agent 自己填的，長度沒限制，整串存進 Redis（主系統的快取伺服器）一天 | Redis 記憶體被灌滿；Redis 也存著所有 Agent 的「防重放」紀錄，滿了會影響其他 Agent 連線 | 控制一台已註冊的 Agent；Redis 沒設記憶體上限 | `app/remote_agent/service/agent_event_dispatcher.py:92`、`:119` | 組去重鍵前限制長度與格式，或改成雜湊值 | 低 |
| 3 | 主控台稽核紀錄的「操作者」是 Agent 自己報的名字，主系統沒核對這個人真的有登入過那台 Agent | 稽核報表可以出現「某管理員在這台 Agent 下載了檔案」但其實不是他；還能偽造本機管理員登入觸發告警信 | 控制一台已註冊、屬於該客戶的 Agent | `core/plugins/_agent_console_audit.py:97-105` | Agent 每筆附主控台登入票的編號，主系統核對是不是發給這台、這個人的；核不到就標「Agent 自報」不換使用者身分 | 低 |

**為什麼都是「低」不是「中」**：三條的共同前提都是「Agent 已經被控制」。被控的 Agent 本來就能在它自己那台機器上做很多事；這三條的後果是讓主系統變慢或稽核紀錄失真，不會讓攻擊者讀到別的客戶資料，也拿不到主系統帳號權限。

## 4. 每條發現的詳述

### 第 1 條：非文字的日誌訊息不受長度上限

- **白話**：Agent 回報錯誤日誌時，每筆的「訊息」主系統會截到 8KB。但截斷只在「訊息是文字」時才做（`:60`）；如果送一個陣列或物件，就跳過截斷，在 `:67` 被轉成文字整包寫進 `public.agent_logs.message`。
- **出事會怎樣**：每個請求最多可塞到請求大小上限（掃描員估約 110MB），保留 90 天；支援頁的「最近錯誤」與診斷包 CSV 匯出會把這些列讀進記憶體。
- **要先有什麼**：一台已註冊 Agent 的通行證與簽章金鑰。
- **在哪裡**：`domain/support/service/agent_log_domain_service.py:59-67`（`to_rows`）。
- **怎麼修**：在 `to_rows` 裡把 `message` 先轉成 `str`（或非文字直接丟掉），再套 `message_max` 截斷。
- **首腦核對註記**：已開檔核對，`:60` 的 `isinstance(message, str)` 判斷與 `:67` 的 `str(message)` 確實如描述。

### 第 2 條：去重鍵長度沒上限，可灌爆 Redis

- **白話**：主系統用「Agent 編號＋事件種類＋工單編號＋發生時間」當去重鍵（`:92`），處理成功後存進 Redis 24 小時（`:119`）。工單編號與發生時間都來自 Agent 送的內容，協定只檢查「是字串」，沒限長度。`agent_status`、`agent_logs`、`console_events` 三種事件的處理都沒檢查這兩欄（升級事件有用 `clean_job_id` 檢查工單編號、`event_time` 解析時間，但要等處理成功才寫鍵，格式錯會先 400，所以升級事件不受影響）。
- **出事會怎樣**：送一個空的 `agent_status`、附超長的發生時間，每次換值就多一個大鍵，一天內不消失，Redis 記憶體可被填滿；Redis 同時存著所有 Agent 的防重放紀錄（DPoP，就是防止別人把攔到的請求重送一次的機制）。
- **要先有什麼**：一台已註冊 Agent 的通行證與簽章金鑰；Redis 沒設記憶體上限與淘汰規則。
- **在哪裡**：`app/remote_agent/service/agent_event_dispatcher.py:92`（組鍵）、`:119`（寫入）。
- **怎麼修**：在 `parse` 或 `dispatch` 組鍵前，要求發生時間是 64 字以內且 `fromisoformat` 解得開的時間、工單編號符合 `_JOB_ID_RE`；或把這兩段做雜湊後再組鍵。
- **首腦核對註記**：已開檔核對，組鍵與寫入行號正確。

### 第 3 條：稽核紀錄的操作者可以被 Agent 冒用

- **白話**：Agent 主控台有人登入、改帳密、下載檔案時，Agent 送 `console_events` 給主系統寫稽核。每筆的 `actor`（操作者帳號）是 Agent 自己填的，主系統在 `:102` 直接把它當成這筆稽核的使用者，還去查真實使用者的暱稱（`:148`）。中間沒有核對「這個帳號真的拿過主系統發給這台 Agent 的主控台登入票」。
- **出事會怎樣**：被控的 Agent 可以讓稽核報表顯示「客戶管理員在這台 Agent 下載了檔案／改了帳密」，嫁禍給真實的人，真正動手的人也可以推說不是他；偽造本機管理員登入事件還能隨時觸發寄給客戶管理員的告警信（`:152`）。
- **要先有什麼**：控制一台屬於該客戶的已註冊 Agent。只能寫進它自己那個客戶的稽核（租戶只認通行證），不能寫到別家客戶。
- **在哪裡**：`core/plugins/_agent_console_audit.py:97-105`（`_write`）、`:148`（查暱稱）。
- **怎麼修**：Agent 每筆有操作者的事件附上主控台登入票的編號（jti）或簽過章的票內容；主系統核對該票是發給這台 Agent、票上的人就是 `actor`，才換成那個使用者寫稽核。核不到時不換使用者身分，改在稽核列標「Agent 自報：<actor>」。
- **首腦核對註記**：已開檔核對，`:98-102` 確實直接取 `event.get("actor")` 設進 user context，沒有任何票據核對。

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

| # | 重點 | 人工查證結果 |
|---|---|---|
| 1 | 去重鍵用 Agent 自報的 `at`、`job_id`，換值就能無限重送；有沒有頻率與大小上限；handler 冪等夠不夠 | **部分成立**。身分與租戶只取通行證（`agent_event_dispatcher.py:57-68`），沒問題。換 `at` 確實能繞過去重重送，但各 handler 有自己的冪等：日誌以 `(agent_uid, agent_log_id)` 唯一、主控台稽核以已寫入的 `at` 去重、升級事件以 `at` 新舊判斷、狀態只寫有變動的值，所以重送不會造成重複資料。本棒範圍內**沒看到每台 Agent 的頻率上限**（可能在通行證守門或前門，不在 scope）。長度沒上限的部分就是第 2 條。 |
| 2 | `_agent_status.py` 白名單鍵寫回 `remote_agents`，有沒有能改狀態、租戶、憑證、enabled 的鍵；`browser_port` 能不能把任意網址塞進 CSP | **不成立**。`:46-49` 先濾成 `AGENT_STATUS_KEYS` 白名單內的鍵，再交給套件各自的值域檢查；`browser_port` 只收 1～65535 的整數（`:37-41`）。接縫 `core/plugins/_agent_origins.py:28-35` 用的主機名取自 `base_url`、埠是整數，組好後再用 `_SAFE_ORIGIN` 正規式驗過才寫進 CSP，塞不進任意網址。白名單內各鍵的實際值域在 `jedi_remote_agent` 套件，不在 scope，沒逐鍵深追。 |
| 3 | `_agent_logs.py`＋`agent_log_domain_service.py`：訊息長度、筆數、等級白名單；文字之後顯示在日誌查詢頁 | **部分成立**。筆數上限 500（`:52`）、等級只收白名單（`:56`）、其他欄位有截長度（`:65`）。訊息長度有一個洞，就是第 1 條。顯示到日誌查詢頁時是否跳脫屬前端，不在 scope。 |
| 4 | `_finish` 用 Agent 自報版本與成敗改更新中心結果：偽造「升級成功」會不會讓更新中心停止重試 | **成立但屬設計前提**。`agent_update_event_handlers.py:188-200` 確實直接用 Agent 自報的版本與成敗呼叫 `record_result`；被控的 Agent 謊報成功，更新中心會把**它自己那一台**記成升完。但它只能謊報自己，不能替別台 Agent 報；是否會讓整批「已全部升完」取決於 `record_result` 與 `tick`（在 `jedi_remote_agent`／更新中心，不在 scope）。主系統沒有別的管道得知 Agent 真實版本以外的心跳版號，這是信任模型的限制，不算本棒新增的洞，工具也沒報。建議首腦評估是否要用下一次心跳回報的版本二次確認。 |
| 5 | `save_log` 附件大小上限；檔名由系統產；`clean_job_id` 白名單 | **不成立**。`:224` 先擋 10MB 上限、`:226` 檢查 gzip 檔頭、`:223` 用 `^[A-Za-z0-9._:-]{1,64}$` 白名單檢查工單編號，檔名由系統組（`:243`），同一工單重傳回第一次那份（`:240`）。注意：大小檢查發生在整包已讀進記憶體之後，讀取上限在 route（不在 scope）。 |
| 6 | `_agent_console_audit.py` 把 Agent 自報的 `actor` 寫進稽核：能不能以真實帳號名義寫稽核 | **成立**，就是第 3 條。 |
| 7 | `_agent_error_tracking.py` 註冊回應附帶錯誤追蹤設定：送給 Agent 的是不是只有它該拿的那一份 | **不成立**。`:16-26` 只回 `enabled`、`environment` 與 Agent 專用的 `SENTRY_AGENT_DSN`，不帶後端自己的 `SENTRY_DSN`；`:46` 只在註冊端點且 200 時才補。所有 Agent 拿到同一份 Agent 專用連線設定（不分租戶），這是錯誤追蹤的連線位址，本身不是可以讀資料的密碼。 |

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

- **「這些存在嗎」**：可信。三條都經過三個獨立檢查員（分別看「打得到嗎」「後果多大」「有沒有別的防線擋住」）投票，三條都是 3 票全數認定成立，首腦也自己開檔核對過行號與程式。
- **「只有這些嗎」**：不能保證。這是 effort low 的掃描，只有一個研究員讀完整個範圍（另一個專掃寫死在程式裡的密碼，沒找到）。範圍外的套件（`jedi_remote_agent`、`jedi_agent_protocol`）與前門、通行證守門只追到接縫，沒深掃；掃描是讀程式碼推論，沒有實際執行任何攻擊。

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

| 項目 | 值 |
|---|---|
| run ID | `wf_80d1e3fa-d85` |
| 掃描版本 | `31345fbe0c9c`（工作目錄乾淨） |
| 設定 | scoped，10 檔，effort low，focus attack-surface |
| verification.status | `verified` |
| reason_kind | 無（null） |
| candidates | 3（去重後 3） |
| panel_votes | 9（三個檢查員對 3 條各投一票，9 票全數投出，沒有漏投） |
| researchers_dispatched／returned | 2／2 |
| 耗時 | 約 33 分鐘 |
| 原始報告歸檔 | `docs/features/security-scan-consolidated/runs/be/CLAUDE-SECURITY-20261108-171721/` |
