FR-177 B3 掃描報告:主控台聚合層(總覽、心跳欄位、線上預覽判定、body 上限)

⚠️ 歷史紀錄:本報告描述的問題已於 2026-11-07 全部修正並合回 Agent 主線(修正卡 CM-3559~3564、3573~3576、3584,驗收紀錄見母卡 CM-3507)。文中的檔名與行號是掃描當下(commit bab2aa3)的版本,修正後已變動,請勿拿來定位現行程式。

§1

🔴 一句話結論

沒有急件。 工具找到 1 個低風險問題:總覽頁的「正在執行」清單會把正在掃的主機 IP 與設備名稱給任何登入的人看,繞過了「要有掃描或除錯權限才看得到目標清單」那道門。三個獨立檢查員 3 比 0 確認成立。另外人工照「重點看什麼」逐條查證時,多記下 1 條範圍外的觀察(登入端點 /console/session 沒掛 body 上限),請首腦判斷要不要另開卡。可以排進一般修正排程。

§2

這一棒在檢查什麼

Agent 主控台(8444 埠,人用瀏覽器操作的那一面)API 背後的聚合邏輯,guidant_agent/console/app/ 共 7 檔:

  • overview.py:總覽頁把各模組狀態湊成一頁;
  • heartbeat.py:主控台回報給主系統的狀態欄位(agent_status 事件);
  • inline_policy.py:「線上預覽」判斷哪些證據檔可以直接在瀏覽器開;
  • body_limit.py:請求 body(瀏覽器送上來的資料本體)的大小上限;
  • view_audit.py:「看頁」類稽核的去重;
  • targets.py、update_console.py:只為接縫帶入(update_console.py 第一期 A3 已掃,不重報)。
§3

掃到什麼:總覽表

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 怎麼修 嚴重度
F1 總覽的「正在執行」清單不看權限,一律帶出掃描目標 IP 與設備名稱 只有「看檔案」權限的人,持續重新整理總覽就能拼出客戶內網有哪些主機在被掃、叫什麼名字、用什麼工具 ①已登入主控台(任何權限都行)②連得到 8444(在白名單網段內)③當下剛好有掃描或除錯在跑 guidant_agent/console/app/overview.py:64(呼叫 _running(),函式本體在 :270) _running() 也吃 include_targets,沒權限時拿掉 targets 與 target_names 兩欄 低
人工-1(範圍外) 登入端點 /console/session 不在 body_limit.guard 保護內,用 request.get_json 整包讀 沒登入的人送超大 body,Agent 會把它整包讀進記憶體,幾個併發可能拖慢或吃光記憶體 ①連得到 8444(在白名單網段內)——不用登入 guidant_agent/console/api/__init__.py:96(範圍外,只為查證) 對 console_bp 掛 body_limit.guard,或該路由先 read_limited 再解 JSON 低(待首腦確認)

為什麼是「低」: F1 不能改東西、不能拿帳密,只看得到「當下正在跑的那幾台」,而且要先登入。人工-1 前面有 CIDR 白名單(只放特定網段連進來),只影響可用性,不洩漏資料。

§4

每條發現的詳述

F1 — 總覽「正在執行」清單繞過目標清單的權限門

白話: 總覽 API(GET /console/api/overview)只要求「有登入」,不要求特定權限。程式裡本來就有一道門:主機/網段清單算內網拓撲,只有帶 run-local-scan 或 net-diag 權限的人才看得到(_targets_view,overview.py:246-252)。但同一頁的 running(正在執行)欄位走另一個函式 _running(),它沒拿到這個權限旗標,每筆任務都原樣帶出 targets(目標 IP)與 target_names(授權清冊裡的設備名稱,來自 local_runs.display_fields,guidant_agent/scan/app/local_runs.py:259-265)。

出事會怎樣: 主系統只發給某人「看檔案」權限,他照理看不到內網有哪些主機。但他只要開著總覽頁(前端本來就會定期刷新),主系統派掃描、或別的操作者手動跑掃描/網路除錯時,就能看到目標 IP、設備名稱、用的工具、誰發動的。時間一長就拼得出一張相當完整的內網受檢主機表。同樣的資料在 /api/scan/targets、/api/netdiag/targets 與總覽自己的 info.allowed_targets.host_list 都會擋掉這個人。

要先有什麼才打得到: 已登入(任何權限)、來源 IP 在 8444 白名單內、Agent 已同步而且當下有任務在跑或在排隊。

在哪裡:

  • guidant_agent/console/app/overview.py:63-64:include_targets 只交給 _info(),_running() 沒拿到;
  • guidant_agent/console/app/overview.py:284-288(主系統派的任務)、:292-296(手動任務):原樣帶 targets 與 display_fields;
  • 呼叫端 guidant_agent/console/api/pages.py:130-136:只掛 @require_session(),include_targets 在這裡算好傳進來(範圍外,只為查證)。

怎麼修: _running(include_targets),旗標為 False 時拿掉 targets 與 target_names(工具、狀態、筆數可以留)。在 test_overview_target_list_needs_scan_or_netdiag 旁補一支測試,斷言 files/configure 權限的 session 看不到 running[*].targets。

runner 開檔核對: 成立。overview.py:63-64 與 :270-297 我都開過,_running() 沒有任何權限參數。display_fields 我也追到 local_runs.py:259-265,確認帶的是清冊設備名。(本條是 LOW,卡片沒要求逐條核對,但仍照做。)

人工-1(範圍外)— 登入端點沒掛 body 上限

白話: body_limit.guard 只掛在 scan_bp、netdiag_bp、ops_bp、local_auth_bp 四支 blueprint 上(blueprint 是 Flask 把一群路由打包在一起的單位)。console_bp 沒掛,它底下的 POST /console/session(拿主系統票或一次性登入碼換 session 的登入入口)直接 request.get_json(silent=True)。專案也沒設 MAX_CONTENT_LENGTH,所以 werkzeug 會把整個 body 讀進記憶體再解析。這支在登入之前就會執行,違反 endpoints.md「身分檢查之前只做便宜、有上限的事(SR-04.1)」。

出事會怎樣: 白名單網段內任何一台機器不用登入,就能送幾百 MB 的 body,幾個併發就能讓 Agent 吃光記憶體,或者讓主控台卡住。

要先有什麼才打得到: 只要連得到 8444。

在哪裡: guidant_agent/console/api/__init__.py:91-96。同一支 blueprint 的 PUT /api/tools/<code>(tools.py:62,經 secret_form.raw_fields → request.get_json)、POST /api/tools/<code>/test(tools.py:118)也沒有上限,不過這兩支要先有 configure 權限。

怎麼修: console_bp 掛 body_limit.guard(/session 的上限給幾 KB 即可),讀 body 的地方改用 body_limit.json_body()。

註記: 這條不在本棒 scope(console/api 不在 console/app 內),工具沒報,是 runner 照「guard 掛在哪些 blueprint、有沒有漏掛」追出來的。沒經過面板投票,請首腦決定要不要另開卡。

§5

「重點看什麼」逐條人工查證

  1. inline_policy.py 線上預覽判定

    • SVG: 不收。_MAGIC(:18-24)沒有 SVG;SVG 是以 < 開頭的文字,_text_mime(:59-60)看到 < 開頭直接回 None,所以不會被當成文字 inline。html_preview(:93)要求檔名結尾是 .html/.htm,所以 .svg 也走不到 HTML 那條。最後呼叫端 files.py:151-154 回 415,不會變成附件。
    • 檔頭繞過: _magic_mime 只讀前 64 bytes(_SNIFF_BYTES,:25)比對特徵碼。前面塞 %PDF-、後面放 HTML 的檔,會以 application/pdf inline 回去,再加上 nosniff,瀏覽器會用 PDF 檢視器開,不會當 HTML 執行。PNG/JPEG/GIF/WebP 同理,瀏覽器以圖片解碼,不會執行裡面的 HTML。
    • CSP sandbox: :85 的字串是 sandbox; default-src 'none'; ...,沒有 allow-scripts,也沒有 allow-same-origin,所以腳本不執行,也讀不到主控台的 cookie。files.py:160 用 headers.update 覆蓋,不會被全域的 setdefault 蓋掉。
    • nosniff: RESPONSE_HEADERS(:31)有 X-Content-Type-Options: nosniff 與 Cache-Control: no-store,apply_headers 每個預覽回應都會套上。
    • 文字類: 內容不是以 < 開頭、但中間夾 HTML 的,會以 text/plain 加 nosniff 回去,瀏覽器不會執行。
    • 結論:不成立。
  2. body_limit.py read_limited

    • chunked(分段傳輸,沒有 Content-Length 標頭): request.content_length 是 None,(None or 0) > max 為假,接著 stream.read(max_bytes + 1) 最多只讀上限加 1 個 byte,超過就 413。chunked 也擋得到,跟檔頭註解說的一致。
    • 漏掛: 已掛的有 scan_bp、netdiag_bp、ops_bp(ldap_routes 也掛在 ops_bp 上)、local_auth_bp;runs_routes、update_routes 在路由內自己呼叫 read_limited。console_bp 漏掛,詳見人工-1。keys_bp 的 POST /renew 不讀 body。
    • 直傳 blob: 離線更新包走 update_routes.py:100 的 werkzeug max_content_length 串流,屬第一期 A3 範圍;8444 上傳源碼包在 files 模組(A8 範圍),不重報。
    • 結論:read_limited 本身不成立;漏掛成立(人工-1,範圍外)。
  3. heartbeat.py 送回主系統的欄位

    • 釐清一點:fields() 不是放在心跳裡,是 agent_status 事件(檔頭 :1-4,決策者 2026-11-01「心跳不帶訊息」)。
    • 送出去的欄位:credential_store: True、console_allow_cidr_installed(安裝時的網段底線)、ops/LDAP 來源/改名的狀態、sync_state、local_admin_set 與 local_admin_password_set_at(只有「有沒有設」和時間,沒有雜湊)、configured_tools(credential_store.status(),沒有欄位值),以及 storage 統計(型別、健康與否、檔數、用量、剩餘磁碟,沒有 endpoint)。
    • 本機密碼狀態: 有出去,這是刻意設計(:138 D13:無人值守升級不問密碼,主系統靠這欄提醒)。只出去「有沒有設」和時間,不含任何祕密。
    • 網段基線: 有出去(allow_cidr.py:105-109)。理由寫在程式裡:主系統要存「收緊值」之前,得先驗它跟底線的交集不是空的。資料送給同一客戶自己的主系統,而且主系統本來就能下發 CIDR。對照記憶「新增資料出口先查同一份資料在既有端點的守門」:同一份底線在總覽的 info.console_allow_cidr(overview.py:262)也回給任何登入的人,兩邊一致,沒有比既有出口開得更大。
    • 結論:不成立(屬設計決定)。底線網段在主控台對任何登入者都看得到這點,與 F1 同性質但等級更低(底線是給維運看的設定、不是受檢主機清單),只記不報。
  4. overview.py build_overview

    • include_targets:呼叫端 pages.py:134,session 帶 run-local-scan 或 net-diag 其中一個才是 True,只影響 info.allowed_targets 的清單。running 沒受這道門管,就是 F1。
    • include_tools:要 configure 權限,內容是 credential_store.status()(只有已設定與測試結果,不含帳密值)。
    • 儲存 endpoint: storage 欄(:49,來自 storage_stats.storage_health,storage_stats.py:79-89)會回 MinIO/SeaweedFS 的 endpoint(host:port)或本機 BASE_DIR,對任何登入者都回,但不含 access key/secret。程式註解明說「不回端點帳密,只回 host 給人辨識」,屬設計取捨。cloud_endpoint、base_url 同理。
    • 結論:除 F1 外不成立。
  5. view_audit.py audit_once

    • 去重 key: (類別, 對象),例如 ("page", "overview")、("run", uid)、("preview", uid),存在各自 session 物件的 audit_marks dict 上(session.py:40),不落地。
    • 記憶體: 每個 session 每個對象最多一筆(同一個 key 是覆寫,不會累加)。總量上限是「登入中的 session 數 × 這個 session 看過的不同對象數」,session 被登出或過期回收時一起清掉。要讓它長大,得先登入,再逐一看大量不同的檔或紀錄,每筆只是一個 tuple 加一個 float。唯一的放大因素是 session 沒有絕對壽命上限(已登記在 A6 F1)。
    • 並行: 讀改寫有 threading.Lock(:25、:30)保護。
    • 結論:不成立。
  6. targets.py/update_console.py:targets.py 只是把授權清冊組成固定形狀,權限門掛在呼叫端;update_console.py 第一期 A3 已掃,零發現。本棒不重報,工具也沒報這兩支。

§6

這份結果可信到什麼程度

  • 「F1 存在嗎?」 可信度高。三個獨立檢查員從三個角度投票(打不打得到、出事多大、有沒有別的防線擋住),結果 3 比 0 成立,我也自己開檔核對過。人工-1 是我讀程式碼查證的,沒經過面板。
  • 「只有這些嗎?」 不能保證。這次是低強度掃描,只派一位研究員讀這 7 檔,另一位做寫死帳密的掃描(兩位都有回來)。沒有跑測試、沒有實際攻擊過,結論全部來自讀程式碼。「重點看什麼」六項我逐條人工補查過。範圍外的檔案(console/api/*、local_runs.py、storage_stats.py、allow_cidr.py、session.py)只為追呼叫端才看。
§7

執行概況

項目 值
run ID wf_12d39046-180
掃描版本 749d3b42529fe63dc2b07d866e7fe9bed3e16951(工作目錄乾淨,dirty: false)
強度與範圍 low,guidant_agent/console/app 7 檔,focus attack-surface
verification.status verified(reason_kind 為 null)
candidates(候選問題) 1(去重後 1)
panel_votes(檢查員投票) 3:三個檢查員對 1 條各投一票,全部投出
researchers_dispatched / returned(派出/回來的研究員) 2 / 2
耗時 約 19 分鐘(1137 秒),5 個 agent,約 29.6 萬 token
原始產物 docs/security-scan/FR-177/runs/CLAUDE-SECURITY-20261105-165639-B3/(原目錄名與 B2 同秒撞名,加 -B3 後綴區分)