FR-187 E 掃描報告:系統日誌線上查詢與日誌分租戶

§1

🟠 一句話結論

找到 1 個問題,嚴重度中。 F1:落地版裡,子公司的管理員可以從支援頁下載「除錯包」,裡面裝的是整台機器的應用程式日誌和錯誤追蹤事件,母公司與其他子公司的紀錄都在內。日誌查詢頁本身的租戶隔離是對的,漏在旁邊的除錯包。三位檢查員投票 3 比 0 成立,我也開檔核對過。

注意:F1 出事的檔 app/support/service/diag_bundle_export_service.py 不在本棒範圍內。問題是範圍內兩處合起來打開的:migration 把支援頁權限開放給租戶授予,log_viewer.py 在落地版又只檢查權限。工具順著這條路追出去,所以照常列入。

§2

這一棒在檢查什麼

FR-178 讓維運人員在畫面上查完整的系統日誌(主系統與 Agent 兩邊),同時讓日誌表分租戶,並開 RLS(就是每個客戶只能看自己資料的隔離機制)。本棒檢查四件事:

  • 查詢端點:誰能打、一次能撈多少、篩選條件有沒有 SQL 注入(把使用者輸入當成 SQL 指令執行);
  • 誰看得到哪些列:RLS 規則、「第一層租戶可以看歸 root 的列」的判斷、分區子表會不會繞過隔離;
  • 能力點:哪些權限改成客戶可以自己授予;
  • 遮罩:日誌裡的密碼、token 有沒有遮,CSV 匯出有沒有擋公式注入(Excel 把儲存格內容當公式執行)。

範圍共 12 檔、約 1110 行。為了查證,另外翻了範圍外的 diag_slice_domain_service.py(遮罩那一段)、diag_bundle_export_service.py、diag_bundle_app_service.py、diagnostic_bundle_route.py 和 jedi_common 的分頁上限。這幾支只為查證而讀,本身不另列發現。

§3

掃到什麼:總覽表

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 怎麼修 嚴重度
F1 落地版的除錯包只檢查「支援頁匯出」權限,不看呼叫者是不是總部;包裡的日誌沒有分租戶 子公司管理員拿到整台機器所有租戶的應用日誌、錯誤事件與環境資訊(已遮罩的設定檔) ①落地版(DEPLOYMENT_MODE=host,第一版預設)②機器上有子公司 ③攻擊者是子公司管理員,或被授予 platform.support.export app/support/service/diag_bundle_export_service.py:75(守門 common/authz/log_viewer.py:34-35;開放權限的是 scripts/sql/2026-11-06-fr178-log-caps-tenant-grantable.sql) 涵蓋整台機器的產物(除錯包、app.log 切片、錯誤事件、環境資訊)在落地版另外要求總部身分(require_tenant_hq());或者非總部呼叫時拿掉不受 RLS 管的那幾段 中

為什麼是中不是高: 得先是子公司管理員(一道真的關卡)。拿走的是日誌,看不到業務資料本身,也改不了東西;密碼、token 這類有固定格式的祕密已經先遮掉。但訊息內文、堆疊追蹤(程式出錯時印出的呼叫路徑)裡的業務識別碼跨了租戶邊界,正是 FR-178 想擋的事。

§4

每條發現的詳述

F1 — 子公司管理員下載得到整台機器的除錯包

白話: FR-178 讓日誌表分租戶,查詢頁只會顯示自己公司的列。同一次改版又把 platform.support.* 這組權限改成客戶可以自己授予(原本只有平台管理員有)。落地版的守門 require_log_viewer 只問「你有沒有這個權限」,不問「你是不是總部」。除錯包讀的是伺服器上的 app.log 檔案和錯誤追蹤站(Bugsink)的事件,這兩樣不在資料庫裡,RLS 管不到。結果是:查詢頁守住了,除錯包從旁邊漏出去。

出事會怎樣: 子公司(例如 /1/102/152/)的「系統管理員」角色帶出廠的 *.*.* 權限樣式,這次改版後就自動包含 platform.support.export。他打 POST /api/1.0/support/diagnostic-bundle、時間範圍拉 30 天,就拿到整台機器的 logs/app.log 切片(所有租戶的請求、使用者編號、錯誤訊息),加上總部和其他子公司的錯誤事件。

要先有什麼才打得到: 落地版、機器上有子公司(授權照預設最多 5 個)、攻擊者是子公司管理員,或被授予 platform.support.export。

在哪裡:

  • app/support/service/diag_bundle_export_service.py:75:require_log_viewer("platform.support.export") 是唯一一道守門;
  • api/support/routes/diagnostic_bundle_route.py:38:route 層也只掛 @require_capability("platform.support.export");
  • common/authz/log_viewer.py:34-35:落地版只呼叫 assert_capability;
  • scripts/sql/2026-11-06-fr178-log-caps-tenant-grantable.sql:10 個能力點改成 is_platform = false;
  • app/support/service/diag_bundle_app_service.py:115、258、321:包進環境資訊、log 檔切片、錯誤事件,三段都沒有租戶條件。

怎麼修: 除錯包和其他涵蓋整台機器的產物,在落地版加一道 require_tenant_hq()(專案裡「誰算總部」唯一的判斷函式)。或者非總部呼叫時,只留下經資料庫 RLS 過濾的那幾份 CSV。日誌查詢頁本身走 RLS,維持現在的守門即可。

runner 開檔核對: 成立。 守門那幾行我都開檔看過:diag_bundle_export_service.py:75、route :38、log_viewer.py:34-35,與描述一致。diag_bundle_app_service.py 的 log 切片(:258)和錯誤事件(:321)呼叫都沒有帶任何租戶參數。「子公司管理員角色帶 *.*.*」這一點我沒有另外開角色檔查,引用的是檢查員的查證結果(resources/roles/shipped-roles.json:9)。

§5

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

  1. log_viewer.py 的「第一層」判斷(:28-29,any + 段數 == 2):人工查過,不成立。 段數 == 2 就是 /1/<客戶>/,也就是客戶總部本身。同一個人同時掛在子公司和總部單位,本來就是總部的人,讓他看歸 root 的列符合設計(落地版一機一客戶)。工具也提出了這一條,面板以 2 比 1 判不成立:客戶管理員建出來的租戶一定是第三層,只有平台端才能建第二層租戶。SaaS 一律回 False。殘留提醒:如果有一天一台落地機上出現兩個第二層租戶,這個判斷就會讓兩邊互看 root 列,屬設計前提,不是現在的漏洞。
  2. 分區子表繞過 RLS/每月新分區:人工查過,不成立。 maintain_log_partitions() 每次執行都依序做三件事:①建本月到下兩個月的分區 ②刪過期分區 ③把兩張母表底下 cm_app 仍有權限的子分區全部 REVOKE。步驟③在步驟①之後,新分區建好的同一次執行就收掉權限。既有分區(含 _default 和 *_old 舊表)由 migration 的 DO 區塊一次收掉。殘留:全新安裝的 _default 是靠 99-stamp.sql 呼叫這支函式收掉,99-stamp.sql 不在範圍內,我沒有開檔驗。
  3. 能力點開放租戶授予(兩支 migration):人工查過,成立,就是 F1 的成因。 caps-tenant-grantable 開放 10 個能力點,含 platform.support.export 和 platform.api-log.*。走資料庫的日誌查詢有 RLS 兜底,不受影響;走檔案的除錯包沒有,所以出了 F1。support-route-own-capability 只改選單綁定(ALL=read、ANY=export),本身沒問題。
  4. _system_log_where 的 f-string 與分頁:人工查過,SQL 注入不成立。 拼進 SQL 的片段全是本檔常數(_NOT_AUDIT、_FROM_AGENT、固定欄名),值一律走綁定參數;keyword 和 agent_uid 都先跳脫 LIKE 萬用字元,agent_uid 另外有正規表示式限制格式。page_size 由 RequestMetaSchema 限在 1~1000。page 沒有上限,給一個很大的頁碼,資料庫就得先掃過整段 OFFSET。不過只有具日誌權限的人打得到,日誌也有保存期限(system_logs 180 天)。屬低風險觀察,不另列發現。
  5. 遮罩:人工查過,部分成立(低,未經面板投票)。 system_logs 的查詢畫面和 CSV 只遮 message(MASKED_SYSTEM_LOG_COLUMNS = ("message",));path 和 user_name 原樣回傳。相比之下,api_logs 有遮 url。如果 system_logs.path 存的是含查詢字串的完整網址,帶 token 的查詢參數就會原樣出現在畫面上;我沒查寫入端存的是不是完整網址,建議另開一張小卡確認。CSV 匯出開了 excel_safe,每一欄都過 neutralize_formula,公式注入有擋。
  6. Agent 日誌:人工查過,不成立。 agent_uid 先過格式檢查,verify_remote_agent_is_exist 是在呼叫者的交易裡查,受 RLS 管,其他租戶的 Agent 查不到,會回 404(前提是 remote_agents 表有 RLS,該表不在範圍內)。Agent 回來的每一列只取白名單欄位,message 再用主系統規則遮一次,以 JSON 回應。畫面渲染的跳脫在前端,不在本棒範圍。
§6

可信度兩層

  • 「這幾條存在嗎」:F1 經三位檢查員(分別從「打不打得到」「影響多大」「有沒有防線擋」三個角度看)3 比 0 成立,我也開檔核對過守門那幾行,可信。第 5 點的遮罩缺口是人工觀察,沒經過投票。
  • 「只有這幾條嗎」:不能保證。這是 effort low 的掃描,只派一位研究員讀完全部範圍,沒有拆組件、也沒有多輪掃描;工具的結果每次跑都可能不同。範圍外的檔(除錯包、遮罩、remote_agents 的 RLS、99-stamp.sql)只為查證讀了幾行,沒有完整掃過。
§7

執行概況

  • run ID:wf_f15a7146-db9;報告目錄 CLAUDE-SECURITY-20261108-191334;掃描 commit 1c9be8d84,開工時工作目錄乾淨(dirty: false)
  • stamp verification:status: verified、reason_kind: null、candidates: 2、panel_votes: 6、researchers_dispatched: 2、researchers_returned: 2
  • 面板完整跑完:2 條候選,各由三位檢查員投一票,共 6 票全部投出。F1 3 比 0 成立;另一條(第一層判斷)1 比 2 判不成立。
  • 耗時約 79 分鐘,8 個 agent 全部完成、0 個失敗。