# 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/` |
