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__.py42 個受版控檔案(啟動前 git ls-files 驗過,與卡片數字吻合) effortlowfocusattack-surface 模型:主 session Opus 5 (1M context),研究員繼承 狀態:🔴 驗證面板未跑成,stamp 為 verification.status: unverified——研究階段完整完成,但 21 張面板票全部因 session 額度上限失敗。本報告全部發現皆為研究員原始宣稱 + runner 人工開檔核對,未經面板背書。 對應卡片:CM-1592(母卡 CM-1591)

§1

一句話結論

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

§2

🔴 掃到什麼:七條一覽

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

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

§3

執行概況

項目 數字
研究員派出 / 回報 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/

§4

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

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

設計是怎麼說的

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

# 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

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

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

  • plugin.pyadmin_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

# 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.confnginx.e2e.confnginx.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,@transactionsession_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 回應:

# 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 → 回應夾帶該租戶的明文第三方憑證


§5

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 第三欄 Falseplugin.pycreate_blueprintFalse 的項目直接 api.add_resource(resource, path),不經過 _guard()

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

# 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),它做租戶檢查:

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.confssl_verify_client

影響

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

觸發前提

  1. 能連到 POST /api/1.0/agents/heartbeat。落地版形態下這與一般 API 同一個入口, 沒有額外的網路層阻隔。不需要任何帳號、token 或憑證。
  2. 知道一個 agent_uiddevice_fingerprintagent_uid 會回給一般已登入使用者 (api/flow_control/serializers/job.py:104:232agent_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 比對。但這不能是唯一防線—— 應用層仍應在拿不到已驗證身分時拒絕,而不是放行。

§6

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 只能當交叉檢查用,不能當身分來源。


§7

F2(HIGH)— 任務 ack/result 端點無認證且不檢查任務歸屬,可偽造稽核證據

嚴重度:HIGH · 研究員信心:HIGH · 面板:未投票 · runner 核對:屬實 · CWE-306 位置jedi-remote-agent/jedi_remote_agent/api/remote_agent_route.py:199-210:222-223jedi-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()

# 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_idtenant_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_idtenant_id 是否與該 agent 相符,不符即拒絕。
  2. ROUTE_TABLE 第三欄從布林改成可表達「agent 認證」的第三種狀態。目前這一欄只有 True(管理員)/False(無),沒有辦法表達「要驗,但驗的是 agent 不是使用者」—— 於是控制面端點只能填 False,看起來就像刻意公開。建議加一個 agent_required adapter 插槽,讓認證意圖宣告能誠實反映實際需求。
  3. 把 uid 當識別碼,不要當授權憑證。目前的實質安全模型是「知道 UUID 就有權操作」, 這對會出現在日誌、回應、封包裡的識別碼而言不是可接受的保護。

§8

F5(MEDIUM)— ack/result 缺少歸屬檢查(F2 的授權面,同檔同根因)

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

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

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


§9

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

# 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 而非網址。


§10

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-238register)、:123-186_guard_fingerprint_collision

問題在說什麼(白話)

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

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

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

核對過程

_resolve_agent 只檢查租戶,不檢查持有證明

# 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 的情況直接豁免

# 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_urldevice_fingerprintstatus="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 證明持有首次註冊時取得的 金鑰/憑證(對註冊內容簽名,或出示既有憑證)才允許更新既有列;證明不了就走新建流程, 讓撞號守門正常判定。


§11

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 越界發現。


§12

待追但本次未涵蓋

卡片「重點看什麼」列了八個方向,工具不照清單走(首腦手冊第三個盲點)。 以下是卡片點名、但本次研究員與 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 面)

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

§13

給首腦的處置建議

  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/), 請決策者裁示。
§14

附件

  • 工具產物:~/Projects/Jedicogy/module/jedi-python-package/CLAUDE-SECURITY-20260907-111311/CLAUDE-SECURITY-RESULTS.md / .jsonl / .sarif 皆為 0 findings, stamp CLAUDE-SECURITY-REVISION-8aa6f0609c44.jsonverification.status: unverified
  • 該目錄有自帶 .gitignore(單行 *),不入版控