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

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

## 🔴 一句話結論

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

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

## 這一棒在檢查什麼

- 主控台手動發的掃描與網診，在本機怎麼排隊、記錄、存報告（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；這些檔只為查證而讀，本身不當作發現。

## 掃到什麼：總覽表

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 | 嚴重度 |
|---|---|---|---|---|---|---|
| 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 是高不是嚴重：** 要先有一個能登入主控台、而且帶掃描權限的帳號（一道真的關卡）。但過了這道關，就沒有任何防線了：一般的「掃描操作員」可以直接拿到整台主機與上面所有帳密，等於權限大躍升。

## 每條發現的詳述

### 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 開檔核對：** **成立。** 三個位置我都開過。

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

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）都不撞號。
   - 結論：**不成立**。

## 這份結果可信到什麼程度

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

## 執行概況

| 項目 | 值 |
|---|---|
| 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/` |
