# FR-177.A7 掃描報告：LDAP 登入、離線操作者、來源網段白名單（CM-3514）

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

## 1. 🔴 一句話結論

找到 2 個問題，**沒有高風險**。最嚴重的一條（中風險）：只有「設定」權限的人，可以把 LDAP 改成指向自己架的假伺服器，然後冒充名單上任何一位權限更大的操作者登入主控台。

## 2. 這一棒在檢查什麼

主控台（客戶在自己機器上用瀏覽器開的 Agent 管理頁，8444 埠）除了本機管理員帳號，還有兩條登入路：
- 用客戶自己的 AD／LDAP（公司帳號系統）帳號登入。
- 主系統事先推下來的「離線操作者」名單：主系統斷線時，誰還能登入這台。

另外還有一張「只准哪些網段連進 8444」的白名單。這一棒看三件事：門有沒有關好、客戶 AD 服務帳號的密碼有沒有保管好、名單會不會被人塞人。

## 3. 掃到什麼：總覽表

| # | 嚴重度 | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修（摘要） |
|---|---|---|---|---|---|---|
| F1 | 中 | 只要有「設定」權限，就能把 AD 登入改成問攻擊者自己的伺服器，等於讓他決定誰的密碼「對」 | 能冒充權限更大的操作者：下載證據檔、裝更新、重啟，稽核紀錄記在被冒充的人頭上；別的操作者用 AD 登入時，密碼也會被他收走 | ① 自己已經有主控台「設定」權限 ② 名單上有權限比他大的人，且離線登入開著 ③ 他架的 LDAP 伺服器 Agent 連得到 | `guidant_agent/console/api/ldap_routes.py:79`、`:94`；驗密碼在 `guidant_agent/identity/adapters/ldap_source.py:249-253` | 改登入來源與本機 LDAP 設定改成最高權限（只准本機管理員）；來源一改就踢掉所有 AD 登入的 session |
| F2 | 低 | 主系統拿掉某位操作者的部分權限（人還在名單上）時，他正在用的登入狀態仍保有舊權限 | 被收回的權限只要他一直在用就持續有效，重開機也還在 | ① 他本來就是合法操作者、正在登入中 ② 主系統只縮權限、沒整個撤銷 | `guidant_agent/identity/app/offline_operators.py:133`（`replace_all`） | `replace_all` 也回傳「權限變少」或「AD 綁定改了」的人，讓 `apply` 一併踢掉 |

**為什麼 F1 是中、不是高**：攻擊者必須先是有「設定」權限的自己人，不是外人打得進來。但它打破了「只有設定權的人不能重啟、不能下載」這條權限切分。

## 4. 每條發現詳述

### F1 — 有「設定」權限就能自己指定 AD 伺服器，冒充別人登入（中）

**白話**：AD 登入時，Agent 會拿你輸入的密碼去問 AD 伺服器「這組密碼對不對」，伺服器說對就放行。問題在於「去問哪一台伺服器」，只要有「設定」權限就能改（`PUT /console/api/ops/ldap/local` 填伺服器位址，`PUT /ldap/source` 選「本機自設」）。改成攻擊者自己架的 LDAP 後，他想讓誰的密碼「對」就讓誰的對。

**出事會怎樣**：
1. 攻擊者在自己的 LDAP 建一個帳號，名字跟名單上某位管理員的帳號（sub）一樣，密碼自己設。
2. 他到 Agent 登入頁的 AD 分頁，用那個名字和自己設的密碼登入。
3. 假伺服器回答「對」，Agent 照名單給他那位管理員的權限。

另外，真的操作者之後用 AD 登入時，輸入的密碼會被送到假伺服器（可以設成「不加密」），密碼就被收走了。

**要先有什麼才打得到**：見總覽表。三個條件都要成立。

**在哪裡**：
- `guidant_agent/console/api/ldap_routes.py:77-85`（改來源）與 `:88-97`（存本機設定）：只檢查 `configure`。
- `guidant_agent/identity/adapters/ldap_source.py:248-253`：非 AD 模式下直接拿名單上的 `op.sub` 當帳號，去問設定好的那台伺服器。

**怎麼修**：在 `ldap_routes.py` 的 `put_ldap_source`、`put_ldap_local`、`delete_ldap_local` 把權限從 `require_session("configure")` 提高到只准本機管理員（或要求持有全部主控台權限）。同時在來源或位址改動後，呼叫 `console_session` 踢掉所有用 LDAP 方式登入的 session。

**人工核對**：已開檔確認。三個路由都只掛 `require_session("configure")`。`_validate`（`ldap_routes.py:55-62`）只檢查埠號與格式，沒限制位址。檔頭第 4 行自己也寫「選錯來源或填錯伺服器，下一個人登入時 AD 密碼就送到那台機器」，表示設計者知道風險，但把它和「設定」權限綁在一起。**成立。**

### F2 — 權限被縮小後，正在用的登入狀態不會跟著縮（低）

**白話**：主系統每次心跳（Agent 定期跟主系統報到）都會推下最新名單。Agent 只把「被撤銷」或「從名單拿掉」的人踢下線（`offline_operators.py:133`、`:142-144`）。權限是在登入那一刻抄進登入狀態的，之後不會再對名單。所以只縮權限的人，舊權限會一直留著。

**出事會怎樣**：主系統拿掉某人的「下載檔案」權限，但他已登入的分頁仍能下載。登入狀態每 30 分鐘會自動延長，重開機也不會消失。

**要先有什麼才打得到**：他本來就是合法操作者且正在登入中。危害有限，所以列低風險。

**在哪裡**：`guidant_agent/identity/app/offline_operators.py:120-133`（`replace_all`）、`:136-144`（`apply`）。

**怎麼修**：在 `replace_all` 讀出舊列時比對 scope，新 scope 不是舊的超集合、或 `ad_account` 不同時，把該 sub 一起放進回傳集合；`apply` 就會照原本的迴圈呼叫 `revoke_by_sub`。

**人工核對**：已開檔確認回傳式只有撤銷與移除兩種，沒有比對 scope。**成立。**

### 被三個檢查員一致否決的一條（供參考）

「測試連線」端點收前端送來的整組設定，可以叫 Agent 連任意內網位址。三票都認為不成立：能叫這個端點的人本來就有權設定任意伺服器，多這條路並沒有多拿到東西。而且改了位址、加密方式或埠時，存著的密碼不會跟著送出去（`PINNED_KEYS`，`ldap_source.py:50`，要重填）。根本問題其實是 F1。

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

| 項目 | 人工結果 | 說明 |
|---|---|---|
| `ldap_source.py:206-207` 服務帳號密碼存哪、回畫面有沒有遮 | 不成立（做得對） | 存在 `credential_store`（Fernet 加密，經 `_secrets()` → `SecretFieldStore`），不是明文 JSON。畫面一律走 `secret_form.masked`（`ldap_routes.py:32`、`:35`）。`:9` 的「密文會被 AD 反鎖」問題的解法：心跳收到 `gAAAAA` 開頭的密文就把該欄丟掉（`_sanitize`，`:164-166`），只收明文（走 mTLS）。 |
| `verify_user` 帳號 filter escape、空密碼匿名 bind、`encryption="none"` | 前兩項不成立（已防）；第三項部分成立 | Agent **沿用 jedi-ldap kernel 庫**（`ldap_source.py:20`），也**繼承了 FR-075／CM-1560 的修正**：`jedi_ldap/infra/directory.py:158` 空密碼直接拒絕；`:224`、`:260` 搜尋前用 `escape_filter_chars` 轉義。「不加密」仍是可選項（`:48`、`:63`，同步模式舊設定空值也當不加密，`:159`），連線結果只給 `PLAINTEXT_WARNING`、不擋。正式環境允許明文是設計選擇，但配合 F1 會讓密碼被收走更容易，建議修 F1 時一併評估是否禁止自設模式選明文。 |
| `ldap_routes.py` 測試連線收整組設定 → 內網探測、密碼送到假 LDAP | 不成立（附註） | 確實收前端整組設定（`:51` `merged`）。但改了 `server`／`port`／`encryption` 時，舊密碼不會沿用（`PINNED_KEYS`），所以不會把客戶的 AD 密碼送出去。內網探測需要「設定」權限，而這些人本來就能設伺服器。三個檢查員一致否決。 |
| `make_guarded_server` 判來源用 TCP 還是 header；env 格式錯；下發格式錯生效哪組；能否放寬底線 | 不成立（做得對） | 用的是 `socket.accept()` 回的 TCP 位址（`allow_cidr.py:180-181`），不看 HTTP header，而且在 TLS 握手前就擋。env 格式錯時只放行本機（`:38-42`，出錯預設擋下來）。主系統下發格式錯時 `return` 不呼叫 `_set_cloud`（`:76-80`），**沿用上一份已生效的收緊值**（或沒收過時用底線），不會退回比較寬。`intersect` 只留落在底線內的那段（`domain/allow_cidr.py:39-46`），底線不空時無法放寬。**例外**：底線為空（安裝時沒設＝不限）時，主系統下發的照單全收（`:59-60`），這是檔頭寫明的設計。 |
| `offline_operators.py:139-182` 名單驗來源、`ad_bindings` 被改、`RECEIVED_FILE` 權限 | 部分成立 | 名單**沒有自己的簽章**，信任只來自心跳通道（mTLS＋https，見 sync 規則）；主系統被打穿就能塞人，這是架構上的信任根選擇，不在本棒範圍內修。`ad_bindings` 來自同一份名單，Agent 本機沒有改綁定的路，綁定改了也不會讓 AD 使用者對到別人的 scope：撞到兩人以上一律當找不到（`:186`）。但**綁定改了不會踢掉舊 session**（同 F2 的根因）。`RECEIVED_FILE` 用一般 `open(tmp,"w")` 寫（`:101`），權限跟著 umask、沒有明確設 0600；內容只有收到時間、不含祕密，影響極小。 |
| `source_cidr_guard` 只有 8444 用？8443、8000 呢 | 不成立（刻意） | 全 repo 只有 `files/api/browser_plane.py:216`（8444）呼叫。8443 是資料面，走 mTLS，客戶端憑證必驗，只有主系統拿得到憑證；白名單是給瀏覽器面用的。8000 不在本棒範圍，未深查。 |

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

- **「這 2 條存在嗎」：可信。** 每條都經三個獨立檢查員（分別從「打不打得到」「傷害多大」「有沒有別的防線」三個角度看）投票，6 票全數成立，我也開檔核對過。
- **「只有這 2 條嗎」：不能保證。** 這次是最省的模式（一位研究員讀完 6 個檔），只讀程式碼、沒有實際執行或攻擊。範圍外的呼叫端（例如 `local_auth.py`、`session.py`）只在追 F1、F2 時順帶看過。同一份程式再掃一次可能找到不同的東西。

## 7. 執行概況（工程師看）

| 項目 | 值 |
|---|---|
| Workflow run ID | `wf_7a9459b4-6fc` |
| 掃描版本 | `3813abd`（branch `ws-CM-3507-CM-3514`，工作區乾淨） |
| 範圍 | 6 檔（`file_count: 6`），effort low |
| `verification.status` | `verified` |
| `reason_kind` | 無（null） |
| `candidates` | 3（去重後 3） |
| `panel_votes` | 9（三個檢查員對 3 條各投一票，9 票全數投出，沒有漏投） |
| 確認成立 | 2（F1、F2 皆 3/3）；否決 1（0/3） |
| `researchers_dispatched` / `returned` | 1 / 1 |
| 耗時 | 約 51 分鐘（10 個 agent，約 63 萬 token） |
| 原始產物 | `docs/security-scan/FR-177/runs/CLAUDE-SECURITY-20261105-155942/` |
