掃描日期:2026-09-07 掃描版本:jedi-python-package
feature/FR-075@8aa6f0609c4434c0fa5206d5e99e31cad249646e(乾淨,無未 commit 變更) 工具:Claude Codeclaude-securityplugin v0.10.2.3(claude-security:scanworkflow) 範圍: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 | 🔴 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-210agent_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 檔。
| 項目 | 數字 |
|---|---|
| 研究員派出 / 回報 | 2 / 2(研究途中 1 次 stall 後 retry 成功) |
| 候選發現 | 8 條原始 → 去重後 7 條 |
| 面板票數 | 0 / 21(7 條 × 3 票,全數未投) |
| 面板存活 | 不適用——沒有任何一條被投過票 |
| agent 總數 | 107(2 完成、105 失敗) |
| 總耗時 | 約 2 小時 21 分(8,463 秒) |
| token 消耗 | 約 158 萬 |
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/) |
六條範圍內發現有五條可以追到同一個地方,先講清楚它,個別發現才好讀。
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.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:
# 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-Uidheader 自報(與心跳 payload 自報 agent_uid 同構——BE 不自己終結 TLS,拿不到 client 憑證)。
這句話同時是設計說明與問題陳述:BE 拿不到 client 憑證,所以只能讓 agent 自報身分。 只要上游沒有真的在驗憑證,「自報」就是身分的全部。
四個控制面端點都沒有 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 回應:
# 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 → 回應夾帶該租戶的明文第三方憑證。
嚴重度: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 取值後直接拿去查:
# 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.conf 無 ssl_verify_client。
strip_secret_params 特意遮蔽的, 代表產品自己認定它們不該被一般使用者看到。last_seen_at、device_fingerprint、agent_version、 capabilities。攻擊者可以把 capabilities 洗掉,讓那台 agent 失去 detection_scan 的 派工資格(靜默的服務阻斷);或持續打心跳,讓一台已經死掉的 agent 在畫面上永遠顯示在線。POST /api/1.0/agents/heartbeat。落地版形態下這與一般 API 同一個入口, 沒有額外的網路層阻隔。不需要任何帳號、token 或憑證。agent_uid 或 device_fingerprint。agent_uid 會回給一般已登入使用者 (api/flow_control/serializers/job.py:104、:232 的 agent_uid 欄位在工作設定讀取路徑上), 所以一個低權限專案成員即可取得;而 uid 是 UUID,猜測不可行,需要先取得。pending_tasks 為空,重放等待即可)。⚠️ 前提②讓這條的實際門檻落在「一個低權限的租戶內使用者」,而非「完全的外部匿名者」。 但端點本身不驗證任何身分,所以取得 uid 的管道不限於 UI——任何曾經持有過一台 agent 的人、 任何看過封包或日誌的人都算。
讓身分變成「證明」而不是「宣稱」。三個層次,由內而外:
remote_agents 存的公鑰驗。 驗不過就拒絕,fail closed。heartbeat() 的兩處查詢比照 register() 帶上由驗證身分推導出的 tenant_id,指紋 fallback 尤其不可跨租戶(VM 模板複製會撞指紋,見 F6 的討論)。nginx.onprem.conf 就必須 為 /api/1.0/agents/ 路徑加上 ssl_verify_client on; 與 CA 設定,並把驗證結果 (憑證 subject/serial)以可信 header 傳給 BE 比對。但這不能是唯一防線—— 應用層仍應在拿不到已驗證身分時拒絕,而不是放行。嚴重度:HIGH · 研究員信心:HIGH · 面板:未投票 · runner 核對:屬實 · CWE-290 位置:jedi-remote-agent/jedi_remote_agent/app/service/agent_enrollment_service.py:286
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 的待辦任務 與明文工具憑證。多租戶隔離在這條路徑上完全不存在。
同 F1 的 1 與 2。關鍵句:驗證得到的身分必須與請求宣稱的身分做比對, agent_uid 只能當交叉檢查用,不能當身分來源。
嚴重度: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():
# 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,讓真正發現問題的結果消失。對一個合規稽核產品而言,這是核心產出物的信任基礎被動搖——客戶拿這些紀錄去應付外部稽核。
POST /api/1.0/agents/tasks/<uid>/ack 或 /result。不需任何憑證。agent_tasks.agent_id 與 tenant_id 是否與該 agent 相符,不符即拒絕。ROUTE_TABLE 第三欄從布林改成可表達「agent 認證」的第三種狀態。目前這一欄只有 True(管理員)/False(無),沒有辦法表達「要驗,但驗的是 agent 不是使用者」—— 於是控制面端點只能填 False,看起來就像刻意公開。建議加一個 agent_required adapter 插槽,讓認證意圖宣告能誠實反映實際需求。嚴重度:MEDIUM · 研究員信心:HIGH · 面板:未投票 · runner 核對:屬實 · CWE-862 位置:同 F2
研究員把同一組端點報了兩次:F2 從「沒有認證」(CWE-306)切入,F5 從「沒有授權檢查」 (CWE-862)切入。兩條講的是同一段程式碼的同一個缺口,差別只在描述角度。
建議與 F2 併為一張修正卡(符合首腦手冊第五節「同檔同根因併一張」的判準)。 獨立列出是為了保留研究員的原始分析——F5 的敘述把「即使有了 agent 認證,仍然需要 逐任務比對歸屬」這點講得比 F2 清楚,修正時兩個層面都要做到。
嚴重度: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]}確認三件事:
status_code 與截斷的例外訊息,這使它成為一個可讀的探測管道(oracle), 而不只是盲打。/health 後綴不構成限制(用 query string 可吸收),這在字串串接 f"{url}/health" 的寫法下屬實。從應用伺服器的網路位置探測內網服務與連接埠、雲端 metadata endpoint 等。 回傳的狀態碼與錯誤訊息足以區分「服務存在但拒絕」與「完全不通」。
ROUTE_TABLE:219 第三欄是 True, 確實有套 admin_required。這是它比 F1/F2 低一級的原因:需要一個合法的管理帳號。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 而非網址。
嚴重度: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 只檢查租戶,不檢查持有證明:
# 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_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 也會送到攻擊者手上。
agent_uid(管理頁清單、工作設定讀取路徑都會回傳)。前提①把攻擊者限縮在「該租戶內部」——這是它列 MEDIUM 而非 HIGH 的原因。 但「客戶機房內一台被攻陷的機器」正是這條產品線的主要威脅模型(agent 就裝在那裡)。
重新註冊時不可把宣告的 agent_uid 當身分。要求 agent 證明持有首次註冊時取得的 金鑰/憑證(對註冊內容簽名,或出示既有憑證)才允許更新既有列;證明不了就走新建流程, 讓撞號守門正常判定。
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 面) |
這一欄的空白必須照實讀作「沒有結論」,不是「沒有問題」。 面板未跑成、 研究員又只回報了七條,範圍內有多少是「讀過判定沒問題」、多少是「根本沒讀到」, 本次無法區分。
nginx.onprem.conf,防線仍只有一層。建議在修正卡內明寫「應用層 fail closed」為驗收條件, 不接受「補了 nginx 就算修好」。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(單行 *),不入版控