FR-086.B1b 掃描報告:儲存後端實作與持久層(jedi-file-upload)

  • 卡片:CM-1654(母卡 CM-1651)
  • 掃描範圍: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 點是最根本的:就算有人想開啟隔離,也無從判起——資料庫拿不到任何可以用來過濾的依據。

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

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 的檔頭都對這件事有自覺,而且說得很清楚:

-- 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:

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 一起刪——程式裡有一支專門做這件事的方法,註解也寫明「避免孤兒」:

# 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))

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)

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。

# 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 時的做法,看這支工廠找不到對應後端時會怎樣。

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 物件儲存。

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