# FR-177 C4 掃描報告：OWASP ZAP connector

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

## 🔴 一句話結論

**沒有急件，最嚴重是中風險。** 工具找到 3 個問題，2 中 1 低，都經三個獨立檢查員投票確認。其中 F2（DNS 查兩次）就是已登記的 #233，從 connector 這端再確認一次，**不當新問題重報**。真正新的是 F1（中）：Agent 連 ZAP 預設走明文 http，就算改成 https，ZAP 官方套件也寫死「不驗憑證」。所以 ZAP 的 API key 和登入後掃描用的「目標網站帳密」，在網路上都能被偷聽。另外 F3（低）：限制爬蟲範圍的規則沒有在主機名結尾封口，爬蟲可能爬到名字很像的別台主機。人工補查另外發現兩件值得記的事（見「重點看什麼」逐條查證）：使用者可以把逾時改到沒有上限；ZAP 報告原樣上傳當證據，沒有遮蔽。

## 這一棒在檢查什麼

`guidant_agent/scan/adapters/connectors/zap.py`（1 檔，642 行）。這支程式叫客戶自備的 ZAP daemon（常駐在背景、等 API 呼叫的 ZAP 服務），對一個網址做被動或主動掃描。主動掃描會送真的攻擊測試流量（payload）。它也可以帶表單登入帳密，做「登入後掃描」。

為了查證，翻了範圍外的 `scan/domain/local_run.py`、`scan/app/local_runs.py`、`sync/app/allowed_targets.py`，以及已安裝的 `zapv2` 套件。這些檔只為查證而讀，本身不當作發現。

## 掃到什麼：總覽表

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 | 嚴重度 |
|---|---|---|---|---|---|---|
| F1 | Agent→ZAP 預設明文 http；https 也不驗憑證（zapv2 寫死 `verify=False`） | 偷聽者拿到 ZAP API key（等於整台 ZAP 的遙控器）與目標網站登入帳密 | 能在 Agent 與 ZAP 之間的網路偷聽或做中間人 | `zap.py:146`、`:160`、`:370-372` | 非本機（loopback）就拒收 http；https 自己帶驗證 CA 的連線，不靠 zapv2 預設 | 中 |
| F2 | 清冊在 Agent 查 DNS，ZAP 自己再查一次（DNS 換綁）——**已登記 #233，不重報** | 主動掃描打到清冊外的內網主機 | 能派 ZAP 任務＋掌控一個 DNS 網域 | `zap.py:460`、`:482` | 同 #233 | 中（已登記） |
| F3 | 爬蟲範圍規則 `re.escape(target_url)+".*"` 沒有在主機名結尾封口 | `http://10.0.0.5` 的規則也會比中 `10.0.0.50`、`10.0.0.5.attacker.tld`、別的埠 | 目標網站頁面上有一條這種長相的連結 | `zap.py:320` | 規則在 `scheme://host[:port]` 後面接 `([/?#].*)?$` | 低 |

## 每條發現的詳述

### F1 — 連 ZAP 走明文，https 也不驗憑證

**白話：** ZAP 的位址（`base_url`）如果沒寫 `http://` 或 `https://`，程式會自己補上 `http://`（`:146`）。每一次 API 呼叫都帶著 `X-ZAP-API-Key` 這個標頭（header）。登入後掃描時，目標網站的帳密是放在網址的查詢參數裡傳給 ZAP 的（`:370-372`，`users/action/setAuthenticationCredentials`）。就算位址寫成 https，ZAP 官方 Python 套件 `zapv2` 內部也寫死 `verify=False`，也就是不檢查對方憑證真假。這直接違反本 repo「不得關閉 TLS 驗證」的規則（SR-03）；只是違規藏在第三方套件裡，守衛 `test_no_cert_none` 抓不到。

**出事會怎樣：** 同網段的人用 ARP 偽造（冒充別台機器的網路位址）攔下流量，就拿到 API key。拿到 key 可以叫 ZAP 掃任何主機、載入腳本，可能等於在 ZAP 主機上執行程式。他也拿到目標網站的帳號密碼。

**要先有什麼：** 能看到或攔下 Agent 與 ZAP 之間的流量；要偷帳密的話，還要剛好有人跑登入後掃描。

**在哪裡：** `guidant_agent/scan/adapters/connectors/zap.py:146`（補 http）、`:160`（`ZAPv2(apikey=..., proxies=...)`）、`:370-372`（帳密送出）。

**怎麼修：** 位址不是本機時，就拒收 http（或至少大聲警告）。https 的情況，改成自己建一個會驗證憑證的連線（可以設定 ZAP 的根憑證 CA），不靠 zapv2 預設。

### F2 — DNS 查兩次（#233 的 ZAP 端）

已登記於主系統總表 #233（B6a F2）。這次從 connector 這端確認：`target_url` 和 `login_url` 都是用主機名原樣交給 ZAP（`:460` ajaxSpider、`:482` ascan、form 登入的 loginUrl），沒有先釘住查到的 IP。**沒有新面向，不重報。**

### F3 — 爬蟲範圍規則沒封口

**白話：** `_context_regex` 把目標網址轉義後，在後面接 `.*`，代表「後面接什麼都算」。目標是 `http://10.0.0.5` 時，這條規則也會比中 `http://10.0.0.51/`、`http://10.0.0.5:8080/`、`http://10.0.0.5.attacker.tld/`。

**出事會怎樣：** 目標網站上如果有一條這種連結（例如使用者能自己貼內容的頁面），ZAP 的爬蟲會把它當成範圍內的網址：爬進去、點按鈕、送表單，流量就打到清冊外的主機。主動掃描的 payload 是從目標網站的節點開始打，所以影響有限。

**要先有什麼：** 目標網站頁面上出現一條開頭跟 `target_url` 一樣的連結。

**在哪裡：** `guidant_agent/scan/adapters/connectors/zap.py:313-320`。

**怎麼修：** 從解析出來的 scheme／host／port 組規則，主機名後面只准接 `/`、`?`、`#` 或網址結尾。

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

| 重點 | 人工查過 |
|---|---|
| `:340-343` 登入帳密從 params 讀，會不會落地或進 log | **落地：不成立。** `local_runs._insert` 存 params 前，會先用 `public_params` 濾掉欄名含 `pass`／`username`／`token` 等字的欄位（`scan/domain/local_run.py:11-17`），所以 `login_password`、`login_username` 不會寫進 `local_runs.params`。**log：不成立。** `:379-381` 只記錄「帳號已提供=True/False」。**傳輸：成立，即 F1。** 主系統→Agent 這段是 8443 mTLS 的派工 payload；本棒沒有深追，屬 B 系列已掃的範圍。 |
| `:153-160` Agent→ZAP 是 http 還是 https、api_key 會不會進 access log | **成立，即 F1。** 預設是 http；https 也不驗憑證。zapv2 0.6.0 的 key 放在標頭 `X-ZAP-API-Key`，不放查詢參數，所以一般 access log 不會記到 key。**但登入帳密是放在查詢參數**（`:371-372`），會進 ZAP 或中間 proxy 的 access log（記錄每筆請求網址的檔），這一點補記進 F1。`base_url` 改位址沿用舊 key（#232）：本檔照收 credentials，沒有另外處理，同 #232，不重報。 |
| `:179`／`:340` 只擋起點，爬蟲會不會爬出去 | **部分成立，即 F3。** 程式有建 context（ZAP 用來界定掃描範圍的設定），爬蟲受它限制，但規則沒封口，相似的主機名或 IP 會被當成範圍內。另外，`login_url` 會過清冊，但它沒有被要求跟 `target_url` 同一台主機；這一點沒有深追。 |
| `:170` active 誰能選、`:187` timeout 上限 | **active：** 本檔不檢權限，只要是合法值就照跑；權限在呼叫端的 scope 控制（B5 已看過）。**timeout：成立（新，低風險）。** `:188` 只做 `int(override)`，沒有上限也沒有下限，使用者可以設成很多天，主動掃描就會長時間對目標送攻擊流量；設成負數會立刻逾時。工具沒報，建議在修正卡補一個上限。 |
| `logged_in_indicator` 長度、帳密含 `&`／`=` 會不會拆欄位 | **拆欄位：不成立。** 帳密和 loginUrl 都經過 `quote(..., safe="")` 編碼（`:359`、`:371`），`&`、`=` 會被轉成 `%26`、`%3D`。**indicator 長度：成立但影響小。** 沒有長度上限，正規表達式交給 ZAP 執行；ReDoS（惡意正規表達式拖垮 CPU）也只會發生在 ZAP 那邊。 |
| 報告抓回、上傳前有沒有遮、檔名拼法 | **沒遮：成立（設計面，記錄不升級）。** `:597` 拿到 `core.htmlreport()` 的原文，直接當證據上傳。ZAP 報告裡可能有目標網站回應的片段（cookie、表單值），Agent 端沒有遮蔽。這是證據保真與遮蔽之間的取捨，交給決策者判斷。**檔名：不成立。** `_build_filename`（`:633-642`）只取 netloc，非安全字元一律換成 `_`，沒有路徑穿越。`_task_uid` 只用在 context 名稱的前 8 碼（`:197-198`），不進檔名。 |
| ZAP 錯誤原文回給使用者前有沒有過濾 | **部分成立（低）。** `_probe_failure_message`（`:233-246`）會把 `base_url` 和例外原文前 200 字回給使用者，可能露出 ZAP 的內網位址。不過看得到這段訊息的人本來就能在工具設定頁看到這個位址，所以影響很小。 |

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

- **工具這一層：** 1 個研究員讀完整個檔（1 派 1 回），沒有被看門狗砍掉（journal 裡沒有 `failed`）。3 個候選問題都由三個檢查員投票：F1、F2 是 3 比 0，F3 是 2 比 1。stamp 是 `verified`。全部都是讀程式碼得出的結論，沒有實際執行任何東西。
- **人工這一層：** 我自己開檔核對了 F1、F3 的行號和 `public_params` 的過濾規則。「重點」7 條逐條看過，另外補出 timeout 沒有上限、報告未遮兩件事。zapv2 內部的 `verify=False` 是採信檢查員讀套件原始碼的結果，我自己沒有再去打開 site-packages 核對。

## 執行概況

| 項目 | 值 |
|---|---|
| run ID | `wf_c71a3350-ff3` |
| 掃描版本 | `3d5aa2afda96a6d7fe52b3c04db27f88346ed059` |
| 強度與範圍 | low，1 檔 |
| verification.status | `verified`（reason_kind 為 null） |
| candidates（候選問題） | 3（去重後 3） |
| panel_votes（檢查員投票） | 9，全部投出 |
| researchers_dispatched / returned（派出／回來的研究員） | 1 / 1 |
| 耗時 | 約 23 分鐘，10 個 agent，約 55 萬 token |
| 原始產物 | `docs/security-scan/FR-177/runs/CLAUDE-SECURITY-20261106-032314/` |
