Guidant AI 資安檢視總報告 · 連線清單
客戶上傳的每一份檔案——稽核證據、程序書、掃描報告、匯出檔——最後都躺在這條線的另一端。儲存本身不對外、不驗「誰在問」,它只認後端給的一把金鑰;所以「這份檔能不能給你」這個問題,全部要在後端回答完再來碰儲存。掃描命中 9 條,幾乎全是後端那一側沒問清楚就去碰儲存。這一頁回答:這條線怎麼連、哪些功能走它、它帶來什麼威脅、駭客怎麼打、我們要怎麼防、目前做到哪。
這一頁怎麼來的:DFD Level 0 把產品運作時的連線編成 C01~C25;STRIDE 六頁每一條問題都標了發生在哪幾條線。這裡以連線為單位整理,命中的 9 條歸成六種攻擊手法。個別問題修了沒不在這頁講,請點條目連回 STRIDE 頁。
%%{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
| 項目 | 現況 |
|---|---|
| 誰 → 誰 | 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。
| 群 | 功能 | 對威脅的意義 |
|---|---|---|
| ① 上傳 | 稽核證據、SSP 程序書、意見回饋附件、框架 PDF、檢測規則包、任務 Excel 匯入來源檔;代理程式回報的掃描報告(經 C20) | 進來的東西全部不可信:內容、檔名、宣稱的格式都是上傳者給的。上傳這一步決定了「儲存裡躺的是什麼」 |
| ② 下載/預覽 | 證據清單點開、程序書下載、報告預覽、匯出檔取回 | 這是兩個方向的風險交會點:往外是「給錯人」(歸屬沒問清楚),往內是「給對人但內容是毒」(別人上傳的網頁檔在受害者瀏覽器執行,見 C01 T4) |
| ③ 刪除 | 刪證據、刪程序書、刪附件、刪範本掛的文件 | 實體檔一刪就回不來。要問三件事:你能刪嗎、你刪的那份是不是你指的那份、紀錄刪成了實體才能刪 |
| ④ 背景取檔 | worker 分類證據時從儲存取檔、放進分類工作目錄給沙箱(C08) | worker 用同一把金鑰,以建單者身分跑(C09);取檔這步有沒有再問歸屬,取決於 C09 的身分傳遞 |
| ⑤ 系統共用資產 | 原廠給所有客戶的公版指引、公版規則包(storage_scope='system') |
全租戶共讀、資料庫隔離對它放行——所以刪除這一側要另外擋,不然任何客戶的一個帳號就能刪掉所有客戶的公版 |
掃描在這條線上抓到 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 是這條線的物理特性,邊界在主機外圍。
對應六種威脅與這條線的本質,防線分八條。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 的好設計,但「哪些模組的檔已登記、哪些還在走平台管理員例外口」沒有清單;歷史孤兒檔有多少、它們是什麼,也沒盤過。例外口開著的那天,平台管理員帳號一被拿到就是全部檔案。
upload_files 有多少筆是四份登記都認不出來的、它們屬於哪些功能,決定例外口能不能收窄。preview_policy 的新出口少一層風險。/app/static 掛主機 ${GUIDANT_DATA_DIR}/static,主機上哪些帳號讀得到這個目錄、備份流程會不會把它帶出去,掃描是程式碼範圍、沒看部署。依據: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。分組依「駭客怎麼打」歸納,同一條若兩種手法都沾,歸主要那種。