# FR-177 A6 掃描報告：主控台本機登入、鎖定與 session

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

## 🔴 一句話結論

**沒有急件。** 找到 3 個低風險問題，都屬於「已經登進來的人，被踢的時機不夠早」或「能看出哪些 AD 帳號可以登入」這一類，都要先有前提才打得到。三條都經三個獨立的檢查員投票，全數確認成立（3 比 0）。可以排進一般修正排程，不用停下手邊的事。

## 這一棒在檢查什麼

客戶的 IT 人員怎麼登入 Agent 主控台（8444 埠，人用瀏覽器操作的那一面），檢查了這幾段：

- 本機帳號密碼登入；
- 打錯幾次會鎖住；
- 登入後發的「通行證」（session，就是瀏覽器 cookie 對應到伺服器上的那筆登入紀錄）怎麼存、怎麼過期、怎麼撤銷；
- 維運用的一次性登入網址（`sudo guidant-agent-ctl console-login` 印出來的那條）。

主控台拿到手，就能看證據檔、改儲存設定、發動更新，所以這一塊值得單獨看。

## 掃到什麼：總覽表

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 | 嚴重度 |
|---|---|---|---|---|---|---|
| F1 | 登入通行證只要一直在用就永遠不過期；從主系統登入的人，主系統把他停權後，Agent 這邊不會跟著踢 | 被停權或被降權的員工，在 Agent 主控台仍保有原本的權限（含下載證據檔） | ①他停權前就已經登入 ②還連得到 8444 ③每 30 分鐘內至少用一次 | `guidant_agent/identity/app/session.py:111` | 記下發證時間，設絕對上限（例如 8～12 小時）；主系統心跳對所有人下發撤銷 | 低 |
| F2 | 在主控台改密碼，不會把同一帳號其他已登入的通行證作廢 | 入侵者知道舊密碼並已登入，管理員改了密碼，入侵者仍然在線上 | ①入侵者已經登入同一個帳號 ②管理員是在主控台改密碼（不是用命令列重設） | `guidant_agent/console/api/local_auth_routes.py:133` | 改完密碼後呼叫 `revoke_by_sub`，再給改密碼的人發一張新通行證 | 低 |
| F3 | 用 AD 分頁登入失敗時，「名單上的人」回應會比「名單外的人」慢，量時間就能分辨 | 沒登入的人可以列出「哪些 AD 帳號能登入這台 Agent」，再集中猜這些帳號的密碼 | ①連得到 8444（在允許的網段內） ②有接 AD 且開了離線登入 ③送夠多次請求，排除網路抖動 | `guidant_agent/identity/app/local_auth.py:70` | 所有失敗回應都補到固定的最短時間（至少 `LOGIN_TIMEOUT_SEC`） | 低 |

**為什麼都是「低」不是「高」：** 三條都不能讓陌生人直接登入。F1、F2 的前提是攻擊者「本來就已經登入過」；F3 只能洩漏「誰在名單上」，猜密碼仍然受每個帳號 15 分鐘 5 次的鎖定限制。

## 每條發現的詳述

### F1 — 通行證永遠續命，主系統停權後 Agent 不會跟著踢

**白話：** 每用一次通行證，到期時間就往後推 30 分鐘，而且沒有「最長只能活多久」的上限。重開 Agent 也不會掉（通行證會存成檔案）。每次請求只對「離線操作者」重新查一次名單；從主系統登入的人（拿主系統簽的票進來的）不會被重查。

**出事會怎樣：** 員工離職、主系統停了他的帳號，但他不在離線操作者名單上，就沒有任何機制去撤銷他在 Agent 上的通行證。只要瀏覽器分頁一直開著（前端會定期打 API），他就一直保有原本的權限，包括下載證據檔、改設定。

**要先有什麼才打得到：** 停權前已經登入、仍連得到 8444、通行證每 30 分鐘內至少用一次。

**在哪裡：**
- `guidant_agent/identity/app/session.py:111`：續期，沒有上限；
- `guidant_agent/identity/app/session.py:56`：`_PERSISTED` 沒有發證時間這個欄位；
- `guidant_agent/console/api/__init__.py:61`：只有 `login_method` 有值才重驗，主系統票的 `login_method` 是 None。

**怎麼修：** `ConsoleSession` 加 `issued_at` 欄位並寫進 `_PERSISTED`；`SessionStore.get` 遇到 `now - issued_at > 絕對上限` 就刪掉，回 None。另外開一張主系統卡：心跳回應對所有被停權或被降權的 sub 下發撤銷，Agent 收到後呼叫 `revoke_by_sub`。

**首腦核對註記（runner 開檔核對）：** 成立。`session.py:111` 與 `__init__.py:61` 我都開過，跟上面描述的一致。

### F2 — 主控台改密碼，其他已登入的通行證不會失效

**白話：** `/console/api/me/password` 存好新密碼就直接回應，沒有把這個帳號的其他通行證作廢。用命令列重設密碼（`reset-admin-password`）那條路會作廢，兩條路的行為不一致。

**出事會怎樣：** 管理員發現帳號被盜，在主控台改了密碼，以為這樣就把人趕出去了，其實入侵者還在線上，仍保有本機管理員的全部權限。

**要先有什麼才打得到：** 入侵者已經登入同一個帳號，而且管理員是在主控台改密碼。

**在哪裡：** `guidant_agent/console/api/local_auth_routes.py:133`（`change_own_password`）。

**怎麼修：** 第 133 行之後呼叫 `console_session.revoke_by_sub(sub)`，再 `_store().create(...)` 發新通行證給呼叫者，並用 `_session_response` 回傳新的 cookie。

**核對：** 成立。函式從第 117 行到第 136 行，沒有任何撤銷呼叫。

### F3 — AD 分頁的登入失敗時間洩漏「誰在名單上」

**白話：** 名單外的帳號，只花一次密碼比對的時間就回失敗；名單上而且綁了 AD 的帳號，會真的連到 AD 伺服器驗一次，再比對一次，所以回應明顯比較慢。程式本身的註解說好「所有失敗花一樣的時間」（SR-01.1，資安需求編號），這條路沒做到。

**出事會怎樣：** 攻擊者拿一串猜測的 AD 帳號，每個送一次錯的密碼並計時，就能篩出哪些帳號可以登入這台 Agent，再集中猜這幾個帳號的密碼。

**要先有什麼才打得到：** 連得到 8444（在允許的網段內）、有接 AD 且開了離線登入、送夠多次請求，把 AD 往返的時間跟網路抖動分開。

**在哪裡：** `guidant_agent/identity/app/local_auth.py:70`（`_check_operator` 的 AD 分支），對照名單外的路徑 `local_auth.py:149`。

**怎麼修：** 在 `local_auth.verify` 外層記下開始時間，回 `FAILED` 前補睡到固定的最短時間（不短於 `LOGIN_TIMEOUT_SEC`）。

**核對：** 成立。兩條路徑我都開過。實際能分得多清楚，要看 AD 往返的時間；這點沒有實測。

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

1. **一次性登入碼（`local_login.py`）**
   - token 用 `secrets.token_urlsafe(32)` 產生；落地只存 sha256，明文不寫檔。
   - token 目錄在 `:58` 設成 0700。root 執行時用 `_owner()`（`:47-52`）把目錄交還給資料目錄的擁有者，也就是服務帳號。目錄裡只有雜湊，就算被讀走也換不回登入碼。
   - claim（認領登入碼）在 `:119-121`，每個請求先把檔案改名成自己獨有的名字（`os.rename`），只有改名成功的那一個算數。兩個行程同時認領，第二個會拿到 FileNotFoundError，**不會兩個都成功**。
   - 過期時間從檔案內容讀。只有能寫資料目錄的人（服務帳號或 root）改得到這個檔，這種人本來就能做更多事。
   - `:153` 排除 127 開頭位址的邏輯，只用在印網址給 CLI 看（`build_url`），以及 `/state` 顯示主機 IP（`local_auth_routes.py:81`），**沒有拿來做授權判斷**。
   - 結論：**不成立**。

2. **鎖定（`lockout.py` 與 `credentials.py`）**
   - **計數依帳號（sub）**，不分來源 IP。後果是別人可以故意打錯，把某個帳號鎖 15 分鐘（屬於可用性問題，是這種設計的取捨）。local-admin 被鎖時，可以用命令列 `reset-admin-password` 解。
   - 鎖定狀態存在 SQLite（Agent 本機的資料庫）的 `local_credentials` 表，用 monotonic 時間（開機後才開始累計、改系統時鐘也不受影響的計時器）；重開機就解鎖，設計文件 §5.1 已接受這點。
   - 鎖定期間（`credentials.py:83`），即使密碼正確也回 LOCKED，不會因為成功登入而重設計數。鎖定過期之後，成功登入（`mark_success`）會把計數歸零。
   - 失敗訊息不會洩漏帳號存不存在：所有失敗都回同一個 401，回應內容也一樣；名單外的帳號也會跑一次密碼比對來拉平時間。唯一的例外就是 F3。
   - 「查鎖定 → 比對 → 記失敗」整段握著同一把鎖，同時送大量猜測也無法繞過計數。
   - 結論：**不成立**（但依帳號鎖定可以被拿來故意鎖別人，見上面說明）。

3. **session 的存活時間（`session.py`）**
   - `SESSION_TTL_SEC = 1800`（30 分鐘），**有滑動延長、沒有絕對上限**，這就是 F1。
   - 從檔案載回時，`session_file._row` 只取白名單欄位；`ConsoleSession(**v)` 拿到的就是 `_PERSISTED` 那幾個欄位，其他欄位會被丟掉。
   - 載回時會重新檢查 `expires_at`：`session_file.py:65` 的 `exp <= now` 會丟掉過期的；`get` 每次用到時也會再檢查（`session.py:107`）。
   - 檔案裡的 `tenant_id` 與 `scope` 會被信任，但檔案必須是本行程擁有、權限 600、不是 symlink（`session_file.py:51`）。能偽造這個檔的人，本來就能寫資料目錄。
   - 結論：除了 F1 之外**不成立**。

4. **session 檔的寫入（`session_file.py`）與 cookie 屬性**
   - 寫入**是先寫暫存檔再改名**（`:107-114`），暫存檔名帶行程 id 與執行緒 id，開檔用 `O_NOFOLLOW`（遇到 symlink 不跟過去）。
   - 多個 store 同時寫會怎樣：每次都是整份覆寫，最後寫的贏。如果有兩個行程各有一份 store，可能互相蓋掉對方剛發的 session，影響是被蓋掉的人要重登。正常部署 8444 只有一顆 app、一份 store，所以實際上不會發生。
   - cookie 不是在 `local_auth_routes.py` 設，是在 `guidant_agent/console/api/__init__.py:144-145`，有 `secure=True, httponly=True, samesite="Strict"`；登出時（`:158`）用同樣的旗標刪除。
   - 結論：**不成立**。

5. **密碼雜湊與預設帳號密碼（`credentials.py` 與 `credential_repo.py`）**
   - 雜湊用 jedi-password 的 argon2id（`credential_repo.py:12`、`:105`），參數由套件決定、跟主系統同一份。舊參數算的雜湊會在登入成功時重算。
   - 改密碼時，**已經設過密碼就一定要給舊密碼**（`local_auth_routes.py:129-132`），舊密碼錯也會計入鎖定。第一次設定不用給舊密碼，因為根本還沒有密碼。
   - **沒有預設帳號密碼**。安裝腳本 `scripts/install/lib/local_admin.sh:131`、`:140` 要求全新安裝一定要現場輸入密碼（或用設定檔帶入），不符合密碼政策（至少 12 碼，含大寫、小寫、數字）會被擋。沒設密碼時 local-admin 不能用密碼登入，只能用一次性登入碼。
   - 結論：**不成立**。

6. **登入與登出端點（`local_auth_routes.py`）**
   - 登入端點**沒有依來源 IP 限速**，只靠每個帳號的鎖定。前面還有三道擋：CIDR 白名單（只允許哪些網段連進來）、body 上限 1024 bytes（`:31`、`:35`），以及「同時只跑一個 argon2」的鎖。效果上暴力猜密碼仍受限於每個帳號 15 分鐘 5 次，但可以輪流換帳號猜。
   - 登出會真的作廢：`SessionStore.revoke` 同時從記憶體刪掉並寫回檔案（`session.py:116-120`）。
   - scope 判定在 `console/api/__init__.py` 的 `require_session` 裡（`:71`）；`CONSOLE_SCOPES` 跟路由的對應，由守衛 `test_fr166_guards.py::test_ops_scope_table` 逐支對表檢查。改密碼這支路由只掛 `@require_session()` 不帶 scope，任何登入的人都能改「自己的」密碼，這是合理設計。
   - 結論：**不成立**；改密碼不會撤銷其他通行證的部分就是 F2。

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

- **「這 3 條存在嗎？」** 可信度高。三個獨立的檢查員從三個角度投票（打不打得到、出事多大、有沒有別的防線擋住），每條都 3 比 0 成立，我也自己開檔核對過。
- **「只有這 3 條嗎？」** 不能保證。這次是低強度掃描，只派一位研究員讀這 8 個檔，沒有跑測試、沒有實際打過，結論全部來自讀程式碼。「重點看什麼」六項我逐條人工補查過，沒有發現新的問題。範圍外的檔案（`require_session`、安裝腳本）只為了追呼叫端才看。

## 執行概況

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