# W5 掃描報告 — 日誌的接線本體（CM-2094）

> 範圍：5 檔／953 行（`core/plugins/api_log.py`、`di_containers/log/apilog_containers.py`、`common/middleware/app_mw.py`、`infra/support/diag_masking.py`、`infra/identity/user_name_resolver.py`）。`main.py` 已由首腦裁定拿掉，只自己開檔看了 `main.py:230-250`。
> 掃描工具：Claude Code 官方 `claude-security` plugin，effort low，focus 生產程式碼。
> 掃描基準 commit：`aa23a63554ce`（工作區有其他 session 的未提交改動）。
> 驗證章：**verified**。只掃不修。

---

## 1. 一句話結論

**工具零發現**：它提出 1 條候選，三位檢查員一致否決。**runner 自己開檔追到 3 條新問題，都不在總表裡**：

1. 任何人**不必登入**，送出特製的請求內容，就能讓伺服器的「遮密碼」那一步算很久。幾個這種請求同時送，整個產品對所有客戶就停止回應。
2. 已登入的使用者只要把網址加長，**這次操作就不會出現在操作日誌裡**。
3. 操作日誌的「來源 IP」欄位**永遠記到的是前端代理伺服器的位址**，追查時找不到真正是誰。

卡片點名要回答的兩個問題：

- **「一個客戶的管理員能把全公司日誌改送到自己機器」是套件設計的問題，還是宿主接錯？**
  **根源在套件**：它把一份全系統只有一份的設定，宣告成「客戶層級」的權限。宿主照套件的要求接線，但也沒補一道門。**另外，總表第 75 項（M09-1）已拍板的修法擋不住它**，見 4.4。
- **多工人模式下，改了轉送目的地之後，其他工人會不會還往舊的地方送？**
  **會，但最多 30 秒**，而且這個延遲是設計好、有對外講明的。**不是漏洞**，見 5.1。

---

## 2. 這一棒在檢查什麼

日誌套件（`jedi-log`，程式裡叫 `jedi_api_log`）管兩件事：

- **操作日誌**：每個請求都會被記一筆，只有平台管理員看得到。
- **日誌轉送**：把系統日誌即時送到客戶自己的監控伺服器。

這一棒檢查的是**主專案怎麼把這個套件接上產品**：

- 每個請求進來時，攔截器（`app_mw.py`）怎麼寫操作日誌。
- 套件的兩半各自由誰守門。
- 回廠診斷包怎麼遮密碼（`diag_masking.py`）。
- 「帳號換暱稱」這個共用零件（`user_name_resolver.py`）。

---

## 3. 掃到什麼：總覽

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度 |
|---|---|---|---|---|---|
| **W5-1** | 每個請求進來、**還沒檢查身分之前**，系統就會把請求內容拿去「遮密碼」。遮密碼的那段比對規則，碰到特製內容時，**耗時會隨長度的平方增加** | 不必帳號。一個 128KB 的請求在開發環境實測卡住 6.4 秒；內容越長越久，平方增加。後端只有 4 個工人在接請求，**同時送幾個就全部卡住，所有客戶都打不開系統** | 什麼都不用。能連到網站就行，不必登入 | `common/middleware/app_mw.py:160-169`（`before_request`，遮罩前沒有限制長度）<br>`jedi_common/utils/common_utils.py:145`（`mark_password`，比對規則本身） | **中**（理由見 4.1，建議首腦考慮升高） |
| **W5-2** | 網址超過 500 字時，寫操作日誌會失敗。**失敗被吞掉，請求照常執行** | 已登入的人在任何操作的網址後面多加一段沒用的參數，**這次操作就不會出現在操作日誌裡**。刪資料、改設定都一樣。管理員事後查操作日誌，會以為沒發生過 | 一個登入帳號（未登入的請求一樣不會被記，但它們本來就做不了什麼） | `common/middleware/app_mw.py:160-197`（`before_request` 寫入前沒有截斷超長欄位，而且失敗只記一行錯誤） | **中**（理由見 4.2） |
| **W5-3** | 操作日誌的「來源 IP」取的是**直接連進後端的那一台**，也就是前端代理伺服器，而不是使用者的電腦 | 所有操作日誌的來源 IP 都一樣。**事後追查「這個操作是從哪台電腦來的」完全查不出來** | 不需要攻擊，**現在就是這樣**：STG 實查 22.8 萬筆全部是 `127.0.0.1` 或 `172.24.0.7` | `common/middleware/app_mw.py:189`（`source_ip=request.remote_addr`） | **低**（理由見 4.3） |

**以上三條全部是 runner 自行開檔核對的，沒有經過三人面板投票。** 工具提出的那一條，已被面板否決（見第 6 節）。

---

## 4. 每條發現的詳述

### 4.1 W5-1 不必登入，就能用特製的請求內容卡住整個產品

**白話說明**

系統要把每個請求記進操作日誌，而請求內容裡可能有密碼，所以記之前會先跑一次「遮密碼」：找出像 `"password":"..."` 這種寫法，把值換成 `***`。這一步寫在攔截器裡，**所有請求都會經過，而且在檢查登入之前就跑**（`app_mw.py:169` 印到檔案一次，`:174` 寫進資料庫又一次，**每個請求跑兩次**）。

問題出在比對規則的寫法。某種特殊形狀的內容會讓它**從每個位置都往後掃到底**，所以工作量是長度的平方。本報告刻意不寫出那個形狀。

**實測（只在開發環境，全程唯讀的量測）**

| 內容長度 | 目前出貨的比對規則 | 修正分支上的新規則（CM-2065） |
|---|---|---|
| 32KB | 0.2 秒 | 1.6 秒 |
| 64KB | 0.8 秒 | 6.5 秒 |
| 128KB | 3.2 秒 | 26.8 秒 |
| 正常的 200KB 請求（對照組） | 0.01 秒 | 0.01 秒 |

我對開發環境的後端送了 3 個**不存在的網址**（不會觸發任何業務動作）：

- 正常內容：0.01 秒。
- 32KB 特製內容：0.4 秒。
- 128KB 特製內容：**6.4 秒**。

這個後端 9/20 就啟動了，跑的是舊規則，所以 6.4 秒 ≈ 3.2 秒 × 2 次，對得上。

**為什麼擋不住**

- 全站請求上限是 50MB（`core/app_factory.py:104`）；前端代理的上限設成不限（`nginx.onprem.conf:83`，`client_max_body_size 0`）。**卡 6 秒只需要 128KB**，遠低於任何上限。
- 攔截器沒有「太長就不遮、直接截斷」的保護。

**⚠️ 要特別告訴首腦的一點**

總表第 82 項（M04-3「密碼有雙引號只遮一半」）的修正卡 **CM-2065 已經 Done**，改在套件的修正分支上（`fix/security-b1`，commit `a9562dd0`）。它把比對規則改得更嚴，**同時讓這個問題惡化約 8 倍**。現在出貨的舊版本身就有這個問題；那個修正一旦發版，會更嚴重。**建議在 CM-2065 發版前一起處理**，不然等於用一個洞換另一個更大的洞。

**為什麼判「中」而不是「高」**

它只影響「能不能用」，**拿不到任何資料**，攻擊一停，系統就恢復。而且 gunicorn 的 120 秒逾時會砍掉卡太久的工人。

但它是目前總表所有「讓全產品停止回應」的問題裡，**門檻最低的一條**：同類的第 83 項、第 94 項都要先登入，這條不用。**建議首腦考慮升為高。**

**怎麼修（方向，不是實作）**

- 套件那邊（根因）：把比對規則改成線性時間的寫法。
- 宿主這邊（第二道）：攔截器遮罩前先限制長度，超過的部分直接截掉、標記「已截斷」。
- **修完要重測上表同一組長度**，確認時間跟長度成正比。

---

### 4.2 W5-2 網址加長，這次操作就不會進操作日誌

**白話說明**

操作日誌那張表（`api_logs`）的「網址」欄位最多 500 字（套件建表腳本 `001-api-log-tables.sql:30`）。攔截器把完整網址（含問號後面的參數）直接塞進去，**沒有先截斷**。超過 500 字，資料庫就拒絕寫入。

攔截器的設計是「寫日誌失敗不可拖垮業務」（`app_mw.py:171-172` 的註解）：失敗了就只記一行錯誤，**請求本身照常執行**。之後回填結果的那兩段（`:216`、`:257`），看到沒有日誌編號也直接跳過。

**場景**

一位客戶的管理員要刪一批資料，但不想留下紀錄。他在網址後面加一段沒用的參數，讓網址超過 500 字（系統會忽略認不得的參數）。刪除照常成功，**操作日誌裡找不到這筆**。平台管理員事後查，只會看到空白。

**實查**

在開發環境用一個**立即回滾**的交易，寫入一筆 501 字的網址，資料庫回 `value too long for type character varying(500)`。確認欄位上限屬實，實際沒有寫入任何資料。

**沒有端到端實測**：沒有真的送一個超長網址的業務請求，再去確認日誌缺那一筆。這一段是讀程式推論的。

**為什麼判「中」**

它破壞的是**稽核紀錄的完整性**，而這正是操作日誌存在的理由。

它不是高，因為：

- 要先有帳號，而且只能藏自己的操作。
- 系統另一條稽核管道（`jedi_common` 的稽核事件、`system_logs`）是分開寫的，**不受影響**。有些重要動作兩邊都有紀錄。

**怎麼修（方向）**

- 寫入前把所有有長度上限的欄位（網址 500、來源／伺服器 IP 255、瀏覽器資訊 5000）截到上限。
- 寫入失敗時至少留一筆「日誌寫入失敗」的替代紀錄，不能只印到檔案。

---

### 4.3 W5-3 操作日誌的來源 IP 全部是代理伺服器

**白話說明**

使用者連到網站時，先經過前端的 nginx，再由 nginx 轉給後端。nginx 有把使用者真正的 IP 放進兩個標頭（`nginx.onprem.conf:95-96`）。但攔截器取的是「直接連進來的那一台」（`request.remote_addr`），也就是 nginx 自己。

**實查**：

- STG（安裝版）的操作日誌共 22.8 萬筆，**全部**是 `127.0.0.1`（20.4 萬）或 `172.24.0.7`（2.5 萬，nginx 容器）。
- 開發環境 178 筆全部是 `127.0.0.1`。

**為什麼只判「低」**

這不是攻擊者造成的，也不外洩任何東西。它讓稽核少了一個追查依據，出事時要花更多力氣對照 nginx 自己的存取紀錄。

**⚠️ 修的時候要注意**：不能直接改讀那兩個標頭。如果後端沒有確認「這個標頭是 nginx 加的」，任何人都能自己帶一個假的 IP 進來，那比現在更糟。正確做法是讓後端只信任 nginx 那一跳（例如 Flask 的 ProxyFix 設定成信任 1 層）。同樣是讀這個值，專案裡有兩處寫法不一：

- `common/integrity/adapters.py:268-272` 直接讀標頭。
- `jedi_iam/api/routes/login_route.py:26` 也直接讀標頭。

**建議修正時一起統一。**

---

### 4.4 卡片點名問題①：「客戶管理員能改全公司日誌去向」是誰的錯？

**宿主側的答案：根源在套件的能力點宣告，宿主照單接線，沒有多補一道門。**

**證據鏈**（全部開檔核對）：

| 層 | 看到什麼 | 位置 |
|---|---|---|
| 套件的資料表 | 自己寫明「這是平台層設定表（一套系統一份）」，只有全域那一列 | `jedi_api_log/forwarding/infra/model/log_forwarding_setting.py:24-26` |
| 套件的讀寫 | 讀的時候永遠只撈全域那一列 | `forwarder.py:355` → `settings_reader.py:99` |
| 套件的能力點宣告 | `log-forwarding.read`／`.update` 兩個都**沒有**設平台層旗標，預設就是「客戶層級」 | `jedi_api_log/forwarding/plugin/contract.py:33-36`；旗標欄位的定義在 `jedi_common/plugin/capability.py:52` |
| 宿主接線 | 守門只有「登入 ＋ 持有能力點」，沒有「必須是平台管理員」 | `core/plugins/api_log.py:268-274`（`_forwarding_guard`）、`:288-289` |
| 宿主出貨 seed | 兩個能力點的平台層旗標都寫成 `'f'`，預設發給 Administrator 角色 | `scripts/init/04-seed-core.sql:207-208`、`:381-382` |
| 開新客戶 | 把所有「非平台層」的能力點發給新客戶的管理員 | `jedi_iam/app/service/tenant_provisioning_service.py:131` |

**同一支接線檔的另一半（操作日誌）用的就是平台管理員那一軸**（`api_log.py:285`）。檔頭說「兩半的守門刻意不共用」，理由是「讀寫分權是產品的決定」（`:38`）。但**讀寫分權**和**平台層還是客戶層**是兩件事：前一件是對的，後一件被順帶決定成「客戶層」，沒有人檢查它跟資料表的設計對不對得上。

**所以這是「套件以為宿主守、宿主以為套件守」的一個實例**：

- 套件的路由註解寫「守門由宿主注入，授權模型是產品決定的」（`log_forwarding_route.py:9-11`）。
- 宿主的接線註解寫「授權模型是產品決定的」，然後照套件宣告的能力點接上去，**沒有對照套件資料表是全域的這件事**。

**🔴 比這更重要的：M09-1 已拍板的修法關不掉它**

總表第 75 項（M09-1）已拍板的修法是：「只開放給客戶自己組織樹第一層（總部）的管理員改；客戶自己決定日誌送去哪，這個自主權完全保留。」

但照目前的程式，**這個「客戶自主權」根本不存在**：

- 設定表只有一列全域設定，讀取端永遠讀全域那列。
- 轉發鏈一個程序只有一條（`forwarder.py` 檔頭「模組級單例是刻意的」），它掛在**整個程序共用的日誌樹**上，所有客戶的日誌都走同一條。

**所以就算只開放給「某家客戶的總部管理員」，他一改，照樣是全公司所有客戶的日誌一起改送到他的機器。** 只把門檻從「任何一層管理員」拉到「總部管理員」，洞還在，只是能動手的人少了。

**真正能關掉它的只有兩條路，要請首腦選：**

- **甲**：這個設定改成只有平台管理員能改（把能力點改成平台層，或接線改用平台管理員那一軸）。客戶不能自己指定日誌去向。**改動小**：宿主一處接線，或套件宣告加一個旗標，再加一支 migration 修正既有的權限發放。
- **乙**：真的做成「每家客戶各自設定、各自轉送」。這要套件重新設計轉發鏈（目前一個程序只有一條、不分客戶），**是一個新功能，不是修正**。

**建議先做甲把洞關掉；乙若是產品需要，另開需求。** M09-1 目前寫的修法需要改寫。

---

## 5. 排除也是結論（查了、不成立，下次不用重查）

### 5.1 卡片問題②：多工人模式下，舊目的地還會送多久？

**最多 30 秒，是設計好的，不是漏洞。**

- 每個工人啟動後都會重建自己的轉發鏈（`main.py:241-247` 呼叫 `restart_after_fork()`，實作在 `forwarder.py:460-484`）。每個工人各有一條「每 30 秒回資料庫看一次設定有沒有變」的背景執行緒（`forwarder.py:431-437`；週期由 `LOG_FORWARDING_WATCH_INTERVAL` 設定，`api_log.py:147`）。
- 管理員按儲存時，**接到那個請求的工人立刻切換**（`log_forwarding_app_service.py:94`）；其他工人在下一輪（最多 30 秒）內跟上。
- 存檔 API 的回應裡會明示這個延遲（`applies_within_seconds`，`:99`），沒有假裝是瞬間生效。

**兩個小注意（不計發現）：**

- 這個週期可以用環境變數調大。調成幾小時，舊目的地就會多收幾小時。**建議部署文件註明不要調大。**
- 讀設定失敗時，套件的做法是「這一輪不轉發」（`settings_reader.py:102-105`）。資料庫短暫斷線時轉發會安靜停掉。這是「少送」不是「送錯地方」，安全面不算問題，但會讓客戶的監控系統漏收。

### 5.2 兩張日誌表的保存期限：宿主側已接上

總表 M09-5 登記的「保存期限沒生效」，根因是維護函式沒有人呼叫。**宿主現在有呼叫它**：`core/scheduler.py:486-517` 每天 03:30 跑 `maintain_log_partitions()`，2026-09-18 加的（commit `68d35e602`，CM-1923），並以系統身分執行，解決先前的權限問題。總表已標「已修」，**本棒確認屬實**。

### 5.3 回廠診斷包的遮罩（`diag_masking.py`）

四條路徑共用同一份鍵名規則，另外補了 DSN、cookie、以 key 結尾的鍵名，註解掉的設定行也會遮。**沒有發現漏遮的機密鍵名。**

唯一的縫是**網址參數**（`key=value&` 的寫法）：它不在任何一條路徑裡。三位檢查員之一也指出，`diag_db_reader.py:237` 用 JSON 寫法的規則去遮操作日誌的參數欄，會漏掉網址參數。但目前網址參數裡只有兩種憑證，都在寫進日誌前就失效了：120 秒的下載簽章、用過即丟的 Google 授權碼（見第 6 節）。**不計發現**，建議修 W5-2 時順手讓網址參數也走遮罩。

### 5.4 帳號換暱稱零件（`user_name_resolver.py`）

它透過 `@transaction` 開 session，查詢會受資料庫的租戶隔離規則約束。看不到的帳號只會顯示成沒暱稱，**不會把別家客戶的暱稱查出來**。讀程式確認，**沒有另外做跨客戶的實測**。

### 5.5 請求標頭的遮罩（`app_mw.py:78-95`）

用白名單：名單外的標頭一律遮成 `***`，Authorization、Cookie 都不在名單內。**這是 FR-085 C2-1 的修正結果，確認有效**。

---

## 6. 工具那一條：為什麼被否決

**場景**：使用者下載檔案時，網址會帶一個簽章（`?st=...`）。攔截器把完整網址參數原樣寫進操作日誌（`app_mw.py:183`），沒有像請求內容那樣先遮。

**三位檢查員 3:0 否決，理由一致**：

- 網址參數裡的憑證只有兩種：下載簽章（`jedi_iam/authz/signed_token.py:43`，**120 秒過期**），和 Google 雲端硬碟的授權碼（同一個請求裡就換掉了，而且換的時候需要伺服器的密鑰）。寫進日誌的時候，這兩個都已經沒用了。
- 操作日誌只有平台管理員看得到（`api_log.py:285`）。
- 登入憑證只從標頭讀，不從網址讀。

**我同意否決。** 它確實是「沒遮」，但遮不遮都不影響安全。

---

## 7. 開工前四件事的結果

| 項目 | 結果 |
|---|---|
| ① 守門次數 vs 對外方法數 | `api_log.py`：5 支模組函式，守門全在 `build_adapters` 裡交給套件（操作日誌→平台管理員；轉發→能力點讀寫分權）。套件那邊的路由表（`routing.py`）逐個 HTTP 方法宣告守門種類，漏寫會直接報錯（`assembly.py:102`），**不會有漏掛的端點**。問題不在「沒守」，在**守錯層級**（4.4）|
| ② 五份接線哪一份漏了 | 本支是五份裡唯一「兩半用不同軸」的。操作日誌那半跟其他接線一樣用平台管理員，轉發那半沒有 |
| ③ 套件以為宿主守、宿主以為套件守 | **命中一處**：轉發設定的層級（4.4） |
| ④ AI 儀表板那條路 | 本批沒有申報給 AI 儀表板的查詢，不適用 |

`app_mw.py` 的三個攔截器**本來就不該守門**：它們在所有請求上跑，包括登入前的請求，職責只是記紀錄。問題不在沒守門，在於**守門之前做的事太多、太貴**（W5-1），而且**寫失敗時放掉紀錄**（W5-2）。

---

## 8. 這份結果可信到什麼程度

**「這三條存在嗎」，W5-1 最可信，W5-2 次之，W5-3 是實查的現況。**

- **W5-1**：比對耗時是實際跑出來的。開發環境的 3 個請求，是打**不存在的網址**、量回應時間，沒有觸發任何業務動作。這 3 筆留在開發環境的操作日誌裡（id 423959～423961），無害。**沒有測過「同時送幾個會不會讓整個產品停止回應」**，那一段是依工人數推論的。
- **W5-2**：欄位上限用立即回滾的交易確認過；「請求照常執行、日誌缺那一筆」是讀程式推論的，**沒有端到端實測**。
- **W5-3**：STG 是**唯讀查詢**（SELECT），沒有改任何東西。
- 卡片問題①②：全部開檔核對，引述都附行號。

**「只有這幾條嗎」，不保證。** 這次用的是最快的檔位（effort low），而且這三條都是工具沒報、runner 自己追出來的。這再一次證明：**卡片的重點要有人逐項回頭核，不能指望工具照著走**。

---

## 9. 執行概況（數字，給工程師看）

| 項目 | 數值 |
|---|---|
| 掃描範圍 | 5 檔／953 行 |
| 基準 commit | `aa23a63554ce`（工作區 dirty，有平行 session 改動） |
| 檔位 | effort low，focus 生產程式碼（因此多跑一輪密鑰專項） |
| 研究員 | 派 2 支（研究 1＋密鑰專項 1），回 2 支 |
| 原始候選 | 1 條，去重後 1 條 |
| 投票 | 三位檢查員 × 1 條 = 3 票，全數投出 |
| 票型 | C1 0:3 否決 |
| 密鑰專項 | 零發現 |
| 驗證章 | **verified**（`CLAUDE-SECURITY-REVISION-aa23a63554ce-dirty.json`） |
| 工具 run ID | `wf_841f2526-25c` |
| 耗時 | 約 51 分鐘（5 個 agent，零失敗） |
| 工具產出原始報告 | `CLAUDE-SECURITY-20260923-130653/`（未入版控） |

---

## 10. 待首腦裁決

1. **W5-1（不必登入就能卡住整個產品）要不要登記成新項次、要不要升為高？** 另外要決定：**CM-2065 發版前要不要先處理它？** 那個修正讓這個問題惡化約 8 倍。
2. **W5-2（網址加長就不進操作日誌）要不要登記成新項次？** 建議同一張卡把所有有上限的欄位一起截斷。
3. **W5-3（來源 IP 全是代理伺服器）要不要登記？** 修的時候要把專案裡三處讀 IP 的寫法一起統一，否則會變成可偽造。
4. **🔴 M09-1（總表第 75 項）的修法需要重新裁決。** 已拍板的「只開放給總部管理員」擋不住跨客戶改送，因為設定和轉發鏈都是全系統一份。請在甲（只給平台管理員，改動小）與乙（做成每家客戶各自轉送，是新功能）之間選一個，**建議先甲**。

---

*沿革見 FR-115 LOG 與 git log。*
