---
title: R1 掃描結果：Agent 身分與註冊（jedi-remote-agent）
---

# R1 掃描結果：Agent 身分與註冊（jedi-remote-agent）

> **掃描日期**：2026-09-07
> **掃描版本**：jedi-python-package `feature/FR-075` @ `8aa6f0609c4434c0fa5206d5e99e31cad249646e`（乾淨，無未 commit 變更）
> **工具**：Claude Code `claude-security` plugin v0.10.2.3（`claude-security:scan` workflow）
> **範圍**：`jedi-remote-agent/jedi_remote_agent/` 的 `api/`、`common/`、`domain/remote_agent/`、`infra/remote_agent/`、三支 app service、`plugin.py`、`__init__.py`，**42 個受版控檔案**（啟動前 `git ls-files` 驗過，與卡片數字吻合）
> **effort**：`low`，**focus**：`attack-surface`
> **模型**：主 session Opus 5 (1M context)，研究員繼承
> **狀態**：🔴 **驗證面板未跑成，stamp 為 `verification.status: unverified`**——研究階段完整完成，但 21 張面板票全部因 session 額度上限失敗。本報告全部發現皆為**研究員原始宣稱 ＋ runner 人工開檔核對**，未經面板背書。
> **對應卡片**：CM-1592（母卡 CM-1591）

## 一句話結論

**範圍內找到 6 條實質問題（3 條 HIGH／3 條 MEDIUM），核心是一件事**：四個控制面端點完全沒有任何認證，而它們宣稱倚賴的 mTLS **在出貨的 nginx 設定裡根本沒有配置**——`nginx.onprem.conf` 全檔沒有一行 `ssl_verify_client`。於是 agent 身分只剩「請求自己說它是誰」，任何能連到 API 的人都能冒充任一台 agent，拿走該租戶掃描工具的明文帳密。第 7 條（`.env` 進版控）屬越界，事實成立但不在本棒範圍。

## 🔴 掃到什麼：七條一覽

**先看這張表就好**，每條的完整核對過程、程式碼行號與建議修法在後面各自的章節（點 ID 跳過去）。

| # | 嚴重度 | 這是什麼問題（白話） | 出事會怎樣 | 要先有什麼才打得到 | 位置 | 修正卡 |
|---|---|---|---|---|---|---|
| [F1](#f1high-心跳端點無認證自報-agent_uid-即可取走該租戶掃描工具明文憑證) | 🔴 **CRITICAL**<br>（決策者升，原報 HIGH） | 「報到」（心跳）這條管道不檢查身分——你在請求裡自己寫「我是哪台 agent」，系統就信 | **拿走客戶自己機器的帳號密碼**：回應夾帶解密後的明文憑證（SonarQube token、SSH／WinRM 帳密），通往客戶自己的基礎設施 | 連得到 API 即可，**不需任何帳號／憑證**；再知道一個 `agent_uid`（一般已登入使用者在畫面上就看得到） | `agent_enrollment_service.py:274-311` | CM-1595 |
| [F3](#f3high-心跳的身分解析可跨租戶與-f1-同一根因不同利用面) | 🟠 HIGH | 同一條管道連「這台 agent 是不是同一家公司的」都沒查（`heartbeat()` 兩處查詢無 `tenant_id`，而 `register()` **有**） | **A 公司能拿走 B 公司的帳密**，多租戶隔離在這條路徑上等於不存在 | 同 F1，再知道別家一台 agent 的 uid | `agent_enrollment_service.py:286` | CM-1595 |
| [F2](#f2high-任務-ackresult-端點無認證且不檢查任務歸屬可偽造稽核證據) | 🟠 HIGH | 「回報工作做完了」這兩條管道（ack／result）不檢查這份工作是不是你的 | **可以偽造或抹掉稽核證據**——把沒執行過的掃描標成「成功」並附上自編結果，或把真的發現問題的結果蓋成「失敗」。客戶拿這些紀錄應付外部稽核 | 連得到 API 即可，**不需任何帳號／憑證**；再知道一個任務 uid | `remote_agent_route.py:199-210`<br>`agent_task_service.py:73-107` | CM-1595 |
| [F5](#f5medium-ackresult-缺少歸屬檢查f2-的授權面同檔同根因) | 🟡 MEDIUM | 同 F2，研究員從「缺授權」角度再報一次的同一個洞 | 同 F2 | 同 F2 | 同 F2 | CM-1595（併） |
| [F4](#f4medium-健康檢查端點可被當成內網掃描器ssrf) | 🟡 MEDIUM | 管理頁「測試連線」按鈕，填什麼網址就打什麼，沒有任何位址限制 | 拿我們的伺服器**當跳板探測客戶內網**；回應會回狀態碼與錯誤訊息，能分辨「服務在但拒絕」與「完全不通」 | **需要租戶管理員權限**（這是它比 F1／F2 低一級的原因） | `remote_agent_service.py:47-70` | CM-1596 |
| [F6](#f6medium-持有安裝-token-即可接管同租戶內任一台-agent-的註冊列) | 🟡 MEDIUM | 安裝通行碼整間公司共用一組、永不過期；拿著它宣稱「我是那台機器」就能覆寫別台的註冊資料 | 把別台 agent 的連線位址改成攻擊者主機——之後雲端向那台取資料（對帳、取檢測報告）會打到攻擊者那裡，簽給那台的短效 JWT 也送過去 | 持有該租戶 enroll token（**裝在他們每一台機器上**）＋知道同租戶內另一台的 uid | `agent_enrollment_service.py:94-99`<br>`:167-172` | CM-1597 |
| [F7](#f7lowwarning-越界-jedi-commonenv-進了版控) | ⚪ LOW<br>**⚠️ 越界** | 有個 `.env` 設定檔進了版控，而 `.gitignore` 對已追蹤的檔案無效 | 目前值是 localhost 預設、**沒有真實密碼**。風險是結構性的：以後有人填了真值就會靜默推進所有 clone | 不適用（現在還沒東西可洩） | `jedi-common/.env` | CM-1598 |

**F1 F3 F2 F5 四條是同一個根因**——四個控制面端點都不掛認證，而它們宣稱倚賴的 nginx mTLS 在出貨設定裡不存在（見下一節）。故併成一張修正卡（CM-1595）。

### ⚠️ 這份結果的可信度

**七條的「存在」可信；「只有這七條」不可信。**

掃描工具的三人驗證面板**全部因 session 額度用盡沒跑成**（105 個 verifier 全滅，21 張票 0 張投出），工具正式 `findings` 因此是空陣列，stamp 記 `verification.status: unverified`。少掉的那一環是「幫忙抓誤報」。

但這七條的複審**由人做了兩遍**：runner 逐條開檔核對行號與 caller，首腦（2026-09-08）再親自複核 F1／F2／F3 行號、`grep` BE 與 FE 兩個 repo 確認三個 nginx 設定檔皆無 `ssl_verify_client`、追明文憑證流向到 `detection_task_payload_provider.py:110-127`。**每一條都是「打開檔案，那幾行就是那樣寫的」**——事實陳述而非推論，不需投票確認其存在。

不可信的是覆蓋率：卡片列的八個重點只實質觸及一個，其中「簽憑證」（`ca.py`）與「簽 JWT」（`jwt_util.py`）零發現，面板未跑的情況下**分不出「讀過判定沒問題」與「根本沒讀到」**（見文末「待追但本次未涵蓋」）。故加開補掃棒 **CM-1599（R1b）**，只掃這 10 檔。

## 執行概況

| 項目 | 數字 |
|---|---|
| 研究員派出 / 回報 | 2 / 2（研究途中 1 次 stall 後 retry 成功）|
| 候選發現 | 8 條原始 → 去重後 **7 條** |
| 面板票數 | **0 / 21**（7 條 × 3 票，全數未投）|
| 面板存活 | 不適用——沒有任何一條被投過票 |
| agent 總數 | 107（2 完成、**105 失敗**）|
| 總耗時 | 約 2 小時 21 分（8,463 秒）|
| token 消耗 | 約 158 萬 |

### 🔴 為什麼 findings 是空的：面板全滅於額度上限

`CLAUDE-SECURITY-REVISION-8aa6f0609c44.json` 的 stamp 寫得很直白：

```
verification.status: unverified
verification.reason: 7 panel round(s) were dispatched but none completed a full
                     3-voter review; no candidate was actually verified
```

105 個 verifier 全部回同一個錯誤：`You've hit your session limit · resets 11:10pm`。
七條候選在 `votes.json` 裡全部標 `continued: true`（"handed to the next verification run"），
正式 `findings` 陣列因此為空。

**這不是「掃乾淨了」，恰恰相反**——研究階段找到的東西一條都沒被否決過，只是沒人投票。
FR-076 L1 的經驗是候選數 × 3 個 verifier、每個從零讀檔，面板本身比研究階段更貴；
本棒 7 條候選要 21 票，撞上額度重置窗口即全滅。

**處置**：依卡片「中途失敗怎麼辦」段的精神，候選已從 workflow 回傳完整撈出（無遺失，
每條含檔案行號、rationale、影響、觸發前提、建議修法），改由 **runner 逐條開檔人工核對**
補上驗證。卡片本就要求「HIGH 級每條自己開檔核對，不要只信工具輸出」——這裡把它擴大到全部七條。
**不重跑掃描**（all-or-nothing，重跑只是再燒一次額度）。

### 每條的核對狀態

| ID | 嚴重度 | 面板 | runner 開檔核對 | 在範圍內？ |
|---|---|---|---|---|
| F1 | HIGH | 未投票 | ✅ 核對屬實 | ✅ |
| F2 | HIGH | 未投票 | ✅ 核對屬實 | ✅ |
| F3 | HIGH | 未投票 | ✅ 核對屬實（與 F1 同根因，見下）| ✅ |
| F4 | MEDIUM | 未投票 | ✅ 核對屬實 | ✅ |
| F5 | MEDIUM | 未投票 | ✅ 核對屬實（與 F2 同根因）| ✅ |
| F6 | MEDIUM | 未投票 | ✅ 核對屬實 | ✅ |
| F7 | LOW | 未投票 | ✅ 事實成立 | ❌ **越界**（`jedi-common/`）|

---

## 貫穿全部發現的根因：宣稱的 mTLS 並不存在

六條範圍內發現有五條可以追到同一個地方，先講清楚它，個別發現才好讀。

### 設計是怎麼說的

`ROUTE_TABLE` 第三欄是「顯式的認證意圖宣告」，四個控制面端點都填 `False`：

```python
# jedi-remote-agent/jedi_remote_agent/api/remote_agent_route.py:220-223
("/agents/register",                  AgentRegisterRoute,   False),
("/agents/heartbeat",                 AgentHeartbeatRoute,  False),
("/agents/tasks/<string:uid>/ack",    AgentTaskAckRoute,    False),
("/agents/tasks/<string:uid>/result", AgentTaskResultRoute, False),
```

`plugin.py:create_blueprint()` 照這張表掛載，`False` 就是**完全不套任何 decorator**：

```python
api.add_resource(_guard(resource, adapters.admin_required) if needs_admin else resource, path)
```

程式碼註解解釋這是刻意的，且說明了替代的守門在哪：

- `plugin.py` 的 `admin_required` docstring：「**控制面端點（register / heartbeat / ack /
  result）不套這個**——agent 不帶 user JWT，身分靠 mTLS。硬套上去會讓所有既有 agent 立刻掉線。」
- `heartbeat()` 的 docstring：「指紋變動 → 自動更新 + 留痕（**mTLS 已在網路層驗 client cert**）。」

讀到這裡，設計是自洽的：應用層不驗身分，因為網路層已經驗過了。

### 實際上網路層沒有驗

出貨的落地版 ingress 是 `compliance-manager-fe/nginx.onprem.conf`。它只有一個 `location /api/1.0`
區塊，把**所有** API 路徑一律 proxy 到 `guidant-api:8000`：

```nginx
# compliance-manager-fe/nginx.onprem.conf:88-90
location /api/1.0 {
    set $api_upstream guidant-api;
    proxy_pass http://$api_upstream:8000;
```

註解自己寫著「BE 全部 HTTP route 都掛在 /api/1.0 底下，故單一 location 即可涵蓋，不需逐模組列舉」——
換句話說，`/agents/heartbeat` 與 `/api/1.0/projects` 走的是同一個沒有客戶端憑證要求的入口。

**三個 nginx 設定檔（`nginx.conf`、`nginx.e2e.conf`、`nginx.onprem.conf`）全部 grep 不到任何一行
`ssl_verify_client`。** 「mTLS 在網路層終結」這個前提，在出貨形態下不成立。

### 產品自己也記錄了這件事

主專案的 `api/remote_agent/routes/agent_file_route.py:14` 檔頭寫著：

> 認證形狀與套件內的 ack / result 一致（純 mTLS 不掛 jwt）；agent 身分由 `X-Agent-Uid`
> header 自報（與心跳 payload 自報 agent_uid 同構——**BE 不自己終結 TLS，拿不到 client 憑證**）。

這句話同時是設計說明與問題陳述：BE 拿不到 client 憑證，所以只能讓 agent 自報身分。
只要上游沒有真的在驗憑證，「自報」就是身分的全部。

### 再加上一層：控制面跑在 super admin scope

四個控制面端點都沒有 user context，`@transaction` 的 `session_scope` 因此走
`app.is_super_admin='t'`（CM-1559 已知的 RLS fail-open 機制）。這代表控制面的每一次
DB 查詢都**繞過租戶隔離**——`heartbeat()` 用 uid 查 agent 時查的是全庫，不是某個租戶的子集。

> **與 CM-1559 的關係**：「無 user context 時 `session_scope` 拿 super admin 繞 RLS」這個
> 機制本身**重複 CM-1559**，不重報。但它在 agent 鏈上造成的具體可利用後果（跨租戶取
> agent 身分、跨租戶改任務狀態）是本棒的新發現，列在下面。

### 這條鏈的最終落點是明文憑證

心跳成功後回傳 `_collect_pending_tasks(agent)`，內容由宿主的 payload provider 組。
主專案的實作會**解密**兩樣東西放進 HTTP 回應：

```python
# compliance-manager-be/app/remote_agent/adapter/detection_task_payload_provider.py:66-71
params = decrypt_secret_params(t.params, self._secret_keys_for(t.detection_tool_id), self._crypto)
payloads.append({
    ...
    "params": self._inject_source_file_limits(params),
    "credentials": self._resolve_tool_credentials(t.detection_tool_id, tenant_id),
})
```

`_resolve_tool_credentials()`（同檔 `:110-127`）把 `tenant_detection_tool_configs.credentials_encrypted`
Fernet 解密後與 `field_values` 合併回傳——**該租戶掃描工具的明文帳密**（SonarQube token、
WinRM／SSH 憑證、掃描器 API key 等，視工具而定）。

所以整條鏈是：**沒有認證的端點 → 自報身分即通過 → 繞過 RLS 查到任一租戶的 agent →
回應夾帶該租戶的明文第三方憑證**。

---

## F1（HIGH）— 心跳端點無認證，自報 agent_uid 即可取走該租戶掃描工具明文憑證

**嚴重度**：HIGH · **研究員信心**：HIGH · **面板**：未投票 · **runner 核對**：屬實 · **CWE-306**
**位置**：`jedi-remote-agent/jedi_remote_agent/app/service/agent_enrollment_service.py:274-311`

### 問題在說什麼（白話）

agent 每隔幾分鐘要跟雲端「報到」一次，順便問「有沒有新工作給我」。雲端回覆的內容裡，
除了工作清單，還附上**執行那份工作所需的帳號密碼**——例如要掃描客戶的 SonarQube，
就得把 SonarQube 的 token 一起給它。這是設計上必要的（agent 在客戶機房、不連雲端 DB）。

問題是：**雲端完全不檢查來報到的是不是真的 agent**。這個端點沒有掛任何認證，
而判斷「你是哪一台」的唯一依據，是請求內容裡自己寫的那個 `agent_uid` 字串。

任何能連到這個 API 的人，只要知道一台 agent 的 uid，送一個 HTTP 請求過去，
就會收到那台 agent 名下待辦工作的完整內容——**包含解密後的明文憑證**。

### 核對過程

**① 端點確實沒有認證**——`ROUTE_TABLE:221` 第三欄 `False`，`plugin.py` 的 `create_blueprint`
對 `False` 的項目直接 `api.add_resource(resource, path)`，不經過 `_guard()`。

**② 身分確實只看自報值**——`heartbeat()` 從 request body 取值後直接拿去查：

```python
# agent_enrollment_service.py:277-286
agent_uid  = (payload.get("agent_uid")  or "").strip()
device_uuid = (payload.get("device_uuid") or "").strip()
agent = None
if agent_uid:
    agent = self._agents.get_one_remote_agent(uid=agent_uid)      # ← 沒有 tenant_id
if not agent:
    agent = self._agents.get_one_remote_agent(device_fingerprint=device_uuid)  # ← 也沒有
```

**這兩個查詢都沒有 `tenant_id` 條件**。對照同一支檔案的 `register()` 走的 `_resolve_agent()`
（`:94-99`），它**有**做租戶檢查：

```python
if agent_uid:
    found = self._agents.get_one_remote_agent(uid=agent_uid)
    if found and found.tenant_id == tenant_id:      # ← register 有這道，heartbeat 沒有
        return found, True
```

差異是真的（卡片重點④的推測命中）。加上控制面跑在 super admin scope（RLS 繞過），
`get_one_remote_agent(uid=...)` 查的是全庫。

**③ 回應確實含明文憑證**——`heartbeat()` 最後一行回 `_collect_pending_tasks(agent)`，
路徑追到主專案 `detection_task_payload_provider.py:110-127` 的 `_resolve_tool_credentials()`，
`self._crypto.decrypt(config.credentials_encrypted)` 後合併回傳。確認屬實。

**④ mTLS 前提不成立**——見上方根因段，`nginx.onprem.conf` 無 `ssl_verify_client`。

### 影響

- **跨租戶的第三方憑證外洩**。攻擊者拿到的不是本產品的密碼，是**客戶被掃描系統的密碼**
  （SonarQube token、遠端執行用的 WinRM／SSH 帳密等）。這些憑證通往客戶自己的基礎設施，
  外洩後的爆炸半徑遠大於本產品。UI 上這些欄位是被 `strip_secret_params` 特意遮蔽的，
  代表產品自己認定它們不該被一般使用者看到。
- **同一個請求還能寫入**。心跳會 update `last_seen_at`、`device_fingerprint`、`agent_version`、
  `capabilities`。攻擊者可以把 `capabilities` 洗掉，讓那台 agent 失去 `detection_scan` 的
  派工資格（靜默的服務阻斷）；或持續打心跳，讓一台已經死掉的 agent 在畫面上永遠顯示在線。

### 觸發前提

1. **能連到 `POST /api/1.0/agents/heartbeat`**。落地版形態下這與一般 API 同一個入口，
   沒有額外的網路層阻隔。**不需要任何帳號、token 或憑證。**
2. **知道一個 `agent_uid` 或 `device_fingerprint`**。`agent_uid` 會回給一般已登入使用者
   （`api/flow_control/serializers/job.py:104`、`:232` 的 `agent_uid` 欄位在工作設定讀取路徑上），
   所以一個低權限專案成員即可取得；而 uid 是 UUID，猜測不可行，需要先取得。
3. **該 agent 名下有 pending 任務**（否則 `pending_tasks` 為空，重放等待即可）。

> ⚠️ 前提②讓這條的實際門檻落在「一個低權限的租戶內使用者」，而非「完全的外部匿名者」。
> 但端點本身不驗證任何身分，所以取得 uid 的管道不限於 UI——任何曾經持有過一台 agent 的人、
> 任何看過封包或日誌的人都算。

### 建議修法

讓身分變成「證明」而不是「宣稱」。三個層次，由內而外：

1. **應用層自己驗**（治本，且不依賴部署設定）：agent 在註冊時已經拿到雲端簽的憑證與
   JWT 公鑰，讓它用對應私鑰對心跳內容簽名，雲端用 `remote_agents` 存的公鑰驗。
   驗不過就拒絕，**fail closed**。
2. **補上租戶邊界**：`heartbeat()` 的兩處查詢比照 `register()` 帶上由驗證身分推導出的
   `tenant_id`，指紋 fallback 尤其不可跨租戶（VM 模板複製會撞指紋，見 F6 的討論）。
3. **修正部署設定**：若要維持「mTLS 在 nginx 終結」的設計，`nginx.onprem.conf` 就必須
   為 `/api/1.0/agents/` 路徑加上 `ssl_verify_client on;` 與 CA 設定，並把驗證結果
   （憑證 subject／serial）以可信 header 傳給 BE 比對。**但這不能是唯一防線**——
   應用層仍應在拿不到已驗證身分時拒絕，而不是放行。

---

## F3（HIGH）— 心跳的身分解析可跨租戶（與 F1 同一根因，不同利用面）

**嚴重度**：HIGH · **研究員信心**：HIGH · **面板**：未投票 · **runner 核對**：屬實 · **CWE-290**
**位置**：`jedi-remote-agent/jedi_remote_agent/app/service/agent_enrollment_service.py:286`

### 與 F1 的關係

F1 講的是「**外部沒有身分的人**能不能取得 agent 的資料」，F3 講的是「**已經合法持有一台
agent 的租戶 A**，能不能拿去讀租戶 B 的資料」。兩者根因相同（身分自報＋查詢無租戶條件），
但**威脅模型不同、修法的必要條件也不同**：即使明天把 nginx 的 mTLS 補上，F1 被擋住了，
**F3 仍然成立**——因為 nginx 只驗「這是不是一張我們簽的合法憑證」，不驗「這張憑證對應的
是不是你宣稱的那台 agent」，而應用層從頭到尾沒有把兩者做比對。

**建議併卡處理，但修正必須涵蓋比對步驟**，只補 nginx 設定不算修好。

### 核對過程

同 F1 的②：`heartbeat()` 兩處查詢皆無 `tenant_id`，且執行於 super admin scope（RLS 繞過）。
`get_one_remote_agent()` 的實作（`domain/remote_agent/service/remote_agent_domain_service.py:33-35`）
是純粹的 `get_one_by_fields(RemoteAgentQueryEntity(**kwargs))`，傳什麼查什麼，本身不加任何隱含條件。
確認：只要 uid 對得上，跨租戶取得。

### 影響

租戶 A 的合法 agent（或其操作者）取得租戶 B 某台 agent 的 uid 後，即可拿到 B 的待辦任務
與明文工具憑證。多租戶隔離在這條路徑上完全不存在。

### 觸發前提

1. 能連到心跳端點（同 F1）。
2. 知道另一個租戶某台 agent 的 uid。**這是本條的實際門檻**——uid 是 UUID，跨租戶取得
   並不容易，但 F1 的心跳回應本身就是一個可以反覆探測的管道，兩條串起來會互相放大。
3. 在未來補上 mTLS 的部署裡，還需要持有**任何一張**合法 agent 憑證（不需要是目標那張）。

### 建議修法

同 F1 的 1 與 2。關鍵句：**驗證得到的身分必須與請求宣稱的身分做比對**，
`agent_uid` 只能當交叉檢查用，不能當身分來源。

---

## F2（HIGH）— 任務 ack／result 端點無認證且不檢查任務歸屬，可偽造稽核證據

**嚴重度**：HIGH · **研究員信心**：HIGH · **面板**：未投票 · **runner 核對**：屬實 · **CWE-306**
**位置**：`jedi-remote-agent/jedi_remote_agent/api/remote_agent_route.py:199-210`、`:222-223`；
`jedi-remote-agent/jedi_remote_agent/app/service/agent_task_service.py:73-107`

### 問題在說什麼（白話）

agent 做完一份掃描工作後，會回報「我做完了，結果在這裡」。雲端收到後，
會把這份結果**轉成合規稽核證據**存進客戶的稽核紀錄——這是本產品的核心產出物。

回報用的兩個端點同樣沒有任何認證，而且**不檢查「這份工作是不是你的」**。
只要知道一個任務編號，任何人都可以宣告那份工作「成功完成」並附上自己編的結果，
或宣告「失敗」把真正的掃描結果蓋掉。

### 核對過程

**① 無認證**——`ROUTE_TABLE:222-223` 兩條第三欄皆 `False`。

**② handler 不驗身分**——`uid` 直接來自 URL、body 直接來自 `request.get_json()`：

```python
# api/remote_agent_route.py:199-210
class AgentTaskAckRoute(MethodResource):
    def post(self, uid):
        return _ok(_ctx().agent_task_service.ack_task(uid))

class AgentTaskResultRoute(MethodResource):
    def post(self, uid):
        payload = request.get_json(silent=True) or {}
        return _ok(_ctx().agent_task_service.receive_result(uid, payload))
```

**③ service 層也不驗歸屬**——`receive_result()`（`agent_task_service.py:86-107`）
只做了一件與安全有關的檢查：`task.status == "cancelled"` 就短路（那是 CM-931 的競合防護，
不是授權）。之後直接 `write_success(uid, payload.get("result_ref"), _AGENT_ACTOR)`。
**全程沒有任何 agent 身分、沒有任何 `agent_id` 或 `tenant_id` 比對。**

**④ 結果會進稽核紀錄**——`write_success` 後呼叫 `self._listener.on_scan_succeeded(task, payload.get("summary"))`，
`summary` 是攻擊者提供的。主專案的 listener 負責把它轉成證據。

**⑤ 同樣繞 RLS**——`@transaction` 無 user context → super admin scope，故偽造的寫入不受租戶限制。

### 影響

**合規證據的完整性被破壞，這比資料外洩更難察覺**：

- 把一份從未執行的掃描標記為 `succeeded`，附上攻擊者選定的 `result_ref`，
  稽核紀錄上就出現一份「乾淨」的掃描結果。
- 或把真實掃描標記為 `failed` 並附上任意 `error_message`，讓真正發現問題的結果消失。
- 由於繞過 RLS，可對**任一租戶**的任務下手。

對一個合規稽核產品而言，這是核心產出物的信任基礎被動搖——客戶拿這些紀錄去應付外部稽核。

### 觸發前提

1. 能連到 `POST /api/1.0/agents/tasks/<uid>/ack` 或 `/result`。**不需任何憑證。**
2. **知道一個任務 uid**。取得管道：任一 agent 從自己的心跳回應就能看到；
   或透過 F1 的心跳端點探測取得（兩條發現互相放大）。

### 建議修法

1. **把任務綁定到呼叫者**：要求已驗證的 agent 身分（同 F1 的修法 1），
   並檢查 `agent_tasks.agent_id` 與 `tenant_id` 是否與該 agent 相符，不符即拒絕。
2. **`ROUTE_TABLE` 第三欄從布林改成可表達「agent 認證」的第三種狀態**。目前這一欄只有
   `True`（管理員）／`False`（無），沒有辦法表達「要驗，但驗的是 agent 不是使用者」——
   於是控制面端點只能填 `False`，看起來就像刻意公開。建議加一個 `agent_required` adapter
   插槽，讓認證意圖宣告能誠實反映實際需求。
3. **把 uid 當識別碼，不要當授權憑證**。目前的實質安全模型是「知道 UUID 就有權操作」，
   這對會出現在日誌、回應、封包裡的識別碼而言不是可接受的保護。

---

## F5（MEDIUM）— ack／result 缺少歸屬檢查（F2 的授權面，同檔同根因）

**嚴重度**：MEDIUM · **研究員信心**：HIGH · **面板**：未投票 · **runner 核對**：屬實 · **CWE-862**
**位置**：同 F2

研究員把同一組端點報了兩次：F2 從「沒有認證」（CWE-306）切入，F5 從「沒有授權檢查」
（CWE-862）切入。**兩條講的是同一段程式碼的同一個缺口**，差別只在描述角度。

**建議與 F2 併為一張修正卡**（符合首腦手冊第五節「同檔同根因併一張」的判準）。
獨立列出是為了保留研究員的原始分析——F5 的敘述把「即使有了 agent 認證，仍然需要
逐任務比對歸屬」這點講得比 F2 清楚，修正時兩個層面都要做到。

---

## F4（MEDIUM）— 健康檢查端點可被當成內網掃描器（SSRF）

**嚴重度**：MEDIUM · **研究員信心**：HIGH · **面板**：未投票 · **runner 核對**：屬實 · **CWE-918**
**位置**：`jedi-remote-agent/jedi_remote_agent/app/service/remote_agent_service.py:47-70`

### 問題在說什麼（白話）

管理頁有一個「測試連線」按鈕，讓管理員確認某台 agent 是否連得上。
它的做法是：把使用者填的網址拿去發一個 HTTP 請求，然後把結果回報給使用者。

問題是**這個網址沒有任何限制**。填什麼就打什麼——包括只有伺服器連得到、
從外面連不到的內網位址。伺服器變成攻擊者的代理人，替他去敲內網的門，
再把「敲得開／敲不開」回報回來。

### 核對過程

`base_url` 來自 request body，未經任何過濾直接進 `httpx.get`：

```python
# api/remote_agent_route.py:92-94
def post(self):
    payload = request.get_json(silent=True) or {}
    return _ok(_ctx().remote_agent_service.check_health(payload.get("base_url")))

# app/service/remote_agent_service.py:55-69
url = (base_url or "").strip().rstrip("/")
if not url:
    return {"reachable": False, "detail": "base_url 為空"}
try:
    settings = self._settings()
    if settings.enabled and url.startswith("https://"):
        ...
    else:
        resp = httpx.get(f"{url}/health", timeout=_HEALTH_TIMEOUT)   # ← 無任何位址檢查
    return {"reachable": resp.status_code == 200, "status_code": resp.status_code}
except Exception as e:
    return {"reachable": False, "detail": str(e)[:200]}
```

確認三件事：
- **沒有 scheme／host／私網位址檢查**，屬實。
- **回應會回傳 `status_code` 與截斷的例外訊息**，這使它成為一個可讀的探測管道（oracle），
  而不只是盲打。
- 研究員指出 `/health` 後綴不構成限制（用 query string 可吸收），這在字串串接
  `f"{url}/health"` 的寫法下屬實。

### 影響

從應用伺服器的網路位置探測內網服務與連接埠、雲端 metadata endpoint 等。
回傳的狀態碼與錯誤訊息足以區分「服務存在但拒絕」與「完全不通」。

### 觸發前提

1. **需要租戶管理員權限**——這條端點的 `ROUTE_TABLE:219` 第三欄是 `True`，
   確實有套 `admin_required`。**這是它比 F1／F2 低一級的原因**：需要一個合法的管理帳號。
2. `mode=none`（預設值，`config.py:261`）或目標為 http 時走裸 `httpx.get` 分支。

### 建議修法

`base_url` 撥號前先驗證：只允許 http／https，解析主機名後拒絕 loopback、link-local
與 RFC1918 位址（**解析後再檢查一次**以避免 DNS rebinding）。
更穩健的做法是**根本不接受自由輸入**——健康檢查只允許對該租戶 `remote_agents` 表裡
已登記的 `base_url` 執行，讓 request 傳 agent uid 而非網址。

---

## F6（MEDIUM）— 持有安裝 token 即可接管同租戶內任一台 agent 的註冊列

**嚴重度**：MEDIUM · **研究員信心**：MEDIUM · **面板**：未投票 · **runner 核對**：屬實 · **CWE-639**
**位置**：`jedi-remote-agent/jedi_remote_agent/app/service/agent_enrollment_service.py:94-99`（`_resolve_agent`）、
`:186-238`（`register`）、`:123-186`（`_guard_fingerprint_collision`）

### 問題在說什麼（白話）

客戶安裝 agent 時，安裝程式裡帶著一組「報到用的通行碼」（enroll token）。
這組碼是**整個租戶共用一組**，發給該客戶的每一台機器。

註冊時，機器可以宣告「我是編號 X 那台」。程式看到這個宣告後，只檢查「編號 X 是不是
屬於這個租戶」，就允許它**覆寫編號 X 那一列的內容**——包括那台 agent 的網址。

於是：拿到安裝碼的人（客戶端任何一台機器的操作者），只要知道另一台 agent 的編號，
就能把那台的網址改成自己的位址。之後雲端要向那台 agent 取資料時，會打到攻擊者那裡。

### 核對過程

**① `_resolve_agent` 只檢查租戶，不檢查持有證明**：

```python
# agent_enrollment_service.py:94-99
if agent_uid:
    found = self._agents.get_one_remote_agent(uid=agent_uid)
    if found and found.tenant_id == tenant_id:
        return found, True          # ← matched_by_uid=True，僅憑「說出 uid」即成立
```

**② 撞號守門對 `matched_by_uid` 的情況直接豁免**：

```python
# agent_enrollment_service.py:167-172
resolved_uid = getattr(resolved, "uid", None) if matched_by_uid else None
for holder in holders:
    if holder.uid == resolved_uid:
        continue                    # ← 認定「就是這次要更新的那一列」，跳過檢查
```

**③ 接著就覆寫該列**（`:216-228`）：`base_url`、`device_fingerprint`、`status="active"`、
`enabled=True` 全部以請求提供的值更新。

**關於卡片重點③提到的 2026-08-21 那次修正**：docstring 詳細記載了「原本只比 `resolved.uid`
而非 `matched_by_uid`」的錯誤與實測過程。**這一版的判定是對的**——`resolved_uid` 確實
用 `if matched_by_uid` 做了條件化，指紋 fallback 撞上的那一列不會被豁免。那個 bug 確實修好了。

**但修好的是另一個問題**。那次修的是「複製出來的 VM 冒充原機」（無 uid，靠指紋撞上）；
本條講的是「知道 uid 的人主動宣告」——後者走的正是被豁免的那條路徑，而豁免的成立條件
只有「說得出 uid」＋「同租戶」，沒有任何持有證明。**註解說服了研究員這裡是安全的，
但它證明的是另一件事。**（這正是首腦手冊第二節「會被程式碼註解說服」的盲點，
差別是這次註解沒有錯，只是回答的不是這個問題。）

### 影響

被接管的 agent 列 `base_url` 指向攻擊者主機後，雲端主動打 agent 的路徑
（`reconcile` 的 `/blob/<name>/sha256`、檢測報告取回）都會導向攻擊者，
使其能供應偽造的掃描報告並被存為稽核證據。為該 agent 簽發的短效資料面 JWT 也會送到攻擊者手上。

### 觸發前提

1. **持有該租戶的 enroll token**。依設計它嵌在發給客戶的 installer 裡，
   **裝在該租戶每一台機器上**——是一組廣泛共享的秘密，不是每機獨有。
   另注意（卡片重點①）：這個 token **沒有到期時間、沒有使用次數上限、不綁機器**。
2. **知道同租戶內另一台 agent 的 `agent_uid`**（管理頁清單、工作設定讀取路徑都會回傳）。

前提①把攻擊者限縮在「該租戶內部」——這是它列 MEDIUM 而非 HIGH 的原因。
但「客戶機房內一台被攻陷的機器」正是這條產品線的主要威脅模型（agent 就裝在那裡）。

### 建議修法

**重新註冊時不可把宣告的 `agent_uid` 當身分**。要求 agent 證明持有首次註冊時取得的
金鑰／憑證（對註冊內容簽名，或出示既有憑證）才允許更新既有列；證明不了就**走新建流程**，
讓撞號守門正常判定。

---

## F7（LOW，⚠️ 越界）— `jedi-common/.env` 進了版控

**嚴重度**：LOW · **研究員信心**：MEDIUM · **面板**：未投票 · **runner 核對**：事實成立 · **CWE-798**
**位置**：`jedi-common/.env`

> ⚠️ **越界（檔案屬 `jedi-common/`，不在本棒 42 檔 scope 內）**。研究員為追脈絡讀到範圍外。
> 這是 plugin 的已知行為（FR-075 S7 九條裡八條、FR-076 L1 三條裡兩條同一模式）——
> 面板只驗「發現是否成立」，不驗「是否在範圍內」。

### 核對結果

事實面成立，兩項都驗過：

- `git ls-files jedi-common/.env` **有回傳該路徑**——檔案確實受版控。
- `.gitignore:123` 確實有 `.env` 一行——但**對已追蹤的檔案無效**，
  該檔早於 ignore 規則加入，之後的修改仍會被 git 視為變更並可能被順手 commit。

檔案內含 `DB_HOST` / `DB_PORT` / `DB_NAME` / `DB_SCHEMA` / `DB_USERNAME` / `DB_PASSWORD` 等鍵。
研究員指其值為 localhost 的預設值、直接外洩價值極低；**本報告不記錄任何實際值**
（依 CLAUDE.md 憑證禁入版控規範，落地文件不寫憑證內容）。

### 為什麼仍值得處理

風險是**結構性的、不是當下的**：一個受版控的 `.env` 待在一個 `.gitignore` 宣稱會忽略它的
repo 裡，任何開發者若把它改成可用的連線資訊（那正是這個檔案的用途），
就會靜默地把真實憑證推進所有 clone 與套件歷史——刪檔也救不回來。

### 建議處置

`git rm --cached jedi-common/.env` 讓既有的 ignore 規則生效，改放 `.env.sample`（值留空）。
研究員判斷現值為預設值故不需重寫歷史；**但這需要有人實際確認該檔歷史上是否曾經填過
真實憑證**，確認前不宜下定論。

**處置建議**：獨立開卡（比照 FR-076 L1 的 PAT 外洩處理方式），註明來源為 R1 越界發現。

---

## 待追但本次未涵蓋

卡片「重點看什麼」列了八個方向，工具不照清單走（首腦手冊第三個盲點）。
以下是**卡片點名、但本次研究員與 runner 核對都未實質觸及**的部分，
建議在 R2／R3 或後續補掃時追：

| 卡片重點 | 本次狀態 |
|---|---|
| ① enroll token 生命週期（無到期、無次數上限、`revoke()` 後既有 agent 的行為、`get_current()` 摘要外洩面）| **部分**——F6 引用了「無到期、共用一組」作為前提，但 `agent_enroll_token_service.py` 本身未被實質審查 |
| ② 註冊端點的 token hash 比對是否常數時間、`token_entity.tenant_id is None` 守門的成因 | **未觸及** |
| ⑤ CA 簽 CSR 的邊界（subject 由 agent 決定、SAN 從自報 base_url 推導、CLIENT_AUTH 用途、無 CRL／OCSP）| **未觸及**——`common/agent_auth/ca.py` 沒有任何發現，無法區分「讀過判定沒問題」或「沒讀到」 |
| ⑥ JWT 簽發與 claim（`op` 的注入面、`aud` 可否被影響、私鑰路徑可控性）| **未觸及**——`jwt_util.py` 同上 |
| ⑦ 套件預設值偏不安全（`mode="none"` 預設、`plugin.py` 漏接 `settings_provider` 的靜默降級、`_guard()` 只套五個 method）| **部分**——根因段引用了 `mode="none"` 預設（`settings.py:26` 已核對屬實）與 `_guard()` 的實作，但「什麼樣的接線疏漏會讓整條鏈靜默跑在 none 模式」未系統性追過 |
| ⑧ 管理面租戶隔離靠 RLS（`verify_remote_agent_is_exist()` 是否真受 RLS 管、`rebind_agent()` 與指紋 fallback 的組合）| **未觸及** |
| ⑨ `reconcile()` 的 `save_file_name` 路徑穿越 | **未觸及**（F4 只涵蓋 `check_health` 的 SSRF 面）|

**這一欄的空白必須照實讀作「沒有結論」，不是「沒有問題」。** 面板未跑成、
研究員又只回報了七條，範圍內有多少是「讀過判定沒問題」、多少是「根本沒讀到」，
本次無法區分。

## 給首腦的處置建議

1. **F1＋F3 併一張修正卡**（心跳身分驗證，含租戶邊界）——**建議最高優先**。
   落地版打得到、無需任何憑證、洩漏的是客戶自己基礎設施的憑證。
2. **F2＋F5 併一張修正卡**（任務端點身分與歸屬驗證）——同屬控制面根因，
   與 1 動到相鄰的程式碼，**建議序列做或同一張卡處理**，避免派工衝突。
3. **F4 獨立一張**（SSRF）——需管理員權限，優先序次之。
4. **F6 獨立一張**（註冊接管）——需 enroll token，但威脅模型（客戶機房內的機器）成立。
5. **F7 獨立一張**，註明越界來源。
6. **nginx 部署設定是跨 repo 的共同前提**——1、2 的修正若只改套件而不處理
   `nginx.onprem.conf`，防線仍只有一層。建議在修正卡內明寫「應用層 fail closed」為驗收條件，
   不接受「補了 nginx 就算修好」。
7. **考慮補掃**：上表七個未觸及的方向中，⑤（CA 簽 CSR）與 ⑥（JWT）屬密碼學核心，
   零發現最不可信。是否值得單獨補一小棒（10 檔上下，僅 `common/agent_auth/`），
   請決策者裁示。

## 附件

- 工具產物：`~/Projects/Jedicogy/module/jedi-python-package/CLAUDE-SECURITY-20260907-111311/`
  （`CLAUDE-SECURITY-RESULTS.md` / `.jsonl` / `.sarif` 皆為 0 findings，
  stamp `CLAUDE-SECURITY-REVISION-8aa6f0609c44.json` 記 `verification.status: unverified`）
- 該目錄有自帶 `.gitignore`（單行 `*`），不入版控
