⚠️ 歷史紀錄:本報告描述的問題已於 2026-11-07 全部修正並合回 Agent 主線(修正卡 CM-3559~3564、3573~3576、3584,驗收紀錄見母卡 CM-3507)。文中的檔名與行號是掃描當下(commit
bab2aa3)的版本,修正後已變動,請勿拿來定位現行程式。
沒有急件。 工具找到 1 個低風險問題:總覽頁的「正在執行」清單會把正在掃的主機 IP 與設備名稱給任何登入的人看,繞過了「要有掃描或除錯權限才看得到目標清單」那道門。三個獨立檢查員 3 比 0 確認成立。另外人工照「重點看什麼」逐條查證時,多記下 1 條範圍外的觀察(登入端點 /console/session 沒掛 body 上限),請首腦判斷要不要另開卡。可以排進一般修正排程。
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 已掃,不重報)。| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 | 嚴重度 |
|---|---|---|---|---|---|---|
| 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 白名單(只放特定網段連進來),只影響可用性,不洩漏資料。
白話: 總覽 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,卡片沒要求逐條核對,但仍照做。)
白話: 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、有沒有漏掛」追出來的。沒經過面板投票,請首腦決定要不要另開卡。
inline_policy.py 線上預覽判定
_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。:85 的字串是 sandbox; default-src 'none'; ...,沒有 allow-scripts,也沒有 allow-same-origin,所以腳本不執行,也讀不到主控台的 cookie。files.py:160 用 headers.update 覆蓋,不會被全域的 setdefault 蓋掉。RESPONSE_HEADERS(:31)有 X-Content-Type-Options: nosniff 與 Cache-Control: no-store,apply_headers 每個預覽回應都會套上。< 開頭、但中間夾 HTML 的,會以 text/plain 加 nosniff 回去,瀏覽器不會執行。body_limit.py read_limited
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。update_routes.py:100 的 werkzeug max_content_length 串流,屬第一期 A3 範圍;8444 上傳源碼包在 files 模組(A8 範圍),不重報。read_limited 本身不成立;漏掛成立(人工-1,範圍外)。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)也回給任何登入的人,兩邊一致,沒有比既有出口開得更大。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()(只有已設定與測試結果,不含帳密值)。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 同理。view_audit.py audit_once
(類別, 對象),例如 ("page", "overview")、("run", uid)、("preview", uid),存在各自 session 物件的 audit_marks dict 上(session.py:40),不落地。threading.Lock(:25、:30)保護。targets.py/update_console.py:targets.py 只是把授權清冊組成固定形狀,權限門掛在呼叫端;update_console.py 第一期 A3 已掃,零發現。本棒不重報,工具也沒報這兩支。
console/api/*、local_runs.py、storage_stats.py、allow_cidr.py、session.py)只為追呼叫端才看。| 項目 | 值 |
|---|---|
| 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 後綴區分) |