FR-177 B5 掃描報告:執行紀錄、本機掃描輸入槽、探測執行

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

§1

🔴 一句話結論

有一條要優先處理。 找到 2 個問題。最嚴重的是 F1(高):只有「發動本機掃描」權限的主控台使用者,可以指定任意 CINC/InSpec 檢測規則包(profile)的路徑或網址,讓 Agent 執行他自己寫的 Ruby 程式碼,也就是拿下整台 Agent 主機。另一條是 F2(中):SonarQube 的 repo_url 欄位沒有經過「授權目標清冊」(只准連哪些主機的名單)的檢查,可以拿來叫 Agent 去連內網任意主機。兩條都經三個獨立檢查員投票 3 比 0 成立,我也自己開檔核對過。

注意:兩條的「出事點」都在範圍外的 connector 檔(inspec.py、sonarqube.py),但問題出在範圍內的 local_runs.start:它把使用者填的 params 原樣交出去,只檢查目標欄位。工具是順著這條資料流追出去的,所以照常報。

§2

這一棒在檢查什麼

  • 主控台手動發的掃描與網診,在本機怎麼排隊、記錄、存報告(SQLite 本機資料庫的 local_runs 表);
  • 本機上傳槽:主控台使用者上傳「要掃的第三方元件源碼」時,Agent 發一張一次性上傳票(Bearer 票,只能用一次),票綁定這次掃描的編號 scan_uid;
  • 探測執行器(probe_runner.py,就是設定工具時按「測試連線」跑的那段);
  • scan 模組的註冊與資料庫 migration(建表、補欄的版本步驟)。

範圍共 13 檔、約 1074 行。為了查證呼叫端,有翻範圍外的 console/api/scan_routes.py、sync/app/allowed_targets.py、files/api/browser_plane.py 與兩支 connector;這些檔只為查證而讀,本身不當作發現。

§3

掃到什麼:總覽表

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 怎麼修 嚴重度
F1 手動掃描時,使用者可以自己填 InSpec 規則包的路徑或網址,Agent 原樣拿去執行 規則包裡的 Ruby 程式碼在 Agent 主機上執行,可以讀走本機存的所有工具帳密、SSH 金鑰與資料庫 ①主控台帳號有 run-local-scan 權限 ②Agent 設好了 inspec/gcb 帳密 ③清冊裡至少有一台授權目標 guidant_agent/scan/adapters/connectors/inspec.py:913(入口 console/api/scan_routes.py:159、scan/app/local_runs.py:114-122) 手動掃描只收「主系統同步下來的規則包代號」,由伺服器端解析;手填的路徑和網址一律拒收 高
F2 SonarQube 的 repo_url 沒過授權目標清冊,Agent 會對任意主機做 git clone Agent 被當跳板去連內網任意主機與埠,錯誤訊息回給使用者,可以拿來探測內網 ①有 run-local-scan 權限 ②設好了 SonarQube ③Agent 主機裝有 git guidant_agent/scan/adapters/connectors/sonarqube.py:838(檢查只看 sync/app/allowed_targets.py:37-40 列的欄位) repo_url 加進清冊檢查,或另設允許的 Git 主機清單;關掉 redirect;錯誤訊息不回傳 git 原文 中

為什麼 F1 是高不是嚴重: 要先有一個能登入主控台、而且帶掃描權限的帳號(一道真的關卡)。但過了這道關,就沒有任何防線了:一般的「掃描操作員」可以直接拿到整台主機與上面所有帳密,等於權限大躍升。

§4

每條發現的詳述

F1 — 手填 InSpec 規則包路徑,執行任意 Ruby 程式碼

白話: InSpec(CINC Auditor)的「規則包」本身就是 Ruby 程式碼,執行規則包就是在 Agent 上跑程式。正常路徑是用主系統同步下來的規則包(參數名 _profile),會經過 archive_safety(安全解壓)和範本檢查。但 connector 還保留一條舊的「手填路徑」:參數名 profile,填什麼就跑什麼。主控台的 _build_params 只拿掉 _ 開頭的欄位,所以使用者送 profile 可以一路傳到 cinc-auditor exec <profile>。

出事會怎樣: 攻擊者先用本機上傳槽傳一個惡意規則包(檔案會落在 <資料目錄>/scan-input/<scan_uid>/),或者乾脆放在自己的網站上,再發一次掃描把 profile 指過去。Agent 以服務帳號身分執行裡面的 Ruby,可以讀 credential_store(所有工具的帳密、SSH 私鑰)、註冊金鑰、SQLite 資料庫。填網址的話,Agent 還會去任意主機下載,這也繞過了授權目標清冊。

要先有什麼才打得到: 主控台帳號有 run-local-scan 權限(不需要 configure 設定權限)、Agent 設好了 inspec 或 gcb 的帳密、清冊至少有一台授權目標。

在哪裡:

  • guidant_agent/console/api/scan_routes.py:159:只濾掉 _ 開頭的欄位(範圍外,只為查證);
  • guidant_agent/scan/app/local_runs.py:114:check(params) 只檢查目標欄位;:122 把 params 原樣交給 _run_one;
  • guidant_agent/scan/adapters/connectors/inspec.py:875-878:_resolve_profile 的手填分支,原樣回傳;
  • guidant_agent/scan/adapters/connectors/inspec.py:913:cinc-auditor exec 拿到這個值。

怎麼修: 主控台發起的手動掃描(source=manual)不接受 profile,只接受同步下來的規則包代號,由伺服器端透過 profile_sync/profile_cache 解析。手填的網址、快取目錄以外的絕對路徑、- 開頭的值一律拒收。可以在 _build_params 或 local_runs.start 擋。

runner 開檔核對: 成立。 scan_routes.py:159 的濾法、inspec.py:874-878 的手填分支(raw_profile is None 就直接 return profile),還有 :913 的指令組裝,我都開過,跟描述一致。allowed_targets.py:37-40 的檢查欄位清單裡也確實沒有 profile。

F2 — SonarQube repo_url 繞過授權目標清冊

白話: 授權目標清冊只檢查固定幾個欄位名(hosts、targets、host、ips、target_url、urls、login_url)。SonarQube 的 repo_url 不在裡面,_resolve_repo_url 也只檢查網址是不是 http/https 開頭,Agent 就會照著 git clone 任何網址。

出事會怎樣: 使用者填 http://10.0.5.7:8080/ 這類內網位址,Agent 會從客戶內網送出 GET 請求。看執行紀錄的錯誤訊息(連線被拒、逾時、404,git 錯誤原文最多 500 字)就能分辨內網哪些主機、哪些埠是開的,等於把 Agent 當成內網探測器,打破「Agent 只連授權目標」的保證。影響有上限:只能送 GET,回來的內容也只有片段。

要先有什麼才打得到: 有 run-local-scan 權限、Agent 設好了 SonarQube、Agent 主機裝有 git。

在哪裡:

  • guidant_agent/scan/adapters/connectors/sonarqube.py:429-443:_resolve_repo_url 只檢查 scheme;
  • guidant_agent/scan/adapters/connectors/sonarqube.py:838:git clone ... repo_url;
  • guidant_agent/sync/app/allowed_targets.py:37-40:檢查的欄位清單(範圍外,只為查證)。

怎麼修: 把 repo_url 加進 URL_PARAM_KEYS,跟其他網址一樣解析成 IP 後比對清冊;或者另外設一份允許的 Git 主機清單。git 加 -c http.followRedirects=false;錯誤訊息只回白話,不回 git stderr 原文。

runner 開檔核對: 成立。 三個位置我都開過。

§5

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

  1. 本機上傳票(scan_input.py:81-111)

    • 票是 secrets.token_urlsafe(32),記憶體只存 sha256 雜湊;有效期 LOCAL_SLOT_TTL_SEC = 600(10 分鐘)。
    • max_bytes:upload 100 MB、sbom 50 MB;同時最多 32 張未用的票。
    • consume_local_token 驗過就刪(del s.pending[key]),所以只能用一次;有綁 scan_uid(:108),對不上就拒絕,而且不燒票。
    • 票存在行程記憶體裡。如果有多個 worker,發票和驗票落在不同行程就會驗不過,結果是「上傳失敗」,不是放行,屬於可用性問題而不是安全問題。發票走主控台 8444、收檔走 files/api/browser_plane.py:111。正常部署是單行程。
    • local_upload(scan_uid, sub) 有驗 slot.sub != sub,別人傳的檔不給用。
    • 結論:不成立。
  2. 路徑拼接(scan_input_files.py、scan_input_slots.py)

    • find_local_input:scan_uid 先過 SAFE_DIR_NAME(只允許單層安全名稱,擋 .. 和 /);file_name 用 os.path.basename 取最後一段,排除 symlink。
    • 落地檔名在範圍外的 browser_plane.py,有 _safe_file_name 處理。
    • purge_expired_inputs 不是定時排程,是 sonarqube/sbom 每次掃描時順手清,保留天數照 scan_input_retention_days() 設定。
    • 沒清掉的檔不會被下一次掃描撿到,因為每次上傳的 scan_uid 都是新的 uuid4,資料夾不會共用。
    • 但同一個上傳檔可以被 F1 拿去當惡意規則包,見 F1。
    • 結論:不成立(本身沒問題,但它是 F1 的「送貨管道」之一)。
  3. local_runs.py 的 params、summary、列表

    • params 落表前經過 public_params(domain/local_run.py:14-17),會拿掉名稱含 pass|secret|token|api_key|private_key|credential|username 的欄位,帳密不會進 local_runs.params 明文欄位;完整 params 只在記憶體交給 connector。
    • 名稱過濾的限制:欄位名不含這些字但內容是密碼的(例如自訂名稱),會漏掉。目前的工具 schema 沒有這種欄位,這是剩下的風險,不另列。
    • finish 的 summary 是 connector 回的結果摘要加錯誤訊息,沒有另外過遮罩。connector 的慣例是不把帳密拼進錯誤訊息(probe_runner.py 也註明了),沒有發現實際外洩點。
    • 列表不依 started_by 過濾,而是依權限範圍(scope)過濾(console/api/runs_routes.py:184-186,範圍外)。也就是有對應權限的人看得到所有人的執行紀錄。這是單租戶 Agent 的設計:同一台 Agent 的操作員共享紀錄,不算越權。另外,報告檔要另外經 send_protected 下載。
    • 結論:不成立;params 原樣交給 connector 的部分就是 F1、F2 的根源。
  4. local_runs_repo.py 的 SQL

    • 值全部參數化(:uid 形式)。
    • 唯一的 f-string 在 :77-85,只用來拼 WHERE 子句的結構::k{i} 這類佔位名稱,i 是迴圈序號,值本身仍然走參數,沒有注入點。
    • json.loads 在 local_runs.py:322-330 的 _decoded 有包 ValueError,壞掉的 JSON 讀回成 None。
    • 結論:不成立。
  5. probe_runner.py

    • 只在 :51 用 sync_ports.targets().check(params) 檢查目標,然後呼叫 connector 的 probe();本身沒有 subprocess,外部指令都在 connector 裡。
    • log 不記 credentials。
    • 結論:不成立。
  6. 模組註冊與授權檢查三條路

    • 手動掃描:local_runs.py:114 有 check(params)。
    • 主系統派任:task_executor.py:74 有 check(範圍外,工具也確認過)。
    • 探測:probe_runner.py:51 有 check。
    • 三條路都會過 check(),但 check() 只看固定的欄位名,所以 repo_url(F2)、profile(F1)這種「間接指定連線對象」的欄位都檢查不到。
    • services() 單例持有上傳槽(pending、uploaded,有上限)與執行佇列,沒有存帳密。
    • 結論:「每條路都會過 check」成立;但 check 本身檢查的欄位不夠,見 F1、F2。
  7. migrations.py

    • 只補了 source、started_by_name、progress 三欄,沒有帳密欄位。
    • 版號 3、4,跟其他模組(console 2、identity 5、platform 6、files 1/7)都不撞號。
    • 結論:不成立。
§6

這份結果可信到什麼程度

  • 「這 2 條存在嗎?」 可信度高。三個獨立檢查員從三個角度投票(打不打得到、出事多大、有沒有別的防線),兩條都是 3 比 0 成立;我也自己開檔核對過。
  • 「只有這 2 條嗎?」 不能保證。這次是低強度掃描,只派一位研究員,沒有跑測試、沒有實際打過,結論全部來自讀程式碼。「重點看什麼」七項我逐條人工補查過,沒有找到新的問題。
§7

執行概況

項目 值
run ID wf_c003862e-f78
掃描版本 749d3b42529fe63dc2b07d866e7fe9bed3e16951
強度與範圍 low,13 檔
verification.status verified(reason_kind 為 null)
candidates(候選問題) 2(去重後 2)
panel_votes(檢查員投票) 6:三個檢查員對 2 條各投一票,全部投出
researchers_dispatched / returned(派出/回來的研究員) 1 / 1
耗時 約 28 分鐘,7 個 agent,約 47.5 萬 token
原始產物 docs/security-scan/FR-177/runs/CLAUDE-SECURITY-20261105-171214/