# FR-086.B1 掃描報告：檔案上傳下載的 HTTP 邊界與契約（jedi-file-upload）

- **卡片**：[CM-1652](https://app.notion.com/p/FR-086-B1-HTTP-35-jedi-monorepo-Opus-1M-low-3d8346da4cd0815a91cde29a0885ab80)（母卡 [CM-1651](https://app.notion.com/p/FR-086-jedi-file-upload-3d8346da4cd081a28367de28f61ef2fa)）
- **掃描範圍**：`jedi-file-upload` 的 `api/`＋`app/`＋`ports/`＋`common/`＋`plugin.py`，共 35 檔
- **版本**：commit `8aa6f060`（jedi monorepo，branch `feature/FR-075`）
- **日期**：2026-09-11
- **工具**：claude-security plugin，`low` effort，focus `attack-surface`
- **面板**：6 條候選 × 3 位檢查員 = **18 票全數投出**，stamp `verified`

---

## 1. 🔴 一句話結論

**只要有一個能登入的帳號，再知道某個檔案的編號，就能把別家客戶的檔案下載走、線上預覽、還能永久刪掉——系統從頭到尾只檢查「你有沒有登入」，從來沒檢查過「這個檔案是不是你的」。**

而且還能替別人的檔案**換發一張下載通行證**，那張證不需要帳號密碼就能開檔，可以直接把網址傳給公司外面的任何人。

這四條全部是同一個病：**認得出你是誰，但不管你能碰什麼。**

好消息有一個：卡片列為「本棒最重要」的①**路徑穿越（把檔案存到不該存的地方）經查不成立**——程式的寫法剛好讓攻擊字串到不了真正寫檔的那一步。詳見第 4 節 B1-5。

---

## 2. 這一棒在檢查什麼（白話）

`jedi-file-upload` 是產品裡**所有檔案功能的對外門面**。使用者做四件事都走它：

- **上傳**檔案（稽核證據、SSP 文件、問卷附件）
- **下載**檔案
- **線上預覽**檔案（Office 檔會先轉成 PDF，在網頁裡直接開）
- 跟系統**要一張短期下載通行證**（因為瀏覽器用 `window.open` 或 `<iframe>` 開檔時帶不了登入憑證，所以先換一張「證」放在網址上）

這一棒要回答三個問題：**能不能把檔案寫到不該寫的地方、能不能下載到別人的檔案、檔名與大小有沒有把關。**

**只找問題、不修問題**——底下所有修法都只是建議，這一棒沒有動任何一行程式碼，也沒有真的上傳或下載任何檔案去測（全部是讀程式碼推論出來的）。

---

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

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 嚴重度 | 來源 |
|---|---|---|---|---|---|---|
| **B1-1** | **任何登入者可以替「任何一個檔案編號」換發一張下載通行證** | 那張證**不用帳號密碼**就能開檔，可直接把網址傳給公司外面的人，在有效期內都能開 | 任何有效帳號 ＋ 知道一個檔案編號 | `api/routes/upload_file_route.py:156` | **HIGH** | 工具 3/3 票 |
| **B1-2** | **上傳的檔案會被原封不動「在網頁裡打開」**，副檔名沒有白名單 | 攻擊者上傳一個網頁檔，別人一點預覽，那段程式就在**我們自己的網站網域下執行**，可以偷走瀏覽器裡的登入憑證、並冒用受害者身分呼叫 API | 能上傳檔案的帳號 ＋ 受害者點開預覽 | `api/routes/upload_file_route.py:251`（送出在 `:259-262`，遠端那條在 `:221-223`） | **HIGH** | 工具 3/3 票 |
| **B1-3** | **任何登入者可以下載任何檔案**，只憑檔案編號 | 別家客戶的稽核證據、SSP 文件、問卷答案全部可被讀走 | 任何有效帳號 ＋ 知道一個檔案編號 | `api/routes/upload_file_route.py:172`（送檔在 `:185-190`） | **HIGH** | 工具 3/3 票 |
| **B1-4** | **任何登入者可以刪除任何檔案**，刪了救不回來 | 別家客戶的稽核證據被永久刪除，連帶轉檔快取一起清掉，引用它的資料變成指向空檔 | 任何有效帳號 ＋ 知道一個檔案編號 | `api/routes/upload_file_route.py:116` | **MEDIUM** | 工具 3/3 票 |
| **B1-5** | 上傳時用**使用者填的字串**拼存檔路徑（卡片①，列為「本棒最重要」） | **經查不成立**——攻擊字串到不了寫檔那一步 | — | `api/routes/upload_file_route.py:101`／`:134` | **無問題** | 工具 0/3 票 ＋ runner 人工複核 |
| **B1-6** | 檔名的副檔名直接取使用者給的字串（卡片④的一部分） | **路徑穿越不成立**（取不到 `../`），但**副檔名無白名單這件事是真的**，且正是 B1-2 的燃料 | — | `common/utils/file_utils.py:16` | **無問題（穿越）**<br>**有問題（無白名單→見 B1-2）** | 工具 0/3 票 ＋ runner 人工複核 |
| **B1-7** | **完全沒有單檔大小上限、也沒有檔數上限**（卡片④的一部分） | 一次請求可塞爆磁碟或物件儲存；全域只有 50MB 的「整個請求」上限，不是單檔上限 | 任何能上傳的帳號 | 全套件查無任何上限設定 | **LOW**（另記） | runner 人工查證 |
| **B1-8** | 套件把守門整個外包給宿主（卡片⑤） | **設計是好的**——缺守門會**拒絕啟動**，不是靜默放行 | — | `plugin.py:282-300` | **無問題** | runner 人工查證 |

**卡片七個重點的逐項結論在第 5 節**，每一項都有「成立／不成立」。

---

## 4. 每條發現的詳述

### B1-1（HIGH）任何登入者可以替任何檔案換發一張「免登入下載通行證」

**這是什麼問題（白話）**

瀏覽器用 `window.open` 或內嵌框架開檔案時，沒辦法帶上登入憑證。所以系統設計了一個折衷：前端先用正常的登入身分去跟後端要一張**短期通行證**（程式裡叫 `st`），這張證會綁定「哪一個檔案」，然後把它接在網址後面去開檔。

問題是——**發證的那個窗口只檢查「你有沒有登入」，完全不檢查「你要的這個檔案是不是你的」。**

你拿自己的帳號，報上別人的檔案編號，它就發一張綁著**別人檔案**的通行證給你。

**出事會怎樣**

拿到那張證之後有兩件事特別糟：

1. **那張證本身不需要帳號密碼**——它就是一段網址。可以用 email、通訊軟體傳給**公司外面任何人**，對方貼進瀏覽器就開得到別家客戶的檔案，在有效期內都算數。
2. 別家客戶的**稽核證據、SSP 文件**就是這樣流出去的，而這正是產品存在的目的——保管這些東西。

**要先有什麼才打得到**

- 一個**任何**有效帳號就行（不需要主管、不需要稽核員、不需要是專案成員）
- 知道一個檔案編號。編號是 UUID 看似難猜，**但不需要猜**——只要在任何一張列表或詳情頁面看得到附件，API 回應裡就帶著編號

**在哪裡**

- `jedi_file_upload/api/routes/upload_file_route.py:153` — 只掛了 `@auth_required`（「有登入就好」）
- `jedi_file_upload/api/routes/upload_file_route.py:156` — `signed_token_issuer(..., resource_uid=uid)`，直接拿使用者給的 `uid` 去簽
- 宿主接線：`compliance-manager-be/core/upload_file_wiring.py:133` — `auth_required` 接的是純粹的 `jwt_required()`，只驗身分不驗權限

**怎麼修**

在 `upload_file_route.py` 的 `UploadFileAccessTokenRoute.get()` 裡，**簽證之前**先把檔案撈出來，確認呼叫者有權讀它（是本人上傳的？同一個租戶？是所屬專案的成員？），不符就 `raise ForbiddenError`。

因為套件本身不知道產品的歸屬規則，正解是**多開一個必填的 port**（例如 `adapters.file_access_authorizer(uid) -> bool`），跟現有的守門三件放在一起，並在 `plugin.py:282` 的 `_assert_api_wiring` 一併檢查——**宿主忘了接就開不起來**，而不是靜默地把所有檔案變成憑編號即可取用。

---

### B1-2（HIGH）上傳的網頁檔會在我們自己的網域裡被執行

**這是什麼問題（白話）**

預覽功能的邏輯是：Office 檔先轉成 PDF 再顯示；**其他格式就原檔直接丟給瀏覽器顯示**（`as_attachment=False` 的意思就是「不要下載，直接在頁面裡開」）。

而「這是什麼類型的檔案」是**依使用者上傳時的副檔名**決定的，**而且副檔名沒有白名單**（我整包 grep 過 `secure_filename`、白名單、MIME 驗證，一個都沒有）。

所以上傳一個 `.html` 或 `.svg`，系統就會老老實實地告訴瀏覽器「這是網頁，請執行它」。

**出事會怎樣**

那段程式是在**我們自己的網站網域底下**執行的，所以瀏覽器認為它是自己人：

- 可以讀走瀏覽器裡存的**登入憑證**（存在 localStorage 的 token）
- 可以**冒用受害者的身分**呼叫任何 API
- 而且後端**沒有設任何一道瀏覽器端的防護標頭**（沒有 CSP、沒有 `nosniff`、沒有 `X-Frame-Options`），所以沒有東西擋得住它

預覽網址正是前端內嵌框架平常在開的那個網址——**這條路是產品的正常流程，不是什麼刁鑽路徑。**

**要先有什麼才打得到**

- 一個能上傳檔案的帳號
- 受害者點開那個檔案的預覽（分享附件、掛在案件上，對方點一下就中）
- 副檔名落在「不能轉 PDF」的那一類（html、svg、xhtml 都是）

**在哪裡**

- `jedi_file_upload/api/routes/upload_file_route.py:251` — 依副檔名猜出 MIME 類型
- `jedi_file_upload/api/routes/upload_file_route.py:259-262` — `send_file(..., as_attachment=False, mimetype=preview_mime)`，直接在頁面裡開
- `jedi_file_upload/api/routes/upload_file_route.py:221-223` — 遠端儲存那條分支，**同款問題**
- 源頭：`jedi_file_upload/common/utils/file_utils.py:16` — 副檔名照抄使用者給的檔名

**怎麼修**

1. **只有 PDF 與圖片才准在頁面裡開**，其餘一律 `as_attachment=True` ＋ `application/octet-stream`（變成下載，不是執行）
2. 檔案回應加上 `X-Content-Type-Options: nosniff` 與嚴格的 `Content-Security-Policy`（例如 `default-src 'none'; sandbox`）
3. **上傳時就擋**：加副檔名白名單，不要信任使用者給的檔名

---

### B1-3（HIGH）任何登入者可以下載任何檔案

**這是什麼問題（白話）**

下載端點掛的守門叫「雙軌」——帶了通行證（`st`）就走通行證那條，**沒帶就退回「只要有登入就好」**。

退回的那條**什麼都不綁**：它只確認「你是一個合法使用者」，完全不比對「這個檔案跟你有沒有關係」。

再往下看也沒有兜底：資料層撈檔案時只用 `uid` 這一個條件，而**存檔案的那張表根本沒有租戶欄位、資料庫隔離也是關的**（建表腳本自陳），所以資料庫層也救不了。

**出事會怎樣**

**別家客戶的所有上傳檔案都可以被讀走**：稽核證據、SSP 文件、問卷答案、合規佐證。

更麻煩的是走通行證那條時，程式會把租戶標成「無」，而系統把「無租戶」當成**最高權限、跳過所有隔離**——等於連資料庫那層本來就很薄的保護也一起繞過。

**要先有什麼才打得到**

- 任何有效帳號
- 知道一個檔案編號（同 B1-1，API 回應裡到處都是）

**在哪裡**

- `jedi_file_upload/api/routes/upload_file_route.py:172` — 用 `uid` 直接取檔
- `jedi_file_upload/api/routes/upload_file_route.py:185-190` — 送出檔案內容
- 守門實作：`jedi-iam/jedi_iam/authz/signed_token.py:155-163` — 沒帶 `st` 就只跑 `verify_jwt_in_request()`
- 資料層：`jedi_file_upload/infra/repository/upload_file_repo_impl.py:140` — 只用 `uid` 過濾（此檔屬 B1b 範圍，此處為追脈絡而讀）

**怎麼修**

在送檔之前，先把這個檔案的**所屬資源**找出來（它掛在哪張稽核證據／哪份 SSP／哪題問卷底下），確認呼叫者的租戶與角色有權看它。

治本的做法二擇一：
1. 在 `upload_files` 表加租戶欄位並開啟資料庫層隔離（讓資料庫成為最後一道防線）
2. 或者**強制走通行證那條**，把「沒帶證就放行」這個退路拿掉——但要注意：**這招必須先修好 B1-1**，否則通行證本身就是隨便發的，等於沒修

---

### B1-4（MEDIUM）任何登入者可以刪除任何檔案

**這是什麼問題（白話）**

刪除端點同樣只掛「有登入就好」，拿到編號就刪，**沒有任何一層檢查這個檔是不是你的**——route 沒查、宿主的 service 也沒查。

**為什麼是 MEDIUM 不是 HIGH**：影響面比前三條窄一點——它破壞資料但不外洩資料，而且刪除通常會被使用者很快發現（檔案不見了）。但**刪掉就救不回來**，且刪的是稽核證據。

**出事會怎樣**

- 檔案本體被刪掉、資料庫紀錄被刪掉
- 物件儲存那條還會**連帶清掉轉檔產生的 PDF 快取**
- 引用這個檔案的資料（案件、證據、答案）變成指向一個不存在的檔
- **這些是合規稽核證據**，產品存在的目的就是保管它們

**要先有什麼才打得到**

- 任何有效帳號
- 知道一個檔案編號

**在哪裡**

- `jedi_file_upload/api/routes/upload_file_route.py:113` — 只掛 `@auth_required`
- `jedi_file_upload/api/routes/upload_file_route.py:116` — `delete_file(uid)` 直接送進去
- 宿主端：`compliance-manager-be/app/upload_file/service/managed_file_upload_service.py:360-362` ＋ `:173-178`（`_adapter_for_uid` 純粹依檔案自己那列資料決定後端，不驗人）

**怎麼修**

刪除前先確認所屬資源與角色：把檔案的母體（證據／文件／答案）撈出來，用主專案的 `common/authz` 檢查呼叫者的租戶與角色（該不該是 manager／auditor）。

退而求其次的最低門檻：**只准刪自己上傳的**——比對 `upload_files.created_user` 與呼叫者，不符就擋。

---

### B1-5（無問題）卡片①「用使用者填的字串拼存檔路徑」——**經查不成立**

這是卡片列為「**本棒最重要**」的一條，結論是**不成立**。因為證偽跟證實一樣有價值，這裡寫清楚**是什麼擋住了**。

**原本擔心什麼**

`upload_file_route.py:101` 這一行：

```python
save_dir = str(os.path.join(payload.get('upload_type'), payload.get('uid')))
```

這兩個值來自請求裡的一段 JSON，**完全由呼叫端填**，而且看不到任何 `../` 過濾。照常理推論，傳 `upload_type="../../etc"` 就能把檔案寫到系統目錄去（這叫**路徑穿越**，就是用 `../` 這種寫法讓檔案被存到或讀到原本不該碰的目錄）。`:134` 批次上傳那條是同款寫法。

**為什麼不成立**

追下去發現這個被汙染的字串**走到一半就死了**，寫檔那一步根本讀不到它：

1. `app/service/file_upload_service.py:82` 呼叫 `set_save_dir(save_dir)`
2. `set_save_dir`（`:68`）寫的是 `self.upload_file_adapter.config.base_dir = save_dir`
3. **但是** `.upload_file_adapter` 這個屬性（`:37-40`）會先跑 `_ensure_initialized()`，那一步**當場把 adapter 建出來**
4. 而 `LocalFileUploadAdapter.__init__`（`infra/adapter/local/local_file_adapter.py:38`）在建構當下就把目錄**抄成自己的 `self.save_dir`**
5. 真正寫檔那行（`:57`）讀的是 `self.save_dir`——**那份在第 4 步就抄好了，第 2 步的寫入晚了一步、寫進了一個沒人再讀的地方**

順序是：**先建構（抄走目錄）→ 才寫入（改到沒人讀的欄位）**。攻擊字串永遠追不上。

**我額外確認過的三件事**（因為這條是卡片的重點，不能只信工具的票）：

- **物件儲存那條完全不吃這個值**：MinIO／SeaweedFS 的 `save_file`（`infra/adapter/minio/minio_adapter.py:92`）用的是 `self.config.bucket_name`，跟 `base_dir` 無關，而且 bucket 名是設定檔來的、不是使用者填的
- **宿主沒有把它改活**：主專案交進去的是 `ManagedFileUploadService`，它**沒有覆寫** `set_save_dir` 或 `upload_files`，繼承的就是上面那套死路（覆寫 `base_dir` 的另外兩支 `upload_files_for_tenant` / `upload_system_file` 是背景 job 與系統資產專用，**不接使用者請求**）
- **DI 是 `providers.Factory`**（`di_containers/upload_file/upload_file_containers.py:29`，每個請求建新的）——我本來擔心「每次都新建 adapter 會不會讓寫入趕上」，但新建反而讓第 3、4 步每次都重跑，**順序不變、結論不變**

**但有一件事要記著**：這是**靠執行順序意外擋住的，不是有人刻意防的**。哪天有人重構掉那個 lazy 建構、或改成先設定再建 adapter，**這個洞就會自己長回來**。`set_save_dir` 那行值得補一道輸入檢查（只允許安全字元、拒絕 `..` 與絕對路徑），把它變成**有意的防護**。

---

### B1-6（部分成立）卡片④「檔名把關很薄」

拆成兩半看，**一半不成立、一半成立**：

**不成立的那半：路徑穿越**

`common/utils/file_utils.py:16`：

```python
upload_file_extension = file.filename.split('.')[-1]
```

擔心的是檔名裡塞 `../` 會被拼進存檔路徑。**不成立**，原因是 `split('.')[-1]` 只取**最後一個點之後**的那一段——這一段**永遠不可能含有點**，所以永遠拼不出 `..`。傳 `../../../../tmp/pwned` 進去，取出來的是 `./tmp/pwned` 的最後一段、不含點的字串；絕對路徑那條也被 `:17` 的字串拼接寫法結構性擋掉了（副檔名被夾在時間戳與 UUID 中間，跑不到最前面去）。

工具的三位檢查員也是 0/3 票（全判不成立），理由與我開檔看到的一致。

**成立的那半：沒有副檔名白名單**

**確實沒有**。我整包 grep 過 `secure_filename`、`allowed_ext`、白名單、MIME 驗證——**一個都沒有**。使用者說這是 `.html` 它就記 `.html`。

這件事本身不會造成路徑穿越，**但它就是 B1-2 的燃料**——沒有白名單，才會有「上傳網頁檔然後被當網頁執行」。修法併入 B1-2。

---

### B1-7（LOW，另記）完全沒有單檔大小上限與檔數上限

**（工具未報，人工查證）**

整包套件查不到任何大小上限或檔數上限的設定。批次上傳端點（`:130`）用 `getlist('file')` 收檔，**要幾個收幾個**。

唯一存在的上限是主專案全域的 `MAX_CONTENT_LENGTH` 50MB（CM-1587 定的），但那擋的是**整個請求**，不是單檔、也不是檔數。

**為什麼只評 LOW**：要打到它需要能登入，而且效果是磁碟吃緊／服務變慢，不是資料外洩；50MB 的全域上限也讓單次傷害有天花板。但**累積型的塞爆**（反覆上傳到把磁碟或物件儲存塞滿）沒有東西擋。

**建議**：在 `plugin.py` 的 `FileUploadConfig` 加兩個參數（單檔上限、單次檔數上限），在 `upload_files` 進迴圈前檢查，超過就 `raise BadRequestError`。

---

### B1-8（無問題）卡片⑤「套件把守門整個外包給宿主」——**設計是好的**

**（工具未報，人工查證）**

擔心的是：套件自己不做任何判斷，全靠宿主注入守門，那**宿主忘了接**會怎樣？靜默掛上一個沒防護的端點嗎？

**不會。** `plugin.py:282-300` 的 `_assert_api_wiring` 會檢查守門三件（`auth_required` / `file_access_guard` / `signed_token_issuer`），**缺任何一個就當場 `raise RuntimeError` 拒絕掛載**，服務直接起不來。

程式裡的註解把理由寫得很清楚，大意是：忘記接守門的後果是整個租戶的檔案變成公開可下載，而服務還是照常啟動、健康檢查照樣綠燈——所以要**在開機時就吵**。

**這是出錯就擋下來（fail-closed），是正確的做法。** 對照組：FR-081 的 jedi-issue 也是拒絕啟動（同樣好），FR-082 的 jedi-ai-bot 只靠欄位必填擋（較弱）。**這支屬於做得好的那一類。**

---

## 5. 卡片七個重點的逐項結論

| 卡片項目 | 結論 | 依據 |
|---|---|---|
| **① 上傳時用使用者可控字串拼存檔路徑**（本棒最重要） | **不成立** | 見 B1-5。被汙染的值寫進 `config.base_dir`，但 adapter 在**更早**的建構期就把目錄抄走了，寫檔讀的是抄本。額外確認：物件儲存不吃這個值、宿主沒覆寫、DI 用 Factory 也不改變順序。**但這是順序意外擋住的、不是刻意防的，建議補輸入檢查** |
| **② 任何登入者可替任意檔案編號換下載憑證** | **✅ 成立（HIGH）** | 見 B1-1。工具 3/3 票。`:153` 只掛 `@auth_required`、`:156` 直接拿 uid 去簽。**憑證不帶租戶綁定，下游也不會再驗歸屬**（正是③與 B1-3 的結論） |
| **③ 下載與預覽的守門在無憑證時退回「只要登入就好」** | **✅ 成立（HIGH）** | 見 B1-3。`jedi-iam/jedi_iam/authz/signed_token.py:155-163` 無 `st` 就只跑 `verify_jwt_in_request()`。**下游沒有任何地方再檢查歸屬**——資料層只用 `uid` 過濾，表上沒有租戶欄位、資料庫隔離也是關的（跨讀 jedi-iam 屬必要追脈絡） |
| **④ 檔名與大小把關很薄** | **一半不成立、一半成立** | 見 B1-6、B1-7。**穿越不成立**（`split('.')[-1]` 取不到 `..`）；**無副檔名白名單成立**（是 B1-2 的燃料）；**無單檔大小上限、無檔數上限成立**（LOW，另記 B1-7） |
| **⑤ 套件把守門整個外包給宿主** | **不成立（設計正確）** | 見 B1-8。缺守門 → `_assert_api_wiring` 當場拒絕掛載，**fail-closed**。與 jedi-issue 同級，優於 jedi-ai-bot |
| **⑥ `plugin.py` 的每個預設值是否出錯就放行** | **無問題** | 逐項看過：守門三件**刻意不給預設值**（缺了就拒絕啟動）；`file_access_scope` 預設凍結（改它等於讓已發出的憑證全失效）；`extra_object_storage_types` 預設空集合，漏設的後果是**送檔當場炸掉**（不是靜默放行）；`pdf_convertible_exts` 是 LibreOffice 能力清單，與安全無關。**沒有一個是出錯就放行** |
| **⑦ `app/` 那層的用例編排有沒有做歸屬檢查** | **✅ 成立：兩層都沒做** | `app/service/file_upload_service.py` 與 `app/service/upload_file_service.py` 兩支從頭到尾**沒有任何權限或歸屬判斷**，只有 `@transaction`。**route 層沒做、app 層也沒做**——這正是 B1-1／B1-3／B1-4 之所以成立的原因 |

**卡片沒問但順手確認的**：範圍內**沒有撈到任何憑證或密鑰**（密鑰專項掃描與人工查證皆無）。物件儲存的憑證在宿主那側（`managed_file_upload_service.py`），屬 **B2（CM-1653）** 範圍。

---

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

分兩層講。

**第一層：「這些問題是真的嗎」——可信度高。**

四條發現每一條都經過三位獨立檢查員投票，**全部 3/3 票通過**（沒有一條是勉強過關的）。我另外自己開檔核對了三條 HIGH 的每一個行號，行號與內容都對得上。stamp 蓋的是 `verified`。

B1-1 特別可靠：三位檢查員的理由完全一致，而且都追到了宿主那一側，確認 `auth_required` 接的真的只是「驗身分」。

**第二層：「只有這些問題嗎」——不保證。**

有四件事要老實說：

1. **這是 `low` 檔次的掃描**——一位研究員讀完全部 35 檔，沒有做威脅建模、沒有跑廣度掃描。深度夠、但不是地毯式。
2. **範圍只有 35 檔**。真正把檔案讀寫落地的程式（`infra/adapter/`）、資料庫存取層（`infra/repository/`）、建表腳本都**不在這一棒**——那是 B1b（CM-1654）。我為了判斷「下游有沒有兜底」有讀進去幾支，但那是追脈絡，不是審計。
3. **宿主接線只讀了跟本棒判斷有關的部分**。儲存後端怎麼挑、租戶憑證怎麼拿、加密開關預設是關的（首腦盤點的疑點⑤）——那是 B2（CM-1653）。
4. **沒有實際測試**。沒有真的上傳檔案、沒有真的打端點、沒有驗證任何一條攻擊路徑可以跑通。**全部是讀程式碼推論**。B1-5 判「不成立」也是讀出來的——**建議修的時候順手寫一個測試把它釘住**，因為它靠的是執行順序，重構就可能復活。

**合起來看**：B1-1、B1-3、B1-4 三條其實是同一個病的三個出口（**只驗身分、不驗歸屬**），修的時候應該當成一件事修，而不是各補各的。**B1-2 則要跟 B1-6 的「無白名單」一起修**，只修其中一半都還是會中。

---

## 7. 執行概況（數字表）

| 項目 | 值 |
|---|---|
| 掃描範圍 | `api/`＋`app/`＋`ports/`＋`common/`＋`plugin.py`，**35 檔**（與卡片數字一致） |
| scanRoot | `jedi-file-upload/`（子目錄，非 monorepo 根） |
| commit | `8aa6f0609c4434c0fa5206d5e99e31cad249646e`，branch `feature/FR-075`，工作區乾淨（`dirty: false`） |
| effort／focus | `low`／`attack-surface` |
| 執行時間 | **2,164 秒**（約 36 分鐘） |
| 派出 agent | 20 個（全部完成，0 失敗、0 跳過、0 空回） |
| 研究員 | 派出 2、回來 2 |
| 候選發現 | 7 條 → 去重後 **6 條** |
| 面板票數 | **18 票**（6 條 × 3 位檢查員），**全數投出**、0 票未投、0 條候選未審 |
| 達標發現 | **4 條**（4 條全部 3/3 票一致通過；另 2 條 0/3 票一致否決） |
| 嚴重度分布 | CRITICAL 0／**HIGH 3**／**MEDIUM 1**／LOW 0 |
| 嚴重度被下修 | 無 |
| 驗證輪數 | 1 輪（無續跑、無遺失候選） |
| stamp | **`verified`**（`reason: null`） |
| 工具產出 | `jedi-file-upload/CLAUDE-SECURITY-20260911-052906/`（該目錄自帶 `.gitignore`，不入版控） |

**工具原始報告**（英文，含完整 exploit 路徑）：`CLAUDE-SECURITY-RESULTS.md` ／ `.jsonl` ／ `.sarif`，同上目錄。
