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

  • 卡片CM-1653(母卡 CM-1651
  • 掃描範圍app/upload_file/infra/upload_file/core/upload_file_wiring.pydi_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、刪檔」這三件事。三支方法長得一模一樣,都只有一行:

@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
    HIDDEN_SECRET = ['SMTP', 'THIRD_PARTY_LOGIN', 'NOTIFY_CONFIG', 'ISSUE_INTEGRATE_CONFIG']
    STORAGE_CONFIG 不在裡面,所以儲存設定這一組完全不進遮罩流程
  2. 欄位名單也對不上。就算進了流程,要刪的欄位只有兩個(:39):
    _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_keyminio_secret_keyaccess_keyminio_access_key:39)。
  3. 兩支 GET 端點補上能力檢查——照同檔案既有寫法加一行 _assert_config_capability(group, "read")(寫入類已經這樣做了,:271 是現成範例,不要另外發明第二套守門)。

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


B2-B(MEDIUM)A 客戶讀不到設定,就去借 B 客戶的帳密

(工具未報,人工查證;同時回答卡片第 ② 與第 ④ 點)

這是什麼問題(白話)

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

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,決定連物件儲存時要不要走加密連線(就是網址上 httpshttp 的差別)。這個開關兩邊的預設值都是「關」

secure=v.get('secure', False),     # managed_file_upload_service.py:105(MinIO)
secure=v.get('secure', False),     # managed_file_upload_service.py:119(SeaweedFS)
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 設備。它有一套完整的保護(雙向憑證、短效簽章、回應指紋比對、撤銷檢查),寫得相當認真——但整套的開關預設是關的

mode=(os.getenv("AGENT_AUTH_MODE", "none") or "none").strip().lower()   # common/util/agent_auth_settings.py:22
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-277system_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 預設是 nonecommon/util/agent_auth_settings.py:22config/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 蓋的是 verified36 票全數投出(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.pydi_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 lowattack-surface
研究員 派出 2、回來 2(第一位中途停滯五次後完成,故耗時長,但無研究產出遺失)
候選發現 12 條 → 去重後 12 條無重複可去
面板票數 36 票(12 條 × 3 位檢查員),全數投出、0 票未投、0 條候選未審
達標發現 10 條(另 2 條被三位檢查員一致否決)
嚴重度分布(工具報的) CRITICAL 0/HIGH 6MEDIUM 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 verifiedreason: 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.pydi_containers/upload_file/(BE repo)
  • 上一棒(必讀對照)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/
  • 首腦手冊:.claude/skills/security-scan-lead/SKILL.md