---
title: R1b 檢查結果：密碼學核心補掃（jedi-remote-agent）
---

# R1b 檢查結果：密碼學核心補掃（jedi-remote-agent）

> 檢查日期 2026-09-16｜對應卡片 CM-1599｜檢查範圍 10 個檔案

## 🔴 一句話結論

**兩條發現通過、都是中等，另有三條候選被檢查員駁回。** F1（註冊用的通行碼永遠不會過期、整家客戶共用同一組）跟 R1 已經開好的 CM-1597 是同一件事、**不另計**，但這次的價值是把 CM-1597 的前提從「引用別處說法」變成「逐檔實證」；F2 是**越界的新發現**——**GitLab 的第二把寫死在程式碼裡的存取權杖，從來沒有被撤銷過**，跟先前 CM-1573 處理掉的那把是不同的兩把。此外，這一棒回答了 R1 留下的那個問題：**「簽憑證這一塊零發現」到底是「讀過了、沒問題」還是「根本沒讀」——答案是前者，讀過了。**

## 這一棒在檢查什麼

遠端 agent（裝在客戶機器上、負責跑掃描工具的小程式）要跟雲端講話，得先證明「我是誰」。這一棒專看**證明身分的那一整套機制**：agent 第一次安裝時拿什麼通行碼來註冊、雲端怎麼幫它簽一張身分憑證、之後每次講話怎麼用短效通行證（JWT）表明身分、連線怎麼加密、心跳怎麼判斷這台還活著。

要確認的是：那張通行碼會不會被撿走還能用、簽出來的憑證權限是不是太寬、短效通行證有沒有寫錯、連線的驗證會不會出錯就放行。這十個檔是整套身分機制的核心，等於整個遠端 agent 的門鎖與鑰匙。

掃描目標 `~/Projects/Jedicogy/module/jedi-python-package`（掃描根目錄是整個 monorepo，但檢查範圍鎖在 `jedi-remote-agent` 的 10 個檔），revision `3cc966f8`（branch `feature/review`，工作區乾淨），mode `scan`，effort `low`，focus 設在攻擊面。**這 10 個檔從 R1 那次的 `cdb0d0f` 到本棒的 `3cc966f8` 零漂移**（首腦用 `git diff` 確認），所以兩棒比得起來。

## Coverage

範圍就是那 10 個檔、沒有別的——`common/agent_auth/` 下的六支（簽憑證、簽短效通行證、TLS 連線、心跳、設定）加上註冊通行碼那條四檔鏈（app service、domain service、ORM model、repository）。因為是窄範圍的低強度跑法，沒有做元件盤點、沒有做威脅建模，`completenessCheckOutcome` 為 `not-applicable`；**這份報告完全不能拿來說 monorepo 其他地方沒事。**

派出兩位研究員、兩位都回報。驗證跑一輪，5 個候選去重後仍是 5 個，沒有候選遺失、沒有嚴重度被降低。研究員為了看懂脈絡讀了範圍外的檔案——這是工具的正常行為，也正因如此才撈到 F2 與其中兩條被駁回的候選（那三個都指向範圍外的檔）。

**這份報告沒有逐檔紀錄**：`coverage.research` 是 `null`，工具沒有留下「哪位研究員把哪個檔讀到什麼程度」的帳，所以無法逐檔宣稱誰被深讀、誰只是掃過。下方「卡片八個重點」段用候選與駁回論證引用的行號回推，再由首腦人工補查沒被提及的四支。

工具沒有實際執行任何程式碼：沒跑測試、沒發請求、沒示範攻擊，兩條發現全部是讀原始碼與 git 歷史推出來的。

### 關於驗證章的狀態（第三種型態）

stamp `CLAUDE-SECURITY-REVISION-3cc966f8488d.json` 蓋的是 `status: unverified`、拒收理由 `reason_kind: findings-refused`。**這不是面板失敗**——理由是渲染器把 F2 退掉，原話是「its file does not exist in the scanned tree」（它指到的檔案不存在於被掃描的樹裡）。F2 講的是 git 歷史裡的舊檔，現在的程式碼樹已經沒有那個字串，渲染器的檢查規則認不得歷史檔，就整份退件。

面板本身完整跑完、15 票全投出、兩條各 3:0 通過。**所以本棒以工具報告 `CLAUDE-SECURITY-RESULTS.md`（內含 F2 全文）為準。** 這是交接文件（STATE）自檢第 6 題所列的**第三種型態**：前兩種是面板真的沒跑完、面板中途斷線；這一種是面板跑完了、只是最後渲染那一步退件。

## Findings

### F1 — 註冊用的通行碼永遠不會過期，而且整家客戶共用同一組（MEDIUM，3:0）

> **現況**：🚫 裁定不修（SUMMARY M01-3，落地版僅內網可達；原卡 CM-1597 作廢）。

**這是什麼問題。** 客戶要在自己機器上裝 agent，安裝時得貼一組通行碼（enroll token，就是「安裝時用來證明你是這家客戶」的一次性口令）。但這組口令**沒有有效期限、沒有使用次數上限、也不綁定任何一台特定機器**——發出去之後除非有人手動按「停用」，否則永遠有效。而且同一時間整家客戶只有一組活著、大家共用。

**出事會怎樣。** 這組口令會出現在管理介面的對話框、被貼進安裝程式的提示、寫進每一台裝過 agent 的客戶機器的設定檔——**流出去的面只會越來越大，不會縮小**。任何人撿到一份（退役機器的設定檔、支援工單裡的安裝紀錄、共用文件），就能：

- 在這家客戶底下註冊新的 agent；
- 更嚴重的是**接管一台既有的 agent**——把雲端派工的目的地改成攻擊者自己的主機。而派工的內容裡帶著**這家客戶掃描工具的明文帳密**，所以撿到一組舊口令，最後換到的是那些帳密。

**要先有什麼才打得到。**
- 手上有一組曾經發出去的通行碼（從退役主機、舊的安裝檔、日誌或共用文件撿到都算）
- 打得到註冊端點（網路連得到）
- 若要做「接管既有 agent」這一步，還要知道目標 agent 的 `device_uuid`，而且那台 agent 已經離線超過 `heartbeat_interval_sec * 3`（心跳間隔的三倍時間）——因為指紋撞號的守門對「離線的既有持有者」是放行的
- 這段期間沒有管理員去按停用

**在哪裡。** 缺口是「四層都沒有到期欄位」：

- `jedi-remote-agent/jedi_remote_agent/app/service/agent_enroll_token_service.py:63` —— `AgentEnrollTokenService.generate()`，用 `secrets.token_urlsafe(32)` 產碼（亂數強度本身沒問題）、存 sha256、`enabled=True`，但沒有任何地方可以設到期
- `jedi_remote_agent/infra/remote_agent/model/remote_agent_enroll_token.py` —— ORM model 只宣告 `uid`／`token_hash`／`label`／`enabled` 四欄
- `migrations/001-remote-agent-tables.sql:53-65` —— 建表 SQL 也沒有到期欄
- `remote_agent_enroll_token_domain_service.py:24-28` —— 兌換時的 `get_enabled_by_hash()` 只比對 `token_hash` 與 `enabled=True`，沒有時間條件可比

`generate()` 確實會先把舊的停用再發新的，所以**同時只有一組活著**——但活著的那一組是無限期、無限次、不綁機器。程式碼註解裡寫的兩項「緩解措施」是文件敘述，不是實際執行的檢查。

**怎麼修。** 給 `remote_agent_enroll_tokens` 加一個 `expires_at` 欄位，發碼時就寫進有界的效期（安裝是一次性的開機動作，幾小時到幾天就夠），並且**在 `get_enabled_by_hash()` 的查詢條件裡比對它**——讓過期由查詢強制執行，而不是靠慣例。再強一點：改成單次使用或限制使用次數，並把每張碼綁定它註冊的那台機器，這樣一張外流的碼就不能無限註冊或接管任意機器。另外記錄「最後一次被兌換的時間」並顯示出來，管理員才看得到「預定的安裝期間已經過了，這張碼還在被用」。

**驗證。** 3/3 三位檢查員（reachability／impact／defenses，就是分別從「打不打得到」「打到會怎樣」「有沒有防線擋住」三個角度獨立審）一致確認成立，嚴重度維持 MEDIUM。為什麼是 MEDIUM 不是 HIGH：要先撿到一組流出的口令，而且接管既有 agent 還得等目標離線。

**首腦核對註記與判定。** 屬實，逐檔開過。**但這與 R1 的 F6 是同一件事、已經開了 CM-1597（「註冊通行碼永久有效 ＋ 重註冊接管」），標「重複 CM-1597」、不另計新項。**

**本棒真正的價值在這裡**：R1 當時自己就寫了「`agent_enroll_token_service.py` 本身未被實質審查」，F6 只是**引用**「沒有到期機制」這個說法當前提。這一次三位檢查員**逐檔確認了 entity、ORM model、建表 SQL、查詢條件四層都沒有到期欄位**——CM-1597 的前提從「引用」升級成「實證」。這句話要補進 CM-1597 卡裡。

攻擊鏈的部分（首腦核對過，與 R1 F6 描述一致）：撿到舊口令 → 打註冊端點、帶上某台離線超過心跳三倍時間的 agent 的 `device_uuid` → 指紋撞號守門對離線持有者放行 → 那一列的 `base_url` 被改成攻擊者主機 → 之後以那台身分心跳、拿到派工內容（含租戶掃描工具的明文憑證）。

---

### F2 — GitLab 的存取權杖曾被寫死在原始碼裡，至今仍留在 git 歷史（MEDIUM，3:0，渲染器退件）

> **現況**：✅ 已處置（該權杖已撤銷，2026-09-20；SUMMARY M01-6）。

**⚠️ 這條越界。** 檔案屬於 `jedi-issue`，不在 R1b 的十檔範圍內——是研究員為了建立脈絡追 git 歷史時撈到的。

**這是什麼問題。** 一把 gitlab.com 的個人存取權杖（personal access token，就是「代替帳號密碼去操作 GitLab 的長期通行證」）曾經被直接打字寫在程式碼裡跟著版控送出去。現在的程式碼已經改成從環境變數讀、不再寫死了，**但把檔案刪掉並不會把秘密從 git 歷史裡拿掉**——任何 clone 過這個 repo 的人都還撈得回來。

**出事會怎樣。** 撿到的人可以直接用那把權杖以帳號本人的身分操作 GitLab——依它當初被授予的權限，讀寫議題、成員、專案都有可能。更麻煩的是：**當時那個檔案就在 `jedi_issue/` 套件目錄裡面，所以那段期間發佈到內部 Nexus 的每一份 `jedi-issue` wheel / sdist 都把這把權杖編進去了**——不只「看得到 git 歷史的人」會撿到，「拿得到舊版套件檔的人」也會。

**要先有什麼才打得到。**
- 有這個 repo 的 clone 權限（或手上有一份 commit `5e3b9a3` 或更早建出來的 `jedi-issue` 套件檔）
- **這把權杖沒有被撤銷過**——目前沒有任何證據顯示它被撤銷過

**在哪裡。**
- `jedi-issue/jedi_issue/infra/issue_adapter/gitlab/gitlab_adapter.py:14`（commit `b740989`，2025-04-16）
- `jedi-issue/jedi_issue/infra/project_member/adapter/gitlab/member_adapter.py:12`（commit `8742059` 與 `5e3b9a3`，2025-06-11）

寫法是模組層級的字串常數，直接拿去建 `gitlab.Gitlab` 用戶端。現在的樹已經沒有這個字串（`git grep glpat- HEAD -- jedi-issue` 零命中），改成 `os.getenv("GITLAB_PRIVATE_TOKEN")` 或由呼叫端注入——介面本身是對的。檢查員用 `git merge-base --is-ancestor` 確認**三個 commit 都是 HEAD 的祖先、存在於十個分支**，所以每一份 clone 都帶著這個值。

**（權杖明文不記錄在本報告內，僅以雜湊前十碼識別。）**

**怎麼修（這不是改程式碼的事）。**
1. **立刻到 gitlab.com 把這把權杖撤銷，並調閱該帳號的稽核紀錄**（看從 commit 落地之後有沒有被用過）。這需要 GitLab 後台權限，**屬決策者動作、不是 runner 能做的**。
2. git 歷史**不重寫**——與 `.env.test` 事件同一裁定：值已經散在已發佈的套件檔與每一份既有 clone 裡，重寫歷史救不回來，輪換才是有效的控制。
3. 加 pre-commit 秘密掃描（gitleaks / trufflehog）擋住 `glpat-`、`ghp_` 這類字串再被 commit——這屬總表 §7 第 24 項的既有待裁事項，不在本棒另開。

**驗證。** 3/3 三位檢查員一致確認成立。其中一位特別去查了「宣稱已處理」的那個 commit，確認它輪換掉的是**另一把不同的權杖**，這把仍然無人認領。

**首腦核對註記與判定。** 🔴 **屬實，而且是新發現，不是 CM-1573 的重複。** 首腦用 sha256 比對（不印明文）確認：

| | 雜湊前十碼 | 出處 |
|---|---|---|
| 本棒 F2 這把 | `5910b7b954…` | 寫死在 adapter 原始碼裡（三個 commit） |
| CM-1573 已處置那把 | `23060f9979…` | `.env` 檔裡，commit `d6a8d09` 移除並宣稱已撤銷 |

**兩把不同。** CM-1573 卡上寫的是「`.env` 裡那兩把」，從來沒有涵蓋這把寫死在 adapter 原始碼裡的。**沒有任何證據顯示這把曾被撤銷。**

處置：登記為總表新項（中），出處記 FR-077 R1b F2（越界，jedi-issue）；在 CM-1573 卡尾 append「還有一把沒涵蓋」；**不另開修正卡**（決策者裁只記錄）——但「去 gitlab.com 撤銷」這個動作要當場提給決策者。

---

## 三條被駁回的候選

**這一段正是本棒存在的理由**——R1 收尾時留下的問題是「簽憑證那一塊零發現，到底是讀過沒問題、還是根本沒讀」。這三條被駁回的候選證明了：**簽憑證那套程式碼確實被深讀過，而且駁回的理由是有論證的，不是沒看到。**

### 候選一：簽憑證時直接沿用 agent 自己送上來的身分資料

**候選說什麼。** 雲端幫 agent 簽身分憑證時，subject（憑證上的「這是誰」欄位）直接照抄 CSR（就是 agent 送上來、請雲端簽章的憑證申請書）裡寫的；SAN（憑證上的「這張憑證可以代表哪些網址」欄位）是從 agent 自己回報的 `base_url` 推出來的；簽出來的憑證同時帶 SERVER_AUTH 與 CLIENT_AUTH 兩種用途（既能當伺服器、也能當客戶端去對別人做身分驗證）；效期 3650 天（十年）。候選主張「不用任何認證就能觸發」。

**為何駁回。** 面板確認**行為描述全部屬實**，駁回的是「無認證即可觸發」這個前提——簽憑證這段是包在 `agent_enrollment_service._register` 裡面的，前面有註冊通行碼那道門擋著（`_tenant_id_for_token`），檢查員實際追過這條呼叫路徑。另外也駁回影響的說法：找不到任何消費端會去信任憑證上的 subject 名稱（雲端連 agent 時只驗 CA 鏈與 SAN）。

**首腦同意理由。** 首腦自己開檔 `ca.py:52-78` 核對過，**事實全部屬實，駁回的是前提不是事實**。

### 候選二：同一件事的另一個角度——CLIENT_AUTH 可以拿去對誰做 mTLS

**候選說什麼。** 既然簽出來的憑證帶 CLIENT_AUTH，那拿到憑證的人可以拿它去對別的服務做 mTLS（雙向憑證驗證）。

**為何駁回 / 首腦同意理由。** 同理——要先過註冊通行碼那道門才拿得到憑證，前提不成立；且同樣找不到信任 subject 名稱的消費端。

### 候選三：`jedi-common/.env` 受版控

**候選說什麼。** `jedi-common` 底下有一份 `.env` 進了版控。

**為何駁回 / 首腦同意理由。** 裡面只有 localhost 的 PostgreSQL 預設佔位值，沒有洩漏任何東西。而且**這是 CM-1598 的重複**，不另計。

### 🔴 但這三條駁回是「暫時的」——兩個結論互相倚靠

工具報告自己也寫了這一點，首腦同意並要求記進來：

**簽憑證那套行為，只靠一道門擋著——就是註冊通行碼。而 F1 說的就是：那道門是一把永不過期、整家客戶共用的鑰匙。**

兩個結論各自都正確（面板照候選自己宣稱的前提駁回，是對的），但**它們互相倚靠**：

- **CM-1597（通行碼到期）修好之前，`ca.py` 那三條「駁回」只是暫時成立。**
- **CM-1597 的修法必須涵蓋三件事：到期、單次使用、綁定機器**——否則那道門仍然是一把可以被撿走的萬用鑰匙，`ca.py` 的問題（照抄 subject、自報 SAN、雙用途、十年效期）就會整組回來。

這一點要同步記進總表 §1 與交接文件（STATE）。

---

## 卡片八個重點 vs 工具實際讀到的

R1b 開卡時要求「額外交一段：讀過但判定沒問題的檔」。**但 stamp 的 `research_coverage` 是 `null`，工具沒有留下逐檔紀錄**，所以只能從候選與駁回論證引用的行號回推。

**六支確實被深讀**（候選與駁回論證都引用了具體行號）：`ca.py`、`settings.py`、`agent_enroll_token_service.py`、ORM model、domain service、repository。

**四支完全沒有任何候選或駁回提及**——這四支由**首腦自己開檔人工補查**，結論如下：

| 檔案 | 首腦人工查證結論 |
|---|---|
| `jwt_util.py` | `mint()` 用 RS256 簽章，claim 有 `iss`／`aud`／`iat`／`exp`／`op`／`tenant_id`／`bound_fp`，有效期由 settings 給、預設 60 秒。`op`（操作名稱）由呼叫端組成 `f"{method} {path}"`，**呼叫端屬 R3 範圍**，套件內部沒有注入面；私鑰路徑由 settings 給、每次簽章重讀檔（沒有快取，效能面不是本棒議題）。**乾淨。** |
| `tls.py` | `build_cloud_mtls_context` 用 `create_default_context(cafile=ca_cert_path or None)`——`ca_cert_path` 為空時 `cafile=None` 會退回系統信任庫。在 `mode=full` 下若漏設 CA 路徑，雲端連 agent 會拿系統 CA 去驗自簽憑證 → **連線失敗（出錯時預設擋下來），不是放行**。**乾淨。** |
| `heartbeat.py` | 純時間工具，UTC naive 單一時間源，負值視為離線。**乾淨。** |
| `settings.py` | `mode` 預設 `none`＝資料面不加認證。**這是 R3 重點③的落點**（要看宿主 installer 有沒有強制設成 `full`），套件端只是個資料容器、自己不決定。**標「屬 R3」，不在本棒判定。** |

---

## 這份結果可信到什麼程度

### 「F1 / F2 是真的嗎」→ ✅ 可信

兩條各 3:0 全票通過，首腦逐條開檔核對（`agent_enroll_token_service.py` 的 `generate()`、ORM model 四欄、建表 SQL 行號、`get_enabled_by_hash()` 的查詢條件；F2 的三個 commit 與現況樹的 grep）。F2 首腦另外用 sha256 雜湊比對確認它與 CM-1573 處置掉的那把**不是同一把**。

### 「十個檔只有這些問題嗎」→ ⚠️ 比 R1 那次好很多，但仍不能宣稱完整

好的地方：**兩位研究員都回報**（R1 那次有派出去沒回來的）、**15 票全數投出、沒有漏投、沒有中斷**、三條駁回的論證顯示密碼學核心六支確實被深讀（論證裡引用得出具體行號，不是空泛帶過）。

不能宣稱完整的地方：低強度跑法、沒有元件盤點、沒有威脅建模、工具沒留逐檔紀錄（`coverage.research` 是 `null`），四支沒被提及的是首腦人工補查的結論、不是工具給的。

**但這一棒確實回答了 R1 留下的那個問題：「簽憑證／簽短效通行證這一塊零發現」是「讀過了、沒問題」，不是「沒讀」。** 前提是——那道門（CM-1597）要修。

---

## 執行概況

| 項目 | 數字 |
|---|---|
| 檢查範圍 | 10 個檔案（`jedi-remote-agent` 密碼學核心六支 ＋ 註冊通行碼四檔鏈） |
| 檢查強度 | 最低（`low`），focus 設在攻擊面 |
| 候選問題 → 去除重複 | 5 → 5 |
| 投票數 | 15（5 條候選 × 3 位檢查員），全數投出，沒有漏投、沒有中斷 |
| F1 / F2 投票結果 | 3:0（MEDIUM）/ 3:0（MEDIUM） |
| 被駁回候選 | 3（`ca.py` 兩條、`jedi-common/.env` 一條） |
| 沒投到票的 / 投票中斷的 / 被降低嚴重度的 | 0 / 0 / 0 |
| 研究員派出 / 回收 | 2 / 2 |
| 驗證章狀態 | `unverified`，拒收理由 `findings-refused`（渲染器退掉 F2：「its file does not exist in the scanned tree」——它是 git 歷史裡的檔，現在的樹沒有。面板本身完整跑完，不算面板失敗） |
| 掃描耗時 | 6791 秒（約 1 小時 53 分） |
| 掃描當下的程式碼版本 | commit `3cc966f8`，branch `feature/review`，工作區乾淨；10 檔自 `cdb0d0f`（R1 當時）到本棒零漂移 |
| scan_id | `46607b07-434a-43da-835a-c28c460a25d3` |
| 首腦人工補查項目 | 四支沒被工具提及的檔（`jwt_util.py`／`tls.py`／`heartbeat.py`／`settings.py`）逐檔開檔核對 |
| 對總表的影響 | F1 併入 CM-1597（不另計，補「前提由引用變實證」）；F2 §3.1 新增一項（中）；§7 C 組加一項「GitLab 第二把寫死的權杖要撤銷」；CM-1573 卡尾 append |

---

## 交付狀況

🔴 **本棒 runner 四件全未交**：沒有寫報告、沒有 commit、沒有回寫 Notion（CM-1599 仍停在「未開始」）、沒有回報。工具產物（stamp 與 `CLAUDE-SECURITY-RESULTS.md`）是首腦自己到掃描目錄撈出來的，F2 與 CM-1573 那把權杖的雜湊比對、四支未被提及檔案的人工查證、三條駁回候選的核對，也全部由首腦完成。**本報告由首腦補寫，這是全計畫第九次 runner 交付掛零。**
