jedi_file_upload/infra/+domain/+migrations/,共 26 檔(jedi monorepo)da25a8d922e682f34b96c4539636ad315a2ab3bb(branch feature/FR-075,工作區乾淨)low effort,focus attack-surfaceverified掃完了,工具零發現,但這一棒最重要的事反而被證實了:存檔案的那張表,資料庫層真的沒有任何隔離——我實際連上 DEV 資料庫查過,隔離開關是關的、規則零條、欄位裡連「這是哪家客戶的」都沒有。
這句話的意思是:B1 與 B2 說的那條「四層全空」的路徑,第③層(查詢無範圍條件)與第④層(資料庫無兜底)在這一棒被實際查詢坐實了,不再是讀 schema 檔推論的。前兩棒只能說「建表腳本自陳沒有租戶欄位」,現在是跑在真實資料庫上的查詢結果。
好消息有三個,而且都是實質的:
shell=True,而且餵進去的路徑沒有一段來自使用者。壞消息除了資料庫那條,還有兩個三個後端不一致的地方(卡片⑤要的對照表在第 5 節):主機硬碟後端少了物件儲存那邊有的衍生檔清理,以及只有物件儲存那條會把含密碼的設定整包寫進 log。
前兩棒看的是「請求怎麼進來」和「宿主怎麼接線」。這一棒看的是檔案真正落地的地方——三個儲存後端各自怎麼存怎麼取、檔案編號怎麼查、以及那張記錄檔案的資料表長什麼樣。
先解釋幾個詞:
儲存後端=檔案實際存放的地方。這套產品支援三種:主機硬碟(直接存在伺服器的資料夾裡)、MinIO 與 SeaweedFS(兩種「物件儲存」,就是專門放檔案的外部服務,連它要帳號密碼)。
持久層=把「這個檔案叫什麼、存在哪」記進資料庫的那段程式,加上那張資料表本身。
路徑穿越=用
../這種寫法,讓檔案被存到或讀到原本不該碰的目錄(例如跳出上傳資料夾、寫進系統目錄)。RLS(Row Level Security)=資料庫層級的自動過濾,就是「每個客戶只看得到自己的資料」這個機制。它在資料庫裡,應用程式忘記檢查時它還會擋,所以是最後一道防線。
這一棒要回答三個問題:路徑是怎麼組出來的、查檔案時有沒有範圍限制、有沒有把外部輸入餵給系統指令。
只找問題、不修問題——底下所有修法都只是建議,這一棒沒有動任何一行程式碼,也沒有真的上傳、下載或寫入任何檔案去測路徑穿越(全部是讀程式碼推論)。對資料庫只做了唯讀查詢(SELECT),沒有任何寫入。
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 嚴重度 | 來源 |
|---|---|---|---|---|---|---|
| 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 節,每一項都有「成立/不成立」,不留白。
現況:已修(M02-6,2026-09-16 補上客戶歸屬欄位並開啟隔離,2,718 筆全數回填)
這是卡片列為「本棒最重要」的第①點,結論是成立,而且這次有實查證據。
這是什麼問題(白話)
一個系統要防止「A 客戶看到 B 客戶的檔案」,通常有兩道防線:
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 筆資料)
這三個結果的白話翻譯:
relrowsecurity = f 的 f 是 false,意思是這張表的隔離開關是關的。policy_count = 0,意思是一條隔離規則都沒有——就算把開關打開也沒有規則可套(而且照建表腳本的註解,貿然打開會讓所有檔案下載當場 404)。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 已經證實:那一層根本沒有做。 這個設計把責任交給了一個不存在的檢查。
所以修法有兩條路,兩條都要做:
ManagedFileUploadService 三支方法補歸屬判定。這是設計原本就假設有、但實際沒有的那一塊。upload_files 加租戶欄位並開啟資料庫隔離——讓資料庫成為最後一道防線。注意建表腳本的警告是對的:加了 ENABLE ROW LEVEL SECURITY 卻沒有 policy,cm_app 會一列都讀不到、所有檔案下載當場 404。所以這條要欄位、回填、policy 三件一起上,屬獨立一張卡的工作量(B2 報告的「丙卡」)。⚠️ 不要只做第 2 條:系統共享資產(storage_scope=system 的合規框架 PDF)本來就該跨租戶讀得到,純靠資料庫隔離會把它們一起擋掉。
(工具候選 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 的程式衛生問題,理由有三個:
怎麼修(很小的改動)
兩個做法擇一:
{config} 改成 {config.endpoint}。access_key / secret_key 換成 ***(這個做法更好,因為以後不管誰在哪裡印它都安全)。建議選第 2 個,改在 ports/dto/upload_config_dto.py 的共同父類別上,三個後端的設定一次全部受惠。
現況:已修(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 這支方法。
出事會怎樣
為什麼是 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 兩處都呼叫它。
更好的做法:這段邏輯三個後端都需要,應該提到共用的父類別或領域服務,讓「刪原始檔要連帶清衍生檔」變成一條不可能漏掉的規則,而不是每個後端各自記得要做。這正是本專案反覆出現的那個形狀——同款功能多份實作、只修了一份。
/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(缺守門就拒絕啟動)是同一個精神——那裡做對了,這裡沒有。
(工具未報,人工查證;回答卡片③的 (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),理由是落地版的唯讀檔案系統會讓原本的做法爆炸——這個理由是正當的,不是隨手寫的。修法不是改回去,而是讓主機硬碟那條比照物件儲存那條:用安全暫存檔機制、讀完就刪。
這是卡片的重點之一,結論是不成立。因為證偽跟證實一樣有價值,這裡寫清楚是什麼擋住了。
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 底下,不是就拒絕。把意外的安全變成有意的安全。
(工具未報,人工查證)
卡片擔心的是:程式呼叫 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 節的對照表。
(工具未報,人工查證)
卡片要我對照 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)。改成一致的錯誤型別會讓錯誤處理更整齊,但不影響安全性。
卡片特別要求「要給一張對照表,一眼看出哪支有哪支沒有」。這是本棒除了資料庫實查之外,最有價值的一節——因為「同款功能多份實作、只修了一份」是本專案反覆出現的形狀(FR-085 C1 剛撞到 Redis 那個漏網複製品)。
先講一件重要的事:SeaweedFS 那一支其實沒有自己的實作。 它整支只有 24 行,完全繼承自 MinIO 那一支,只覆寫兩個「身分標籤」(seaweedfs_adapter.py:22-23)。所以凡是 MinIO 有的,SeaweedFS 都有;MinIO 缺的,SeaweedFS 也缺。檔案裡的註解把這個設計講得很清楚,而且特別警告不要因為「幾乎是空的」就把它併回去——理由是兩者的差別不在行為,而在資料列上留下的後端身分。這個設計判斷是對的。
所以真正的比較只有兩方:主機硬碟 vs 物件儲存。
| 項目 | 主機硬碟local |
MinIOminio |
SeaweedFSseaweedfs |
一致嗎 |
|---|---|---|---|---|
| 刪檔時清掉衍生的 PDF 快取 | ❌ 沒有 | ✅ 有 | ✅ 有(繼承) | 🔴 不一致 → B1b-3 |
| 連線失敗時把含密碼的設定寫進 log | ➖ 不適用 (不需連線) |
⚠️ 會 | ⚠️ 會(繼承) | 🔴 不一致 → B1b-2 |
| 轉檔暫存檔用安全機制產生 + 用完刪除 | ❌ 留在共用暫存區 不刪、檔名可預測 |
✅ 隨機檔名finally 確實刪 |
✅ 有(繼承) | 🔴 不一致 → B1b-5 |
| 檢查轉檔指令的執行結果 | ❌ 取了卻不檢查 ( :138) |
✅ 檢查並記錄原因 ( :276-278) |
✅ 有(繼承) | 🟡 不一致(非安全問題) |
| 設定型別守衛(防止吃到別種後端的設定) | ✅ 有(:31-34) |
✅ 有(:45-48) |
✅ 有(繼承+覆寫型別) | ✅ 一致 |
| 呼叫系統指令用安全的陣列形式 | ✅ 是 | ✅ 是 | ✅ 是(繼承) | ✅ 一致 |
| 寫檔路徑含使用者可控成分 | ✅ 否 | ✅ 否(不走路徑) | ✅ 否(繼承) | ✅ 一致 |
| 查檔案時帶範圍條件 | ❌ 沒有 | ❌ 沒有 | ❌ 沒有 | ⚠️ 一致地都沒有 → B1b-1 |
這張表的三句話總結:
工具這一棒的達標發現是 0 條。 兩條候選都被面板以 1:2 否決。
如果只交工具的結果,這一棒的報告會是一句「無發現」——而那會是嚴重誤導的,因為卡片列為「本棒最重要」的第①點(資料庫有沒有隔離)是成立的,而且是本 arc 最關鍵的一塊拼圖。
這件事本身就是結論,而且它印證了首腦手冊裡那條:「報出來的都真,沒報的不保證」——工具不會照著卡片的清單走。 本棒卡片列了八個重點,工具只碰到了其中一個半(②路徑穿越,以及④的一部分),其餘六項半全是人工照卡片追出來的。
verified,6 票全數投出(2 條候選 × 3 位檢查員),沒有漏投、沒有未審候選。coverage.research 是空的),所以「這 26 檔都被看過」這件事沒有可查核的紀錄。low 檔次的掃描——一位研究員讀完全部範圍,不做威脅建模、不跑廣度掃描。深度夠,但不是地毯式。合起來的建議:B1b-1 可以直接進修正卡(證據是資料庫查詢結果,等級最高)。B1b-3 建議一併進(它是「刪除沒真的刪掉」,對稽核產品是實質缺陷,而且修法很小)。不要因為工具報 0 條,就宣稱這 26 個檔乾淨——真正被三人面板審視過的只有兩條候選,而且都被否決了。
| 卡片項目 | 結論 | 依據 |
|---|---|---|
| ① 查檔案的資料層完全沒有範圍條件(本棒最重要) | ✅ 成立(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 不同的是,這一棒工具的密鑰專項沒有撈到範圍外的舊案重複,所以本報告沒有「範圍外重複」那一節。
| 項目 | 值 |
|---|---|
| 掃描範圍 | 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,同上目錄。
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 檔)security-scan-consolidated/.claude/skills/security-scan-lead/SKILL.md