# FR-086.B1b 掃描報告：儲存後端實作與持久層（jedi-file-upload）

- **卡片**：[CM-1654](https://app.notion.com/p/FR-086-B1b-26-jedi-monorepo-Opus-1M-low-3d8346da4cd081d2a87ddb0e81f30724)（母卡 [CM-1651](https://app.notion.com/p/FR-086-jedi-file-upload-3d8346da4cd081a28367de28f61ef2fa)）
- **掃描範圍**：`jedi_file_upload/infra/`＋`domain/`＋`migrations/`，共 **26 檔**（jedi monorepo）
- **版本**：commit `da25a8d922e682f34b96c4539636ad315a2ab3bb`（branch `feature/FR-075`，工作區乾淨）
- **日期**：2026-09-11
- **工具**：claude-security plugin，`low` effort，focus `attack-surface`
- **面板**：2 條候選 × 3 位檢查員 = **6 票全數投出**，stamp `verified`
- **🔴 工具達標發現：0 條**（兩條候選都被 1:2 否決）。**本報告的內容幾乎全部是人工查證的**——這件事本身就是結論的一部分，見第 6 節。

---

## 1. 🔴 一句話結論

**掃完了，工具零發現，但這一棒最重要的事反而被證實了：存檔案的那張表，資料庫層真的沒有任何隔離——我實際連上 DEV 資料庫查過，隔離開關是關的、規則零條、欄位裡連「這是哪家客戶的」都沒有。**

這句話的意思是：**B1 與 B2 說的那條「四層全空」的路徑，第③層（查詢無範圍條件）與第④層（資料庫無兜底）在這一棒被實際查詢坐實了**，不再是讀 schema 檔推論的。前兩棒只能說「建表腳本自陳沒有租戶欄位」，現在是**跑在真實資料庫上的查詢結果**。

好消息有三個，而且都是實質的：

- **路徑穿越（卡片②）不成立**——而且與 B1 的理由不同：這一棒查的是「進來之後有沒有被正規化」，答案是**根本沒有使用者可控的字串能走到寫檔那一步**。三個後端都是如此。
- **把檔名餵給系統指令（卡片③）不成立**——兩處 LibreOffice 呼叫**都用陣列形式**（安全寫法），沒有 `shell=True`，而且餵進去的路徑**沒有一段來自使用者**。
- **工廠找不到後端時是拋錯，不是亂挑一個**（卡片⑥）——**fail-closed，做得對。**

壞消息除了資料庫那條，還有兩個**三個後端不一致**的地方（卡片⑤要的對照表在第 5 節）：**主機硬碟後端少了物件儲存那邊有的衍生檔清理**，以及**只有物件儲存那條會把含密碼的設定整包寫進 log**。

---

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

前兩棒看的是「請求怎麼進來」和「宿主怎麼接線」。**這一棒看的是檔案真正落地的地方**——三個儲存後端各自怎麼存怎麼取、檔案編號怎麼查、以及那張記錄檔案的資料表長什麼樣。

先解釋幾個詞：

> **儲存後端**＝檔案實際存放的地方。這套產品支援三種：**主機硬碟**（直接存在伺服器的資料夾裡）、**MinIO** 與 **SeaweedFS**（兩種「物件儲存」，就是專門放檔案的外部服務，連它要帳號密碼）。
>
> **持久層**＝把「這個檔案叫什麼、存在哪」記進資料庫的那段程式，加上那張資料表本身。
>
> **路徑穿越**＝用 `../` 這種寫法，讓檔案被存到或讀到原本不該碰的目錄（例如跳出上傳資料夾、寫進系統目錄）。
>
> **RLS**（Row Level Security）＝資料庫層級的自動過濾，就是「每個客戶只看得到自己的資料」這個機制。它在資料庫裡，**應用程式忘記檢查時它還會擋**，所以是最後一道防線。

這一棒要回答三個問題：**路徑是怎麼組出來的、查檔案時有沒有範圍限制、有沒有把外部輸入餵給系統指令。**

**只找問題、不修問題**——底下所有修法都只是建議，這一棒**沒有動任何一行程式碼**，也**沒有真的上傳、下載或寫入任何檔案去測路徑穿越**（全部是讀程式碼推論）。對資料庫**只做了唯讀查詢**（`SELECT`），沒有任何寫入。

---

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

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 嚴重度 | 來源 |
|---|---|---|---|---|---|---|
| **B1b-1** | **存檔案的資料表在資料庫層完全沒有隔離**——隔離開關是關的、規則零條、連「這是哪家客戶的」欄位都沒有 | 應用層一旦漏掉歸屬檢查（**B1／B2 已證實它確實漏了**），**沒有任何東西會擋**——猜到或看到檔案編號就能取得別人的檔案 | 應用層無檢查（已成立） | 查詢層 `infra/repository/upload_file_repo_impl.py:41-45`、表定義 `infra/models/upload_file.py:16-32`、建表 `migrations/001-upload-file-tables.sql:29-47`、不掛隔離 `migrations/002-upload-file-grants.sql:10-15` | **HIGH**（承接 B1／B2，本棒坐實） | **工具未報，人工＋資料庫實查** |
| **B1b-2** | **連物件儲存失敗時，把含帳號密碼的整包設定寫進 log** | 誰能看到 log（維運人員、log 收集系統、被拿到的備份）就拿到物件儲存帳密，**可完全繞過系統直接開倉庫** | 要先觸發連線失敗；**寫設定需要管理能力**（正因如此工具判不成立） | `infra/adapter/minio/minio_adapter.py:62` | **MEDIUM**（工具 1/3 票否決，**runner 保留為程式衛生問題**） | 工具候選 C2（被否決）＋人工複核 |
| **B1b-3** | **主機硬碟後端刪檔時，不會清掉轉檔產生的 PDF 快取**——物件儲存那邊會清，它不會 | 使用者刪了稽核證據，**由它轉出來的 PDF 還留在硬碟上、資料庫紀錄也還在**，等於「刪了但沒真的刪掉」 | 用主機硬碟後端 ＋ 該檔曾被預覽過 | `infra/adapter/local/local_file_adapter.py:83-95`（缺 `_delete_derived_files`，對照 `minio_adapter.py:140`／`:170-181`） | **MEDIUM** | **工具未報，人工查證（卡片⑤的對照）** |
| **B1b-4** | **主機硬碟後端的預設存放位置是 `/tmp/upload/`** | `/tmp` 在多數系統上**所有使用者都讀得到**，且重開機會被清空 | 宿主沒給設定（**Guidant AI 有給，故實際不觸發**） | `infra/adapter/local/local_file_adapter.py:38` | **LOW**（實際部署不觸發） | 工具未報，人工查證 |
| **B1b-5** | **轉檔產生的 PDF 落在系統共用暫存區**，檔名可預測 | 同一台機器上的其他使用者可能讀到別人的預覽 PDF | 要能登入該台機器（一般使用者打不到） | 呼叫端 `api/routes/upload_file_route.py:216`／`:235`（`tempfile.gettempdir()`）＋ `local_file_adapter.py:130` | **LOW** | 工具未報，人工查證 |
| **B1b-6** | 路徑穿越（**卡片②，本棒重點**） | **經查不成立**——三個後端都沒有使用者可控的字串能到達寫檔那一步 | — | 見第 4 節 B1b-6 | **無問題** | 工具 1/3 票否決 ＋ runner 人工複核 |
| **B1b-7** | 把檔名餵給系統指令（**卡片③**） | **經查不成立**——兩處都用陣列形式、無 `shell=True`、餵的路徑不含使用者輸入 | — | `local_file_adapter.py:135`／`minio_adapter.py:263` | **無問題** | 工具未報，人工查證 |
| **B1b-8** | 工廠找不到後端時的退路（**卡片⑥**） | **無問題——拋錯，fail-closed，做得對** | — | `infra/factory/upload_file_factory.py:21-22` | **無問題** | 工具未報，人工查證 |

**卡片八項的逐項結論在第 7 節，每一項都有「成立／不成立」，不留白。**

---

## 4. 每條發現的詳述

### B1b-1（HIGH）存檔案的表，資料庫層完全沒有隔離 —— **實際查詢結果**

**現況**：已修（M02-6，2026-09-16 補上客戶歸屬欄位並開啟隔離，2,718 筆全數回填）

**這是卡片列為「本棒最重要」的第①點，結論是成立，而且這次有實查證據。**

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

一個系統要防止「A 客戶看到 B 客戶的檔案」，通常有兩道防線：

1. **應用層**：程式在給檔案之前，自己檢查「這個檔是不是你的」。
2. **資料庫層**（RLS）：就算程式忘了檢查，資料庫也會自動只回傳屬於你的那幾列。

**B1 與 B2 已經證實第 1 道完全不存在。** 這一棒要回答的是：**第 2 道有沒有？**

**答案是沒有。以下是我對 DEV 資料庫實際跑的唯讀查詢與原始輸出：**

```
$ psql -h 192.168.50.188 -p 25432 -U cm_app -d guidant_ai_dev

-- 這張表有沒有開啟隔離？
SELECT relname, relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname='upload_files';

   relname    | relrowsecurity | relforcerowsecurity
--------------+----------------+---------------------
 upload_files | f              | f
(1 筆資料)

-- 有幾條隔離規則？
SELECT count(*) AS policy_count FROM pg_policies WHERE tablename='upload_files';

 policy_count
--------------
            0
(1 筆資料)

-- 這張表有哪些欄位？
SELECT column_name FROM information_schema.columns
 WHERE table_name='upload_files' ORDER BY ordinal_position;

  column_name
----------------
 id
 uid
 file_name
 file_ext
 save_file_name
 size
 mimetype
 path
 created_user
 created_at
 updated_user
 updated_at
 storage_type
 ref_id
 checksum
 sha256
 storage_scope
(17 筆資料)
```

**這三個結果的白話翻譯：**

1. `relrowsecurity = f` 的 `f` 是 false，意思是**這張表的隔離開關是關的**。
2. `policy_count = 0`，意思是**一條隔離規則都沒有**——就算把開關打開也沒有規則可套（而且照建表腳本的註解，貿然打開會讓所有檔案下載當場 404）。
3. 17 個欄位裡**沒有 `tenant_id`、沒有 `org_unit_id`**，**連一個能判斷「這是哪家客戶的」的欄位都沒有**。

第 3 點是最根本的：**就算有人想開啟隔離，也無從判起**——資料庫拿不到任何可以用來過濾的依據。

**而查詢層也沒有補上任何條件。** 這一棒範圍內的資料查詢方法，每一支都只用檔案編號或關聯編號當條件：

```python
def get_by_uid(self, _uid) -> UploadFileEntity | None:          # :41-45
    res = self.session.query(self.model).filter_by(uid=_uid).first()   # ← 只有 uid 一個條件

def get_by_uids(self, uids: list[str]) -> list[UploadFileEntity]:      # :47-52
    res = self.session.query(self.model).filter(self.model.uid.in_(uids)).all()

def get_by_ref_id(self, ref_id: str) -> list[UploadFileEntity]:        # :54-59
    res = self.session.query(self.model).filter(self.model.ref_id == ref_id).all()

def delete_by_uid(self, uid) -> bool:                                  # :108-111
    res = self.session.query(self.model).filter_by(uid=uid).delete()   # ← 刪除也一樣
```

**七支查詢／刪除方法，沒有任何一支帶租戶、擁有者或專案條件。** 連刪除都是憑編號直接刪。

**（a）有沒有別的地方補上這道檢查？**

卡片要我追這一題，答案是**沒有**，而且三層都查過：

| 哪一層 | 有沒有補 | 依據 |
|---|---|---|
| 套件的領域服務 | ❌ 沒有 | `domain/service/upload_file_domain_service.py` 全檔 45 行，**每一支都是一行轉呼叫 repo**，零判斷 |
| 套件的應用服務 | ❌ 沒有 | B1 已查證（`app/service/` 兩支只有 `@transaction`） |
| 宿主 | ❌ 沒有 | B2 已查證（`managed_file_upload_service.py:352-362` 三支各一行） |

**（b）資料庫層有沒有設隔離規則？** 上面的實查結果：**沒有，而且沒有可以用來設的欄位。**

**（c）所以結論是什麼？** **猜到或看到檔案編號，就能取得別人的檔案**——而且**不需要猜**，任何列出附件的頁面，API 回應裡就帶著編號（B1 已查證）。

**出事會怎樣**

別家客戶的稽核證據、SSP 文件、問卷附件可被讀走或**永久刪除**。這與 B1／B2 是同一件事的最後一段：**四層全空，這一棒證實的是最後兩層。**

**建表腳本自己怎麼說？（卡片⑧要追的）**

兩支 SQL 的檔頭都對這件事有自覺，而且說得很清楚：

```sql
-- 001-upload-file-tables.sql:24-27
-- 🔴 本表**沒有多租戶欄位**（無 tenant_id / org_unit_id）……租戶隔離由
--    「檔案的持有者資源」那一層負責（job_evidences 等），不在本表。

-- 002-upload-file-grants.sql:10-15
-- 🔴 本表**刻意不掛 RLS**……`upload_files` 沒有 tenant_id 欄位，掛租戶隔離無從判起；
--    隔離在「檔案的持有者資源」那一層（job_evidences / ssp_reference_documents 等）
```

**這個設計決定在今天還站得住嗎？** 卡片問的正是這題，答案是：**不站得住了，但不是因為當初想錯，而是因為它依賴的前提沒有被實現。**

當初的設計是：「本表不管隔離，**由持有者資源那一層負責**」——這在理論上是合理的分層（檔案本身沒有租戶概念，掛在誰底下才有）。**問題是 B1 與 B2 已經證實：那一層根本沒有做。** 這個設計把責任交給了一個**不存在的檢查**。

所以修法有兩條路，**兩條都要做**：

1. **（治標，該優先）補上持有者那一層的檢查**——也就是 B2 報告建議的「甲卡」，在 `ManagedFileUploadService` 三支方法補歸屬判定。**這是設計原本就假設有、但實際沒有的那一塊。**
2. **（治本）給 `upload_files` 加租戶欄位並開啟資料庫隔離**——讓資料庫成為最後一道防線。**注意建表腳本的警告是對的**：加了 `ENABLE ROW LEVEL SECURITY` 卻沒有 policy，`cm_app` 會一列都讀不到、所有檔案下載當場 404。所以這條要**欄位、回填、policy 三件一起上**，屬獨立一張卡的工作量（B2 報告的「丙卡」）。

**⚠️ 不要只做第 2 條**：系統共享資產（`storage_scope=system` 的合規框架 PDF）本來就該跨租戶讀得到，純靠資料庫隔離會把它們一起擋掉。

---

### B1b-2（MEDIUM）連物件儲存失敗時，把含密碼的整包設定寫進 log

**（工具候選 C2，被面板 1:2 否決；runner 複核後保留為程式衛生問題，說明如下）**

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

建立物件儲存連線失敗時，程式記一行錯誤 log：

```python
except Exception as e:
    logger.error(f"Connect to Minio field {config} failed: {e}")   # minio_adapter.py:62
```

`{config}` 是**整個設定物件**。那個物件是個單純的資料類別，**沒有自訂顯示方式**，所以 Python 會把它的每一個欄位都印出來——**包含 `access_key` 與 `secret_key`（物件儲存的帳號與密碼）**。

> 為什麼會這樣：Python 的資料類別（dataclass）預設的顯示方式就是「把所有欄位列出來」。要讓密碼不被印出來，必須自己覆寫顯示方法把它遮掉，**而這裡沒有覆寫**（`ports/dto/minio_upload_config_dto.py:6-12`）。

**為什麼工具判不成立，我又為什麼保留它**

三位檢查員裡兩位判不成立，理由是**攻擊路徑兩端都斷**：要讓這行執行，必須先讓設定變成畸形，**而改設定需要管理能力**——所以「能觸發外洩的人，本來就已經握有那組密碼」。**這個推論是對的，我複核過，同意它不構成一條可被外人利用的攻擊路徑。**

**但我保留它為 MEDIUM 的程式衛生問題，理由有三個：**

1. **log 的讀者比設定的寫者多很多。** 觸發需要管理權限沒錯，但**觸發之後密碼就躺在 log 檔裡**——維運人員、log 收集系統、備份檔的讀者都看得到，這些人不需要管理權限。
2. **B2 已經證實同一組密碼另有一條真的外洩路徑**（B2-A：任何登入者打一支 GET 就拿到明文）。兩條合看，**這組憑證的保護是全面性地薄弱**，不是單點。
3. **它會因為別處的改動而變成真問題。** 今天擋住它的是「改設定要管理能力」這個外部前提，**不是這行程式碼本身**。哪天設定來源變了（例如從環境變數讀、或加了自助設定畫面），這行就直接變成外洩點。

**怎麼修**（很小的改動）

兩個做法擇一：

1. **log 不要印整包設定**，只印不敏感的部分：把 `{config}` 改成 `{config.endpoint}`。
2. **或在設定類別上覆寫顯示方式**，把 `access_key` / `secret_key` 換成 `***`（這個做法更好，因為以後不管誰在哪裡印它都安全）。

建議選第 2 個，改在 `ports/dto/upload_config_dto.py` 的共同父類別上，**三個後端的設定一次全部受惠**。

---

### B1b-3（MEDIUM）主機硬碟後端刪檔時，漏清轉檔產生的 PDF —— **卡片⑤要找的「三個後端不一致」**

**現況**：已修（CM-2062，M02-9：衍生檔清理抽成共用函式，1.21.0 出貨）

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

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

使用者預覽 Office 檔時，系統會把它轉成 PDF 存起來當快取。那份 PDF 是**由原始檔衍生出來的副本**。

物件儲存那條**刪原始檔時會連衍生的 PDF 一起刪**——程式裡有一支專門做這件事的方法，註解也寫明「避免孤兒」：

```python
# minio_adapter.py:140（單筆刪除）與 :167（批次刪除）都有呼叫
self._delete_derived_files(file_uid)
```

**主機硬碟那條沒有。** `local_file_adapter.py` 的 `delete_file`（`:83-95`）與 `delete_files_by_uids`（`:97-109`）**兩支都沒有這一步**，整個檔案裡也**沒有 `_delete_derived_files` 這支方法**。

**出事會怎樣**

- 使用者刪了一份稽核證據，**由它轉出來的 PDF 還留在硬碟上**，而且**資料庫裡那筆紀錄也還在**（指向一個仍然存在的 PDF 檔）。
- 對合規產品而言這是真問題：**「刪除」沒有真的刪乾淨**。若那份證據是因為含敏感內容才被刪，**內容還在 PDF 裡躺著**。
- 累積下來也是磁碟空間的漏水。

**為什麼是 MEDIUM**：它不讓外人拿到新東西（要讀到那個 PDF 仍得經過同樣沒有歸屬檢查的下載路徑——那是 B1b-1 的問題），但**它讓「已刪除」這個狀態變成假的**，而這對稽核證據保管系統是實質缺陷。

**在哪裡**

```
jedi_file_upload/infra/adapter/local/local_file_adapter.py:83-95    delete_file（缺清理）
jedi_file_upload/infra/adapter/local/local_file_adapter.py:97-109   delete_files_by_uids（缺清理）
jedi_file_upload/infra/adapter/minio/minio_adapter.py:170-181       _delete_derived_files（正確的參考實作）
jedi_file_upload/infra/adapter/minio/minio_adapter.py:140／:167     兩處呼叫點
```

**怎麼修**

在 `LocalFileUploadAdapter` 補一支對應的 `_delete_derived_files`，照 `minio_adapter.py:170-181` 的形狀：用 `get_by_ref_id(source_uid)` 找出衍生檔、刪掉硬碟上的檔案、刪掉資料庫紀錄，然後在 `delete_file` 與 `delete_files_by_uids` 兩處都呼叫它。

**更好的做法**：這段邏輯三個後端都需要，**應該提到共用的父類別或領域服務**，讓「刪原始檔要連帶清衍生檔」變成一條**不可能漏掉**的規則，而不是每個後端各自記得要做。**這正是本專案反覆出現的那個形狀**——同款功能多份實作、只修了一份。

---

### B1b-4（LOW）主機硬碟後端的預設存放位置是 `/tmp/upload/`

**（工具未報，人工查證；回答卡片②的 (a)）**

```python
self.save_dir = self.config.base_dir if self.config.base_dir else "/tmp/upload/"   # :38
```

**這是什麼問題**：`/tmp` 是系統共用暫存區，在多數 Linux 系統上**所有使用者都讀得到**，而且**重開機會被清空**。上傳的稽核證據若存在那裡，既不安全也不持久。

**卡片問「實際部署有沒有給設定？」——有。** 這是個 fallback 預設值，Guidant AI 的部署有明確指定目錄，**所以實際上不會落到 `/tmp`**。因此只評 LOW。

**但它仍值得修**：安全的預設值應該是「沒設定就拒絕啟動」，而不是「靜默落到一個全世界都讀得到的目錄」。這與 B1 查到的 `_assert_api_wiring`（缺守門就拒絕啟動）是同一個精神——**那裡做對了，這裡沒有**。

---

### B1b-5（LOW）轉檔產生的 PDF 落在系統共用暫存區

**（工具未報，人工查證；回答卡片③的 (d)）**

卡片問「轉檔產生的暫存檔放哪、會不會被別人讀到」。追下去：

- **輸出目錄**來自呼叫端，兩處都是 `tempfile.gettempdir()`（`api/routes/upload_file_route.py:216` 與 `:235`），也就是系統共用暫存區（通常是 `/tmp`）。
- **主機硬碟那條**（`local_file_adapter.py:130`）把轉好的 PDF **留在那裡**給下載程式讀，**不會刪掉**。檔名是 `save_file_name` 把副檔名換成 `.pdf`，**是可預測的**。
- **物件儲存那條**（`minio_adapter.py:253-341`）做得比較好：用系統的安全暫存檔機制產生檔名（隨機、權限僅擁有者），轉完**搬走並上傳回倉庫**，而且在 `finally` 區塊**確實刪掉暫存檔**。

**所以這一條只有主機硬碟那條中招**，又是一個三後端不一致的點。

**出事會怎樣**：**同一台機器上的其他使用者**可能讀到別人的預覽 PDF。要打到它得先能登入那台伺服器，**一般使用者從網路打不到**，所以評 LOW。

**程式碼註解對這個選擇有說明**（`upload_file_route.py:226-234`），理由是落地版的唯讀檔案系統會讓原本的做法爆炸——**這個理由是正當的**，不是隨手寫的。修法不是改回去，而是**讓主機硬碟那條比照物件儲存那條**：用安全暫存檔機制、讀完就刪。

---

### B1b-6（無問題）路徑穿越 —— **卡片②，經查不成立**

這是卡片的重點之一，**結論是不成立**。因為證偽跟證實一樣有價值，這裡寫清楚**是什麼擋住了**。

**B1 已經從「檔名」那一側證偽過一次**（`split('.')[-1]` 取不到 `..`）。**這一棒是從「落地」那一側再驗一次**——卡片要的是「進來之後有沒有被正規化」。

**答案比「有沒有正規化」更乾脆：根本沒有使用者可控的字串能到達寫檔那一步。**

逐一追三個後端：

**① 主機硬碟後端**（`local_file_adapter.py:57-60`）

```python
file_info_dto = generate_save_info(self.save_dir, file)
fill_path = os.path.join(file_info_dto.path, file_info_dto.save_file_name)
```

兩段各自來自哪裡（卡片②的 (c) 問的就是這個）：

| 這一段 | 來自哪裡 | 使用者能不能控制 |
|---|---|---|
| `file_info_dto.path` | 就是 `self.save_dir`，在**建構當下**從設定抄來（`:38`） | ❌ 不能——**B1 已查證**：想改它的那條路（`set_save_dir`）寫入的時機晚於建構，寫進去也沒人再讀 |
| `file_info_dto.save_file_name` | `f'{時間戳}_{隨機編號}.{副檔名}'`（`file_utils.py:17`） | ❌ 不能——副檔名被夾在時間戳與隨機編號**之後**，且該段**依定義不含點**，拼不出 `..`，也跑不到最前面變成絕對路徑 |

**兩段都不可控，所以組出來的路徑必然在設定的目錄底下。**

**（d）有沒有檢查「組出來的路徑還在允許的目錄底下」？** 卡片問的這道檢查（常見做法是把路徑展開後比對前綴）——**沒有**。程式沒有做這道檢查。**但因為兩段輸入都不可控，這道檢查在目前的寫法下沒有發揮空間。**

**② 取檔時**（`local_file_adapter.py:45-47`）：兩段都來自**資料庫裡那筆紀錄**（`entity.path` 與 `entity.save_file_name`），而那兩個值是上傳時由上面那套機制產生並寫入的，**同樣不含使用者可控成分**。

**③ 兩個物件儲存後端**：**結構上不吃路徑**。物件儲存是用「桶名 + 物件名」定位，不是檔案系統路徑，`..` 對它沒有意義。桶名來自設定（`self.config.bucket_name`），物件名是上面那個安全的檔名。

**⚠️ 但要記著一件事**（與 B1 的提醒同一件）：**這是靠「兩段輸入剛好都不可控」擋住的，不是有人刻意防的。** 程式裡**沒有任何一道明確的路徑檢查**。哪天有人改了檔名產生邏輯（例如為了保留原始檔名而改成 `f'{原檔名}_{編號}'`），**這個洞就會立刻長回來**。

**建議**：在 `generate_save_info` 產生檔名後、或在 `save_file` 寫檔前，補一道明確的檢查——把組出來的路徑展開，確認它仍在 `self.save_dir` 底下，不是就拒絕。**把意外的安全變成有意的安全。**

---

### B1b-7（無問題）把檔案路徑餵給系統指令 —— **卡片③，經查不成立**

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

卡片擔心的是：程式呼叫 LibreOffice 做轉檔時，會不會把使用者控制的檔名餵進系統指令，讓人有機會夾帶別的指令進去。

**逐項回答卡片的四個小問：**

**（b）是用陣列形式呼叫（安全）還是字串加 `shell=True`（危險）？**

**兩處都是陣列形式，都沒有 `shell=True`。**

```python
# local_file_adapter.py:135-138
result = subprocess.run([
    libreoffice_path, '--headless', '--convert-to', 'pdf',
    '--outdir', output_folder, input_file
], capture_output=True, text=True)

# minio_adapter.py:263-275
result = subprocess.run(
    [libreoffice_path, '--headless', '--convert-to', 'pdf',
     '--outdir', output_folder, tmp_input_path],
    capture_output=True, text=True
)
```

> **為什麼陣列形式是安全的**：用陣列時，每一個元素都被當成**一個完整的參數**原樣傳給程式，不會經過命令列的解析。所以就算某個參數裡含有 `;`、`|`、`$()` 這類特殊字元，它們也只是**檔名裡的普通字元**，不會被當成指令。危險的是另一種寫法（把整串指令拼成一個字串加上 `shell=True`），**這裡沒有用**。

**（a）餵進去的檔名來自哪裡？**

- 主機硬碟那條：`input_file` 來自 `self.get_file(file_uid)`，也就是**資料庫紀錄拼出來的路徑**（如 B1b-6 所述，不含使用者可控成分）。
- 物件儲存那條：`tmp_input_path` 來自**系統的安全暫存檔機制**（`tempfile.mkstemp`），檔名是隨機產生的，**完全不含使用者輸入**。

**（c）檔名含特殊字元會怎樣？**

不會怎樣。**兩層保護疊在一起**：第一層是檔名裡本來就不含使用者輸入；第二層是就算含了，陣列形式也會把它當成普通字元。

唯一與使用者沾邊的是**副檔名**（`suffix = f".{input_file_entity.file_ext}"`，`minio_adapter.py:249`），但它只是被當成暫存檔的結尾，同樣經過陣列形式傳遞，**不構成指令注入**。

**（d）轉檔產生的暫存檔放哪、會不會被別人讀到？** → 這一問有實質發現，另記為 **B1b-5**。

**結論：指令注入不成立，兩處寫法都正確。** 但 `local_file_adapter.py:138` 有個小瑕疵（**非安全問題**）：它取了 `result` 卻**從來沒檢查過執行結果**，轉檔失敗時只會安靜地回傳 `None`。物件儲存那條有檢查（`minio_adapter.py:276-278` 會記錄錯誤原因）。**又是一個三後端不一致的點**，順手記在第 5 節的對照表。

---

### B1b-8（無問題）工廠找不到後端時的退路 —— **卡片⑥，做得對**

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

卡片要我對照 FR-075 S7 查 `auth_factory` 時的做法，看這支工廠找不到對應後端時會怎樣。

```python
def get_upload_adapter(self, provider: str, config=None) -> IUploadFileProvider:
    if provider not in TYPE:
        raise ValueError(f"Type {provider} is not supported")     # :21-22
    adapter = TYPE[provider]
    return adapter(config)
```

**它是拋錯，不是隨便挑一個後端頂替。** 這是**出錯就擋下來（fail-closed）**，正確。

> **為什麼這件事重要**：如果它改成「找不到就用主機硬碟」之類的退路，那麼一個設定錯誤就會讓**本該進客戶物件儲存的檔案，靜靜地落到伺服器硬碟上**——沒有人會發現，因為功能看起來是正常的。**fail-open（出錯就放行）的可怕之處就在於它不會報錯。**

**與對照組比較**：FR-075 S7 查的 `auth_factory` 是拋 405（正確），這支是拋 `ValueError`（同樣正確）。**兩支都屬於做得對的那一類。**

**唯一的小建議**（非安全問題）：拋的是內建的 `ValueError`，而套件其他地方用的是專屬的錯誤類別（`jedi_common.handler.exception`）。改成一致的錯誤型別會讓錯誤處理更整齊，但**不影響安全性**。

---

## 5. 🔴 三個後端的一致性對照表（卡片⑤要的）

卡片特別要求「要給一張對照表，一眼看出哪支有哪支沒有」。**這是本棒除了資料庫實查之外，最有價值的一節**——因為「同款功能多份實作、只修了一份」是本專案反覆出現的形狀（FR-085 C1 剛撞到 Redis 那個漏網複製品）。

先講一件重要的事：**SeaweedFS 那一支其實沒有自己的實作。** 它整支只有 24 行，**完全繼承自 MinIO 那一支**，只覆寫兩個「身分標籤」（`seaweedfs_adapter.py:22-23`）。所以**凡是 MinIO 有的，SeaweedFS 都有；MinIO 缺的，SeaweedFS 也缺**。檔案裡的註解把這個設計講得很清楚，**而且特別警告不要因為「幾乎是空的」就把它併回去**——理由是兩者的差別不在行為，而在資料列上留下的後端身分。**這個設計判斷是對的。**

**所以真正的比較只有兩方：主機硬碟 vs 物件儲存。**

| 項目 | 主機硬碟<br>`local` | MinIO<br>`minio` | SeaweedFS<br>`seaweedfs` | 一致嗎 |
|---|:---:|:---:|:---:|---|
| **刪檔時清掉衍生的 PDF 快取** | ❌ **沒有** | ✅ 有 | ✅ 有（繼承） | 🔴 **不一致** → **B1b-3** |
| **連線失敗時把含密碼的設定寫進 log** | ➖ 不適用<br>（不需連線） | ⚠️ **會** | ⚠️ **會**（繼承） | 🔴 **不一致** → **B1b-2** |
| **轉檔暫存檔用安全機制產生 + 用完刪除** | ❌ 留在共用暫存區<br>不刪、檔名可預測 | ✅ 隨機檔名<br>`finally` 確實刪 | ✅ 有（繼承） | 🔴 **不一致** → **B1b-5** |
| **檢查轉檔指令的執行結果** | ❌ 取了卻不檢查<br>（`:138`） | ✅ 檢查並記錄原因<br>（`:276-278`） | ✅ 有（繼承） | 🟡 **不一致**（非安全問題） |
| **設定型別守衛**（防止吃到別種後端的設定） | ✅ 有（`:31-34`） | ✅ 有（`:45-48`） | ✅ 有（繼承＋覆寫型別） | ✅ 一致 |
| **呼叫系統指令用安全的陣列形式** | ✅ 是 | ✅ 是 | ✅ 是（繼承） | ✅ 一致 |
| **寫檔路徑含使用者可控成分** | ✅ 否 | ✅ 否（不走路徑） | ✅ 否（繼承） | ✅ 一致 |
| **查檔案時帶範圍條件** | ❌ 沒有 | ❌ 沒有 | ❌ 沒有 | ⚠️ **一致地都沒有**<br>→ **B1b-1** |

**這張表的三句話總結：**

1. **不一致的四項全部是「主機硬碟那支缺了物件儲存那支有的東西」**，沒有反過來的情況。合理的解釋是：物件儲存那支後來被持續維護（衍生檔清理、暫存檔處理、錯誤檢查都是後加的），**而主機硬碟那支沒有跟上**。
2. **修的時候應該把共通邏輯往上提**（共用父類別或領域服務），而不是把四段程式碼複製到主機硬碟那支——**否則下次又會有第五項不一致。**
3. **最後一列是唯一「一致」卻是壞事的**：三個後端都沒有範圍條件，因為那不是後端的責任，**而該負責的那一層沒有做**（B1b-1）。

---

## 6. 🔴 這一份的可信度：分兩層講，而且第一層要先講一件事

### 先講：本報告的內容幾乎全部是人工查證的

**工具這一棒的達標發現是 0 條。** 兩條候選都被面板以 1:2 否決。

如果只交工具的結果，這一棒的報告會是一句「無發現」——**而那會是嚴重誤導的**，因為卡片列為「本棒最重要」的第①點（資料庫有沒有隔離）**是成立的，而且是本 arc 最關鍵的一塊拼圖**。

**這件事本身就是結論**，而且它印證了首腦手冊裡那條：**「報出來的都真，沒報的不保證」——工具不會照著卡片的清單走。** 本棒卡片列了八個重點，**工具只碰到了其中一個半**（②路徑穿越，以及④的一部分），其餘六項半全是人工照卡片追出來的。

### 第一層：「這些問題是真的嗎」——**可信度高，而且本棒有一條是最高等級的證據**

- **B1b-1 是實際查詢結果，不是推論。** 我連上 DEV 資料庫跑了三個唯讀查詢，原始輸出原樣貼在第 4 節。**這是本 arc 三棒裡證據等級最高的一條**——前兩棒只能說「建表腳本自陳沒有租戶欄位」，這一棒是資料庫本人回答的。任何人可以自己跑同樣的查詢複驗。
- **B1b-3 / B1b-4 / B1b-5 / B1b-7 / B1b-8 是開檔核對的**，每一條都附了確切行號，**任何人可以自己打開驗**。第 5 節那張對照表是逐項對讀三個檔案做出來的。
- **B1b-2 保留的是工具否決掉的候選。** 我同意面板「不構成外人可利用的攻擊路徑」的判斷，**但不同意就此當作沒事**——理由寫在該條裡（log 的讀者比設定的寫者多、同組憑證另有真外洩路徑、擋住它的是外部前提不是這行程式）。**這是一個判斷，不是事實陳述，決策者可以推翻它。**
- stamp 蓋的是 `verified`，**6 票全數投出**（2 條候選 × 3 位檢查員），沒有漏投、沒有未審候選。

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

1. **🔴 工具在這 26 個檔上只產出兩條候選，而且都不成立。** 這有兩種可能的解釋：一是這 26 檔確實乾淨（就「可被外人利用的攻擊路徑」而言，我人工看下來傾向同意）；二是**研究員的注意力沒有真正鋪滿 26 個檔**。**無法分辨是哪一種**，因為工具**沒有提供逐檔閱讀的帳本**（`coverage.research` 是空的），所以「這 26 檔都被看過」這件事**沒有可查核的紀錄**。
2. **這是 `low` 檔次的掃描**——一位研究員讀完全部範圍，**不做威脅建模、不跑廣度掃描**。深度夠，但不是地毯式。
3. **沒有實際測試。** **沒有真的上傳或下載任何檔案、沒有真的嘗試任何路徑穿越、沒有拿任何帳密去連線試。** 程式碼部分全部是讀出來的推論。**唯一的例外是 B1b-1 的資料庫查詢，那是真的跑了**（唯讀）。
4. **B1b-6 判「不成立」是讀出來的，而且它靠的是執行順序與字串處理的巧合。** 與 B1 的提醒同一件事：**建議修的時候順手寫一個測試把它釘住**，因為重構就可能讓它復活。

**合起來的建議**：**B1b-1 可以直接進修正卡**（證據是資料庫查詢結果，等級最高）。**B1b-3 建議一併進**（它是「刪除沒真的刪掉」，對稽核產品是實質缺陷，而且修法很小）。**不要因為工具報 0 條，就宣稱這 26 個檔乾淨**——真正被三人面板審視過的只有兩條候選，而且都被否決了。

---

## 7. 卡片八個重點的逐項結論

| 卡片項目 | 結論 | 依據 |
|---|---|---|
| **① 查檔案的資料層完全沒有範圍條件**（本棒最重要） | **✅ 成立（HIGH），且已用資料庫實查坐實** | 見 **B1b-1**。**(a)** 三層（領域服務／應用服務／宿主）都沒有補檢查；**(b)** 資料庫實查：`relrowsecurity = f`、policy 數 `0`、17 個欄位**無 tenant_id**——**原始輸出貼在第 4 節**；**(c)** 因此**看到或猜到編號就能取得別人的檔案**。查詢層七支方法（`:41-45`／`:47-52`／`:54-59`／`:108-111` 等）**無一支帶範圍條件**，連刪除都是憑編號直接刪 |
| **② 主機硬碟後端的存放位置與路徑組法** | **穿越不成立；預設值另記 LOW** | 見 **B1b-6** 與 **B1b-4**。**(a)** 實際部署**有給設定**，不會落到 `/tmp/upload/`（故 LOW）；**(b)(c)** 存檔路徑兩段（`save_dir` 與 `save_file_name`）**都不含使用者可控成分**，取檔兩段來自資料庫紀錄；**(d)** **沒有**「組出來的路徑還在允許目錄底下」這道檢查——**但目前沒有可控輸入到得了那裡**。⚠️ 這是意外擋住的、不是刻意防的，**建議補明確檢查** |
| **③ 把檔案路徑餵給系統指令** | **不成立（兩處寫法都正確）；暫存檔另記 LOW** | 見 **B1b-7** 與 **B1b-5**。**(a)** 餵進去的來自資料庫紀錄或系統安全暫存檔，**都不是使用者給的**；**(b)** **兩處都是陣列形式、無 `shell=True`**（安全寫法）；**(c)** 特殊字元不會被當指令（兩層保護）；**(d)** **有實質發現**——主機硬碟那條把轉好的 PDF 留在系統共用暫存區、檔名可預測、不刪除（**B1b-5**），物件儲存那條做得對 |
| **④ 連不上物件儲存時吞掉例外，之後可能是 None** | **行為屬實；外洩風險工具判不成立，runner 保留為 MEDIUM** | 見 **B1b-2**。**(a)** 失敗後 client 為 `None`，**後續每次操作都會炸 NoneType**（不是靜默改用別的後端——工廠是依資料列的 `storage_type` 挑，不會自動退回）；**(b)** **錯誤訊息確實印出整包 config，而 config 含憑證**（`:62`，DTO 無自訂顯示方式）；**(c)** **SeaweedFS 完全繼承這支，所以同款寫法照樣存在**（`seaweedfs_adapter.py` 只覆寫兩個標籤）。工具 1:2 否決（因寫設定需管理能力），**runner 複核同意不構成外人攻擊路徑，但保留為程式衛生問題**，理由見該條 |
| **⑤ 三個後端的實作有沒有一致** | **🔴 四項不一致，全部是「主機硬碟缺了物件儲存有的」** | **對照表見第 5 節**。不一致四項：①刪檔不清衍生 PDF（**B1b-3**，MEDIUM）②暫存檔不用安全機制也不刪（**B1b-5**，LOW）③不檢查轉檔執行結果（非安全）④連線失敗印憑證（**B1b-2**，物件儲存側才有）。**一致的有**：型別守衛、陣列形式呼叫、路徑不含可控輸入。**SeaweedFS 沒有自己的實作**（24 行全繼承 MinIO，只覆寫兩個身分標籤）——**這個設計是對的**，檔內註解並已警告不要併回去 |
| **⑥ 工廠找不到後端時的 fallback** | **✅ 無問題——拋錯，fail-closed，做得對** | 見 **B1b-8**。`upload_file_factory.py:21-22` 找不到就 `raise ValueError`，**不會挑一個頂替**。與 FR-075 S7 的 `auth_factory`（拋 405）同級。小建議：錯誤型別可改用套件專屬的，**不影響安全** |
| **⑦ `migrations/` 兩支 SQL** | **GRANT 正確；無索引缺漏；🔴 但「本表不管隔離」這個設計決定今天已站不住** | **GRANT**：001 建表、002 授權給 `cm_app`（含 sequence 權限），**寫法正確且冪等**。**約束／索引**：主鍵 + `uid` 唯一約束都在，與 Guidant AI 實況覆蓋範圍相同（只有名字不同，檔頭已說明且已 diff 驗過）。**🔴 設計決定**：檔頭自陳「本表沒有多租戶欄位，隔離由持有者資源那一層負責」——**這個分層在理論上合理，但它依賴的那一層 B1／B2 已證實根本沒做**，所以**今天站不住**。詳見 **B1b-1** 末段（兩條修法都要做，且不可只做資料庫那條，否則系統共享資產會被一起擋掉） |
| **⑧ `domain/` 那 7 檔** | **無業務規則被跳過（因為根本沒有業務規則）；entity 無不該外送的欄位** | `domain/service/upload_file_domain_service.py`（45 行）**每一支都是一行轉呼叫 repo，零判斷**——不是「規則被跳過」，是**這一層從來沒有規則**。唯一像檢查的 `verify_upload_file_exist`（`:35-44`）只判存在性，**且是死碼**（它呼叫的 `get_by_uid` 找不到時自己就已經拋 404 了，`:43` 那行永遠到不了）。**entity**（`upload_file_entity.py`）欄位皆為檔案自身的屬性（檔名／大小／路徑／校驗和／建立者），**無密碼、金鑰或其他不該外送的欄位**；mapper（`upload_file_mapper.py`）為一對一直譯，**無隱藏欄位外洩** |

**卡片沒問但順手確認的**：本棒 26 檔的程式碼裡**沒有任何硬編的帳密或金鑰**（工具的密鑰專項掃描與人工逐檔查看皆無）。與 B2 不同的是，**這一棒工具的密鑰專項沒有撈到範圍外的舊案重複**，所以本報告沒有「範圍外重複」那一節。

---

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

| 項目 | 值 |
|---|---|
| 掃描範圍 | `jedi_file_upload/infra/`＋`domain/`＋`migrations/`，**26 檔**（與卡片數字一致，啟動前已核對） |
| scanRoot | `jedi-file-upload/`（子目錄，非 monorepo 根） |
| commit | `da25a8d922e682f34b96c4539636ad315a2ab3bb`，branch `feature/FR-075`，**工作區乾淨**（`dirty: false`） |
| effort／focus | `low`／`attack-surface` |
| 執行時間 | **1,521 秒**（約 25 分鐘） |
| 派出 agent | 8 個（全部完成，0 失敗、0 跳過、0 空回） |
| 研究員 | 派出 **2**、回來 **2** |
| 候選發現 | 2 條 → 去重後 **2 條** |
| 面板票數 | **6 票**（2 條 × 3 位檢查員），**全數投出**、0 票未投、0 條候選未審 |
| **工具達標發現** | **0 條**（兩條皆 1:2 否決） |
| **人工另外查到** | **5 條**（B1b-1 HIGH／B1b-2 MEDIUM＊／B1b-3 MEDIUM／B1b-4 LOW／B1b-5 LOW）<br>＊B1b-2 是 runner 保留工具否決掉的候選 |
| 嚴重度分布（本報告採用） | CRITICAL 0／**HIGH 1**／**MEDIUM 2**／**LOW 2** |
| 嚴重度被下修 | 無 |
| 驗證輪數 | 1 輪（無續跑、無遺失候選、無因上限被砍掉的候選） |
| 研究員閱讀帳本 | **無**（`coverage.research` 為空——見第 6 節第二層第 1 點） |
| stamp | **`verified`** |
| 工具產出 | `jedi-file-upload/CLAUDE-SECURITY-20260911-134338/`（該目錄自帶 `.gitignore`，不入版控） |
| 資料庫實查 | DEV `guidant_ai_dev`（188:25432），**唯讀 3 支 SELECT**，無任何寫入 |

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

---

## 9. 座標

- 本棒範圍：`jedi_file_upload/infra/`、`domain/`、`migrations/`（jedi monorepo）
- **前兩棒（必讀對照）**：[`scan-B1-http-boundary.md`](scan-B1-http-boundary.md)（CM-1652，HTTP 邊界 35 檔）／[`scan-B2-host-wiring.md`](scan-B2-host-wiring.md)（CM-1653，宿主接線 10 檔）
- **本棒回答了 B2 報告第 5 節那張表的第③④層**：查詢無範圍條件（③）與資料庫無兜底（④），**兩者都已用實查坐實**
- 相關前案：CM-1594（FR-077 R3）、CM-1559（FR-085 C1，無身分時隔離關掉）、CM-1631（Google 金鑰外洩）
- 跨 arc 總表：[`security-scan-consolidated/`](../security-scan-consolidated/)
- 首腦手冊：`.claude/skills/security-scan-lead/SKILL.md`
