本卡為 FR-077 母案。用
claude-securityplugin 掃「客戶機房的 agent 撥回雲端」這條控制鏈,切三棒,只掃不修。子卡清單在末段。
我們的產品會發一支程式(叫 agent)裝在客戶自己的機房裡。它會主動撥回雲端問「有沒有工作要我做」,然後下載檔案、執行掃描、回報結果。這一整條來回的通道,從來沒有人系統性檢查過安全性。
這次就是把這條通道從頭到尾看一遍:agent 怎麼證明「我是我」、證明完之後能拿到什麼、能不能假裝成別台機器、能不能拿到不該拿的檔案。只找問題、不修問題——找到的問題另外開修正卡。
這條鏈是完全空白:2026-07 的 security-scan-2607 只掃主專案 BE 與 FE;FR-075 掃 jedi-common 與 jedi-iam;FR-076 掃 License 簽發與驗證鏈。Agent 控制鏈三個 arc 都沒碰到。
而它的風險形狀跟前兩個 arc 都不一樣,有三點特別:
/agents/register、/agents/heartbeat、/agents/tasks/<uid>/ack、/agents/tasks/<uid>/result)。這是設計不是疏漏——agent 不帶 user JWT,身分靠 mTLS 在上游 nginx 終結。但BE 自己不終結 TLS、拿不到 client 憑證,所以「是哪一台 agent」全靠 payload 或 header 自報。自報的東西能不能被冒充,是本次的核心問題。GET /agents/files/<uid> 回源碼壓縮包的 binary 串流,授權判定在 jedi-detection 的 resolver 清單裡。以下是我讀過全部程式碼後標出的六個面。這是思考起點不是檢查清單——工具不會照清單走,寫在這裡是給研究員與 runner 的方向。
agent_task_service.ack_task(uid) 與 receive_result(uid, payload)——參數只有路徑上的 task uid,沒有 agent_uid、沒有 tenant 檢查。任何能打到這條路徑的 agent,只要猜到(或知道)別人的 task uid,就能替別人 ack、替別人回報「執行成功」並塞任意 result_ref。jedi-detection 的 agent_file_access_service.py docstring 自己寫了「tasks ack / result 則根本不識別身分」——這是已知且被文件化的,但沒有人判斷過它可不可接受。agent_enrollment_service.heartbeat() 先用 agent_uid 查,查不到就 get_one_remote_agent(device_fingerprint=device_uuid) ——這一查沒有帶 tenant_id,而該方法跑在無 user context 的 @transaction(super admin 繞 RLS)。對照 register() 的同款查詢是有帶 tenant_id 的。指紋在 FR-066 D11 之後只剩兩源(product_uuid|machine-id),VM 模板複製會撞號——跨租戶撞號時心跳會更新到別的租戶那一列。agent_enroll_token_service.generate() 產 secrets.token_urlsafe(32),per-tenant 一組 active,DB 存 sha256。沒有 expires_at、沒有使用次數上限、沒有綁定特定機器。這組 token 進了 installer、發給客戶、裝在每一台 agent 上——任何拿到它的人都能註冊新 agent 進該租戶。register() 驗 token 那一路也是無 user context 的 super admin scope。X-Agent-Uid header 自報身分是檔案下載授權的唯一輸入。api/remote_agent/routes/agent_file_route.py 從 header 取 agent_uid,交給 AgentFileAccessService.get_file_for_agent()。程式碼註解說「自報成別台 agent 也只能拿到那台 agent 名下任務正在用的檔案」——這個論證要驗,因為它假設 mTLS 在網路層已經擋掉所有非 agent 的來源;若 nginx 設定有洞或有內網直連路徑,這個假設就垮了。_guard_fingerprint_collision() 的規則是「持有該指紋的列若已離線就放行接手」。理由寫在 docstring(客戶重灌後 uid 丟失、指紋不變,硬擋會永遠註冊不上)。但這意味著:拿到 enroll token + 知道目標指紋 + 等目標機器離線(或讓它離線),就能接手它那一列。離線門檻是心跳間隔 ×3(預設 900 秒)。jedi-detection/jedi_detection/common/agent_auth.py 的 _DisabledSettings —— provider 沒接線時回 enabled=False,等同資料面完全不加認證。2026-09-01 arc-review 已補一次性 warning,但降級行為本身還在。同款預設也在 AgentAuthSettings.mode = "none"(common/agent_auth/settings.py)——套件的預設值是不安全的那一邊,安全與否完全取決於宿主有沒有正確組出 settings。這條鏈被 repo 邊界切成三段,而掃描的 scanRoot 只能指一個目錄、跨 repo 無法同一棒,故切三棒。切法是按業務模組垂直切(route→service→domain→repository 完整路徑),不按 DDD 分層水平切——權限漏洞活在層與層的接縫上,只給 route 或只給 service 兩邊都看不出來。