# FR-177 B6a 掃描報告：目標清冊、結果回報 outbox、事件送出

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

## 🔴 一句話結論

**沒有急件，但有兩個「清冊這道門關不緊」的中風險問題，建議排進近期修正。** 找到 2 個中風險問題，都是「Agent 能被拿去連清冊外的主機」：一個是有些工具的參數欄位根本沒被清冊檢查（F1），一個是主機名先查一次 DNS 比對、工具連線時又自己查一次，兩次結果可以不同（F2）。兩條都經三個獨立的檢查員投票，3 比 0 確認成立。outbox 與事件送出沒有發現問題。

## 這一棒在檢查什麼

- **允許目標清冊**（`allowed_targets`）：主系統下發「這台 Agent 只能掃哪些 IP／網段」，Agent 每次連線前都要比對。這是「主系統被打穿時，Agent 不會被拿去掃客戶內網任意主機」的最後一道門。
- **outbox**（離線續傳佇列）：主系統連不上時，掃描結果和事件先存在 Agent 本機的 SQLite（單檔資料庫），連上後再補送。
- **事件送出**（`event_sender`）：Agent 通知主系統的唯一管道。

## 掃到什麼：總覽表

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 | 嚴重度 |
|---|---|---|---|---|---|---|
| F1 | 清冊只檢查固定幾個參數名（`hosts`、`targets`、`target_url`…）；SonarQube 的 `repo_url`、InSpec 的 `profile` 不在名單，填任何網址都不會被擋 | Agent 會對清冊外的內網或外網主機發 HTTP 請求（`git clone`），可被當成跳板探測內網 | ①主控台有「執行本機掃描」權限的帳號，或主系統被打穿 ②有設定 SonarQube（且 Agent 裝了 git）或 InSpec | `guidant_agent/sync/app/allowed_targets.py:37-40` | 改成預設拒絕：每個 connector 自己宣告「哪些參數會拿去連線」，沒宣告的 connector 或欄位一律拒；短期先把 `repo_url`、`profile` 加進清單 | 中 |
| F2 | 網址目標是主機名時，Agent 查一次 DNS 比對清冊，ZAP 實際連線時又自己查一次 DNS，兩次可以拿到不同 IP（DNS rebinding，DNS 換綁攻擊） | ZAP 主動掃描（會送攻擊 payload）打到清冊外的內網網站 | ①能發動 ZAP 掃描 ②攻擊者掌控一個 DNS 網域 ③有設定 ZAP | `guidant_agent/sync/app/allowed_targets.py:263`（解析在 `:230-238`） | 網址目標只收 IP 寫法；要收主機名，就要求主系統在清冊裡列出主機名本身，不信 Agent 自己查到的 DNS | 中 |

**為什麼是「中」不是「高」：** 兩條都要攻擊者先拿到「能發動掃描」的身分（主控台帳號加對應權限，或主系統本身被打穿），陌生人碰不到。但清冊的設計目的正是防「主系統被打穿」這種情況，所以門沒關緊仍值得修。

## 每條發現的詳述

### F1 — 清冊只看固定欄位名，SonarQube `repo_url`／InSpec `profile` 沒被檢查

**白話：** 清冊比對（`check()`）只會檢查 `iter_targets()` 挑出來的目標。`iter_targets()` 只看七個固定的參數名：`hosts`、`targets`、`host`、`ips`、`target_url`、`urls`、`login_url`。SonarQube 從 `repo_url` 拿原始碼網址，然後 `git clone` 下來，只檢查開頭是不是 http/https；InSpec 把 `profile` 原樣交給 `cinc-auditor exec`，這個工具也吃遠端網址。檔頭的註解自己就寫了「新 connector 若用別的鍵名，必須加進來，否則那一欄完全不受比對」，也就是說這道門要靠人記得補。

**出事會怎樣：** 填 `repo_url=http://10.20.0.7:8080/`，Agent 就會去連這台清冊外的主機。git 的錯誤訊息會回到畫面，等於提供了「這個 IP／port 有沒有開」的探測工具。

**要先有什麼才打得到：** 主控台帳號有執行本機掃描的權限，或主系統被打穿後派任務；Agent 上設定了 SonarQube 並裝了 git（`profile` 那條則要有 cinc-auditor）。

**在哪裡：**
- `guidant_agent/sync/app/allowed_targets.py:37`、`:40`：兩份固定名單；
- `guidant_agent/sync/app/allowed_targets.py:217-227`：`iter_targets`；
- 範圍外，只為查證、不當發現：`guidant_agent/scan/adapters/connectors/sonarqube.py:435`（讀 `repo_url`）、`:838`（`git clone`），以及 `guidant_agent/scan/adapters/connectors/inspec.py:875`（`profile`）。

**怎麼修：** 根本解法是改成預設拒絕：每個 connector 宣告自己哪些參數會拿去連線，執行前沒有宣告的就拒。短期先把 `repo_url` 加進 `URL_PARAM_KEYS`，git 加 `-c http.followRedirects=false`，避免比對的主機和實際連的主機因為重新導向而不同。

**runner 開檔核對：** 成立。`allowed_targets.py:37-40` 兩份名單沒有 `repo_url`／`profile`。`grep` 過 `sonarqube.py:435`、`:838`，確認讀的是 `params.repo_url`，clone 前沒有再過清冊。三個呼叫入口 `local_runs.py:114`、`probe_runner.py:51`、`task_executor`（檢查員引用 `:74`）都只呼叫 `check(params)`，沒有別的防線。

### F2 — 主機名查兩次 DNS，比對和實際連線用的 IP 可以不同

**白話：** 網址目標寫的是主機名（例如 `http://web.example`）時，`_resolve` 會用 `getaddrinfo` 查一次 DNS，確認每個 IP 都在清冊內才放行，但只留下「過／不過」的結果。ZAP connector 交給 ZAP 的還是原本的主機名網址，ZAP 連線時會自己再查一次 DNS。

**出事會怎樣：** 攻擊者的 DNS 伺服器第一次回清冊內的 IP（TTL 0，不快取），第二次回 10.9.9.9。ZAP 的主動掃描 payload 就打到清冊外的內網網站。如果 ZAP 主機用的 DNS 跟 Agent 不同（例如內外部 DNS 回不同答案），不需要搶時機也會發生。

**要先有什麼才打得到：** 能發動 ZAP 掃描、掌控一個兩邊都查得到的 DNS 網域、Agent 有設定 ZAP。

**在哪裡：** `guidant_agent/sync/app/allowed_targets.py:263`（主機名交給 `_resolve`）、`:230-238`（只查一次，不綁定連線）；範圍外，只為查證：`guidant_agent/scan/adapters/connectors/zap.py:179-184`、`:460`、`:482`（把原主機名交給 ZAP）。

**怎麼修：** 網址目標只收清冊內的 IP 寫法；一定要支援主機名，就由主系統在清冊裡明列主機名，或把網址改寫成比對過的 IP（再帶 Host header）後才交給 ZAP。

**runner 開檔核對：** 成立。`_target_networks` 只回網段清單，沒有把解析結果往下傳；`zap.py` 的 `_resolve_target_url` 回的是原字串。三個檢查員裡有一位（看「打不打得到」那一位）評為低，另外兩位評為中，最終維持中。

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

1. **`_apply`／`apply` 內容驗證（`allowed_targets.py:94-131`）**
   - `ip_network(..., strict=False)` **會接受 `0.0.0.0/0` 和 `::/0`**，Agent 不擋萬用網段。主系統下發這個，就等於全部放行。不過清冊內容本來就由主系統決定，主系統能下發 `0.0.0.0/0`，也就能直接列出任何單一 IP，所以 Agent 擋萬用網段只是多一層防呆，擋不住被打穿的主系統。**人工查過：成立，但屬縱深防禦建議（低）**：可在 Agent 端拒絕前綴長度太短的網段（例如 IPv4 小於 /8）。工具沒報。
   - **舊版本覆蓋新版本**：`apply` 只比對「版本跟目前是否相同」（`:126`），沒有比大小，所以舊的清冊再送一次會被套用。`version` 沒給時用內容 hash 算（`:300-304`），本來就沒有先後可言。能回放舊清冊的只有設定通道，而設定通道的守門是 A2 那一棒的範圍。**人工查過：成立，屬設計限制（低）**：要防回放，需要主系統給單調遞增的版號、Agent 拒絕較舊的版號。
   - **`_persist` 落地檔權限**：`open(tmp, "w")` 沒指定權限，跟著 umask 走。但檔案放在憑證目錄，`cert_files.py:90` 建目錄時用 `0o700`，其他帳號進不去；內容也只是 IP 清單，不是秘密。**人工查過：不成立。**
   - 半份清冊：形狀不對整份拒（`:121-123`），單筆看不懂就略過該筆（`:104`、`:109`）。略過只會讓清冊變小，不會變大，方向是安全的。**不成立。**

2. **`iter_targets`／`_resolve`／`_target_networks`（`:217-263`）**
   - DNS rebinding：**成立，即 F2。**
   - 多個 IP：`_resolve` 回全部的 IP，`is_allowed` 用 `all(...)`（`:186`），**全部都在清冊內才過**，一個不在就拒。**不成立。**
   - 帶 port 的目標：網址走 `urlsplit().hostname`，port 會被去掉，只比主機；`hosts` 欄位裡的 `10.0.0.1:22` 用 `ip_network` 解析會失敗，結果是拒絕。**不成立**（清冊不管 port 是既定設計）。
   - 帶 scheme 的目標：只有 `URL_PARAM_KEYS` 那幾欄會補 `http://` 再解析；`hosts` 欄填 `https://host`，因為有 `://` 也會被當網址解析主機名。**不成立。**
   - CIDR 當目標：`_network_allowed` 用 `subnet_of`（`:177`），**整個網段都要被清冊某一段包含**，不是有交集就過。末段範圍 `10.0.0.1-50` 會先拆成幾個網段再逐一比。**不成立。**
   - IPv6 寫法：`[::1]` 會去掉括號，`ip_network` 會正規化寫法，zone id（`%eth0`）只在解析結果裡去除。IPv4 對應 IPv6 位址（`::ffff:10.0.0.1`）會被當成 IPv6，不會對到 IPv4 清冊，結果是拒絕，方向安全。**不成立。**
   - 裸主機名（不是網址）：`hosts` 欄填主機名時，`ip_network` 失敗，`is_url` 為假，回空清單，`is_allowed` 回 False。**一律拒，不成立。**

3. **`apply_names`／`_clean_names`（`:134-152`、`:203-214`）**
   - 名稱對照表只拿來畫面顯示（`local_runs.py:261` 讀 `names`）。`_target_networks`／`is_allowed` 完全不讀 `_names`，名稱對不到任何連線行為；數量和長度都有上限（10,000 筆、255 字）。**不成立。**

4. **outbox（`outbox.py`＋`outbox_repo.py`）**
   - 存在 SQLite 的 `outbox` 表（`outbox_repo.py`），不是散落的檔案；SQL 全部用參數綁定，沒有字串拼接 SQL。
   - 總量上限 `MAX_ROWS = 10_000`（`outbox.py:49`）。滿了先丟 audit，再丟 local_run／source_scan，**task_result 和 event 永不丟，必要時超過上限也收**（`:101`）。也就是說，主系統斷線很久、任務又多，這兩類可以無上限長大。**單筆大小沒有上限**，task_result 裡有完整掃描報告。另外 audit 在磁碟低於門檻時不入列（`:87`），其他類沒有這個檢查。**人工查過：成立，屬可用性風險（低）**：斷線一週、每天跑很多任務時可能塞滿磁碟。建議 task_result／event 也看 `disk_space.is_low()`，或設單筆上限。工具沒報。
   - 重送有 backoff：1、2、4…分鐘，最長 1 小時（`:74-76`），**沒有「失敗幾次就放棄」**，會一直重試直到主系統回定論碼（`_TASK_SETTLED` 等）。是刻意設計，不算資安問題。
   - 送成功（或主系統回定論碼）就刪本機副本（`_settle`，`:310-312`）。**不成立。**
   - 遮罩：outbox 存的是原樣 payload，沒有另外遮罩。內容是要送給主系統的結果本身，遮罩應在產生結果時做（屬 scan 模組，範圍外）。SQLite 檔在資料目錄。**人工查過：本範圍不成立**；資料目錄權限屬其他棒的範圍，不在此判斷。

5. **`event_sender.send_event`（`:39-68`）**
   - log 有沒有印 payload：直送失敗只印 `kind`、HTTP 碼和例外類別名（`type(e).__name__`，`:55-59`）；入列失敗那條（`:73`）有 `exc_info=True` 會印堆疊，堆疊裡是 DB 例外，不含 body 內容。**不成立。**
   - kind 白名單：`build()` 建 `AgentEvent` 時，`jedi_agent_protocol/events.py:53-55` 的 `__post_init__` 會拒絕不在 `EVENT_KINDS`（六種）裡的 kind，outbox 的 `enqueue` 也只收已知 kind。程式內任何模組都能呼叫 `send_event`，但只能發這六種，而且是 Agent 自己的程式碼，不是外部輸入。**不成立。**

6. **`ports/target_authorizer.py`**
   - Protocol 的 docstring 有寫「沒收過清冊一律 False」「沒收過清冊或任一不過 → TargetNotAuthorized」，但 Protocol 只是型別介面，**規定靠實作自律，沒有強制**。唯一的實作 `AllowedTargets` 有做到（`:184-185`、`:191-192`）。**人工查過：不成立**：目前只有一個實作，而且做對了；未來新增實作時要靠測試守住。

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

- **「這 2 條存在嗎？」** 可信度高。三個獨立的檢查員從三個角度投票（打不打得到、出事多大、有沒有別的防線擋住），兩條都 3 比 0 成立，我也自己開檔核對過。
- **「只有這 2 條嗎？」** 不能保證。這次是低強度掃描，只派一位研究員讀這 5 個檔，沒有跑測試、沒有實際打過，結論全部來自讀程式碼。「重點看什麼」六項我逐條人工補查過，另外記下三項低風險建議：萬用網段沒擋、舊清冊可回放、task_result／event 沒有磁碟上限。工具沒報這三項，也沒經過檢查員投票，屬人工判斷。範圍外的檔案（connectors、`local_runs.py`、`probe_runner.py`、`cert_files.py`、`jedi_agent_protocol/events.py`）只為追呼叫端才看，不當發現。

## 執行概況

| 項目 | 值 |
|---|---|
| run ID | `wf_bb4d13b2-4dc` |
| 掃描版本 | `63334f464215c18fa89c334b360a86f31679c1f8`（工作目錄乾淨，沒有未提交的改動） |
| 強度與範圍 | low，5 檔 |
| verification.status | `verified`（reason_kind 為 null） |
| candidates（候選問題） | 2（去重後 2） |
| panel_votes（檢查員投票） | 6：三個檢查員對 2 條各投一票，全部投出，沒有漏投 |
| researchers_dispatched / returned（派出／回來的研究員） | 1 / 1 |
| 耗時 | 約 34 分鐘，7 個 agent，約 48.3 萬 token |
| 原始產物 | `docs/security-scan/FR-177/runs/CLAUDE-SECURITY-20261105-174554/` |
