⚠️ 歷史紀錄:本報告描述的問題已於 2026-11-07 全部修正並合回 Agent 主線(修正卡 CM-3559~3564、3573~3576、3584,驗收紀錄見母卡 CM-3507)。文中的檔名與行號是掃描當下(commit
bab2aa3)的版本,修正後已變動,請勿拿來定位現行程式。
卡片:CM-3515(母卡 CM-3507)|範圍
guidant_agent/files(19 檔)|掃描日 2026-11-05|程式版本3813abd
這一塊沒有掃到需要修的資安問題。 工具只提出一條疑點:「沒設定的話 /blob 不用登入就能下載」。三個獨立的檢查員都投票否決,理由是兩種出貨安裝方式都會把登入檢查打開。卡片「重點看什麼」列的 7 項我逐項開檔看過,有 2 個小地方值得記一筆,都不需要現在修(見第 4 節)。
證據檔是客戶最敏感的資料(掃描報告、設定檔、系統清冊)。它進出 Agent 有四條路:
這一棒看的是這四條路有沒有沒鎖好的門。
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 | 結論 |
|---|---|---|---|---|---|---|
| 工具 C1 | 程式內建的預設值是「不檢查登入」,萬一沒設定,誰都能下載或刪除證據檔 | 拿得到 Agent 網址的人可以下載、刪除證據檔 | 安裝時沒寫 AGENT_AUTH_MODE=full,而且埠對外開放。兩種出貨安裝都不會這樣 |
config/config.py:166、guidant_agent/sync/api/data_plane_auth.py:90-91 |
不用修(可另議:把程式內建的預設值改成 full) |
❌ 三票否決 |
| 人工 M1 | 8444 上傳失敗時,回應裡「允許哪個網站讀這個回應」照抄請求者自己的網站 | 別的網站只讀得到錯誤代碼,讀不到檔案、也拿不到登入狀態 | 沒有實質可利用的路 | guidant_agent/files/api/browser_plane.py:145 |
可不修;想收緊就改成只在驗票成功後回 | 備註,不算問題 |
| 人工 M2 | 擋「同一張上傳票用兩次」的紀錄只放在記憶體 | Agent 重啟後,還沒過期(5 分鐘內)的票可以再用一次 | 要先偷到一張有效票,而且剛好碰上 Agent 重啟 | guidant_agent/sync/domain/agent_auth.py:261-276(browser_plane.py:134 使用) |
可不修(程式註解已寫明這是刻意接受的風險) | 備註,已知且接受 |
AGENT_AUTH_MODE 時,程式退回 none,也就是不檢查主系統簽發的登入憑證。這時 /blob/<uid> 的下載與刪除誰都能打。scripts/install/install.sh:1125 會寫入 AGENT_AUTH_MODE=full,而且把不加密的那個埠只綁在本機(127.0.0.1,外面連不到)。docker 交付包同樣會打開檢查。所以「預設就開著門」的情況實際出貨時不存在。三個檢查員各從「打不打得到」「後果多大」「有沒有其他防線」看,三票都判否決。config/config.py:166 的程式內建預設值改成 full(出錯時預設擋下來)。這是加一層保險,不是修漏洞。browser_plane.py:145:驗票成功時,回應只允許票上記的那個網站讀取。驗票前(瀏覽器的事前詢問、驗票失敗),則照抄請求者帶來的 Origin(請求從哪個網站發出的)。TicketReplayGuard 用一個記憶體裡的 dict 記用過的票編號。上傳票只活 5 分鐘,程式註解(agent_auth.py:262)明說「重啟後的窗口可接受」。Agent 是單一行程,不存在多個工作程序紀錄不同步的問題。_verify_ticket/防重放/CORS 退路(browser_plane.py:106-149):票會驗簽章、綁 agent_uid 與 scan_uid,同一張票不能用第二次(agent_auth.py:282-303)。瀏覽器帶的來源網站和票上的不同就拒絕(:302)。防重放紀錄只在記憶體,見 M2。CORS(瀏覽器的「允許別的網站讀回應」機制)的退路見 M1。不成立(只留備註)。browser_plane.py:66-103):檔名先取最後一段、再用 secure_filename 消毒,.. 和斜線都會被拿掉。scan_uid 先用正規式驗過才拼進路徑(:168)。檔案先寫進暫存檔 .upload-<jti>.part,超過上限就拋錯,except 會把暫存檔刪掉(:100-101)。上傳的檔沒有限制格式,但它是源碼包,只給掃描工具讀,不會被主控台當網頁打開,所以沒有 XSS(存進去的內容被當程式執行)的風險。不成立。blob_route.py 三處 send_file:uid 是拿去資料庫查,不是直接拼成路徑。本機儲存讀檔前會確認真實路徑在 BASE_DIR 底下(_read_bytes :28-40)。PDF 預覽用 LibreOffice 轉檔,只轉白名單裡的 office 副檔名(:25)。這幾支都在 8443,必須帶主系統簽的登入憑證(見 C1)。主控台的預覽另有 inline_policy(讀檔頭判斷格式),不在本棒範圍。不成立。storage_settings.py):主控台只能選「系統內建 SeaweedFS」(FIELDS :36-39;verify :118 其他型別一律拒)。所以主控台不能填任意網址,沒有「騙 Agent 去連攻擊者指定的網址」(SSRF)的空間。自訂端點只能用本機指令 configure-storage。金鑰存在加密的 SecretFieldStore 裡。storage_trust.py 只是把 CA 憑證追加進信任清單,沒有關閉憑證檢查的寫法。不成立。file_protect.py):用 jedi-file-protect,註解寫明是 AES-256 zip,不是可以破解的舊式 ZipCrypto。密碼由前端產生(19 字元),只放在這一次請求的 header 裡。兩處稽核(:47、:59)只記檔名與 encrypted,沒有記密碼。保護開啟卻沒帶密碼時回 400,不會偷偷給沒加密的檔。不成立(加密演算法本身在 jedi-file-protect 套件內,不在本棒範圍)。files/__init__.py、browser_plane.py):8444 是獨立的 app,只掛 /scan-input/<scan_uid> 上傳、根目錄轉址、主控台(registry.mount_browser_plane)。/blob 只由 register 掛在主 app(8443)。沒有 blob 下載掛在 8444。不成立。customer_files_repo.py、migrations.py):全部用 SQLAlchemy 的查詢語法,沒有自己拼 SQL 字串。關鍵字搜尋有 autoescape;排序欄位用白名單 SORTS 對照。查詢都限定 storage_scope='customer'。Agent 只服務一個客戶,不需要依客戶過濾。不成立。| 項目 | 值 |
|---|---|
| run ID | wf_7fd381a8-b9d |
| 掃描版本 | 3813abd05232(branch ws-CM-3507-CM-3515,沒有未提交的改動) |
| effort | low;聚焦 attack-surface(只看攻擊者摸得到的正式程式,測試與產生的程式碼當背景) |
| verification.status | verified |
| reason_kind | null |
| candidates | 1(一條疑點進投票,不是 0,所以這次「verified」代表真的投過票) |
| panel_votes | 3(三個檢查員對 1 條各投一票,3 票全數投出,全判否決) |
| researchers_dispatched/returned | 2/2 |
| 耗時 | 約 22 分鐘(1362 秒) |
| 歸檔 | docs/security-scan/FR-177/runs/CLAUDE-SECURITY-20261105-155941/ |