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

## 🟠 一句話結論

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

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

## 這一棒在檢查什麼

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` 的分頁上限。這幾支只為查證而讀，本身不另列發現。

## 掃到什麼：總覽表

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 | 嚴重度 |
|---|---|---|---|---|---|---|
| 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 想擋的事。

## 每條發現的詳述

### 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`）。

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

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 回應。畫面渲染的跳脫在前端，不在本棒範圍。

## 可信度兩層

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

## 執行概況

- 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 個失敗。
