Guidant AI 資安檢視總報告 · 連線清單

C07 api/worker → 檔案儲存(SeaweedFS S3 :8333/MinIO/本機磁碟)

客戶上傳的每一份檔案——稽核證據、程序書、掃描報告、匯出檔——最後都躺在這條線的另一端。儲存本身不對外、不驗「誰在問」,它只認後端給的一把金鑰;所以「這份檔能不能給你」這個問題,全部要在後端回答完再來碰儲存。掃描命中 9 條,幾乎全是後端那一側沒問清楚就去碰儲存。這一頁回答:這條線怎麼連、哪些功能走它、它帶來什麼威脅、駭客怎麼打、我們要怎麼防、目前做到哪。

必有 金鑰驗身分・不分使用者 威脅 6 種 掃描命中 9 條

這一頁怎麼來的:DFD Level 0 把產品運作時的連線編成 C01~C25;STRIDE 六頁每一條問題都標了發生在哪幾條線。這裡以連線為單位整理,命中的 9 條歸成六種攻擊手法。個別問題修了沒不在這頁講,請點條目連回 STRIDE 頁。

§1

一、連線圖

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB'}}}%%
flowchart LR
    U(["👤 已登入使用者"])
    AG(["🤖 代理程式<br/>回報掃描報告"])
    subgraph API["guidant-api"]
        direction TB
        OWN["歸屬登記表<br/>這份檔是誰的?你能看/能刪?"]
        PREV["預覽出口<br/>伺服器決定格式"]
        DEL["刪除<br/>先刪紀錄再刪實體"]
    end
    WK["guidant-worker<br/>分類時取檔"]
    subgraph ST["檔案儲存(三選一,依 STORAGE_CONFIG)"]
        direction TB
        SW[("SeaweedFS S3 :8333<br/>出廠預設・stack 內建")]
        MN[("MinIO/任何 S3<br/>客戶自備")]
        LC[("本機磁碟<br/>/app/static 掛主機目錄")]
    end
    U -- "C02 上傳/下載/預覽/刪除" --> API
    AG -- "C20 掃描報告" --> API
    API == "C07 · S3 金鑰(一把,不分使用者)<br/>物件名=時間戳_亂數.副檔名" ==> ST
    WK == "C07 · 同一把金鑰" ==> ST
    PREV -- "C01 回程:內容進瀏覽器" --> U
    classDef ext fill:#FBFCFC,stroke:#9AA8AA,stroke-dasharray:3 2
    classDef open fill:#FDECEC,stroke:#C0392B
    class U,AG ext
    class PREV,DEL open
C07 — 後端到檔案儲存。儲存端只認一把 S3 金鑰、不分使用者;「這個人能不能拿這份檔」在 api 的歸屬登記表回答。紅框是這條線上的兩個關鍵出口:預覽(把儲存裡的內容交回瀏覽器執行)與刪除(實體檔一去不回)。
§2

二、這條線怎麼連

項目 現況
誰 → 誰 guidant-api(上傳、下載、預覽、刪除、匯出)與 guidant-worker(證據分類時把檔從儲存取出、放進分類工作目錄)→ 檔案儲存後端。儲存後端不對外:SeaweedFS 只 expose: 8333 給內網,master 狀態頁 :9333 刻意不映射(它沒有認證)
協定/埠 依系統設定 STORAGE_CONFIG.storage_type 三選一:SeaweedFS(出廠預設,stack 內建 guidant-seaweedfs,S3 HTTP :8333)、MinIO 或任何 S3 相容端點(客戶自備,位址客戶填)、本機磁碟(無網路,容器內 /app/static 掛主機 ${GUIDANT_DATA_DIR}/static)。S3 兩種走同一個 SDK、同一支 adapter(套件 jedi_file_upload/infra/adapter/minio/),差別只在型別代號。HTTP 明文(secure=false;總表 #124 裁定接受)
傳什麼 使用者上傳的檔案本體(證據、程序書、附件、匯入來源檔)、代理程式回報的掃描報告、預覽時轉出的 PDF(衍生檔)、匯出檔。每個檔在 upload_files 表記一筆(原檔名、副檔名、儲存型別、物件名、歸屬範圍 storage_scope)
怎麼驗身分 一把 S3 存取金鑰,所有行程、所有使用者共用(SeaweedFS 的 s3.json 由 installer 產生後唯讀掛入;MinIO 的金鑰存在 system_configs)。儲存端不知道「哪個使用者在要檔」——「這份檔能不能給你」完全由 api 的歸屬登記表(core/plugins/file_upload.py 的 *FileOwnershipProvider)回答,儲存只負責存取。本機磁碟模式沒有金鑰,靠主機目錄權限
物件怎麼命名 伺服器自己產:<時間戳>_<亂數 uuid>.<副檔名>(套件 common/utils/file_utils.py 的 generate_save_info())。上傳者的檔名不進儲存路徑,只有副檔名照抄;原檔名另存資料庫供顯示
大小上限 全站請求上限 50MB(MAX_REQUEST_BODY_MB,core/app_factory.py 第 113 行,werkzeug 讀 body 階段就擋 413)。套件本身沒有另設單檔上限;前門 nginx 不限(C01 D7)
沒有 -s3.config 就是匿名全開 SeaweedFS 的 S3 gateway 不帶憑證檔參數就任何連得到 :8333 的人都能讀寫全部檔案。compose 強制帶 -s3.config,憑證檔掛的是「檔」不是目錄——主機端檔案不存在時 docker 會建成目錄,容器讀不到憑證會退回匿名或起不來
何時存在 必有。BE 三個行程 depends_on SeaweedFS healthy 才起

依據:docker/production/docker-compose.yml(guidant-seaweedfs 段、x-guidant-common 的 volumes 與 depends_on)、app/upload_file/service/managed_file_upload_service.py 的 _build_config_dto()、core/plugins/file_upload.py、core/app_factory.py 第 96~113 行、套件 jedi_file_upload/common/utils/file_utils.py、infra/adapter/minio/minio_adapter.py、common/utils/preview_policy.py、DFD Level 0 §2.2/§2.7。

§3

三、哪些功能會走這條線

群 功能 對威脅的意義
① 上傳 稽核證據、SSP 程序書、意見回饋附件、框架 PDF、檢測規則包、任務 Excel 匯入來源檔;代理程式回報的掃描報告(經 C20) 進來的東西全部不可信:內容、檔名、宣稱的格式都是上傳者給的。上傳這一步決定了「儲存裡躺的是什麼」
② 下載/預覽 證據清單點開、程序書下載、報告預覽、匯出檔取回 這是兩個方向的風險交會點:往外是「給錯人」(歸屬沒問清楚),往內是「給對人但內容是毒」(別人上傳的網頁檔在受害者瀏覽器執行,見 C01 T4)
③ 刪除 刪證據、刪程序書、刪附件、刪範本掛的文件 實體檔一刪就回不來。要問三件事:你能刪嗎、你刪的那份是不是你指的那份、紀錄刪成了實體才能刪
④ 背景取檔 worker 分類證據時從儲存取檔、放進分類工作目錄給沙箱(C08) worker 用同一把金鑰,以建單者身分跑(C09);取檔這步有沒有再問歸屬,取決於 C09 的身分傳遞
⑤ 系統共用資產 原廠給所有客戶的公版指引、公版規則包(storage_scope='system') 全租戶共讀、資料庫隔離對它放行——所以刪除這一側要另外擋,不然任何客戶的一個帳號就能刪掉所有客戶的公版
§4

四、這條線會帶來什麼威脅、駭客怎麼打

掃描在這條線上抓到 9 條,歸納成六種攻擊手法。前提都是攻擊者有一個有效帳號(任何客戶的任何員工),T1 的部分條目連帳號都不用(控制得了代理程式回報的人)。每種寫駭客實際的步驟與得手什麼,條目是實例。

# 風險 威脅 駭客怎麼打 得手什麼 STRIDE 實例
T1 🟠 高 上傳一份帶程式的網頁檔,等別人預覽時在他的瀏覽器裡執行 ① 做一個內含程式的網頁檔,或把它改名成 .png;② 當證據上傳,或讓代理程式用這個檔名回報掃描報告;③ 檔案進儲存,一切正常;④ 稽核人員在證據清單點「預覽」;⑤ 伺服器照上傳者宣稱的副檔名把它當網頁送回,瀏覽器在本站網域執行那段程式,用受害者的身分讀改資料 受害者(通常是稽核人員)的登入身分 E 權限提升、S 冒充身分 M02-4 上傳的網頁檔,別人點預覽時被當成程式執行 🟠
M03-13 掃描報告「檔名叫什麼就存什麼」,走同一條預覽路徑 🟠
T2 🟠 高 拿到檔案編號就下載,不管那份檔是誰的 ① 用自己的帳號在看得到的頁面記下檔案編號長什麼樣;② 從別的清單(例如範本掛的程序書清單,任何登入帳號都列得出來)拿到別人檔案的編號,或直接猜;③ 打下載網址,端點只驗「有登入」,儲存只認 api 的金鑰,檔案就下來了 別部門、別專案的稽核證據;範本附掛的程序書全文 I 資料外洩 M02-1 拿到檔案編號就能下載別人的檔案 🟠
M11-16 範本掛了哪些程序書任何人都列得出來,附檔案編號就能下載 🟡
T3 🟡 中 檢查的是 A、刪的實體檔是 B ① 自己開一個專案,成為管理者;② 刪除請求的網址填自己的計畫,但文件編號填別人專案(甚至別家公司)的佐證文件;③ 系統確認「你管自己的計畫」就放行,去儲存把那份實體檔刪掉;④ 或者批次刪附件時系統算出了「哪些檔真的屬於這張」的過濾結果,刪實體檔卻用回沒過濾的清單 刪掉別人專案、別家公司的佐證文件,實體檔一起銷毀、不留痕 T 竄改資料 M11-11 刪佐證文件時檢查一份、刪的是另一份 🟡
M23-7 刪附件時算出了過濾結果卻沒拿來用 ⚪
T4 🟡 中 先刪實體、再刪紀錄——紀錄被擋下但實體已經沒了 ① 任何一家客戶的一般員工,在畫面上看到一個原廠公版檔的編號;② 按刪除;③ 系統先去儲存刪實體檔;④ 接著刪資料庫紀錄時被租戶隔離擋下(那不是你的),但程式不看結果、回「刪除成功」;⑤ 紀錄還在、檔沒了,所有客戶打開這份公版都是壞檔 一個帳號讓原廠給所有客戶的共用範本全部打不開 D 讓服務停擺 M02-12 刪原廠共用檔時實體先被刪、紀錄卻刪不掉 🟡
T5 ⚪ 低 刪了主檔,預覽時轉出的 PDF 還留在儲存裡 ① 使用者刪掉一份敏感檔;② 本機儲存模式只刪主檔;③ 先前預覽時轉出的 PDF 副本還在硬碟上;④ 能讀主機目錄或備份的人照樣拿到內容 使用者以為已刪除的檔案內容 I 資料外洩 M02-9 本機儲存刪檔時漏清轉檔產生的 PDF ⚪
T6 ⚪ 低 在容器網路上側錄後端與儲存之間的明文流量 ① 攻擊者先進到客戶那台主機;② 在容器網路上側錄 :8333 的流量;③ 讀到檔案內容與 S3 金鑰;④ 但到了這一步,直接讀主機上的設定檔與儲存資料夾更快 客戶上傳的檔案內容與儲存金鑰(前提是已進到主機) I 資料外洩 M02-11 連自己裝的檔案儲存空間時不加密 ⚪

六種手法的共同點:T2~T5 全是同一件事——儲存不分使用者、只認 api 的金鑰,所以「能不能」「刪哪份」「先刪誰」每一個問題都要在 api 回答完再碰儲存;api 漏問一個,儲存就照做。T1 是另一件事——儲存裡躺的是別人給的東西,交回瀏覽器之前要由伺服器重新決定它是什麼。T6 是這條線的物理特性,邊界在主機外圍。

§5

五、我們要怎麼防、目前做到哪

對應六種威脅與這條線的本質,防線分八條。D1~D5 直接對應掃描抓到的手法;標 ◇ 的(D6~D8)是依這條線的特性補的標準防線,掃描範圍沒涵蓋、沒有實例。「目前」欄寫程式與部署裡實際有的機制;⚠️ 表示目前只靠慣例或只守到局部,沒有機制保證。

# 防線 擋哪種威脅 目前做到哪
D1 預覽出口由伺服器決定格式,不信上傳者說的:可在瀏覽器裡開的格式白名單;伺服器讀檔頭驗真實格式;網頁類帶 CSP sandbox;所有檔案回應帶 nosniff;外來檔名進儲存前先消毒 T1 ✅ 預覽路徑全部做到(套件 preview_policy.py:白名單 pdf/html/png/jpg/gif/webp,檔頭特徵碼比對,不用主機的 mimetypes 表);掃描報告檔名先過 sanitize_report_filename()。⚠️ 下載(非預覽)那條回 octet-stream 強制下載,不受影響;上傳時不驗格式,毒檔照樣進儲存,只是出口擋住——新增任何「不經 preview_policy 的出口」就會再開一次
D2 檔案歸屬登記表,每份檔要有人認領:各業務模組登記「這檔是不是我的、這人能不能碰」;沒人認領的孤兒檔一律拒絕;拒絕回「找不到」不回「無權限」;下載/預覽問「能看」、刪除問「能刪」,分開問 T2、T3 ✅ 套件 IFileOwnershipProvider 介面+主專案四份登記(稽核證據、SSP 程序書、意見回饋附件、系統共用資產)+套件可擴充(build_extra_ownership_providers);下載路由與底層取檔各掛一道(CM-2033);範本程序書清單補看範本權限(CM-2186)。⚠️ 平台管理員例外口(_platform_admin_escape())對歷史孤兒檔一律放行,範圍到 root 租戶管理員;新模組的檔若忘了登記,會變成孤兒被擋(fail-closed,但症狀是「檔案不見了」)
D3 刪除時兩個編號必核對歸屬;刪實體檔用過濾後的清單 T3 ✅ 兩條逐支修(_ref_doc_belongs_to_ssp、delete_from_pool 單一查詢、delete_attachment 改用 matched_file_uids)。⚠️ 逐支修,沒有框架層強制(同 C02 D2)
D4 先刪紀錄、紀錄真的刪掉才刪實體;原廠共用檔對寫入一律拒絕 T4 ✅ 套件 record_first_delete.py(CM-2228):先刪紀錄、看結果、只對真的刪掉的那幾筆動實體;SystemAssetFileOwnershipProvider 對寫入回「找不到」,只有平台管理員走例外口(CM-2033)
D5 衍生檔跟主檔同生共死:預覽轉出的 PDF 在刪主檔時一起清,物件儲存與本機儲存同一套 T5 ✅ 套件 derived_files.py 的 delete_derived_files 兩種模式共用(CM-2062)。⚠️ 刪衍生檔失敗只記日誌不擋主檔,屬刻意寬容
D6 ◇ 儲存端點不對外、憑證不缺、物件名由伺服器產 T6、T2 的基礎 ✅ SeaweedFS 只 expose 不 ports、:9333 不映射、-s3.config 強制;物件名 時間戳_uuid.副檔名,上傳者檔名不進路徑。⚠️ 不加密(裁定接受,邊界在主機);上傳時 S3 的 content_type 照上傳者宣稱的 mimetype 存(儲存不對外所以目前無差,若日後開 presigned URL 直連就有差);DFD §3 點名 STG 的 STORAGE_CONFIG 指向 188 上一組對外開 8333 的舊 SeaweedFS,不是 stack 內那台——出貨版 installer 預設指哪台要確認
D7 ◇ 上傳大小有上限、解析前先量:單檔上限、解開後大小、檔案數 T1 的延伸(特製檔讓解析卡死,見 C02 T10) ✅ 全站 50MB(MAX_REQUEST_BODY_MB)在 werkzeug 讀 body 階段就擋。⚠️ 套件沒有單檔上限,各業務模組各自設(檢測源碼包 100MB、規則包 50MB…),通用上傳走全站上限;前門不限
D8 ◇ 切換儲存後端不丟舊檔:每個檔記自己躺在哪種後端,讀取時借對應設定 維運面 ✅ upload_files.storage_type 記每檔後端,讀取時據此借設定。⚠️ 切換後端不搬舊檔(DFD §2.7 已知缺口)——舊後端的憑證要一直留著,否則舊檔全部讀不到

最該補的一步:D2 的覆蓋率清單。歸屬登記表是 fail-closed 的好設計,但「哪些模組的檔已登記、哪些還在走平台管理員例外口」沒有清單;歷史孤兒檔有多少、它們是什麼,也沒盤過。例外口開著的那天,平台管理員帳號一被拿到就是全部檔案。

§6

六、依這些防線,掃描還沒看過的地方

  • 歸屬登記表覆蓋率與孤兒檔盤點(D2):全庫 upload_files 有多少筆是四份登記都認不出來的、它們屬於哪些功能,決定例外口能不能收窄。
  • 上傳時的格式驗證(D1):目前只在出口擋。上傳時就驗檔頭、拒絕「副檔名與內容不符」的檔,能把毒檔擋在儲存外面,也讓不經 preview_policy 的新出口少一層風險。
  • 本機磁碟模式的主機目錄權限(D6):/app/static 掛主機 ${GUIDANT_DATA_DIR}/static,主機上哪些帳號讀得到這個目錄、備份流程會不會把它帶出去,掃描是程式碼範圍、沒看部署。
  • worker 取檔時的歸屬(④ 背景取檔):worker 以建單者身分跑,取檔那步有沒有再過 D2 的登記表、還是直接用物件名取,沒逐段追。

依據:STRIDE 六頁信任邊界連線標記(CM-2403/2404 驗收後版本)、docker/production/docker-compose.yml、app/upload_file/service/managed_file_upload_service.py、core/plugins/file_upload.py、core/app_factory.py、套件 jedi-file-upload(file_utils.py、minio_adapter.py、preview_policy.py、record_first_delete.py、derived_files.py)、DFD Level 0 §2.2/§2.7/§3。分組依「駭客怎麼打」歸納,同一條若兩種手法都沾,歸主要那種。