# FR-187.I 掃描報告：證據檔經長輪詢存取、自主執行報告、測試端點

## 1. 一句話結論

**掃完沒有找到經過投票確認成立的問題。** 工具提出 2 條疑慮，三個獨立的檢查員都判定不成立。人工逐條核對時另外記下 3 個值得留意、但不到漏洞程度的小地方（見第 4 節），都不急，可以等下次動到這些檔時順手處理。

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

客戶的稽核證據檔存放在客戶自己主機上的 Agent，主系統只記一筆「檔案在哪」的紀錄。這一棒查的是：

- 下載、上傳、刪除這些證據檔時，有沒有可能拿到別家公司的檔；
- 下載 Agent 產出的報告時，報告裡的網頁腳本會不會在主系統網域上執行；
- 一支壓測用、不需要登入的測試端點，會不會出現在交給客戶的版本裡。

範圍共 5 個檔、約 1241 行（`api/common/file_protect.py`、`core/plugins/_file_ownership.py`、`infra/upload_file/remote_agent_adapter.py`、`core/plugins/_agent_local_runs.py`、`core/plugins/_bench_longpoll.py`）。

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

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 結果 |
|---|---|---|---|---|---|
| C1 | 懷疑附件可以掛上別家公司的檔案編號 | 讀到別家公司的檔 | 有資源庫編輯權限的帳號 | 檔案資料表 `public.upload_files` | **不成立**（3 票全判不成立） |
| C2 | 懷疑管理員填的儲存位址，會讓伺服器去連任意網址 | 伺服器被騙去探內網 | 有儲存設定權限的管理員，而且系統關掉了 Agent 身分驗證 | `infra/upload_file/remote_agent_adapter.py:299-305` | **不成立**（3 票全判不成立） |

被否決的理由：

- **C1**：研究員引用的是 `scripts/init/02-schema.sql`，那是已經過期的出貨基線。實際釘的 `jedi-file-upload` 套件 migration（`004-upload-files-tenant-owner-rls.sql`）已經在檔案表加上公司欄位，也開了 RLS（就是「每個客戶只能看自己資料」的資料庫隔離機制）。
- **C2**：只要 Agent 身分驗證開著，`remote_agent_adapter.py:300-302` 在連線之前就直接回「找不到」；出貨安裝程式（`scripts/installer/install.sh`）每次安裝和升級都會寫入 `AGENT_AUTH_MODE=full`，也就是一律開著。

## 4. 首腦指定「重點看什麼」逐條人工查證

這幾條都是我自己打開檔案看的，**沒有經過三人投票**。

1. **`remote_agent_adapter.py:96-107` `get_file`：查檔有沒有公司隔離。**
   人工查過：**成立（有守住）**。adapter 本身不檢查「這個人能不能看」，靠兩層擋：資料庫層由套件 migration 開的 RLS 隔離公司；功能層由下載端點先問檔案歸屬登記。`_verify_integrity`（`:192-203`）會重算 SHA-256 雜湊值，和上傳當下記的值比對，不一致就寫稽核紀錄並擋下。舊資料沒有記雜湊值的就跳過比對，這是刻意的相容處理。

2. **`:71-94` `save_file`：檔名送給 Agent；Agent 回的 `file_uid` 只檢查是不是字串。**
   人工查過：**屬實，但風險低**。`:91` 只檢查「是非空字串」，沒有像 `_agent_local_runs.py:33` 的 `_AGENT_FILE_UID` 那樣限制字元形狀。這個值之後只會在對**同一台** Agent 下指令時帶回去（`:105`、`:129`），所以就算 Agent 被攻陷、回了怪字串，影響的也只是它自己那台。
   → **小地方 1**：建議 `:91` 也套用 `agent_file_uid_ok()`，和自主執行報告那條路用同一套檢查。

3. **`:109-117` `fetch_agent_blob` 的 `agent_file_uid` 由誰給；`:118` `delete_file` 的權限。**
   人工查過：**成立（有守住）**。唯一的呼叫端是 `_agent_local_runs.py:213`，它傳進來的值先經過 `_report_file_uid()`（`:73-75`）的正規式形狀檢查。`delete_file` 本身不檢查權限，由上層的檔案歸屬政策用「write」動作先問過才會呼叫到。這一段在本棒範圍外，沒有逐行追，只確認了 adapter 的設計是這樣。

4. **`_file_ownership.py`、`file_protect.py` 新增的歸屬類型：「讀＝能力點、刪＝一律拒」有沒有套對。**
   人工查過：**成立**。刪除都比讀取嚴格：匯入類只給上傳者本人（`:100-102`）；框架 PDF 一律拒（`:219-220`）；資源庫建議附件一律拒（`:361`）；檢測規則包的系統公版和別家公司的不能刪（`:272`）。每份歸屬答案查詢出錯時都回「不認領」（`:83-91`），也就是**出錯時預設擋下來**，不會預設放行。
   → **小地方 2**：`EvidenceBatchFileOwnershipProvider` 拒絕時拋的是 `ForbiddenError`（403，`:337`），其他各份都回 `NotFound`（404）。專案規範要求一律回 404，因為回 403 等於告訴對方「這個檔案編號存在」。影響是可以探出某個編號存不存在，不能拿到檔。
   → **小地方 3**：`file_protect.py:51` 的 `unzip` 預設是 True，Agent 送來的報告檔名如果是 `.zip`，下載時會被拆開重新包裝。`_unzip`（`:106-114`）已經先檢查解壓上限，所以不會被壓縮炸彈打爆，但拆開的檔名直接沿用 zip 裡的名字。建議 `_agent_local_runs` 那條路呼叫 `send_protected` 時明確傳 `unzip=False`（檔頭註解 `:43-44` 本來就是這樣要求的）。

5. **`_agent_local_runs.py`：Agent 產的 HTML 報告，回應有沒有 CSP sandbox、nosniff、強制下載。**
   人工查過：**成立（有守住）**。對應的端點在 `api/remote_agent/routes/agent_local_run_route.py`：
   - 下載（`:57-61`）：走 `send_protected`，以 attachment 強制下載，加上 `nosniff`（要求瀏覽器不要自己猜檔案型別）；
   - 執行紀錄（`:67-72`）：固定用純文字回，加上 `nosniff`；
   - 線上看（`:83-91`）：加上 CSP `sandbox; default-src 'none'`，報告裡的腳本、表單、外部連線都不會執行，另外有 `nosniff` 和 `X-Frame-Options: SAMEORIGIN`。

6. **`_bench_longpoll.py`：免登入、會佔住執行緒的假端點，只有 `DEBUG` 或 `BENCH_LONGPOLL=1` 時才掛上（`main.py:197-199`）。**
   人工查過：**出貨預設不會掛上，但有一個前提要留意**。正式 image 的預設是 `DEBUG=false`（`docker/production/Dockerfile:354`）；`docker/`、`scripts/installer/`、`scripts/init/` 裡都沒有設定 `BENCH_LONGPOLL`。不過同一份 Dockerfile 的註解（`:326`）寫著 STG 現況是 `DEBUG=true`，客戶的 env 檔也可以覆寫這個值。一旦打開 DEBUG，這支不用登入的端點就會出現，一次最多佔住一條執行緒 60 秒。任何人連續打它，都能把其他使用者的 API 拖慢。
   → 不列為漏洞（要先有人違反「正式環境不開 DEBUG」的設定），但建議掛載條件只認 `BENCH_LONGPOLL=1`，不要綁 DEBUG。

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

- **「這些存在嗎」**：C1、C2 都經過三個獨立檢查員投票，6 票全數投出，全部判定不成立，可信度高。第 4 節的三個小地方是我人工開檔看到的，行號都可以直接打開核對，但沒有經過投票。
- **「只有這些嗎」**：不能保證。這次用的是 low 等級：一位研究員讀完整個範圍，另一位專找寫死在程式裡的密碼。兩位都有回報，沒有漏掉沒讀的人；但工具每次跑的結果不完全一樣，沒報不代表一定乾淨。範圍外的呼叫端（下載端點、歸屬政策本體）只有為了核對才去看，沒有完整掃過。

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

| 欄位 | 值 |
|---|---|
| run ID | `wf_2426c728-42c` |
| 掃描的版本 | `058a84c02409190dadc109f1bddb86afd2027abe` |
| effort | low，只看正式程式碼（attack-surface） |
| verification.status | `verified` |
| reason_kind | 無 |
| candidates（交給檢查員的疑慮） | 2 |
| panel_votes | 6（三個檢查員對 2 條各投一票，全數投出） |
| researchers_dispatched / returned | 2 / 2 |
| 成立的發現 | 0 |
| 耗時 | 約 104 分鐘 |
| 歸檔位置 | `docs/features/security-scan-consolidated/runs/be/CLAUDE-SECURITY-20261108-204012/` |

`candidates: 2` 表示面板確實有疑慮可投、而且投完了，不是「沒東西可投」的空 verified。

沿革見 LOG 與 git log。
