---
title: D3-1 檢查結果：掃描報告回收（jedi-detection）
---

# D3-1 檢查結果：掃描報告回收（jedi-detection）

> 檢查日期 2026-09-18｜對應卡片 CM-1875（D3 第 1 小棒）｜檢查範圍 4 個檔案 859 行

## 🔴 一句話結論

**找到 1 條中風險：代理程式（裝在客戶機房、實際去跑掃描的那支程式）回報掃描報告時，說檔名叫什麼系統就存什麼，不做任何檢查；如果代理程式被入侵，它可以把一段程式碼偽裝成報告，稽核人員在畫面上點開時就會被執行。** 不急（要先攻下代理程式或猜中一次派工編號），但修法很小——只允許 html／xml／json／csv 這幾種副檔名，或乾脆由我們自己命名。另外查證了三件事**確認沒問題**：報告存檔不會被塞到目錄外、代理程式自報的檔案編號拿不到別台的東西、對外抓網址那支的五道防護是真的有做。

## 這一棒在檢查什麼

客戶按下「執行掃描」後，系統會派工給裝在客戶機房的代理程式；代理程式跑完工具（OpenSCAP、OpenVAS 這類），把報告放在自己本地，然後回頭通知雲端「我好了、報告在我這裡的某某編號」。雲端接著主動去代理程式那裡把報告抓回來，存成這次稽核的證據。

這一棒看的就是「抓回來、存起來」這一段：**雲端到底多信任代理程式講的話**。代理程式說檔名叫什麼、說內容是什麼格式、說報告的編號是幾號、要去哪個網址抓——這四件事只要有一件系統照單全收，被入侵的代理程式就能把假東西寫進客戶的稽核證據裡。

掃描目標 `/Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-detection`，revision `3ddd7218ca8a`（branch `feature/review`，工作區乾淨），mode `scan`，effort `low`，範圍 4 個檔案：結果回收處理器 `detection_result_handler.py`（223 行）、代理程式認證設定入口 `agent_auth.py`（67 行）、對外網址受限下載 `safe_http_fetch.py`（457 行）、上傳源碼包契約 `detection_source_file.py`（112 行），共 859 行。

## Coverage

`low` 強度：一位研究員讀完這 4 個檔案就提報候選，未做元件盤點、未做威脅建模、未跑額外密鑰專項掃描，`completenessCheckOutcome` 為 `not-applicable`（低強度本來就不跑盤點）。驗證跑了 **1 輪**，3 個候選去重後仍是 3 個，沒有候選遺失、沒有嚴重度被降低、沒有候選被駁回。研究員為了追資料流另外讀了範圍外的幾處（jedi-file-upload 的存檔與預覽、jedi-remote-agent 的路由表與代理程式認證設定、主專案的插件接線與安裝腳本），那些只當佐證、沒有納入稽核。

工具沒有實際執行任何程式碼：沒跑測試、沒發請求、沒示範攻擊，所有判斷都是讀原始碼推出來的。

**工具碰到了卡片重點③的四個小項裡的一項（檔名），另外三項（內容格式、檔案編號、來源網址）完全沒提出候選，由首腦回頭開檔查證補上**，詳見下方「卡片重點逐項人工查證」段。

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - F1（代理程式自報檔名原樣存成證據）＝M03 第 13 條，✅ 已修（CM-2062）。

## Findings

### F1 — 代理程式自報的檔名與副檔名原樣存成證據，預覽時會被當網頁執行（MEDIUM，confidence medium）

**這是什麼問題。** 掃描報告存檔時，檔名完全由代理程式決定，系統不檢查。代理程式如果把報告取名 `報告.html`、內容塞一段 JavaScript，這個檔會原樣存進客戶的證據庫；之後稽核人員在畫面上點「預覽」時，瀏覽器會把它當成一個網頁來跑，而不是當成一份檔案來顯示。這種攻擊叫**儲存型 XSS**（攻擊者存進去的內容，別人打開頁面時會被當成程式執行）。

**出事會怎樣。** 那段程式碼會用「稽核人員本人的身分」在系統裡跑。在前端與後端同一個網域的部署（落地版就是這樣）上，它讀得到稽核人員的登入權杖，等於可以冒用他做任何事——看別的專案、改資料、下載證據。**而 `.html` 正好是 OpenSCAP 報告的正式格式**（這支程式的註解裡就寫著這件事），所以這不是牽強的想像，是日常路徑上就會出現的副檔名。次要影響是代理程式可以在客戶的證據庫裡放任意類型的檔案。

**要先有什麼才打得到。**
- 攻擊者控制了一台已註冊的代理程式（入侵客戶機房那台機器），**或**能打到回報端點：`POST /api/1.0/agents/tasks/<uid>/result` 這條路由**沒有使用者登入檢查**（`jedi-remote-agent/jedi_remote_agent/api/routing.py:40`，第三欄是 `False`），只要猜中或取得一個派工編號（UUID）就能送假結果
- 之後要有人在畫面上點開這份報告預覽（前端 `JobExecutionDrawer.previewFile` 會把非純文字的副檔名送去 `/file/pdf-preview/<uid>`）

**在哪裡。**
- 源頭一：`jedi-detection/jedi_detection/app/service/detection_result_handler.py:162` —— 代理程式回報內容裡的 `upload_uids[].filename` 直接取用
- 源頭二：同檔 `:218-221` —— 抓報告時讀代理程式回應的 `Content-Disposition` 標頭，有值就覆蓋上面那個，一樣不檢查
- 落點：同檔 `:102-112` —— 用這個檔名建 `FileStorage` 後交給 `upload_files_for_tenant()` 存檔
- 副檔名取出處：`jedi-file-upload/jedi_file_upload/common/utils/file_utils.py:16` —— `file.filename.split('.')[-1]`，沒有白名單
- 執行點：`jedi-file-upload/jedi_file_upload/api/routes/upload_file_route.py:198`（宿主自有儲存分支）與 `:220`（一般分支）—— `mimetypes.guess_type(f'preview.{_ext}')` 依副檔名決定內容型別，配上 `as_attachment=False`（不強制下載、直接在頁面裡顯示）

**怎麼修。** 在 `detection_result_handler.py` 的 `_fetch_blob()`（`:181`）回傳前把檔名收乾淨，兩步：① `filename = os.path.basename(filename)`（去掉任何路徑成分）；② 比對副檔名白名單，只留 `html／xml／json／csv／txt／pdf`，不在名單內就改用 `detection_report_{agent_task_uid}` 這個我們自己組的名字。更保險的做法是**完全不用代理程式給的名字**，一律自己命名，只從代理程式那裡取「格式」並且照白名單校驗。

另外建議連帶修預覽端點（屬 jedi-file-upload，不在本棒範圍，建議另開卡）：`upload_file_route.py:198`／`:220` 不該把儲存的內容用 `text/html` 直接顯示在頁面裡；HTML 類一律改成強制下載，或加 `Content-Security-Policy: sandbox` 標頭。

**驗證。** 2/3 三位檢查員確認成立，嚴重度維持 MEDIUM。投反對票的那位認為 `:218-221` 的 `Content-Disposition` 覆寫會限縮檔名（因為官方代理程式一定會送這個標頭），但這個論點只在「代理程式是正常的」前提下成立——而本條的前提正是代理程式被入侵，被入侵的代理程式想送什麼標頭就送什麼。另外兩位把資料流從源頭追到顯示端，確認中間沒有任何一處做過白名單。**首腦同意多數意見。**

**為什麼是 MEDIUM 不是 HIGH：** 要先攻下代理程式（或猜中派工編號），而且要等有人來點預覽，不是打一個網址就中。

**首腦核對註記。** 屬實，四個檔案逐段開過。**與 D1／R2b 不重複**——D1 查的是憑證「存」與「守」，R2b 查的是代理程式檔案授權，都沒碰到結果回收這一段的檔名處理。淨新增 1 條，登記跨 arc 總表。

## 卡片重點逐項人工查證

卡片重點③列了四個小項，工具只碰到第一項，其餘三項首腦開檔補查：

### ③-1 檔名 — ⚠️ 有問題

見上方 F1。**補一項工具沒提的**：副檔名是用 `file.filename.split('.')[-1]` 取的，這個寫法在檔名含斜線時會把斜線一起帶進副檔名（例如 `a.b/../../../evil` 取出的「副檔名」是 `/evil`）。**但實際存檔路徑組不出穿越效果**——實測 `os.path.join('JOB_EVIDENCES/abc', '20260918_uuid.' + ext)` 得到 `JOB_EVIDENCES/abc/20260918_uuid./evil`，因為時間戳與 UUID 擋在前面，`..` 永遠在一個新目錄名的後面、構不成回上層。**判定：不是路徑穿越漏洞**，但副檔名取法本身脆弱，順手在 F1 的修法裡一併收掉（`os.path.basename` + 白名單兩步都做，就同時解掉這一項）。

### ③-2 內容格式（content_type）— ✅ 沒問題

代理程式回報的 `Content-Type` 確實被原樣存進 `upload_files.mimetype`（`detection_result_handler.py:222` → `file_utils.py:28` → DB 欄位）。**但這個欄位在顯示端從頭到尾沒被使用**：下載端點（`upload_file_route.py:169`）寫死 `application/octet-stream`（強制下載，不會在頁面裡執行），預覽端點（`:198`／`:220`）用的是**副檔名**推出來的型別、不是這個欄位。所以代理程式謊報內容格式沒有任何效果，真正的施力點是檔名——已收斂進 F1。

### ③-3 檔案編號（upload_uids）— ✅ 沒問題

擔心的是「代理程式 A 能不能報一個編號、讓雲端跑去抓代理程式 B 的檔案」。**做不到**：雲端抓檔的網址是 `f"{agent.base_url}/blob/{upload_uid}"`（`detection_result_handler.py:189-190`），其中 `agent` 是用 `agent_task.agent_id` 從資料庫查出來的（`:92` → `_resolve_agent()` `:172`），**不是代理程式自報的**。代理程式只能決定「去我這裡拿哪一個編號」，決定不了「去哪一台拿」。而且該代理程式若已被撤銷（`status == "revoked"`）會直接拒絕（`:176-177`）。

### ③-4 來源網址 — ✅ 沒問題（且是正面案例）

兩個層面：

**（a）抓報告的網址**：如上，主機位址來自資料庫、路徑是固定的 `/blob/{uid}`，代理程式插不進自己的網址。

**（b）`safe_http_fetch.py` 的對外下載**（這支是掃描基準從網址匯入時用的，不在報告回收路徑上，但在本棒範圍內）：這支防的是 **SSRF**（可以騙伺服器去連攻擊者指定的網址）。逐條核過它宣稱的五道防線，**五道都真的有做**：
1. 只允許 `https`（`:242-246`）—— `file://`、`gopher://` 打不進來
2. 解析出來的**每一顆** IP 都檢查（`:255-263`），用 Python 內建旗標涵蓋私有網段、本機、link-local（含雲端 metadata 位址 `169.254.169.254`）、保留段，且把 IPv4-mapped IPv6 攤回 v4 再判（`:425-427`）——這一點是真的容易漏而它沒漏
3. **IP pinning**（`:290-296`）：用檢查過的那顆 IP 直接連線，SNI 另帶原主機名——這道防的是 DNS rebinding（檢查時 DNS 回公網位址、連線時回 127.0.0.1），是 SSRF 防護最常被做漏的一環
4. 大小上限邊讀邊算、超限即斷（`:330-341`），不是先收完再看
5. 重導向自己走、**每一跳重跑前四道檢查**（`:146-160`），且刻意不用 `urljoin`（`:371-393`，避免 `//evil.example.com/x` 這種寫法換掉主機卻看不出來）

錯誤訊息全部收斂成自己寫的中文、技術細節只進 log（`:311-322`），避免把探測結果回傳給攻擊者。**判定：這支是正面案例，與 FR-098 A1 ⑤、`common/guard.py` 同類，不列為發現。**

### 附帶查證：`agent_auth.py` 的「沒接線就變成不認證」— ✅ 不成立（面板全否，首腦同意）

研究員提報這條，三位檢查員一致否決。首腦複核：主專案的插件接線 `core/plugins/detection.py:318` 無條件傳入 `agent_auth_settings_provider`，插件本身也是無條件掛載，**沒有任何攻擊者輸入能造成「沒接線」這個狀態**——那是我們自己程式碼的固定寫法，要嘛永遠接上、要嘛程式碼被改壞（那是另一回事）。**判定：不是漏洞。** 不過這支確實只發一次警告就靜默降級，若日後有人重構插件接線漏掉那一行，症狀是「功能全部正常、只是認證悄悄關掉」——**建議在插件組裝時把這個欄位列為必填、缺了直接拒絕啟動**（和本套件 `api/guards.py` 的 `_guard()` 缺接線就 RuntimeError 同一形狀）。列為體質改善建議，不計為發現。

### 附帶查證：抓報告走明文連線 — ✅ 出貨機器上打不到（面板 2:1 否決，首腦同意）

研究員提報「代理程式認證不是 `full` 模式時，抓報告走的是沒加密的 http，不驗對方身分也不帶權杖」（`detection_result_handler.py:206`）。程式碼確實是這樣寫的，`AGENT_AUTH_MODE` 的程式預設值也確實是 `none`（`config/config.py:258`）。**但實際出貨的機器打不到**：安裝腳本在全新安裝與升級時都會把 `AGENT_AUTH_MODE=full` 寫進環境設定檔（`scripts/installer/install.sh:1677`），維運指令 `guidant` 啟動前還會再檢查一次、不是 `full` 就當硬錯誤擋下（`scripts/installer/guidant:872-886`）。**判定：不計為發現。**

⚠️ **但留一句給開發環境**：本機開發與 demo 環境不跑安裝腳本，那裡確實是明文連線。開發環境不算出貨面，但若有人拿 demo 機接真實客戶的代理程式做展示，那條路是開的。**建議把 `:195` 那行「必須是 https」的檢查搬出 `if settings.enabled:` 區塊、變成無條件檢查**——這是一行的改動，讓「不加認證」與「不加密」兩件事解耦（認證可以關，加密不該跟著關）。列為體質改善建議。

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

分兩層講：

**「報出來的這條存在嗎」——可信度高。** F1 的資料流首腦逐檔開過：從代理程式送進來的欄位、到覆寫它的標頭、到存檔取副檔名的那一行、到預覽時決定內容型別的那一行，四個位置的行號都對得上，中間沒有任何一處做過檢查。三位檢查員裡兩位獨立追出同一條鏈。

**「只有這條嗎」——可信度中等，有兩個已知限制。**
1. **強度是 `low`**，只有一位研究員讀一遍，不是多位分工交叉讀。低強度的設計目的是快速篩，不是窮盡。
2. **這 4 個檔案的下游不在範圍內**。F1 能成立，關鍵的最後一哩（預覽端點用副檔名決定內容型別、且不強制下載）在 jedi-file-upload 裡，那支套件本棒沒掃。同理，若 jedi-file-upload 的存檔路徑另有問題，本棒看不到。
3. 卡片重點③的四個小項首腦都補查了，但那是**人工定向查證**，不是面板投票的結果——這三項「沒問題」的結論沒有三票背書，可信度低於 F1 那條。

`verification.status` 為 **`verified`**：三位檢查員對 3 條候選各投一票，9 票全數投出，沒有漏投，票數由工具自己的程式碼統計、不是任何 agent 自報。

## 執行概況

| 項目 | 數字 |
|---|---|
| run ID | `wf_fae30d77-78f` |
| 掃描 commit | `3ddd7218ca8a8f2cfd6c81351e1435cd39f62b3b`（工作區乾淨） |
| 範圍 | 4 檔 859 行 |
| 強度 | `low`（一位研究員 ＋ 三票面板） |
| 研究員 | 派 1 位、回 1 位、零重試（**這是 D3 五次嘗試以來第一次沒被停滯偵測砍掉**） |
| 研究員耗時 | 約 62 分鐘 |
| 面板 | 3 條候選 × 3 位檢查員 ＝ 9 票，全數投出 |
| 總耗時 | 約 110 分鐘（19:06 啟動 → 20:56 報告產出） |
| 候選 → 成立 | 3 → 1 |
| 驗證輪數 | 1 |
| 驗證章 | `verified` |
| 工具原始產物 | `jedi-detection/CLAUDE-SECURITY-20260918-110643/`（不入版控） |

**這一棒同時驗證了「每棒總行數 ≤ 2,000」這個切法是對的。** 前四次失敗（34 檔／17 檔／16 檔）都在 48～96 分鐘時被工具的停滯偵測砍掉——死因是研究員 agent 固定跑 `xhigh` 強度，上下文累積到 25～30 萬 token 時單次思考超過 180 秒，就被判定為「無進展」。本棒 859 行跑完全程沒有觸發，D3-2 起照同一判準切。
