D3-1 檢查結果:掃描報告回收(jedi-detection)

D3-1 檢查結果:掃描報告回收(jedi-detection)

檢查日期 2026-09-18|對應卡片 CM-1875(D3 第 1 小棒)|檢查範圍 4 個檔案 859 行

§1

🔴 一句話結論

找到 1 條中風險:代理程式(裝在客戶機房、實際去跑掃描的那支程式)回報掃描報告時,說檔名叫什麼系統就存什麼,不做任何檢查;如果代理程式被入侵,它可以把一段程式碼偽裝成報告,稽核人員在畫面上點開時就會被執行。 不急(要先攻下代理程式或猜中一次派工編號),但修法很小——只允許 html/xml/json/csv 這幾種副檔名,或乾脆由我們自己命名。另外查證了三件事確認沒問題:報告存檔不會被塞到目錄外、代理程式自報的檔案編號拿不到別台的東西、對外抓網址那支的五道防護是真的有做。

§2

這一棒在檢查什麼

客戶按下「執行掃描」後,系統會派工給裝在客戶機房的代理程式;代理程式跑完工具(OpenSCAP、OpenVAS 這類),把報告放在自己本地,然後回頭通知雲端「我好了、報告在我這裡的某某編號」。雲端接著主動去代理程式那裡把報告抓回來,存成這次稽核的證據。

這一棒看的就是「抓回來、存起來」這一段:雲端到底多信任代理程式講的話。代理程式說檔名叫什麼、說內容是什麼格式、說報告的編號是幾號、要去哪個網址抓——這四件事只要有一件系統照單全收,被入侵的代理程式就能把假東西寫進客戶的稽核證據裡。

掃描目標 /Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-detection,revision 3ddd7218ca8a(branch feature/review,工作區乾淨),mode scan,effort low,範圍 4 個檔案:結果回收處理器 detection_result_handler.py(223 行)、代理程式認證設定入口 agent_auth.py(67 行)、對外網址受限下載 safe_http_fetch.py(457 行)、上傳源碼包契約 detection_source_file.py(112 行),共 859 行。

§3

Coverage

low 強度:一位研究員讀完這 4 個檔案就提報候選,未做元件盤點、未做威脅建模、未跑額外密鑰專項掃描,completenessCheckOutcome 為 not-applicable(低強度本來就不跑盤點)。驗證跑了 1 輪,3 個候選去重後仍是 3 個,沒有候選遺失、沒有嚴重度被降低、沒有候選被駁回。研究員為了追資料流另外讀了範圍外的幾處(jedi-file-upload 的存檔與預覽、jedi-remote-agent 的路由表與代理程式認證設定、主專案的插件接線與安裝腳本),那些只當佐證、沒有納入稽核。

工具沒有實際執行任何程式碼:沒跑測試、沒發請求、沒示範攻擊,所有判斷都是讀原始碼推出來的。

工具碰到了卡片重點③的四個小項裡的一項(檔名),另外三項(內容格式、檔案編號、來源網址)完全沒提出候選,由首腦回頭開檔查證補上,詳見下方「卡片重點逐項人工查證」段。

現況(2026-10-01):本棒各條後來的處理結果如下(過程紀錄保留,不改)。

  • F1(代理程式自報檔名原樣存成證據)=M03 第 13 條,✅ 已修(CM-2062)。
§4

Findings

F1 — 代理程式自報的檔名與副檔名原樣存成證據,預覽時會被當網頁執行(MEDIUM,confidence medium)

這是什麼問題。 掃描報告存檔時,檔名完全由代理程式決定,系統不檢查。代理程式如果把報告取名 報告.html、內容塞一段 JavaScript,這個檔會原樣存進客戶的證據庫;之後稽核人員在畫面上點「預覽」時,瀏覽器會把它當成一個網頁來跑,而不是當成一份檔案來顯示。這種攻擊叫儲存型 XSS(攻擊者存進去的內容,別人打開頁面時會被當成程式執行)。

出事會怎樣。 那段程式碼會用「稽核人員本人的身分」在系統裡跑。在前端與後端同一個網域的部署(落地版就是這樣)上,它讀得到稽核人員的登入權杖,等於可以冒用他做任何事——看別的專案、改資料、下載證據。而 .html 正好是 OpenSCAP 報告的正式格式(這支程式的註解裡就寫著這件事),所以這不是牽強的想像,是日常路徑上就會出現的副檔名。次要影響是代理程式可以在客戶的證據庫裡放任意類型的檔案。

要先有什麼才打得到。

  • 攻擊者控制了一台已註冊的代理程式(入侵客戶機房那台機器),或能打到回報端點:POST /api/1.0/agents/tasks/<uid>/result 這條路由沒有使用者登入檢查(jedi-remote-agent/jedi_remote_agent/api/routing.py:40,第三欄是 False),只要猜中或取得一個派工編號(UUID)就能送假結果
  • 之後要有人在畫面上點開這份報告預覽(前端 JobExecutionDrawer.previewFile 會把非純文字的副檔名送去 /file/pdf-preview/<uid>)

在哪裡。

  • 源頭一:jedi-detection/jedi_detection/app/service/detection_result_handler.py:162 —— 代理程式回報內容裡的 upload_uids[].filename 直接取用
  • 源頭二:同檔 :218-221 —— 抓報告時讀代理程式回應的 Content-Disposition 標頭,有值就覆蓋上面那個,一樣不檢查
  • 落點:同檔 :102-112 —— 用這個檔名建 FileStorage 後交給 upload_files_for_tenant() 存檔
  • 副檔名取出處:jedi-file-upload/jedi_file_upload/common/utils/file_utils.py:16 —— file.filename.split('.')[-1],沒有白名單
  • 執行點:jedi-file-upload/jedi_file_upload/api/routes/upload_file_route.py:198(宿主自有儲存分支)與 :220(一般分支)—— mimetypes.guess_type(f'preview.{_ext}') 依副檔名決定內容型別,配上 as_attachment=False(不強制下載、直接在頁面裡顯示)

怎麼修。 在 detection_result_handler.py 的 _fetch_blob()(:181)回傳前把檔名收乾淨,兩步:① filename = os.path.basename(filename)(去掉任何路徑成分);② 比對副檔名白名單,只留 html/xml/json/csv/txt/pdf,不在名單內就改用 detection_report_{agent_task_uid} 這個我們自己組的名字。更保險的做法是完全不用代理程式給的名字,一律自己命名,只從代理程式那裡取「格式」並且照白名單校驗。

另外建議連帶修預覽端點(屬 jedi-file-upload,不在本棒範圍,建議另開卡):upload_file_route.py:198/:220 不該把儲存的內容用 text/html 直接顯示在頁面裡;HTML 類一律改成強制下載,或加 Content-Security-Policy: sandbox 標頭。

驗證。 2/3 三位檢查員確認成立,嚴重度維持 MEDIUM。投反對票的那位認為 :218-221 的 Content-Disposition 覆寫會限縮檔名(因為官方代理程式一定會送這個標頭),但這個論點只在「代理程式是正常的」前提下成立——而本條的前提正是代理程式被入侵,被入侵的代理程式想送什麼標頭就送什麼。另外兩位把資料流從源頭追到顯示端,確認中間沒有任何一處做過白名單。首腦同意多數意見。

為什麼是 MEDIUM 不是 HIGH: 要先攻下代理程式(或猜中派工編號),而且要等有人來點預覽,不是打一個網址就中。

首腦核對註記。 屬實,四個檔案逐段開過。與 D1/R2b 不重複——D1 查的是憑證「存」與「守」,R2b 查的是代理程式檔案授權,都沒碰到結果回收這一段的檔名處理。淨新增 1 條,登記跨 arc 總表。

§5

卡片重點逐項人工查證

卡片重點③列了四個小項,工具只碰到第一項,其餘三項首腦開檔補查:

③-1 檔名 — ⚠️ 有問題

見上方 F1。補一項工具沒提的:副檔名是用 file.filename.split('.')[-1] 取的,這個寫法在檔名含斜線時會把斜線一起帶進副檔名(例如 a.b/../../../evil 取出的「副檔名」是 /evil)。但實際存檔路徑組不出穿越效果——實測 os.path.join('JOB_EVIDENCES/abc', '20260918_uuid.' + ext) 得到 JOB_EVIDENCES/abc/20260918_uuid./evil,因為時間戳與 UUID 擋在前面,.. 永遠在一個新目錄名的後面、構不成回上層。判定:不是路徑穿越漏洞,但副檔名取法本身脆弱,順手在 F1 的修法裡一併收掉(os.path.basename + 白名單兩步都做,就同時解掉這一項)。

③-2 內容格式(content_type)— ✅ 沒問題

代理程式回報的 Content-Type 確實被原樣存進 upload_files.mimetype(detection_result_handler.py:222 → file_utils.py:28 → DB 欄位)。但這個欄位在顯示端從頭到尾沒被使用:下載端點(upload_file_route.py:169)寫死 application/octet-stream(強制下載,不會在頁面裡執行),預覽端點(:198/:220)用的是副檔名推出來的型別、不是這個欄位。所以代理程式謊報內容格式沒有任何效果,真正的施力點是檔名——已收斂進 F1。

③-3 檔案編號(upload_uids)— ✅ 沒問題

擔心的是「代理程式 A 能不能報一個編號、讓雲端跑去抓代理程式 B 的檔案」。做不到:雲端抓檔的網址是 f"{agent.base_url}/blob/{upload_uid}"(detection_result_handler.py:189-190),其中 agent 是用 agent_task.agent_id 從資料庫查出來的(:92 → _resolve_agent() :172),不是代理程式自報的。代理程式只能決定「去我這裡拿哪一個編號」,決定不了「去哪一台拿」。而且該代理程式若已被撤銷(status == "revoked")會直接拒絕(:176-177)。

③-4 來源網址 — ✅ 沒問題(且是正面案例)

兩個層面:

(a)抓報告的網址:如上,主機位址來自資料庫、路徑是固定的 /blob/{uid},代理程式插不進自己的網址。

(b)safe_http_fetch.py 的對外下載(這支是掃描基準從網址匯入時用的,不在報告回收路徑上,但在本棒範圍內):這支防的是 SSRF(可以騙伺服器去連攻擊者指定的網址)。逐條核過它宣稱的五道防線,五道都真的有做:

  1. 只允許 https(:242-246)—— file://、gopher:// 打不進來
  2. 解析出來的每一顆 IP 都檢查(:255-263),用 Python 內建旗標涵蓋私有網段、本機、link-local(含雲端 metadata 位址 169.254.169.254)、保留段,且把 IPv4-mapped IPv6 攤回 v4 再判(:425-427)——這一點是真的容易漏而它沒漏
  3. IP pinning(:290-296):用檢查過的那顆 IP 直接連線,SNI 另帶原主機名——這道防的是 DNS rebinding(檢查時 DNS 回公網位址、連線時回 127.0.0.1),是 SSRF 防護最常被做漏的一環
  4. 大小上限邊讀邊算、超限即斷(:330-341),不是先收完再看
  5. 重導向自己走、每一跳重跑前四道檢查(:146-160),且刻意不用 urljoin(:371-393,避免 //evil.example.com/x 這種寫法換掉主機卻看不出來)

錯誤訊息全部收斂成自己寫的中文、技術細節只進 log(:311-322),避免把探測結果回傳給攻擊者。判定:這支是正面案例,與 FR-098 A1 ⑤、common/guard.py 同類,不列為發現。

附帶查證:agent_auth.py 的「沒接線就變成不認證」— ✅ 不成立(面板全否,首腦同意)

研究員提報這條,三位檢查員一致否決。首腦複核:主專案的插件接線 core/plugins/detection.py:318 無條件傳入 agent_auth_settings_provider,插件本身也是無條件掛載,沒有任何攻擊者輸入能造成「沒接線」這個狀態——那是我們自己程式碼的固定寫法,要嘛永遠接上、要嘛程式碼被改壞(那是另一回事)。判定:不是漏洞。 不過這支確實只發一次警告就靜默降級,若日後有人重構插件接線漏掉那一行,症狀是「功能全部正常、只是認證悄悄關掉」——建議在插件組裝時把這個欄位列為必填、缺了直接拒絕啟動(和本套件 api/guards.py 的 _guard() 缺接線就 RuntimeError 同一形狀)。列為體質改善建議,不計為發現。

附帶查證:抓報告走明文連線 — ✅ 出貨機器上打不到(面板 2:1 否決,首腦同意)

研究員提報「代理程式認證不是 full 模式時,抓報告走的是沒加密的 http,不驗對方身分也不帶權杖」(detection_result_handler.py:206)。程式碼確實是這樣寫的,AGENT_AUTH_MODE 的程式預設值也確實是 none(config/config.py:258)。但實際出貨的機器打不到:安裝腳本在全新安裝與升級時都會把 AGENT_AUTH_MODE=full 寫進環境設定檔(scripts/installer/install.sh:1677),維運指令 guidant 啟動前還會再檢查一次、不是 full 就當硬錯誤擋下(scripts/installer/guidant:872-886)。判定:不計為發現。

⚠️ 但留一句給開發環境:本機開發與 demo 環境不跑安裝腳本,那裡確實是明文連線。開發環境不算出貨面,但若有人拿 demo 機接真實客戶的代理程式做展示,那條路是開的。建議把 :195 那行「必須是 https」的檢查搬出 if settings.enabled: 區塊、變成無條件檢查——這是一行的改動,讓「不加認證」與「不加密」兩件事解耦(認證可以關,加密不該跟著關)。列為體質改善建議。

§6

這份結果可信到什麼程度

分兩層講:

「報出來的這條存在嗎」——可信度高。 F1 的資料流首腦逐檔開過:從代理程式送進來的欄位、到覆寫它的標頭、到存檔取副檔名的那一行、到預覽時決定內容型別的那一行,四個位置的行號都對得上,中間沒有任何一處做過檢查。三位檢查員裡兩位獨立追出同一條鏈。

「只有這條嗎」——可信度中等,有兩個已知限制。

  1. 強度是 low,只有一位研究員讀一遍,不是多位分工交叉讀。低強度的設計目的是快速篩,不是窮盡。
  2. 這 4 個檔案的下游不在範圍內。F1 能成立,關鍵的最後一哩(預覽端點用副檔名決定內容型別、且不強制下載)在 jedi-file-upload 裡,那支套件本棒沒掃。同理,若 jedi-file-upload 的存檔路徑另有問題,本棒看不到。
  3. 卡片重點③的四個小項首腦都補查了,但那是人工定向查證,不是面板投票的結果——這三項「沒問題」的結論沒有三票背書,可信度低於 F1 那條。

verification.status 為 verified:三位檢查員對 3 條候選各投一票,9 票全數投出,沒有漏投,票數由工具自己的程式碼統計、不是任何 agent 自報。

§7

執行概況

項目 數字
run ID wf_fae30d77-78f
掃描 commit 3ddd7218ca8a8f2cfd6c81351e1435cd39f62b3b(工作區乾淨)
範圍 4 檔 859 行
強度 low(一位研究員 + 三票面板)
研究員 派 1 位、回 1 位、零重試(這是 D3 五次嘗試以來第一次沒被停滯偵測砍掉)
研究員耗時 約 62 分鐘
面板 3 條候選 × 3 位檢查員 = 9 票,全數投出
總耗時 約 110 分鐘(19:06 啟動 → 20:56 報告產出)
候選 → 成立 3 → 1
驗證輪數 1
驗證章 verified
工具原始產物 jedi-detection/CLAUDE-SECURITY-20260918-110643/(不入版控)

這一棒同時驗證了「每棒總行數 ≤ 2,000」這個切法是對的。 前四次失敗(34 檔/17 檔/16 檔)都在 48~96 分鐘時被工具的停滯偵測砍掉——死因是研究員 agent 固定跑 xhigh 強度,上下文累積到 25~30 萬 token 時單次思考超過 180 秒,就被判定為「無進展」。本棒 859 行跑完全程沒有觸發,D3-2 起照同一判準切。