⚠️ 歷史紀錄:本報告描述的問題已於 2026-11-07 全部修正並合回 Agent 主線(修正卡 CM-3559~3564、3573~3576、3584,驗收紀錄見母卡 CM-3507)。文中的檔名與行號是掃描當下(commit
bab2aa3)的版本,修正後已變動,請勿拿來定位現行程式。
沒有急件。 找到 3 個低風險問題,都屬於「已經登進來的人,被踢的時機不夠早」或「能看出哪些 AD 帳號可以登入」這一類,都要先有前提才打得到。三條都經三個獨立的檢查員投票,全數確認成立(3 比 0)。可以排進一般修正排程,不用停下手邊的事。
客戶的 IT 人員怎麼登入 Agent 主控台(8444 埠,人用瀏覽器操作的那一面),檢查了這幾段:
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 次的鎖定限制。
白話: 每用一次通行證,到期時間就往後推 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 我都開過,跟上面描述的一致。
白話: /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 行,沒有任何撤銷呼叫。
白話: 名單外的帳號,只花一次密碼比對的時間就回失敗;名單上而且綁了 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 往返的時間;這點沒有實測。
一次性登入碼(local_login.py)
secrets.token_urlsafe(32) 產生;落地只存 sha256,明文不寫檔。:58 設成 0700。root 執行時用 _owner()(:47-52)把目錄交還給資料目錄的擁有者,也就是服務帳號。目錄裡只有雜湊,就算被讀走也換不回登入碼。:119-121,每個請求先把檔案改名成自己獨有的名字(os.rename),只有改名成功的那一個算數。兩個行程同時認領,第二個會拿到 FileNotFoundError,不會兩個都成功。:153 排除 127 開頭位址的邏輯,只用在印網址給 CLI 看(build_url),以及 /state 顯示主機 IP(local_auth_routes.py:81),沒有拿來做授權判斷。鎖定(lockout.py 與 credentials.py)
reset-admin-password 解。local_credentials 表,用 monotonic 時間(開機後才開始累計、改系統時鐘也不受影響的計時器);重開機就解鎖,設計文件 §5.1 已接受這點。credentials.py:83),即使密碼正確也回 LOCKED,不會因為成功登入而重設計數。鎖定過期之後,成功登入(mark_success)會把計數歸零。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)。能偽造這個檔的人,本來就能寫資料目錄。session 檔的寫入(session_file.py)與 cookie 屬性
:107-114),暫存檔名帶行程 id 與執行緒 id,開檔用 O_NOFOLLOW(遇到 symlink 不跟過去)。local_auth_routes.py 設,是在 guidant_agent/console/api/__init__.py:144-145,有 secure=True, httponly=True, samesite="Strict";登出時(:158)用同樣的旗標刪除。密碼雜湊與預設帳號密碼(credentials.py 與 credential_repo.py)
credential_repo.py:12、:105),參數由套件決定、跟主系統同一份。舊參數算的雜湊會在登入成功時重算。local_auth_routes.py:129-132),舊密碼錯也會計入鎖定。第一次設定不用給舊密碼,因為根本還沒有密碼。scripts/install/lib/local_admin.sh:131、:140 要求全新安裝一定要現場輸入密碼(或用設定檔帶入),不符合密碼政策(至少 12 碼,含大寫、小寫、數字)會被擋。沒設密碼時 local-admin 不能用密碼登入,只能用一次性登入碼。登入與登出端點(local_auth_routes.py)
:31、:35),以及「同時只跑一個 argon2」的鎖。效果上暴力猜密碼仍受限於每個帳號 15 分鐘 5 次,但可以輪流換帳號猜。SessionStore.revoke 同時從記憶體刪掉並寫回檔案(session.py:116-120)。console/api/__init__.py 的 require_session 裡(:71);CONSOLE_SCOPES 跟路由的對應,由守衛 test_fr166_guards.py::test_ops_scope_table 逐支對表檢查。改密碼這支路由只掛 @require_session() 不帶 scope,任何登入的人都能改「自己的」密碼,這是合理設計。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/ |