# FR-187.C 掃描報告：金鑰登記簿、Agent 授權照開通、註冊碼管理

## 一句話結論

找到 2 個低風險問題，都在「Agent 授權照線上開通」那個不需登入的入口：一是任何拿著一把註冊碼的人，可以讓整台主系統所有客戶的線上開通暫時全部被 License Center 鎖住；二是開通碼會以明文寫進 api 日誌。金鑰登記簿本身（換鑰匙、撤鑰匙、私鑰保管）這次沒找到問題。

## 這棒檢查什麼

主系統 9 個檔、約 822 行：

- 金鑰登記簿（主系統拿來簽 Agent 通行證的鑰匙清單，可以新增、切換現行、撤銷）：`core/plugins/_agent_keys.py`、`app/remote_agent/service/trust_anchor_service.py`、`api/remote_agent/routes/trust_anchor_route.py`、`domain/remote_agent/trust_anchor_domain_service.py`、`infra/remote_agent/trust_anchor_repo_impl.py`、`core/trust_anchor_bootstrap.py`
- Agent 授權照線上開通（Agent 帶註冊碼來，主系統代它去 License Center 開通）：`app/remote_agent/service/agent_license_activation_service.py`、`api/remote_agent/routes/agent_license_route.py`
- 註冊碼的產生與撤銷：`api/remote_agent/routes/agent_enroll_token_route.py`

## 總覽表

| 編號 | 問題（白話） | 嚴重度 | 急不急 | 在哪裡 |
|---|---|---|---|---|
| C-1 | 有註冊碼的人亂打開通，會讓全部客戶的線上開通被鎖 | 低 | 不急，出貨前排進修正 | `agent_license_activation_service.py:95` |
| C-2 | 開通碼明文寫進 api 日誌與 app.log | 低 | 不急，改遮罩規則一行 | `common/middleware/app_mw.py:257` |

## 每條詳述

### C-1　有註冊碼的人亂打開通，會讓全部客戶的線上開通被鎖

- **白話**：`POST /agents/license/activate` 不需要登入，只要帶一把有效的註冊碼（產給安裝人員裝 Agent 用的那串碼）就能打。主系統收到後直接幫忙轉給 License Center（LC，原廠的授權簽發站），自己不做任何頻率限制。LC 是用「來源 IP」計算失敗次數、15 分鐘錯 5 次就鎖。所有客戶的開通請求都經同一台主系統出去，在 LC 眼裡都是同一個 IP。
- **出事會怎樣**：有人每 15 分鐘丟 5 個亂碼，這台主系統所有租戶（客戶）的 Agent 線上開通、以及主系統自己的線上授權開通，都會一直回「已鎖定」。只影響「能不能開通」，不會洩資料，也沒辦法靠這條暴力猜出開通碼（因為一樣被鎖）。
- **要先有什麼才打得到**：任一把沒撤銷、沒過期的註冊碼。註冊碼預設沒有到期日，而且這裡刻意不看剩餘次數，所以「已經用完的舊碼」也算數。拿得到的人包括有建碼權限的子公司管理員、裝機人員、或能讀 Agent 主機上設定的人。
- **在哪裡**：`app/remote_agent/service/agent_license_activation_service.py:71-90`（驗碼不看次數）、`:95`（直接轉打 LC）；入口 `api/remote_agent/routes/agent_license_route.py:31-38`。nginx 前門的通用限流是每 IP 每分鐘 30 次，擋不住 5 次。
- **怎麼修**：主系統這端先擋失敗次數（以註冊碼雜湊＋來源 IP 為單位，存 Redis，可沿用 `common/util/redis_rate_limiter.py`）；另請 LC 改成依主系統轉帶的租戶或註冊碼識別鎖定，而不是依 IP；可考慮要求註冊碼還有剩餘次數或已綁定一台註冊過的 Agent 才轉打。
- **面板**：三位檢查員 3 票全數成立，嚴重度維持「低」。

### C-2　開通碼明文寫進 api 日誌與 app.log

- **白話**：主系統有一層全域攔截，每個請求的內容都會先「遮掉密碼類欄位」再寫進 api 日誌（`api_logs` 表，管理頁可查，保留 90 天）和 `log/app.log`。遮罩規則只認 password、secret、token 等字眼，所以 `enroll_token` 會被遮，但 `activation_code`（開通碼，買授權拿到的那串一次性序號）原樣寫進去。
- **出事會怎樣**：如果開通沒成功（LC 連不上、回 503、被鎖），開通碼還沒被用掉，卻已經躺在日誌裡。有權看 api 日誌的人可以把它抄走，拿去開通自己的機器，搶走原本要給別台的授權。
- **要先有什麼才打得到**：①那次開通沒成功、碼沒被用掉；②能讀 api 日誌（`platform.api-log.read` 能力點）或主機上的 app.log。
- **在哪裡**：`common/middleware/app_mw.py:243-244`（寫 app.log）、`:257`（寫 `api_logs.request`）；遮罩規則在套件 `jedi_common/utils/common_utils.py:139-142`。這個檔在本棒範圍外，是因為範圍內的開通端點會經過它才列入。
- **怎麼修**：在共用遮罩規則（jedi-masking／jedi_common）加 `activation_code`，最好加一條通用的「`*_code` 類憑證欄位」規則，api 日誌、app.log、錯誤追蹤三邊一起生效。主系統自己的 `/license/activate` 也有同樣的缺口，可以一起修。
- **面板**：三位檢查員 3 票全數成立，嚴重度維持「低」。

## 被面板否決的一條

- 登記簿每把鑰匙可設 `not_after`（到期日），但簽票、驗票都只看狀態、不看到期日。三位檢查員一致判定不成立（0/3）：到期日只是管理參考欄位，鑰匙要不要用由管理員切換狀態決定，沒有造成可利用的後果。

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

1. **`guarded=True` 那四支有沒有真的套到守門**：成立（守門有效）。`core/plugins/_agent_keys.py:192-195` 四支標 `True`；`core/plugins/remote_agent.py:292` 把 `key_routes(guarded=True)` 併進 `_host_admin_routes()`，`:422-423` 逐支以 `_admin_guarded()` 包起來（逐 method 套 `admin_required`，能力點來自 `KEY_ADMIN_CAPABILITIES`）。`guarded=False` 四支在 `:424-425` 原樣掛，而它們在 `trust_anchor_route.py` 每支都有 `@jwt_required()`＋`@require_platform_admin_route`。八支都有守門。
2. **能不能撤掉最後一把在用的鑰匙／`private_key_ref` 擋不擋 `../`**：不成立。`trust_anchor_service.py:114-115` 狀態是 active 的鑰匙直接拒絕撤銷，要先切換到別把才能撤，所以不會出現某用途「沒有現行鑰匙」。讀私鑰走 `private_key_path()`（`:143-150`），先 `resolve()` 再檢查是否仍在保管目錄底下，`../` 與符號連結跳出都會被拒。
3. **`_write_private_key` 的保管目錄會不會流出去**：不成立。目錄是 `AGENT_JWT_PRIVATE_KEY` 所在的資料夾，安裝版在 `${GUIDANT_DATA_DIR}/pki/agent/trust-anchors/`（`scripts/installer/install.sh:1716,1756`），檔案 0600、目錄 0700、`O_EXCL` 不覆蓋。`guidantai diag` 診斷包只收容器狀態、設定檔（金鑰類欄位遮成 `***`）與 log、升級紀錄，不收 `pki/` 目錄。備份是否包含 `pki/` 屬客戶主機的備份策略，不是程式缺口。
4. **開機補登用哪把公鑰、會不會覆蓋**：不成立。公鑰讀 `AGENT_JWT_PUBLIC_KEY` 指的檔（`trust_anchor_service.py:131`），三個用途用同一把，靠 kid 前綴區分。只在登記簿整張表是空的時候才補（`:125`），每列走 `add_if_absent`，不會覆蓋既有的列。
5. **開通端點有沒有頻率限制、會不會一人亂打全部客戶被鎖**：成立，就是 C-1。主系統這端沒有限流（`redis_rate_limiter` 沒套到這支），只有 nginx 前門每 IP 每分鐘 30 次的通用限流。
6. **LC 回的 `license` 原樣回、log 有沒有記開通碼與指紋全文**：服務本身不成立。`_call_lc` 的 log 只記指紋雜湊前 12 碼、開通碼雜湊前 8 碼與狀態碼（`:93,101,106`），不記回應內容。但全域攔截會把請求內容（含開通碼明文）寫進日誌，這就是 C-2。回應那一面：成功時的授權照會經 `after_request` 寫進 `api_logs.response`（`app_mw.py:307`），授權照綁機器指紋、Agent 端會驗章，流出風險低，這次沒有列為問題。
7. **產生註冊碼的能力點、明文碼有沒有進 log**：不成立。建碼要 `detection.remote-agent.create`、撤碼要 `detection.remote-agent.delete`、列表要 `detection.remote-agent.read`（`_agent_keys.py:156-162`），經 `_admin_guarded` 套上。明文只出現在建碼的回應裡，回應的鍵名是 `token`，`api_logs.response` 寫入前會被遮罩規則遮掉；route 自己的 log 只記前門憑證路徑，不記碼。

## 可信度（兩層）

- **這兩條存在嗎**：存在。三位檢查員各自從「打不打得到」「後果多大」「有沒有別的防線」三個角度投票，兩條都是 3/3。我也開檔逐行核對過上面列的行號。全部是讀程式碼推論，沒有實際打過端點。
- **只有這兩條嗎**：不能這樣保證。effort low 只有一位研究員讀全部範圍，另有一輪掃寫死在程式裡的密碼／金鑰，是一次快速掃描，不是逐檔窮舉。C-1 涉及 LC 的鎖定邏輯在另一個 repo（`license_center`），研究員只讀了程式碼，沒有實測。

## 執行概況（照 stamp 抄）

- run ID：`wf_0d7a8ea3-eb7`；掃描版本 `eee99445e72a`（branch `ws-CM-3711-CM-3716`，工作目錄乾淨）；effort low，只看正式程式（attack-surface）
- `verification.status`：**verified**；`reason_kind`：null
- `candidates`：3（去重後 3）；`panel_votes`：9（三位檢查員對 3 條各投一票，全部投出）
- `researchers_dispatched`：2；`researchers_returned`：2
- 結果：2 條成立（皆低），1 條否決
- 歸檔：`docs/features/security-scan-consolidated/runs/be/CLAUDE-SECURITY-20261108-180426/`
