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

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

§1

🔴 一句話結論

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

§2

這一棒在檢查什麼

客戶的 IT 人員怎麼登入 Agent 主控台(8444 埠,人用瀏覽器操作的那一面),檢查了這幾段:

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

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

§3

掃到什麼:總覽表

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 怎麼修 嚴重度
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 次的鎖定限制。

§4

每條發現的詳述

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 往返的時間;這點沒有實測。

§5

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

  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。
§6

這份結果可信到什麼程度

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

執行概況

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