FR-177.A1 掃描報告:Agent 註冊、憑證與身分

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

1. 🔴 一句話結論

工具沒找到問題;我自己照「重點看什麼」逐條核對,找到 1 個值得開修正卡的小問題(嚴重度:低~中):私鑰檔建立的那一瞬間權限是「大家都能讀」,之後才改成只有本人能讀,而且改權限失敗時會默默放過。其餘六點人工查過都不成立。

2. 這一棒在檢查什麼

Agent(裝在客戶機器上的程式)第一次裝好時怎麼向主系統報到、拿到自己的憑證與通行證;憑證快到期時怎麼自己換新;這些金鑰檔怎麼存在客戶機器上。這段是 Agent 身分的根:私鑰或通行證被同機其他程式拿走,就能冒充這台 Agent 跟主系統講話。

範圍 6 個檔、1336 行:guidant_agent/sync/app/enroll.py、guidant_agent/sync/app/cert_renew.py、guidant_agent/sync/domain/registration.py、guidant_agent/sync/domain/agent_auth.py、guidant_agent/sync/adapters/cert_files.py、guidant_agent/platform/agent_identity.py。

3. 掃到什麼:總覽表

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 怎麼修 來源
M1 私鑰與通行證檔是「先建檔、寫完、再改權限」,建檔當下用系統預設權限(通常大家可讀);改權限失敗時不報錯 同台機器上的其他帳號可能在那一瞬間(或改權限失敗時永久)讀到 Agent 私鑰與通行證,拿去冒充這台 Agent 攻擊者已經在客戶機器上有一個普通帳號,且能讀到憑證目錄(目錄本身權限也沒鎖) guidant_agent/sync/adapters/cert_files.py:106-115(write_secret);呼叫端 guidant_agent/sync/app/enroll.py:131,138;目錄建立 enroll.py:90 見下方詳述 人工核對(工具未報,未經三人投票確認)

急不急:不急,排一般修正卡即可。打得到的前提是「客戶機器已經被別人登入」;正式安裝形態下憑證目錄多半在只有服務帳號能進的資料目錄裡,會再擋一層(本棒範圍外,未核對)。

4. 每條發現的詳述

M1 — 私鑰檔建檔當下權限太寬,改權限失敗默默放過(低~中)

  • 白話:程式寫私鑰檔的步驟是 ① 用一般方式開新檔(這時檔案權限跟著系統預設,Linux 一般是「擁有者可寫、所有人可讀」,也就是 0644)② 把私鑰寫進去 ③ 才把權限改成「只有擁有者能讀寫」(0600)。①到③之間有一小段時間,同台機器的其他帳號讀得到。另外③如果失敗(例如某些掛載點不支援改權限),程式直接略過、連一行 log 都沒有,私鑰就一直是大家可讀。
  • 出事會怎樣:拿到 agent.key+agent.crt 的人可以自己簽「持有證明」(DPoP,就是每個請求附上「我真的有這把私鑰」的簽章),再配上 agent.json 裡的通行證,就能以這台 Agent 的身分打主系統;拿到私鑰還能在換證時冒充「舊的那台」。
  • 要先有什麼才打得到:
    1. 攻擊者在客戶機器上已經有帳號(或能跑程式);
    2. 憑證目錄對他是可進入的——enroll.py:90 用 os.makedirs(cfg.cert_dir, exist_ok=True) 建目錄,沒指定權限,跟著系統預設(通常 0755);
    3. 剛好在註冊/換證那一刻讀,或者遇到改權限失敗的掛載點。
  • 在哪裡:
    • guidant_agent/sync/adapters/cert_files.py:110:open(path, "wb") 建檔,沒指定權限
    • guidant_agent/sync/adapters/cert_files.py:112-115:寫完才 os.chmod,OSError 直接 pass
    • guidant_agent/sync/app/enroll.py:131(私鑰)、:138(含通行證的 meta):呼叫上面那支
    • guidant_agent/sync/app/enroll.py:90:憑證目錄建立沒指定權限
    • 附帶:首次註冊(enroll.py:93 → write_registration 直接寫正式目錄)不是「先寫暫存再改名」,中途斷電可能留下寫一半的檔。後果只是下次開機判定不完整而重新註冊(is_enrolled 要四個檔都在),或某檔是半截而啟動失敗;換證那條(cert_renew.py:185-195)已經走暫存目錄+swap,沒這個問題。
  • 怎麼修:在 cert_files.write_secret 裡改成「建檔當下就給定權限」:fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode) 寫進暫存檔、os.fsync、再 os.replace(tmp, path);mode=0o600 時若 os.chmod/os.open 權限設定失敗就 raise(或至少 logger.warning),不要默默 pass。enroll.py:90 改成 os.makedirs(cfg.cert_dir, mode=0o700, exist_ok=True),並對既有目錄補一次 os.chmod(cfg.cert_dir, 0o700)。
  • 核對註記:本條是 runner 開檔讀出來的,工具的研究員沒提、三人投票沒投過。系統預設權限(umask)在正式安裝時實際是多少、install.sh 有沒有事先把目錄設成 0700,不在本棒範圍,沒查;如果安裝腳本已經鎖住目錄,實際風險降為「只剩改權限失敗那條」。

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

卡上的疑點 人工結果 為什麼
enroll.py:110/:131-134 私鑰權限、目錄權限、是否先寫暫存再改名;write_secret 預設 mode 成立(部分)→ M1 write_secret 預設 mode=0o600(cert_files.py:106),私鑰與 meta 用預設、其他三個公開檔用 0644,最終權限是對的;但「建檔當下權限太寬」「改權限失敗不報」「目錄沒設權限」「首次註冊不是暫存再改名」四點成立,見 M1
registration.py:3 SAN 來自外部 san_host,能不能改成別台主機名 不成立(Agent 這側) san_host 取自 cfg.base_url(enroll.py:109),是 Agent 自己的安裝設定,不是網路上別人給的值;能改它的人已經能改 Agent 設定檔。CSR 的名字(CN)固定是本機指紋(enroll.py:110 common_name=device_uuid)。主系統驗不驗 SAN 在主系統 repo,本棒沒查
cert_renew.py:182-186 換證帶什麼證明自己是舊的那台;失敗降級會不會讓舊憑證永遠有效;:244 狀態檔 0644 不成立 換證帶 agent_uid 加上「舊私鑰對新 CSR 的簽章」(cert_files.py:118-132 prior_key_proof);主系統回的 uid 不同就不換(:183-184)。失敗只是留用舊證,舊證到期日不變、照樣會過期,不會變永久有效。狀態檔只有「上次嘗試時間、成功/失敗、錯誤訊息前 200 字」(:242),錯誤訊息來自例外文字,程式裡的 RenewRejected 訊息都不含路徑與金鑰;唯一可能夾帶的是主系統回的錯誤訊息,無機密
agent_auth.py:206 不驗簽解 control_token 拿 tenant_id,拿去做什麼 不成立 呼叫端只有兩處:guidant_agent/console/api/__init__.py:135、guidant_agent/console/api/local_auth_routes.py:98,放進本機主控台 session 的 tenant_id 欄。通行證來自本機 0600 的 agent.json(不是網路輸入),Agent 是單租戶,系統身分固定 AGENT_TENANT_ID = 1(agent_identity.py:21),寫 DB 的 tenant_id 走的是那個常數、不是這裡的值。能改 agent.json 的人早已能拿走通行證本身
agent_auth.py:141-148 _decode:沒 kid 時用整組試?JWT_ALGS 能否降級成 none/HS 不成立 卡上的前提與程式不同:拿到信任錨清單後,kid 沒有或查不到一律拒絕(:144-147,key_for(None) 回 None),不會整組試。演算法:註冊鑰那條只收 ["RS256"](:37、:143);清單那條演算法由公鑰型別決定(_alg_for,:72-78,只有 RS256/EdDSA),不看票頭寫什麼,所以 none 與「拿公鑰當 HMAC 密碼」那招都進不來
agent_identity.py 的 device_uuid 怎麼算、會不會漂移、能否被環境變數覆寫 **本棒查不到(範圍外) agent_identity.py 只處理「單租戶系統身分」(每請求、每背景執行緒裝 tenant_id=1),不算 device_uuid**。device_uuid 在 guidant_agent/platform/host_identity.py+fingerprint.py(見 .claude/rules/security/sync-and-identity.md),不在本棒六檔內,建議下一棒補掃。agent_identity.py 本身人工看過:身分只在行程內組,不讀任何標頭或 token,無問題
enroll.py:214 cloud_tls_verify(cfg) 哪些設定會回 False 不成立 guidant_agent/sync/app/cloud_trust.py:49-66:只會回 True(系統信任的憑證機構)或自訂信任檔的路徑;檔案設了但不存在就丟錯擋下,沒有任何回 False(不檢查對方是不是真的主系統)的路徑,而且每次都先過 ensure_secure_endpoint 要求 https

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

  • 「這些問題存在嗎」:M1 是 runner 開檔逐行讀出來的,行號都對過,但沒經過三個獨立檢查員投票。程式邏輯本身確定(建檔→寫→改權限、失敗 pass),實際風險取決於安裝時目錄與系統預設權限,這部分沒查。
  • 「只有這些嗎」:不能這樣宣稱。 工具只派了一個研究員,他回報零條;三個檢查員因此沒有東西可投(0 票)。工具標示的「verified(已驗證)」在這裡的意思是「沒有東西需要驗」,不等於確認這六個檔是乾淨的。另外 device_uuid 的算法、主系統那頭驗不驗 SAN,都在範圍外。

7. 執行概況(工程師看)

項目 值
Workflow run ID wf_8d84cad0-16c
掃描版本 bab2aa31f00d3bf9b2e34c2a6dab88d1885b227f
effort low(一個研究員掃全範圍+三人投票)
範圍檔數 6(1336 行)
研究員 派出/回來 1/1
候選發現(candidates) 0
投票數(panel_votes) 0(沒有候選,所以沒有票可投)
verification.status verified
reason_kind null
研究員耗時 約 15 分鐘(907 秒)、約 9.6 萬 token
歸檔 docs/security-scan/FR-177/runs/CLAUDE-SECURITY-20261105-152137/