# FR-086.B2 掃描報告：宿主接線——儲存解析與租戶憑證

- **卡片**：[CM-1653](https://app.notion.com/p/FR-086-B2-10-910-BE-repo-Opus-1M-low-3d8346da4cd08143b38ef6646e2d8762)（母卡 [CM-1651](https://app.notion.com/p/FR-086-jedi-file-upload-3d8346da4cd081a28367de28f61ef2fa)）
- **掃描範圍**：`app/upload_file/`＋`infra/upload_file/`＋`core/upload_file_wiring.py`＋`di_containers/upload_file/`，共 **10 檔**（BE 主專案）
- **版本**：commit `8aa36333`（BE repo，branch `feature/FR-075`，工作區有未提交異動）
- **日期**：2026-09-11
- **工具**：claude-security plugin，`low` effort，focus `attack-surface`
- **面板**：12 條候選 × 3 位檢查員 = **36 票全數投出**，stamp `verified`
- **🔴 但請先看第 3 節的分區**：工具報了 10 條，**其中只有 1 條在本棒範圍內**，另外 9 條是密鑰專項撈到的範圍外舊案重複。

---

## 1. 🔴 一句話結論

**B1 問「套件那半邊有沒有檢查這個檔是不是你的」，答案是沒有；B2 問「宿主這半邊有沒有補上」，答案也是沒有——四層防線（網址入口、業務邏輯、資料查詢、資料庫本身）從頭到尾一個檢查都沒有，中間沒有任何一層會擋。**

宿主給套件的「守門」只有一行 `jwt_required()`，意思是**「只要能登入就放行」**；再往下的業務邏輯只做一件事——拿檔案編號去問「這個檔存在哪個後端」，然後把檔案吐出來。

**這一棒的真正產出只有一條發現（F1）。** 工具另外報的九條全是**同一批已知憑證外洩問題被第 N 次撈到**，不是新發現（詳見第 3 節下半與第 4 節）。

另外人工查到兩件工具沒報的事，都與卡片重點直接相關：

- **物件儲存的帳號密碼，任何登入者打一支查詢端點就能看到明文**（遮罩清單漏了儲存設定這一組，且讀取端點沒有權限檢查）。
- **跨租戶「借憑證」的退路是真的存在的**——A 客戶的檔案讀不到設定時，程式會去拿 B 客戶的儲存帳密來連線。

---

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

`jedi-file-upload` 這支套件只懂「檔案上傳下載長什麼樣子」，**它不知道 Guidant AI 的檔案實際上存在哪裡**。「存哪裡」是產品自己的事，所以主專案寫了這 10 個檔當作**接線盒**，回答套件三個問題：

1. **這個檔案要存到哪／從哪拿**——主機硬碟？MinIO／SeaweedFS 這類物件儲存？還是客戶自己機房裡那台 agent 設備？
2. **連那個儲存要用誰的帳號密碼**——每個客戶（租戶）的儲存帳密各自存在資料庫的設定表裡。
3. **誰可以碰這個檔**——套件把這題整個丟回給宿主回答。

> **租戶＝一家客戶**。這套產品一份程式同時服務多家客戶，靠「租戶」這個標記把資料分開。
> **物件儲存**＝專門放檔案的外部服務（像公司自架的網路硬碟），連它要帳號密碼。

**B1 已經確認套件那一側沒有做歸屬檢查。這一棒要回答的核心問題就是一句：「宿主這一側有沒有補上？」**

**只找問題、不修問題**——底下所有修法都只是建議，這一棒沒有動任何一行程式碼，沒有真的上傳或下載任何檔案，也沒有拿任何帳密去連線試。全部是讀程式碼、讀設定與唯讀查資料庫 schema 推論出來的。

---

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

### 3.1 🟢 範圍內的發現（本棒真正的產出）

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 嚴重度 | 來源 |
|---|---|---|---|---|---|---|
| **F1** | **拿檔案編號要檔案時，完全不檢查「這個檔是不是你的」**——程式只拿編號去查「這檔存在哪個後端」，然後直接讀／轉檔／刪除 | 任何登入者可以**下載、線上預覽、永久刪除別家客戶的檔案**（稽核證據、SSP 文件、問卷附件） | 任何有效帳號 ＋ 知道一個檔案編號 | `app/upload_file/service/managed_file_upload_service.py:352-362`（三支方法）＋ `:173-178`（挑後端的那一段） | **HIGH** | 工具 3/3 票 ＋ 首腦開檔核對 |
| **B2-A** | **物件儲存的帳號密碼會明文回給任何登入者**——遮罩清單漏掉儲存設定這一組，而且那支查詢端點沒有權限檢查，只要登入就能打 | 拿到帳密後可以**繞過整個系統**直接連物件儲存，把該租戶所有檔案列出來、下載、覆蓋或刪掉 | 任何有效帳號（不需任何角色） | 遮罩清單 `api/system_config/routes/system_config_route.py:35-39`；沒掛權限檢查的讀取端點 `:202-207`、`:367-372`；憑證讀進程式的地方 `app/upload_file/service/managed_file_upload_service.py:99-119` | **HIGH** | **工具未報，人工查證** |
| **B2-B** | **A 客戶讀不到自己的儲存設定時，程式會去借 B 客戶的帳號密碼來連線** | 檔案可能被存到／讀自**別家客戶的儲存空間**，租戶界線在儲存這一層不成立；程式自己只記一行警告，不擋 | 檔案的儲存設定在本租戶查不到（歷史錯位檔、換過後端） | `app/upload_file/service/managed_file_upload_service.py:218` 呼叫 `:263-277`，實際繞隔離的查詢在 `infra/upload_file/system_storage_config_reader.py:51-58` | **MEDIUM** | **工具未報，人工查證** |
| **B2-C** | **連物件儲存預設不加密**（`secure` 預設關），帳密與檔案內容在內網以明文傳輸 | 能看到內網流量的人可以**側錄到儲存帳密與檔案內容** | 能監看內網流量（同網段、被入侵的網路設備） | `app/upload_file/service/managed_file_upload_service.py:105`／`:119`；套件端預設值 `jedi-file-upload/.../ports/dto/minio_upload_config_dto.py:11` | **LOW** | **工具未報，人工查證** |
| **B2-D** | **客戶端 agent 通道的認證預設整個關閉**（`AGENT_AUTH_MODE` 預設 `none`） | 預設狀態下雲端與客戶設備之間**不驗憑證、不簽章、走明文 http** | 能接觸該網路路徑者 | `common/util/agent_auth_settings.py:22`（預設 `none`）、`config/config.py:261`；受影響的轉發在 `infra/upload_file/remote_agent_adapter.py:200-207` | **記錄用**（歸 FR-077 CM-1594） | **工具未報，人工查證** |

### 3.2 🔁 範圍外的重複（**不是本棒的成果，全部是舊案**）

工具的密鑰專項掃描（focus 開啟時會附帶跑的一次全 repo 憑證搜查）**不受範圍限制**，所以它每一棒都會把同一批問題再撈一次。**這九條沒有一條是新發現**：

| 工具編號 | 一句話 | 已經記在哪 |
|---|---|---|
| F2 | 三家 AI 服務的 API 金鑰被寫進對話紀錄檔 | **CM-1607**（已撤銷，清版控殘留）＋ FR-079 B2 F1/F2/F3 |
| F3 | POC 資料庫密碼硬編在一支腳本裡 | **CM-1608**（決策者已裁：測試機專用、**密碼不需更換**，只做程式碼衛生） |
| F4 | 資料庫管理員（可繞過租戶隔離）的密碼寫進一份分析文件 | **CM-1629**（同一組密碼散落 249 個檔） |
| F5 | 用來加密 Google 雲端硬碟權杖的金鑰被寫進版控 | **CM-1631**（Google 密鑰與加密金鑰外洩，22 檔） |
| F6 | GitLab 個人存取權杖寫進對話紀錄與一份設定傾印 | **CM-1607** ＋ FR-079 B2 F1/F2/F3 |
| F7 | 登入用的簽章金鑰寫進對話紀錄 | **CM-1607**（開發機那把；各安裝版自己產生） |
| F8 | 共用測試帳號的密碼寫在參考文件裡 | **CM-1608**（同上裁決） |
| F9 | 出貨用的資料庫初始化腳本，**每一套安裝都內建同一組原廠最高權限密碼** | 跨 arc 總表 §3.1 第 3 項（FR-079 B2 F15，**決策者已裁「先記錄，之後看怎麼調整」**） |
| F10 | 另一份設定傾印裡的 LangChain 權杖與物件儲存帳密 | **CM-1607**（清版控殘留）＋ **CM-1631** 同批 |

**這九條唯一的新資訊價值是：密鑰專項每掃一棒就把同一批撈回來一次，佐證那批憑證確實還沒清乾淨。** 除此之外請把它們當成已知項，不要重複開卡、不要計入本棒成果。

> ⚠️ **本報告一律不寫任何金鑰值或密碼值**，只寫「哪個檔、第幾行、對應哪張卡」。要看實際值請直接開那些檔案或查對應卡片。

---

## 4. 每條發現的詳述

### F1（HIGH）拿編號就給檔案，中間零檢查

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

主專案寫了一支服務叫 `ManagedFileUploadService`，它負責「讀檔、轉 PDF、刪檔」這三件事。三支方法長得一模一樣，都只有一行：

```python
@transaction
def get_upload_file(self, uid: str):
    return self._adapter_for_uid(uid).get_file(uid)          # :352-354

@transaction
def convert_to_pdf(self, file_uid, output_folder, output_filename=None):
    return self._adapter_for_uid(file_uid).convert_to_pdf(...)  # :356-358

@transaction
def delete_file(self, uid: str) -> bool:
    return self._adapter_for_uid(uid).delete_file(uid)        # :360-362
```

`_adapter_for_uid` 的意思是「**依這個檔案編號，決定要用哪個儲存後端去拿它**」。它自己的說明文字（docstring，就是程式裡給人看的註解）寫得很清楚：

> 依「**檔案自己的** storage_scope」選 adapter

**關鍵字是「檔案自己的」**——它看的是**檔案那一列資料自己記著什麼**，從頭到尾**不看呼叫的人是誰**。整個函式（`:173-178`）只有四行，沒有任何一行在問「這個人有沒有資格碰這個檔」。

再往下一層也沒有兜底：套件的資料查詢（`UploadFileRepoImpl.get_by_uid`）只用編號一個條件去撈，**沒有任何範圍限制**；而存檔案的那張資料表 `upload_files` **連「這是哪家客戶的」這個欄位都沒有**（見下方「首腦核對註記」），所以資料庫層也不可能幫忙擋。

**出事會怎樣**

- **看得到**：任何登入者可以下載或線上預覽別家客戶的稽核證據、SSP 文件、問卷附件——而保管這些東西**正是這個產品存在的目的**。
- **刪得掉**：`delete_file` 會把儲存裡的檔案本體與資料庫紀錄一起刪除，物件儲存那條還會**連帶清掉轉檔產生的 PDF 快取**。**刪了救不回來**，而引用這個檔案的資料（案件、證據、答案）會變成指向一個不存在的檔。

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

- **一個任何有效帳號就夠了**——不需要是主管、不需要是稽核員、不需要是該專案成員、不需要跟該檔案有任何關係。
- **知道一個檔案編號**。編號是 UUID（一長串亂碼）看起來很難猜，**但根本不需要猜**——只要在任何一張列出附件的頁面看得到那個檔，API 回應裡就帶著編號。

**在哪裡**

```
app/upload_file/service/managed_file_upload_service.py:352-354   get_upload_file（讀檔）
app/upload_file/service/managed_file_upload_service.py:356-358   convert_to_pdf（轉 PDF 預覽）
app/upload_file/service/managed_file_upload_service.py:360-362   delete_file（刪檔）
app/upload_file/service/managed_file_upload_service.py:173-178   _adapter_for_uid（只看檔案自己記的 scope，不看人）
core/upload_file_wiring.py:133                                   auth_required = jwt_required()（只驗「有沒有登入」）
```

**怎麼修**

**修在 `ManagedFileUploadService` 這一層是對的位置**——本專案的規範明訂「資源類守門放 app service 層」（要先把資源撈出來才知道要判誰，route 層做不到）。

具體做法：在 `get_upload_file` / `convert_to_pdf` / `delete_file` 三支的**第一行**（`_adapter_for_uid` 之前）插一道檢查：

1. 先用檔案編號把**這個檔掛在誰底下**找出來（掛在哪張稽核證據／哪份 SSP／哪個問卷答案）。
2. 用專案既有的授權守門判定——**不要另外寫一套**（本專案規範明訂守門一律走 `common/authz`）：
   - 專案類資源 → `assert_project_participant(...)`
   - SSP 類資源 → `SspPermissionChecker.require_write_access()`（刪除走寫入權限）
3. 找不到所屬資源的孤兒檔（背景 job 產的、系統資產）→ **最低門檻是比對 `upload_files.created_user` 與呼叫者**，不符就擋。
4. `storage_scope=system` 的產品共享資產（合規框架 PDF）本來就該全租戶讀得到，**可以明文放行讀取，但刪除仍要擋**（改成只有平台管理員能刪）。
5. **失敗回 404 而不是 403**——回 403 等於告訴對方「這個編號是存在的」，變成探測工具。

**治本的第二道**：給 `upload_files` 加租戶欄位並開啟資料庫層隔離，讓資料庫成為最後一道防線。這件事要配 migration 與既有資料回填，屬另一張卡的工作量，但**沒有它，應用層那一道就是唯一防線**。

**⚠️ 修的時候必須跟 B1 一起修**：只修 B2 這半邊，B1 的「換發免登入通行證」那條還在——對方拿到證之後走的是同一條路，而那條路上的檢查是本條要補的這一道。**兩邊是同一件事的兩半。**

**首腦核對註記（首腦親自開檔，逐項屬實）**

- `:352-362` 三個方法（`get_upload_file` / `convert_to_pdf` / `delete_file`）**全部只有 `self._adapter_for_uid(uid).xxx(uid)` 一行，中間零檢查**。✅ 屬實
- `:173-177` 的 `_adapter_for_uid` 只看「檔案自己記的 `storage_scope`」挑 adapter（**它的 docstring 自己就這樣寫**），**完全不看呼叫者是誰**。✅ 屬實
- 底層 `UploadFileRepoImpl.get_by_uid` 也**無任何範圍條件**；`upload_files` 表**無租戶欄位**（建表腳本 `scripts/init/02-schema.sql:16839-16857` 的欄位清單裡確實沒有 `tenant_id`）、**資料庫層隔離是關的**。✅ 屬實

---

### B2-A（HIGH）物件儲存的帳密會明文回給任何登入者

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

這條是**卡片第 ③ 點追下去的結果**。卡片原本只問「憑證存在資料庫的 JSONB 欄位裡、加密開關預設是關的」，但追下去發現**更前面還有一道**：**那些帳密根本就會被 API 明文回出去。**

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

每個客戶的物件儲存帳號密碼，存在系統設定表裡一個叫 `STORAGE_CONFIG` 的群組。系統設定有一支通用的查詢端點可以把某一組設定整個讀出來。

這支端點有一道**遮罩**機制（回傳前把敏感欄位刪掉），問題是它**兩邊都對不上**：

1. **群組名單漏了儲存設定**。要遮罩的群組清單寫死在 `api/system_config/routes/system_config_route.py:36`：
   ```python
   HIDDEN_SECRET = ['SMTP', 'THIRD_PARTY_LOGIN', 'NOTIFY_CONFIG', 'ISSUE_INTEGRATE_CONFIG']
   ```
   **`STORAGE_CONFIG` 不在裡面**，所以儲存設定這一組**完全不進遮罩流程**。
2. **欄位名單也對不上**。就算進了流程，要刪的欄位只有兩個（`:39`）：
   ```python
   _SECRET_KEYS = ('secret', 'private_token')
   ```
   而儲存憑證的欄位叫 `secret_key` / `minio_secret_key` / `access_key`（見 `managed_file_upload_service.py:101-119`）——**一個都不在名單裡**。

**更關鍵的是：那支讀取端點沒有任何權限檢查。** 同一個檔案裡，寫入類動作（改設定、刪設定）每一支都呼叫了 `_assert_config_capability(...)` 做能力檢查，**唯獨讀取類一支都沒有**：

| 端點 | 有沒有能力檢查 |
|---|---|
| `GET /api/1.0/system/configs/<group>`（`:202-207`） | ❌ **只有 `@jwt_required()`** |
| `GET /api/1.0/system/config/<group>/<key>`（`:367-372`） | ❌ **只有 `@jwt_required()`** |
| `PUT`（改）／`DELETE`（刪）／`POST`（新增） | ✅ 有（`:271`／`:299`／`:326`／`:397`／`:420`） |

**出事會怎樣**

任何登入者打一支 GET，就拿到自己租戶物件儲存的**位址、帳號、密碼明文**。拿到之後就**不必再經過這個系統了**——可以用標準的 S3 工具直接連上去，把該租戶所有檔案**列出來、整批下載、覆蓋、刪除**。應用層再怎麼補歸屬檢查都管不到，因為對方根本不從應用層進來。

**這條與 F1 是兩件不同的事**：F1 是「繞過檢查拿單一檔案」，這條是「拿到鑰匙直接開倉庫」。

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

- 一個任何有效帳號。**不需要任何角色或能力點**（讀取端點沒檢查）。
- 讀到的是**自己租戶**的設定（這一層有資料庫隔離擋著），所以危害範圍是「本租戶全部檔案」，不是「全平台」。**這也是為什麼評 HIGH 而不是 CRITICAL。**

**在哪裡**

```
api/system_config/routes/system_config_route.py:36        HIDDEN_SECRET 清單漏了 STORAGE_CONFIG
api/system_config/routes/system_config_route.py:39        _SECRET_KEYS 只有 secret / private_token
api/system_config/routes/system_config_route.py:202-207   GET by group，只掛 @jwt_required()
api/system_config/routes/system_config_route.py:367-372   GET by group+key，只掛 @jwt_required()
app/upload_file/service/managed_file_upload_service.py:99-119   憑證從 JSONB 讀進來的地方（本棒範圍內）
```

> **範圍註記**：`api/system_config/` 這個檔**不在本棒的 10 檔範圍內**（本棒範圍只到憑證被讀進程式的那一行）。之所以追過去，是因為卡片第 ③ 點問的就是「憑證怎麼保管」，不追到讀取端就答不完整。**建議獨立開卡**，與本棒的 F1 分開。

**怎麼修**（三件都要做，缺一還是漏）

1. **把 `STORAGE_CONFIG` 加進 `HIDDEN_SECRET`**（`:36`）。
2. **把 `_SECRET_KEYS` 補上 `secret_key`、`minio_secret_key`、`access_key`、`minio_access_key`**（`:39`）。
3. **兩支 GET 端點補上能力檢查**——照同檔案既有寫法加一行 `_assert_config_capability(group, "read")`（寫入類已經這樣做了，`:271` 是現成範例，**不要另外發明第二套守門**）。

**做完要寫測試。** 本專案的測試政策雖然對小改動預設不寫測試，但**授權守門屬於明文列出的「核心共用邏輯」例外**——請針對「非管理員打 GET 拿不到 `secret_key` 欄位」寫一支測試釘住。

---

### B2-B（MEDIUM）A 客戶讀不到設定，就去借 B 客戶的帳密

**（工具未報，人工查證；同時回答卡片第 ② 與第 ④ 點）**

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

要讀一個舊檔案時，程式得先知道「用哪組帳密連哪個儲存」。正常路徑是查**本租戶**的儲存設定。但如果查不到（例如這個檔案當初被存錯後端、或後端換過），程式**不會停下來報錯，而是跨過租戶界線去找別家客戶的同型設定來用**：

```python
config = self._load_config_for_storage_type_any_tenant(file_type)   # :218
if config is not None:
    logger.warning("...已跨租戶 fallback 借同型設定讀取...")           # :220-224
```

`_load_config_for_storage_type_any_tenant`（`:263-277`）會呼叫 `read_all_storage_config_values()`，那一支（`infra/upload_file/system_storage_config_reader.py:51-58`）最終跑的是一句**明確關掉租戶隔離的資料庫查詢**（`SET LOCAL app.is_super_admin = 't'`，見 `infra/system_config/system_config_root_reader.py:110-133`），**把所有租戶的儲存設定全部撈回來**，然後**取第一筆型別相符的**。

程式碼註解對此有自覺，說明實際檔案位置會用「檔案自己記的路徑」覆寫，所以不會讀錯物件。**問題不在讀錯檔，在於「A 客戶的操作，用了 B 客戶的帳號密碼去連線」**——而且它只記一行警告就繼續跑。

**為什麼是 MEDIUM 不是 HIGH**：要打到它需要滿足一個不是攻擊者能直接控制的前提（該檔的儲存型別在本租戶查無設定）。它比較像是**租戶界線在程式裡被刻意開了一個口**，而不是一條可以主動觸發的攻擊路徑。但**這個口一旦被別的 bug 碰到，就是跨租戶的**。

**在哪裡**

```
app/upload_file/service/managed_file_upload_service.py:211-230   讀不到本租戶設定 → 走跨租戶 fallback
app/upload_file/service/managed_file_upload_service.py:263-277   跨租戶查詢的實作
infra/upload_file/system_storage_config_reader.py:51-58          read_all_storage_config_values（全租戶）
infra/system_config/system_config_root_reader.py:110-133         真正關掉隔離的那一句 SQL
```

**怎麼修**

短期：把這條 fallback 的**觸發範圍收窄**——只在「檔案本身沒有租戶歸屬」（系統共享資產）時才允許跨租戶借設定，其餘情況直接拋既有的 `FILE_STORAGE_CONFIG_TYPE_NOT_FOUND` 讓錯誤浮出來。並把 `logger.warning` 升級成稽核事件（`audit(...)`），讓這件事有紀錄可查而不是只留一行 log。

長期：程式註解自陳這條 fallback 是為了處理「歷史錯位檔」（背景 job 曾把檔存進別租戶的後端）。**正解是把那些錯位檔搬回正確位置，然後把這條退路整個拿掉**——它現在是拿「永久放寬租戶界線」在換「幾筆歷史資料能讀」。

---

### B2-C（LOW）連物件儲存預設不加密

**（工具未報，人工查證；回答卡片第 ③ 點的後半）**

儲存設定裡有一個開關叫 `secure`，決定連物件儲存時**要不要走加密連線**（就是網址上 `https` 與 `http` 的差別）。這個開關**兩邊的預設值都是「關」**：

```python
secure=v.get('secure', False),     # managed_file_upload_service.py:105（MinIO）
secure=v.get('secure', False),     # managed_file_upload_service.py:119（SeaweedFS）
```
```python
secure: bool = False               # 套件端 ports/dto/minio_upload_config_dto.py:11
```

這個值被原封不動交給連線用的程式庫（`jedi-file-upload/.../minio_adapter.py:59`）。`secure=False` 的意思是**整條連線都不加密**：帳號密碼與檔案內容都以明文在網路上傳輸。

**出事會怎樣**：能看到那段網路流量的人（同網段、被入侵的交換器或路由器）可以**側錄到儲存帳密**，之後就等同 B2-A 的後果。

**為什麼只評 LOW**：物件儲存通常與應用服務在同一台機器或同一個內網（落地版是同一個 docker stack），攻擊者要先進到那個網路才打得到——他到了那個位置通常已經有更直接的手段。但**「預設關閉」這件事本身是設計缺陷**——安全選項的預設值應該是安全的那一邊。

**怎麼修**：把兩處預設值改成 `True`，並在部署文件明寫「內網自簽憑證的環境要顯式設 `secure: false`」。**改預設值會影響既有部署**（現有設定沒填 `secure` 的會突然改走加密而連不上），所以這條**必須配合升級說明一起上**，不能默默改。

---

### B2-D（記錄用）客戶端 agent 通道的認證預設整個關閉

**（工具未報，人工查證；回答卡片第 ⑦ 點）**

卡片明訂 `remote_agent_adapter.py`（273 行，本棒 10 檔中最大的一支）**只讀不深追**，完整掃描屬 **FR-077 的 CM-1594**。照辦，這裡只記讀到的一件事：

這支 adapter 負責把檔案操作轉發到**客戶自己機房裡的 agent 設備**。它有一套完整的保護（雙向憑證、短效簽章、回應指紋比對、撤銷檢查），寫得相當認真——**但整套的開關預設是關的**：

```python
mode=(os.getenv("AGENT_AUTH_MODE", "none") or "none").strip().lower()   # common/util/agent_auth_settings.py:22
```
```python
AGENT_AUTH_MODE = os.getenv("AGENT_AUTH_MODE", "none")                   # config/config.py:261
```

`none` 那條分支（`remote_agent_adapter.py:200-207`）**直接用設定裡的網址發請求，不加任何憑證、不強制 https**。程式註解自陳理由是「維持現狀，demo 不破」。

**不在本棒下判斷**——這條的完整評估（含客戶端那一側）請看 **CM-1594**。這裡只是把預設值這件事記下來，免得下一棒以為「有寫 mTLS 就等於有保護」。

**唯一附帶確認的好消息**：檔案完整性檢查（`_verify_integrity`，`:113-128`）是**無條件執行**的，不受這個開關影響——下載時會重算檔案指紋比對，不符就擋下並記稽核。**這一段做得對。**

---

## 5. 🔴 把 B1 和 B2 串起來：一條四層全空的完整路徑

**這是本報告最重要的一節。** B1 與 B2 各自只看到半條路，合起來才看得到全貌。

**B1（CM-1652，已驗收）證實：套件那一側四個對外端點零歸屬檢查。**
**B2（本棒）回答了 B1 留下的問題——「宿主那半邊有沒有補上？」答案是：沒有。**

一個請求從外面打進來，理論上會經過四道關卡。**實際上四道全是空的**：

| 第幾層 | 這一層應該做什麼 | 實際上做了什麼 | 誰查到的 |
|---|---|---|---|
| **① 網址入口**（route） | 確認「你是誰」＋「你能不能碰這個檔」 | **只確認「你有沒有登入」**。宿主交給套件的守門就一行 `jwt_required()`（`core/upload_file_wiring.py:133`），連 `?st=` 通行證那條的發證窗口也只驗登入 | B1 ＋ B2 |
| **② 業務邏輯**（app service） | 把檔案的所屬資源撈出來，判定呼叫者有沒有權限 | **什麼都不做**。三支方法各一行，只拿編號去挑後端（`managed_file_upload_service.py:352-362`），挑後端的函式明文只看「檔案自己記的 scope」、不看人（`:173-178`） | **B2 F1（本棒）** |
| **③ 資料查詢**（repository） | 查詢時至少帶一個範圍條件（你的租戶／你的專案／你建的） | **只用檔案編號一個條件**，沒有任何範圍限制（`UploadFileRepoImpl.get_by_uid`） | B1 追脈絡 ＋ B2 核對 |
| **④ 資料庫本身**（table／RLS） | 靠資料庫的租戶隔離當最後一道防線 | **那張表連「這是哪家客戶的」欄位都沒有**（`scripts/init/02-schema.sql:16839` 起的欄位清單），隔離機制也是關的——**想擋也擋不了** | B2 核對 |

> **RLS**（就是「每個客戶只能看自己資料」的資料庫隔離機制）——資料庫層級的自動過濾。這張表上沒開，而且**就算開了也沒用**，因為表上沒有可以用來判斷歸屬的欄位。

**結果：從網址進來到檔案吐出去，沒有任何一個地方會問「這個檔是不是你的」。**

而且**還有一個加乘**：B1 發現的「免登入下載通行證」那條路，走的時候程式會把租戶標成「無」，而系統把「無租戶」當成**最高權限、跳過所有隔離**（`jedi-iam/.../signed_token.py:88-107` 的 `_set_minimal_context`，docstring 自陳 `tenant_id=None` → 判為 super）。所以那條路不只沒有歸屬檢查，**它連原本就很薄的資料庫保護都一併關掉**。（這與 **CM-1559／FR-085 C1「無身分時隔離關掉」是同一個模式**。）

### 這對修法的意義（三句話）

1. **不能只修一層，因為沒有任何一層是「快修好了只差一點」——四層都是零。** 但真正該補的是**第②層**：那是唯一「已經把檔案撈出來、知道它掛在誰底下、而且照本專案規範就該做守門」的位置。
2. **B1 和 B2 必須同一張卡一起修。** 只修 B1（route 加檢查）→ 內部其他呼叫者繞過去；只修 B2（service 加檢查）→ B1 的通行證發放窗口還是照發（它在發證時根本沒碰到 service）。**兩邊各修一半，等於沒修。**
3. **第④層是獨立的一張卡，而且要排進去。** 給 `upload_files` 加租戶欄位＋開啟資料庫隔離需要 migration 與既有資料回填，工作量與風險都跟前兩者不同。但**沒有它，應用層那一道就是唯一防線**——而這份報告已經證明，唯一防線是會被漏掉的。

---

## 6. 卡片七個重點的逐項回覆

| 卡片項目 | 結論 | 依據 |
|---|---|---|
| **① 挑儲存後端時有沒有驗歸屬** | **❌ 沒有，成立（HIGH）** | 見 **F1**。工具 3/3 票 ＋ 首腦開檔核對。`_adapter_for_uid`（`:173-178`）的 docstring 自陳只看「檔案自己的 storage_scope」；三支對外方法（`:352-362`）各一行、中間零檢查 |
| **② 跨租戶借用憑證的 fallback** | **✅ 確實存在（MEDIUM）** | 見 **B2-B**。卡片標的 `:46` 是 docstring 講 remote_agent 的段落；**真正的跨租戶借用在 `:211-230`**（呼叫 `:263-277` → `system_storage_config_reader.py:51-58` → 一句關掉隔離的 SQL）。程式只記一行 warning 就繼續，不擋 |
| **③ 物件儲存憑證存 JSONB 且 `secure` 預設 False** | **✅ 成立，而且比卡片以為的更嚴重** | 見 **B2-A（HIGH）** 與 **B2-C（LOW）**。卡片問的是「存得安不安全」，追下去發現**更前面就漏了**：遮罩清單沒含 `STORAGE_CONFIG`（`:36`）、欄位名單沒含 `secret_key`（`:39`）、**而且兩支 GET 端點根本沒有能力檢查**（`:202-207`／`:367-372`）→ 任何登入者可讀明文帳密。`secure` 預設關（`:105`／`:119`）另記 LOW |
| **④ 三支繞過隔離的超級管理員讀取**（`system_storage_config_reader.py:21-57`） | **分兩半：兩支合理、一支就是 ② 的成因** | 逐支看過：`read_root_storage_config_value`（`:21-33`）與 `read_tenant_storage_config_value`（`:36-48`）**都釘死在一個指定的租戶＋指定的 group/key**，範圍極窄、有明確業務理由（系統共享資產／背景 job 無租戶脈絡），**判定合理**。但 `read_all_storage_config_values`（`:51-58`）**把所有租戶的儲存設定全撈**，它就是 **B2-B 的執行機關**——問題不在這支 reader 本身，在於 `managed_file_upload_service.py:218` 拿它來當「讀不到就借別人的」退路 |
| **⑤ 吞例外**（`core/upload_file_wiring.py:104`） | **✅ 確實是「什麼錯都吞」，但沒有資安影響** | `except Exception: logger.warning(...); return None`（`:104-106`）。它包的是「查使用者暱稱」這件事，吞掉之後的後果是**畫面顯示帳號而不是暱稱**，不影響任何權限判斷、不會放行任何請求。**這是出錯就降級顯示，不是出錯就放行。** 程式碼註解自己也把這個降級的副作用寫得很清楚（暱稱恆為 null 很難查）。**建議但不急**：把 `except Exception` 縮小到預期的例外型別，免得日後把真正的程式錯誤也一起吞掉 |
| **⑥ `di_containers/upload_file/` 接線有沒有漏傳守門** | **沒有漏，但「有傳」不等於「有用」** | `upload_file_containers.py`（34 行）**只組裝服務、完全不涉及守門**，看不出漏不漏。守門實際是在 `core/upload_file_wiring.py:124-139` 交給套件的，**三件都有傳**（`auth_required` / `file_access_guard` / `signed_token_issuer`），所以套件開機時的檢查（B1-8 查證的 `_assert_api_wiring`，缺件就拒絕啟動）**會過**。**真正的問題是傳進去的東西太弱**——`auth_required=lambda fn: jwt_required()(fn)`（`:133`）只驗身分。**套件的防呆只檢查「有沒有傳」，不檢查「傳的東西夠不夠」**，於是「宿主接了一個空守門」這件事在開機時完全看不出來。順帶確認一件做得對的事：服務是以 provider（工廠）形式傳入而非現成實例（`:128-129`，檔頭有專段說明），避免了併發下共用同一個物件的問題 |
| **⑦ `remote_agent_adapter.py` 只讀不深追** | **照辦。記一件事，判斷歸 CM-1594** | 見 **B2-D**。該檔有完整的雙向憑證＋簽章＋指紋保護，**但總開關 `AGENT_AUTH_MODE` 預設是 `none`**（`common/util/agent_auth_settings.py:22`、`config/config.py:261`），關閉時走明文 http、不帶任何憑證（`:200-207`）。**完整評估請派 FR-077 的 CM-1594，不要在本 arc 重開。** 附帶好消息：檔案完整性比對（`:113-128`）無條件執行、不受開關影響，這段做得對 |

**卡片沒問但順手確認的**：本棒 10 檔的**程式碼裡沒有任何硬編的帳密或金鑰**（人工逐檔看過）——憑證全部從資料庫設定或環境變數讀取，這一點是對的。工具報的那九條憑證問題**全都不在這 10 檔裡**（見第 3.2 節）。

---

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

分兩層講，**兩層的答案不一樣**。

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

- **F1 有雙重確認**：三位獨立檢查員**全票（3/3）通過**，而且**首腦親自開檔逐行核對過**（核對結果逐條寫在第 4 節「首腦核對註記」，三項全部屬實）。打開那幾行，程式就是那樣寫的——這是**事實陳述，不是推論**。
- **B2-A / B2-B / B2-C / B2-D 四條是人工查證的**，工具沒報。每一條的證據都是「某檔某行寫著什麼」，報告裡都附了確切行號，**任何人可以自己打開驗**。
- stamp 蓋的是 `verified`，**36 票全數投出**（12 條候選 × 3 位檢查員），沒有一票漏投、沒有一條候選未審。

### 第二層：「只有這些問題嗎」——**不保證，而且本棒有一個特別要老實說的地方**

五件事要說清楚：

1. **🔴 研究員在這 10 個檔上的實際注意力，可能不如「36 票」聽起來那麼多。** 本棒 12 條候選裡，**只有 1 條在範圍內，另外 11 條（去重後 9 條寫進報告）全是密鑰專項在全 repo 撈到的**。換句話說，**面板那 36 票裡有 27 票是在投範圍外的憑證問題**，真正投在這 10 個檔上的只有 3 票（F1 那一條）。數字看起來很飽滿，但**注意力的分配是嚴重傾斜的**。
2. **工具有沒有申報「逐檔讀過」的帳本？沒有。** 工具的報告只說「一位研究員讀了全部 10 個範圍內檔案」，**沒有逐檔列出讀了哪些、每個檔看了什麼**。所以「這 10 檔都被看過」這件事**只有一句自述，沒有可查核的紀錄**。
3. **這是 `low` 檔次的掃描**——一位研究員讀完全部範圍，**不做威脅建模、不跑廣度掃描**。深度夠，但不是地毯式。
4. **人工補洞這件事本身就是證據。** 卡片列的七個重點裡，**工具只碰到了第 ① 點**；②③④⑤⑥⑦ 六項全是人工照卡片追出來的，而其中 **B2-A 是 HIGH 級**。這正好印證首腦手冊裡寫的那條：**「報出來的都真，沒報的不保證」——工具不會照著卡片的清單走。**
5. **沒有實際測試。** 沒有真的打任何端點、沒有真的下載任何檔案、沒有拿任何一組帳密去連線試。**全部是讀程式碼、讀設定、唯讀查資料庫 schema 推論出來的。**

**合起來的建議**：F1 這條**可以直接開修正卡**（證據是事實陳述、首腦核對過）。但**不要因為這棒掃完了，就宣稱這 10 個檔已經乾淨**——真正被三人面板審視過的只有 1 條，其餘都是人工補的。若日後這塊要再掃一次，**把密鑰專項關掉**（focus 不開或範圍內先清乾淨），讓研究員的注意力全部落在範圍內。

---

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

| 項目 | 值 |
|---|---|
| 掃描範圍 | `app/upload_file/`＋`infra/upload_file/`＋`core/upload_file_wiring.py`＋`di_containers/upload_file/`，**10 檔**（與卡片數字一致；實際 910 行，其中 4 個是空的 `__init__.py`） |
| scanRoot | **BE 主專案** `compliance-manager-be/`（與 B1／B1b 的 jedi monorepo 不同） |
| commit | `8aa36333777342cbf4cfc1eb45e58d529081f64e`，branch `feature/FR-075`，**工作區有未提交異動**（`dirty: true`） |
| effort／focus | `low`／`attack-surface` |
| 研究員 | 派出 **2**、回來 **2**（第一位中途停滯五次後完成，故耗時長，但無研究產出遺失） |
| 候選發現 | 12 條 → 去重後 **12 條**（**無重複可去**） |
| 面板票數 | **36 票**（12 條 × 3 位檢查員），**全數投出**、0 票未投、0 條候選未審 |
| 達標發現 | **10 條**（另 2 條被三位檢查員一致否決） |
| 嚴重度分布（工具報的） | CRITICAL 0／**HIGH 6**／**MEDIUM 4**／LOW 0 |
| **其中在本棒範圍內的** | **1 條**（F1，HIGH，3/3 票）——**其餘 9 條全是範圍外的既有已知憑證問題**，見第 3.2 節 |
| 人工另外查到 | **4 條**（B2-A HIGH／B2-B MEDIUM／B2-C LOW／B2-D 記錄用），工具全未報 |
| 未達標被否決 | 2 條（工具未列明細） |
| 驗證輪數 | 1 輪（無續跑、無遺失候選、無因上限被砍掉的候選） |
| stamp | **`verified`**，`reason: null` |
| 工具產出 | `compliance-manager-be/CLAUDE-SECURITY-20260911-065519/`（該目錄自帶 `.gitignore`，不入版控） |

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

---

## 9. 給修正卡的建議切法（供決策者參考，本棒不開卡）

| 建議卡 | 內容 | 嚴重度 | 備註 |
|---|---|---|---|
| **甲：檔案存取歸屬檢查（B1＋B2 合併一張）** | B1-1／B1-3／B1-4 ＋ 本棒 F1，**四個出口同一個病、同一道修法**，套件側與宿主側**必須一起改** | **HIGH** | 拆開修＝等於沒修（理由見第 5 節） |
| **乙：儲存憑證明文外洩** | B2-A 三件事（補遮罩群組、補遮罩欄位、兩支 GET 端點補能力檢查） | **HIGH** | 改的是 `api/system_config/`，**與甲不同檔、可平行做**。要寫測試 |
| **丙：`upload_files` 加租戶欄位＋開啟資料庫隔離** | 第④層防線 | **HIGH** | 需 migration ＋ 既有資料回填，工作量與風險與甲乙不同，建議獨立排 |
| **丁：跨租戶借憑證的退路收窄** | B2-B | MEDIUM | 動同一個檔（`managed_file_upload_service.py`），**與甲有派工衝突，要序列做** |
| **戊：`secure` 預設改為加密** | B2-C | LOW | 改預設值會影響既有部署，**必須配升級說明**，不能默默改 |
| （不另開） | B2-D agent 通道認證預設關閉 | — | **歸 FR-077 CM-1594**，不在本 arc 重開 |
| （不另開） | 工具報的 F2～F10 九條憑證問題 | — | **全是舊案**：CM-1607／CM-1608／CM-1629／CM-1631／總表 §3.1 第 3 項 |

---

## 10. 座標

- 本棒範圍：`app/upload_file/`、`infra/upload_file/`、`core/upload_file_wiring.py`、`di_containers/upload_file/`（BE repo）
- **上一棒（必讀對照）**：[`scan-B1-http-boundary.md`](scan-B1-http-boundary.md)（CM-1652，套件的 HTTP 邊界 35 檔）
- **下一棒**：B1b（CM-1654，儲存後端與持久層 26 檔）——**它要回答第 5 節那張表的第③④層**，本報告的核對只到「開檔看到沒有範圍條件、表上沒有租戶欄位」為止
- 相關前案：**CM-1594**（FR-077 R3，遠端 agent 宿主接線，B2-D 歸它）、**CM-1559**（FR-085 C1，無身分時隔離關掉，與第 5 節的「通行證把隔離一併關掉」同型）
- 舊案對照（第 3.2 節的九條）：CM-1607／CM-1608／CM-1629／CM-1631
- 跨 arc 總表：[`security-scan-consolidated/`](../security-scan-consolidated/)
- 首腦手冊：`.claude/skills/security-scan-lead/SKILL.md`
