FR-187.D2 資安掃描:主系統「推送 Agent 升級與升級紀錄」

§1

一句話結論

掃到 3 個低風險問題,沒有高風險。三條都要先控制一台已經註冊的 Agent(或是持有更新權限的子公司管理員)才打得到。最值得修的是第 1 條:一個客戶的 Agent 可以讓另一個客戶的 Agent 升級卡住或被暫停。

§2

這一棒在檢查什麼

主系統下載好 Agent 新版之後,怎麼推到各台 Agent:

  • 更新中心勾選幾台推送
  • Agent 主控台自己按「請更新這一台」
  • Agent 收到升級指令後下載安裝包
  • Agent 上傳升級紀錄,以及誰能下載這份紀錄

推送等於「讓遠端機器以高權限換掉自己的程式」,所以重點看誰能推、推給誰、推哪一版。

範圍是 7 支檔案、共 1181 行,見文末執行概況。

§3

總覽

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 怎麼修 嚴重度
1 甲客戶的 Agent 自己要求更新時,會被排進乙客戶那次推送的同一批 乙客戶其餘 Agent 的升級永遠卡住,或被自動暫停。資安修正版可能因此推不出去 控制甲客戶一台 Agent;乙客戶同一版的推送還有後面的批次在等 app/product_update/service/agent_rollout_service.py:483 批次編號改在「看得到全部客戶」的身分下計算,或分批時把客戶別一起當分組條件 低
2 Agent 上傳升級紀錄時,沒有限制能傳幾次 每換一個工單編號就多存一份 10 MB 的檔案,最後把主機硬碟塞滿、服務停擺;存進去的紀錄刪不掉 控制一台 Agent app/product_update/service/agent_update_event_handlers.py:243(由 api/remote_agent/routes/agent_update_log_route.py:26 呼叫) 只收「這台真的被派過的工單」的紀錄,並加每台次數上限與總容量上限 低
3 Agent 主控台按「立即檢查」時,會拿到全機共用的上次檢查結果 任何一台 Agent 都看得到「哪個管理員帳號剛按過檢查」,以及所有客戶裡最舊的 Agent 版本 持有任一台 Agent 的通行證 core/plugins/_agent_console_update.py:57 回給 Agent 的內容另做一份精簡版,拿掉 by、agent_base/current、host_version 低

為什麼三條都是「低」:每一條都要先控制一台 Agent,而且影響只到「延誤升級、塞硬碟、洩漏一個帳號名稱」,不能拿到資料、也不能執行程式。

§4

每條詳述

1. 甲客戶的 Agent 會干擾乙客戶的分批升級

白話說明。 推送採「先推 1 台,成功了才推其餘」,靠批次編號決定誰等誰。Agent 主控台按「請更新這一台」時,程式用這台 Agent 所屬客戶的身分去查「這一版目前排到第幾批」。因為有 RLS(就是「每個客戶只能看自己資料」的資料庫隔離機制),它只看得到自己客戶的列,所以以為自己是第 2 批。可是每分鐘的排程用系統身分推進批次,看到的是全部客戶的列,按批次編號分組。結果甲客戶這台跟乙客戶的「先推的那 1 台」被算成同一批。甲客戶這台只要回報失敗,排程就把乙客戶第 3 批全部暫停;如果它一直不回報,乙客戶第 3 批就永遠等下去。

在哪裡。

  • agent_rollout_service.py:483(_rebatch 算 base)
  • agent_rollout_service.py:448-466(_advance_group 依 batch 分組)
  • core/plugins/_agent_console_update.py:90(tenant_context(agent.tenant_id))

怎麼修。 二選一:

  • _rebatch 的 base 改在系統身分下計算
  • _advance_group 的分組條件加上 tenant_id,或每次推送一個獨立 id

首腦核對。 我開檔追過:request_update 確實在 tenant_context 內呼叫 push/reapply,tick → _advance 是系統身分。成立。

2. 升級紀錄上傳沒有數量上限

白話說明。 Agent 每次升級完會上傳一份壓縮的紀錄檔(每份最多 10 MB)。工單編號是 Agent 自己帶的,主系統只檢查格式,不檢查「這張工單是不是真的派給過這台」。被控制的 Agent 每次換一個新編號,主系統就多存一份檔案、多一筆紀錄,而且紀錄表禁止刪除。

在哪裡。

  • agent_update_event_handlers.py:235-247(_save_log:同一張工單只擋重傳,不擋新編號)
  • 呼叫端:api/remote_agent/routes/agent_update_log_route.py:26

這支檔案不在本棒範圍內,因為範圍內的上傳端點會走到它才列入。

怎麼修。

  • _save_log 先確認該 job_id 已經有這台 Agent 的升級事件列,沒有就回 404,不要新建
  • 加每台每小時的上傳次數上限,以及每個客戶的附件總容量上限

首腦核對。 我開檔看過 _save_log:self._row(...) 遇到沒看過的編號會直接建新列。成立。

3. 「立即檢查」回給 Agent 的內容太多

白話說明。 Agent 主控台按「立即檢查」時,如果 5 分鐘內有人查過,就直接回上次的結果。這份結果是主系統更新中心用的,只拿掉了簽章原文,下列欄位都還在:

  • by:上次按檢查的管理員帳號
  • host_version:主系統版本
  • agent_base:所有客戶裡最舊的 Agent 版本

在更新中心,要有更新中心的讀取權限才看得到這份資料;在 Agent 這條路上,任何一台 Agent 都看得到。

在哪裡。

  • core/plugins/_agent_console_update.py:57
  • app/product_update/service/agent_release_service.py:484-494(public_check 只拿掉 signed_manifest)

怎麼修。 在 check_for_agent 回一份給 Agent 專用的欄位:status、latest、compatible、notes、at。

首腦核對。 我開過 public_check,確認只拿掉 signed_manifest。成立。

§5

「重點看什麼」逐條人工查證

  • 推送對象跨子公司會不會被擋(agent_rollout_service.py:_push):不成立。get_remote_agent_by_uid 在登入者身分(受 RLS)下查,別的客戶的 uid 查不到,直接列入 skipped。推送端點 ProductUpdateAgentPushRoute 有掛 @jwt_required() 加 @require_capability(CAP_UPDATE)(api/product_update/routes/product_update_route.py:258-259),也就是有檢查這個人有沒有更新權限。另外有發現:雖然推不到別人的 Agent,但批次編號會互相干擾,見第 1 條。
  • Agent 主控台能不能要求別台更新或降版(_agent_console_update.py):不成立。哪一台只認通行證(current_agent().agent_uid),不收 body 帶的編號。版本由主系統手上的包決定,Agent 不能指定。is_lagging 只推比目標舊的台,不會降版。同版重套要主系統開關打開,而且更新站上沒有更新的版。
  • 升級指令內容,以及下載端點會不會給待核准或停用的 Agent(_agent_update.py):不成立。心跳交給 Agent 的只有 target_version、bundle_sha256、size,沒有下載網址。下載端點有掛 agent_pass_required,路由檔註明撤銷、停用、待核准一律擋(api/remote_agent/routes/agent_update_bundle_route.py:3,17)。_tenant_of 也會排除 revoked。之後的 _dispatchable_bundle 要求這台要有「已下發」的推送列,下發前只放 active 的台(_take_woken、_push 都檢查 status == active)。
  • Agent 身分的端點是不是每支都掛 agent_pass_required:成立。agent_update_request_route.py:25,35,45 三支、agent_update_log_route.py:17 一支,全都有掛。
  • 升級紀錄附件下載時是不是強制下載(_agent_update_log_ownership.py):成立,會強制下載。檔名由系統產生,固定是 agent-update-<編號>.tar.gz;.tar.gz 不在可直接開啟的副檔名清單(pdf/html/svg/圖片)內。jedi-file-upload 的 decide_preview 對清單外的副檔名一律設成附件下載加 octet-stream,所以瀏覽器不會直接開啟。讀取要有 system.product-update.read 權限,寫入與刪除一律拒絕。
  • 回畫面的欄位有沒有帶 Agent 自報的錯誤字串、有沒有截長度(agent_rollout_views.py):部分成立。error 欄位確實是 Agent 自報的原文,但存進去之前已經在 agent_update_event_handlers.py:116 用 _text(..., _ERROR_MAX=500) 截到 500 字。這份資料用 JSON 回給前端,顯示時是否會被當成網頁內容執行,要看前端怎麼渲染,本棒沒有掃前端。
§6

可信度

  • 這三條存在嗎? 存在。三個獨立的檢查員各從「打不打得到」「影響多大」「有沒有其他防線」三個角度投票,三條都是 3 比 0 成立。首腦也逐條開檔核對過。
  • 只有這三條嗎? 不能保證。這次用最省的模式:只派 1 位研究員讀完整範圍,另派 1 位專找寫死在程式裡的密碼。這種模式適合快速篩一輪,不是逐行窮舉。另外,前端怎麼顯示 Agent 自報的錯誤字串,不在本棒範圍內。

全部結論都是讀程式碼得出的,沒有執行程式,也沒有實際攻擊驗證。

§7

執行概況

項目 值
工具 Claude Code claude-security plugin 0.12.0,effort low,著重攻擊面
run ID wf_2d63cdaa-128
掃描的版本 eee99445e,working tree 乾淨
範圍 7 檔/1181 行
研究員 派 2 位、回 2 位
候選發現 3 條
投票 三個檢查員對 3 條各投一票,9 票全數投出,沒有漏投
stamp verification.status verified(reason_kind 為空)
耗時 約 45 分鐘
原始產物 docs/features/security-scan-consolidated/runs/be/CLAUDE-SECURITY-20261108-180500/