---
title: FR-085.C2 掃描報告：log 全鏈（寫什麼、寫到哪、身分怎麼標）
---

# FR-085.C2 掃描報告：log 全鏈（寫什麼、寫到哪、身分怎麼標）

- **卡片**：[CM-1648](https://app.notion.com/p/FR-085-C2-log-32-jedi-monorepo-Opus-1M-low-3d7346da4cd081e08d19f597d5d20032)（母卡 [CM-1646](https://app.notion.com/p/FR-085-jedi-common-3d7346da4cd081f18862dd193cf355c1)）
- **掃描範圍**：`jedi-common` 套件的 `jedi_common/logger/`，共 32 個受版控檔
- **版本**：commit `8aa6f060`（jedi monorepo，branch `feature/FR-075`）
- **日期**：2026-09-10
- **工具產物**：`jedi-common/CLAUDE-SECURITY-20260910-141953/`（不入版控）

---

## 1. 🔴 一句話結論

**使用者的登入密碼、以及每一個請求的登入憑證（JWT），目前是原文寫進伺服器 log 檔與資料庫的——而且產品自己另一條 log 路徑明明有遮罩功能，就是這一條沒用上。**

工具這一輪的正式發現是 **0 條**（四個候選全被三位檢查員一致否決，否決理由我核對後都同意）。**但底下最嚴重的那條工具完全沒碰到**——它需要同時看套件與主產品兩邊才看得出來，而工具這一輪只掃套件。

一個好消息：卡片原本認為「最重要」的那條（**log 上的操作者可以被偽造**）**不成立**——那段程式碼從頭到尾沒有被啟用過，是死碼。

---

## 2. 這一棒在檢查什麼（白話）

`jedi-common` 的 `logger/` 是全站 log 的總開關。它決定三件事：

1. **每一行 log 長什麼樣**、上面標的「這是誰做的」從哪來
2. **log 寫到哪裡**——螢幕、檔案（`log/app.log`）、資料庫的 `system_logs` 表、以及一個遠端監控服務
3. **哪些程式的 log 會被寫進資料庫**

這一棒要回答三個問題：

- **會不會把不該寫的東西寫出去？**（密碼、憑證、完整網址、完整錯誤堆疊）
- **log 上標的「操作者」能不能被偽造？**（能的話稽核紀錄就沒有意義）
- **寫進資料庫的那份，有沒有做到「每個客戶只看得到自己的」？**

**只找問題、不修問題。** 底下所有「怎麼修」都只是建議，沒有動任何一行程式碼。

---

## 3. 掃到什麼：總覽表

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 嚴重度 |
|---|---|---|---|---|---|
| **C2-1** | **每個請求的完整標頭與內容原文寫進 log**，包含登入密碼與登入憑證 | 拿得到 log 檔或資料庫的人，可直接取得使用者密碼與可冒用的登入憑證 | 能讀 `log/app.log`（主機上的檔案）或能查 `system_logs` 表 | `common/middleware/app_mw.py:45-46`（主產品） | **HIGH** |
| **C2-2** | **`system_logs` 表完全沒有客戶隔離**，且沒有可用來隔離的欄位 | 任何連得到資料庫的帳號都看得到**全部客戶**的 log；目前該表有 66 萬筆 | 能用 `cm_app` 或更高權限連資料庫 | DEV 實查：RLS 關閉、0 條規則 | **MEDIUM** |
| **C2-3** | 完整錯誤堆疊寫進上面那張沒有隔離的表 | 堆疊會夾帶內部路徑、SQL 片段，與 C2-1 疊加時夾帶憑證 | 同 C2-2 | `db_log/db_handler.py:15-16` | **MEDIUM** |
| **C2-4** | **寫 log 到資料庫失敗會把原本的請求一起弄壞**，且能否運作取決於 handler 的排列順序 | log 系統的故障變成業務 API 的故障（500） | 資料庫暫時寫不進去，或有人調整了 handler 順序 | `db_log/db_handler.py:13-30` | **MEDIUM** |
| **C2-5** | 出貨設定漏了一個環境變數，**正式環境實際套用的是開發用的 log 設定** | 上面每一條的影響範圍從「開發機」擴大到**客戶的正式機** | 就是出貨預設值，不需任何條件 | `docker/production/guidant.env`（缺 `RUN_ENV`） | **MEDIUM**（放大器） |
| **C2-6** | 遠端監控連線在 import 時就啟動、位址寫死、不加密 | 沒有人接收時靜默重試；有人接收時追蹤資料明文外送 | 無（開機就執行） | `custom_formatter.py:27`、`:35` | **LOW** |
| **C2-7** | 正式與測試環境把兩個 logger 設成最詳細等級 | log 量放大，且 SQL 層 log 可能帶查詢內容 | 就是出貨預設值 | `config_prod.py:57`、`:82` | **LOW** |
| **C2-8** | **log 上的操作者取自請求標頭、可被偽造**（卡片認定「本棒最重要」） | **不成立——這段程式碼從未被啟用**，見第 5 節① | — | `middleware.py:13` | **無（死碼）** |

**C2-1 是本棒唯一的 HIGH，也是唯一需要盡快處理的。** C2-2／C2-3／C2-5 是它的放大器：沒有隔離的表 ＋ 正式機套用開發設定，讓 C2-1 的影響從「開發機的檔案」變成「客戶正式機的資料庫與檔案」。

---

## 4. 每條發現的詳述

### C2-1（HIGH）每個請求的完整標頭與內容原文寫進 log——包含密碼與登入憑證

**（工具未報，runner 人工查證）**

**這是什麼問題（白話）**

主產品在每個請求進來時，會寫三行 log。其中兩行是這樣：

```python
logger.info(f"Request Headers: {request.headers}")   # 第 45 行
logger.info(f"Request Body: {request.data}")         # 第 46 行
```

- **第 45 行**把**所有請求標頭原封不動印出來**。使用者登入後，每個請求都會帶一個 `Authorization: Bearer <一長串憑證>` 標頭——**那串憑證等於通行證，拿到就能冒充該使用者**，不需要知道密碼。
- **第 46 行**把**請求內容原封不動印出來**。登入請求的內容就是 `{"login_name": "...", "password": "..."}`——**使用者的密碼，原文**。

**真正讓這件事嚴重的是對比**：**產品有遮罩功能，而且就在同一個檔案裡用著。**

同一支 `app_mw.py`，往下 5 行的 `:51` 就是 `req_data = mark_password(request.data.decode())`——寫進另一張表（`api_logs`）之前**有先遮罩**。遮罩函式 `mark_password`（`jedi_common/utils/common_utils.py:150`）會把 `password`、`token`、`secret`、`private_key` 這類欄位的值換成 `***`。

**所以同一個請求的同一份內容，在同一支程式裡走了兩條路**：

| 路徑 | 有沒有遮罩 | 寫到哪 |
|---|---|---|
| `:51` → `api_logs` 表 | ✅ **有**（`mark_password`） | 資料庫 `api_logs` |
| `:45-46` → `logger.info()` | ❌ **沒有** | 螢幕 ＋ `log/app.log` 檔 ＋ `system_logs` 表 |

**這回答了卡片重點⑧**：共用地基層**有**遮罩工具，但 **logger 這一整條鏈從頭到尾沒有任何一處呼叫它**（全樹 grep `mark_password` 確認：只有 `app_mw.py` 兩處與測試檔用到，`jedi_common/logger/` 底下零命中）。

**出事會怎樣**

拿得到 log 的人可以直接取得兩樣東西：

1. **使用者密碼原文**（來自登入請求）——可直接登入，也可拿去試其他系統（很多人重複用密碼）
2. **登入憑證 JWT**（來自每個已登入請求）——**不需要密碼就能冒充該使用者**，直到憑證過期

而「拿得到 log 的人」比想像中多：

- **`log/app.log` 檔**：正式部署會把它掛到主機的 `/srv/guidant-ai/log`（`docker-compose.yml:147`），任何能進主機的人都讀得到
- **`system_logs` 表**：**完全沒有客戶隔離**（見 C2-2），任何連得到資料庫的帳號看得到全部
- **螢幕輸出**：`docker logs` 看得到，且會進 docker 的 json 檔

**這是本專案第三次同型問題**——前兩次是 CM-1606（SMTP 密碼寫進 log）與 FR-076 L3（License Center 的 log 遮蔽失效）。差別在於：**前兩次是個別功能寫錯，這次是「每一個請求都會發生」。**

**要先有什麼才打得到**

- **不需要任何攻擊技巧就會發生**——這是每個請求的預設行為，log 已經寫在那裡了
- 要取得需要：能讀主機上的 log 檔，或能查資料庫的 `system_logs` 表
- **不需要登入權限就能讓自己的密碼被寫進去**（登入請求本身就會觸發），但要**讀取**才需要上述存取權

**為什麼是 HIGH 而不是 CRITICAL**：攻擊者需要先有主機或資料庫的讀取權才拿得到。它不是「從外網直接打進來」的洞，而是「一旦有人進到內部，或有人不當存取 log，損失立刻放大到全體使用者的密碼」。

**在哪裡**

- `compliance-manager-be/common/middleware/app_mw.py:45` — `logger.info(f"Request Headers: {request.headers}")`（**憑證外洩點**）
- `compliance-manager-be/common/middleware/app_mw.py:46` — `logger.info(f"Request Body: {request.data}")`（**密碼外洩點**）
- `compliance-manager-be/common/middleware/__init__.py:2` — `logger = logging.getLogger("middleware")`，確認用的是 `middleware` 這個 logger
- `jedi-common/jedi_common/logger/config_dev.py:84-88` — `middleware` logger 掛了 `['app', 'file', 'db']` 三個輸出：螢幕、`log/app.log`、資料庫
- **對照組（有遮罩的那條）**：`app_mw.py:51`、`:101` — 寫 `api_logs` 前先 `mark_password`
- **遮罩工具本身**：`jedi-common/jedi_common/utils/common_utils.py:150-164`（涵蓋 password／passphrase／secret／token／credential／authorization／api_key／private_key）
- **接線確認**：`core/app_factory.py:259` `init_app_interceptor(app)`（這支中介層**確實有掛上**，不是死碼）
- **log 檔掛到主機**：`docker/production/docker-compose.yml:147`

**怎麼修**

**最小改動（建議先做這個）**：把 `app_mw.py:45-46` 兩行改成遮罩過的版本，並且**不要印全部標頭**：

```python
# 只印需要排錯的標頭，且不含 Authorization / Cookie
safe_headers = {k: v for k, v in request.headers.items()
                if k.lower() not in ("authorization", "cookie", "x-api-key")}
logger.info(f"Request Headers: {safe_headers}")
logger.info(f"Request Body: {mark_password(request.data.decode(errors='replace'))}")
```

`mark_password` 已經 import 在這支檔案裡（`:11`），不必新增相依。

**根治（建議一併排程）**：**在 logger 這一層加一道遮罩**，不要只靠呼叫端記得。做法是在 `CustomFormatter.format()`（`custom_formatter.py:44`）回傳前，對 `record.getMessage()` 的結果套一次 `mark_password`。這樣不管哪支程式寫了什麼進 log，都會先過一次遮罩。

**理由**：目前的設計是「每個呼叫端自己記得遮罩」，而這已經漏了三次（CM-1606、FR-076 L3、本條）。**只要遮罩的責任還在呼叫端，就會有第四次。** 放在 formatter 這一層的代價是每行 log 多跑一次正規表示式，對照風險是划算的。

**注意**：`mark_password` 只認得 JSON 格式的 `"key": "value"`。標頭是 `Authorization: Bearer xxx` 的形式，**不是 JSON，遮不到**——所以標頭那行必須另外處理（用上面的白名單做法），不能只靠加遮罩。

**首腦核對註記**：**工具未報，runner 人工查證。** 工具沒碰到是可以理解的——問題檔在主產品 repo，不在本次掃描範圍（`jedi-common`）內，而「這條 log 會流到哪」則要看套件的設定檔，**兩邊都看得到才拼得出來**。我逐項核對了：① `app_mw.py:45-46` 原文屬實；② logger 名稱是 `middleware`（`__init__.py:2`）；③ `config_dev.py:84-88` 確實把 `middleware` 掛上 db handler；④ 這支中介層確實有接線（`app_factory.py:259`）；⑤ 同檔 `:51` 確實有用遮罩，形成對比；⑥ 全樹 grep 確認 `jedi_common/logger/` 零處呼叫 `mark_password`。**未實測**——沒有實際發一個登入請求去看 log 內容，也**依卡片紀律沒有讀取任何一列 `system_logs` 的 message 原文**。結論是讀程式碼與設定檔的推論。

---

### C2-2（MEDIUM）`system_logs` 表完全沒有客戶隔離，而且沒有可以拿來隔離的欄位

**（卡片重點③b 指定實查，已用 DEV 庫查證）**

**這是什麼問題（白話）**

產品是多客戶共用一套系統的，靠資料庫的一道機制讓每個客戶只看得到自己的資料（RLS，就是「每個客戶只能看自己資料」的資料庫隔離機制）。

**`system_logs` 這張表沒有開這道機制，一條規則都沒有。**

我用 DEV 庫實際查了（唯讀，沒有改任何東西）：

| 查什麼 | 結果 |
|---|---|
| 有沒有開隔離 | **沒有**（`relrowsecurity = f`） |
| 有幾條隔離規則 | **0 條** |
| 表裡有多少筆 | **668,813 筆**（其中 ERROR 等級 3,646 筆） |
| 誰能讀 | `cm_app`（應用程式用的帳號）有完整讀寫權 |

**更根本的問題是：這張表連「哪個客戶」的欄位都沒有。** 它的欄位是 `act_time / user_uid / user_name / level / method / message / event_code / path / func_name / line_no`——**沒有 tenant 或客戶編號**。所以就算現在想開隔離也開不了，得先改表結構。

**出事會怎樣**

任何連得到資料庫的帳號（包括應用程式自己用的 `cm_app`）都看得到**全部客戶**的 log 紀錄。log 內容包含操作者的帳號 uid 與暱稱、他碰了哪支程式、出了什麼錯——**等於一份跨全部客戶的行為紀錄**。

**與 C2-1 疊加時最麻煩**：C2-1 讓密碼與憑證寫進這張表，而這張表沒有任何隔離。

**對照組**：同樣是 log 表的 `api_logs` **也沒有開隔離**（一併查證）。所以這不是「漏了這一張」，而是**兩張 log 表都在隔離機制之外**。

**為什麼是 MEDIUM 不是 HIGH**：要看到這張表得先有資料庫存取權。應用程式的一般查詢路徑不會去讀 `system_logs`（這張表目前沒有任何正式的讀取端點，見第 5 節），所以不會經由 API 洩漏給一般使用者。它的風險在於「內部人員或已進入內部的攻擊者」，而不是外部直接可打。

**要先有什麼才打得到**

- 能用 `cm_app` 或更高權限連上資料庫（例如已進到主機、或拿到資料庫密碼——順帶一提，資料庫密碼散落 249 個檔的問題是既有的 CM-1629）
- **不需要任何應用層權限**——這張表沒有 API 讀取端點，反過來說也沒有 API 層的守門保護它

**在哪裡**

- DEV 庫實查（唯讀）：`SELECT relrowsecurity FROM pg_class WHERE relname='system_logs'` → `f`；`SELECT count(*) FROM pg_policies WHERE tablename='system_logs'` → `0`
- 表結構：`\d public.system_logs`（12 個欄位，**無 tenant 欄位**，依 `act_time` 做時間分割，5 個分割）
- 權限：`cm_app=arwd/nicsmgr`（完整讀寫）
- 寫入端：`jedi-common/jedi_common/logger/db_log/db_handler.py:30`
- 表定義：主產品 `scripts/init/02-schema.sql`

**怎麼修**

這條要分兩層看，**建議先做第一層**：

1. **先確認「這張表該不該分客戶」**。系統層 log（排程、啟動、框架錯誤）本來就不屬於任何客戶，硬加隔離反而會讓維運看不到全貌。**如果結論是「不分客戶、只給維運看」，那正確的修法不是加 RLS，而是把存取權收緊**：撤掉 `cm_app` 對這張表的 `SELECT` 權（應用程式只需要寫，不需要讀），只留給管理帳號。這是成本最低、效果最直接的一步。
2. **如果決定要分客戶**，就得先加欄位（`tenant_id`）、回填既有 66 萬筆、再加 policy。**這是有成本的工程**，要先有第 1 點的結論再做。

**無論選哪條，C2-1 都要先修**——把密碼與憑證擋在 log 之外，比事後討論誰能讀這張表更有效。

**首腦核對註記**：**卡片重點③b 明確要求實查，已完成。** 查詢全部唯讀（`pg_class`／`pg_policies`／`\d`／`count(*)`／依 level 分組計數），**依卡片紀律沒有讀取任何一列的 message 原文**——那正是可能夾帶憑證的欄位。只查了 DEV 庫；**STG／POC 未查**（依環境異動鐵律，唯讀本可做，但本棒沒有必要——schema 來自同一份 init 腳本，且真正該修的是 C2-1）。

---

### C2-3（MEDIUM）完整錯誤堆疊寫進那張沒有隔離的表

**這是什麼問題（白話）**

程式出錯時，會把**完整的錯誤堆疊**（就是那一長串「哪個檔案第幾行呼叫了哪個函式」的清單）接在 log 訊息後面，一起寫進資料庫：

```python
if record.exc_text:
    message += '\n\n' + record.exc_text
```

錯誤堆疊本身會揭露內部檔案路徑、函式名稱、以及有時候的 SQL 片段。**單獨看不算嚴重**——這些資訊沒有直接的攻擊價值，而且是寫進內部資料庫、不是回給前端（回給前端的那條是 C1 已經報過的 `sql_exception.py`）。

**它之所以值得列出來，是因為疊加效應**：這些堆疊進的是一張**完全沒有客戶隔離的表**（C2-2），而且當請求本身夾帶密碼時（C2-1），堆疊裡也可能一併帶出那些值。DEV 庫目前有 3,646 筆 ERROR 等級的紀錄。

**出事會怎樣**

拿得到 `system_logs` 的人可以拼出系統的內部結構（哪些模組、哪些函式、哪裡容易出錯），這對後續攻擊有幫助但不直接造成損失。**真正的風險來自與 C2-1 的組合。**

**要先有什麼才打得到**

- 同 C2-2（能查資料庫）
- 要看到有價值的內容，還需要系統真的出過帶敏感值的錯

**在哪裡**

- `jedi-common/jedi_common/logger/db_log/db_handler.py:15-16` — `if record.exc_text: message += '\n\n' + record.exc_text`
- 落點：`system_logs.message`（`text` 型別，**無長度上限**）

**怎麼修**

三個選項，建議第二個：

1. 不寫堆疊進資料庫（只留在檔案）——但這會讓維運查問題變困難，**不建議**
2. **寫之前先過一次遮罩**——與 C2-1 的根治修法是同一件事（在 formatter 層加遮罩，堆疊也會被遮到）。**一次改動同時解決兩條**
3. 限制堆疊長度（例如只留最後 20 行）——治標，減少夾帶機率但不根治

**首腦核對註記**：工具的候選 C3 碰到了這個檔案，但問的是另一件事（handler 會不會炸，見 C2-4），**沒有問「寫了什麼進去」**。本條為 runner 人工查證。**未讀取任何實際 log 內容**，判斷來自程式碼結構與 ERROR 筆數統計。

---

### C2-4（MEDIUM）寫 log 到資料庫失敗會把原本的請求一起弄壞，而且它能運作是靠運氣

**（工具候選 C3，三票一致否決；我同意「不是資安漏洞」，但認為是該修的穩定性問題）**

**這是什麼問題（白話）**

負責把 log 寫進資料庫的那段程式（`db_handler.py` 的 `emit`）有兩個問題：

**問題一：沒有防護，寫失敗會往外炸。**

一般來說，log 系統壞掉不應該影響業務——log 寫不進去，頂多少一筆紀錄，請求還是要正常完成。但這段程式**沒有任何錯誤攔截**（沒有 `try/except`），所以資料庫一旦寫不進去，錯誤會沿著呼叫鏈往回傳，**把原本那個請求打成 500**。

**問題二：它能運作，靠的是 handler 剛好的排列順序。**

這段程式讀了兩個欄位：`record.asctime`（時間）與 `record.user_uid`（操作者）。**這兩個欄位不是本來就有的**——要等某個 handler「格式化」過這筆紀錄之後才會被補上去。

目前設定裡，資料庫 handler 排在第三個（`['app', 'file', 'db']`），前兩個跑的時候順手把這兩個欄位補上了，所以第三個才讀得到。**如果有人把順序調換、或拿掉前面兩個，這段程式就會當場出錯**——而依問題一，那個錯誤會把請求一起打死。

**出事會怎樣**

- 資料庫短暫寫不進去（連線滿了、磁碟滿了、分割表沒有對應區間）→ **正在處理的請求跟著失敗**，而且錯誤訊息會指向 log 系統，不是真正的問題所在，很難查
- 有人調整 log 設定的 handler 順序 → **每一個會寫 log 的請求都壞掉**

**這不是資安漏洞**，三位檢查員一致否決的理由是正確的：**找不到攻擊者可以控制的觸發點**。會寫進去的欄位裡，有長度上限的（`user_name` 500 字、`method` 500 字）都是系統自己產生的，唯一受外部影響的 `message` 是無上限的 `text` 欄位。所以攻擊者沒辦法故意送一個超長的值把它撐爆。

**我把它列出來，是因為它是「會在最不方便的時候壞掉」的那種問題**——資料庫出狀況的當下，正是最需要 log 的時候，而這個設計會讓 log 系統反過來擴大故障。

**要先有什麼才打得到**

- **不需要攻擊者**——資料庫暫時寫不進去就會發生
- 或有人修改 log 設定時調動了 handler 順序

**在哪裡**

- `jedi-common/jedi_common/logger/db_log/db_handler.py:13-30` — `emit()` 全段，**沒有 try/except、沒有 handleError**
- `db_handler.py:19` — `datetime.strptime(record.asctime, ...)`，`asctime` 是格式化後才有的欄位
- `db_handler.py:20-21` — `record.user_uid`、`record.user_nickname`，由 `CustomFormatter.format()`（`custom_formatter.py:46-49`）補上
- `config_dev.py:55` 等 — handler 順序 `['app', 'file', 'db']`，**`db` 必須排最後才能運作**
- `config_dev.py:18-20` — `db` 這個 formatter 沒有給格式字串，所以它自己**不會**補上 `asctime`

**怎麼修**

兩處都要改，各一行等級的改動：

1. **加防護**：`emit()` 整段包 `try/except Exception: self.handleError(record)`。`handleError` 是 Python 標準庫給 log handler 用的標準做法——把錯誤印到 stderr 就好，不往外傳。**log 系統的故障不該變成業務的故障。**
2. **不要依賴順序**：在 `emit()` 開頭先呼叫 `self.format(record)`，自己把需要的欄位補齊，而不是期待別人先跑過。或者退一步，用 `record.created`（這個欄位**本來就有**，是時間戳）取代 `record.asctime`，就不需要別人先格式化。

**首腦核對註記**：工具候選 C3，**三位檢查員一致否決（0:3）**，理由是「缺攻擊者可控的觸發點」。**我核對後同意這個否決**——確實不是資安漏洞，我沒有把它算進資安發現，而是列為穩定性問題。三位檢查員的分析品質很高：其中一位甚至查到 Python 標準庫 `logging/__init__.py` 的 `handle()` 只有 `try/finally` 不吞例外，證實錯誤真的會往外傳；另一位查到 `guidant.env` 沒設 `RUN_ENV` 所以正式機也掛著這個 handler（**這一點我獨立驗證了，見 C2-5**）。

---

### C2-5（MEDIUM，放大器）出貨設定漏了一個環境變數，正式環境實際套用開發用的 log 設定

**這是什麼問題（白話）**

log 有三套設定：開發用（`dev`）、測試用（`stg`）、正式用（`prod`）。程式靠一個叫 `RUN_ENV` 的環境變數決定用哪一套：

```python
RUN_ENV = os.getenv("RUN_ENV", "dev")   # 沒設就用 dev
```

**出貨用的設定檔 `docker/production/guidant.env` 裡沒有 `RUN_ENV` 這一行。** 它只有 `ENV=PRD`（那是另一個變數，管資料庫連線設定，不管 log）。

所以**客戶正式機跑的是開發用的 log 設定**。

**這為什麼要緊**：三套設定的差異不小。

| | 開發設定（實際生效的） | 正式設定（原本該生效的） |
|---|---|---|
| 寫進資料庫 | ✅ **7 個 logger 都寫** | ❌ 完全不寫 |
| 寫進檔案 `log/app.log` | ✅ 寫 | ❌ 不寫（被註解掉） |
| `middleware` logger | ✅ **有**（就是 C2-1 那條） | ❌ **沒有定義** |

也就是說：**C2-1 那條把密碼寫進資料庫與檔案的路徑，在正式設定裡本來是不存在的**——正式設定根本沒有定義 `middleware` 這個 logger，也沒有掛資料庫 handler。**是這個漏掉的環境變數，讓開發環境的行為原封不動搬到了客戶的正式機上。**

**出事會怎樣**

它自己不造成損失，但它**把 C2-1、C2-2、C2-3 的影響範圍從「開發機」擴大到「客戶的正式機」**。沒有這條，C2-1 就只是開發環境的問題。

順帶一提，這也解釋了為什麼正式機會有 `log/app.log`（且掛到主機 `/srv/guidant-ai/log`）——按正式設定它根本不該存在。

**要先有什麼才打得到**

- **不需要任何條件**，這是出貨的預設狀態

**在哪裡**

- `jedi-common/jedi_common/logger/config_logger.py:44` — `RUN_ENV = os.getenv("RUN_ENV", "dev")`（沒設就當 dev）
- `compliance-manager-be/docker/production/guidant.env` — **全檔 24 行，沒有 `RUN_ENV`**，只有 `:15` 的 `ENV=PRD`
- 對照：`compliance-manager-be/.env.sample:63` — 開發用的範本**有** `RUN_ENV=dev`
- 差異證據：`config_dev.py:43-47`（有 db handler）vs `config_prod.py:18-30`（**沒有** db handler，檔案 handler 也被註解掉 `:31-38`）
- `config_prod.py:44-84` — logger 清單裡**沒有 `middleware`**

**怎麼修**

**先確認要哪一種行為，再改**——這條不能直接補上 `RUN_ENV=prod` 就了事：

- 如果**要**正式機把 log 寫進資料庫（多數產品會要，方便維運查問題），那就**改 `config_prod.py`**，把該掛的 handler 補上，並且**先修 C2-1**（否則等於正式承認要把密碼寫進資料庫）
- 如果**不要**，那就在 `guidant.env` 補 `RUN_ENV=prod`——但要先確認維運不依賴 `log/app.log`（目前 compose 有掛載它，代表有人在用）

**無論選哪個，都建議把「沒設 `RUN_ENV` 就當 dev」這個預設改掉**——正式環境因為少一行設定就套用開發設定，是很容易重演的錯誤。建議改成沒設就當 `prod`（安全的那一邊），或啟動時明確報錯要求設定。

**首腦核對註記**：工具的檢查員在否決 C3 時順帶提到了這一點，**我獨立驗證過**：實際打開 `guidant.env` 確認 24 行內無 `RUN_ENV`（只有 `ENV=PRD`），並逐一比對三個設定檔的 handler 與 logger 清單。**這條是本報告裡「工具間接提到、我核實後認為值得單獨列出」的一條**——因為它是其他三條的放大器，不列出來會讓人低估影響範圍。

---

### C2-6（LOW）遠端監控連線在程式載入時就啟動，位址寫死且不加密

**這是什麼問題（白話）**

有一段程式在**被載入的當下**（不是「被呼叫的時候」，是 import 進來就執行）建立了兩條到遠端監控服務的連線：

```python
processor = BatchSpanProcessor(OTLPSpanExporter(endpoint="localhost:4317", insecure=True))
reader = PeriodicExportingMetricReader(OTLPMetricExporter(endpoint="localhost:4317", insecure=True))
```

`OTLP`（就是把程式的追蹤資料送到遠端監控服務的標準協定）的位址**寫死成本機的 4317 埠**，而且 `insecure=True` 意思是**不加密、不驗證對方身分**。

**我查了：整個主產品沒有任何地方部署 4317 這個服務**（grep 全樹的程式碼、compose、Dockerfile、環境設定檔，零命中）。

**出事會怎樣**

分兩種情況：

- **目前的情況（沒人在 4317 聽）**：資料送不出去。OTLP 的匯出器會累積在記憶體佇列裡、定期重試、失敗後丟棄。**影響是持續的無效重試與少量記憶體佔用**，不是資安問題。這也是它是 LOW 的主因。
- **如果哪天有人在同一台機器上開了 4317**（例如另一個容器、或攻擊者已進到主機）：追蹤資料會**明文**送過去。追蹤資料含函式呼叫路徑與時間，敏感度低於 log 本身，但仍是不該外流的內部資訊。

**另一個角度的問題（不是資安，是設計）**：這種「import 就產生副作用」的寫法本身很脆弱——同一支套件的 `config_logger.py` 檔頭（`:1-34`）花了整整 34 行說明「logging 設定必須是宿主明確呼叫，不能是 import 副作用」，還記錄了因此踩過的坑（CM-1513）。**同一個套件裡，一邊立了規矩，另一邊還留著違反規矩的程式碼。**

**要先有什麼才打得到**

- 目前狀態下無法利用（沒有接收端）
- 要有實質影響，需要有人在該機器的 4317 埠開一個接收服務

**在哪裡**

- `jedi-common/jedi_common/logger/custom_formatter.py:27` — span 匯出器，`endpoint="localhost:4317", insecure=True`
- `jedi-common/jedi_common/logger/custom_formatter.py:35` — metric 匯出器，同樣寫死
- `custom_formatter.py:22-40` — **整段在模組頂層**，import 就執行
- `custom_formatter.py:40` — `LoggingInstrumentor().instrument()`，同樣是 import 副作用
- 對照（同套件的正確做法）：`config_logger.py:1-34` 的檔頭說明

**怎麼修**

1. **位址改成可設定**（環境變數），並且**預設不啟用**——沒設就不建立匯出器。這樣沒有監控服務的部署就不會有無效重試
2. **把這段從模組頂層搬進一個明確的初始化函式**，比照 `configure_logging()` 的做法，由宿主決定要不要呼叫
3. 若日後真的要用，`insecure=True` 要改成走加密連線

⚠️ **新增環境變數前記得先 grep 既有變數**（CLAUDE.md 鐵則，FR-064 曾因此自創了與既有變數重複的名字）。

**首腦核對註記**：工具未報（**工具未報，runner 人工查證**）。卡片重點④指定要追。我查證了「有沒有人在 4317 聽」——**全樹 grep `4317`／`otlp`／`OTLP` 零命中**，所以判定為目前無接收端。**未實測**——沒有實際觀察匯出器在無接收端時的記憶體行為，那需要跑起來量測。

---

### C2-7（LOW）正式與測試環境把兩個 logger 設成最詳細等級

**這是什麼問題（白話）**

log 有等級之分，`DEBUG` 是最詳細的一級，一般只在開發時用。正式環境的設定裡有兩個 logger 被設成 `DEBUG`：

- `infra`（資料存取層）
- `sqlalchemy.orm`（資料庫套件本身）

**出事會怎樣**

- **log 量放大**：`DEBUG` 等級會產生大量紀錄。docker 那層有上限保護（每容器 20MB×5，`docker-compose.yml:108`），所以不會撐爆磁碟，但會讓有用的訊息被淹沒
- **`sqlalchemy.orm` 的 DEBUG 可能帶查詢內容**：這個套件在 DEBUG 等級會輸出 SQL 相關的訊息。**需要說明的是**：真正會印出完整 SQL 與參數值的是 `sqlalchemy.engine` 這個 logger（設定裡**沒有**動到它），`sqlalchemy.orm` 印的多是物件關聯的內部運作。**所以這條的實際洩漏風險比乍看小**，我判 LOW 而不是 MEDIUM

**為什麼還是列出來**：`infra` 是我們自己的資料存取層，把它設成 DEBUG 意味著開發時寫的除錯訊息會全部進正式環境的 log——而那些訊息的內容沒有人審過。

**要先有什麼才打得到**

- 就是出貨預設值
- （但因 C2-5，正式機實際套用的是 dev 設定，那份設定裡 `infra` 是 `INFO`——**所以這條目前反而沒生效**）

**在哪裡**

- `jedi-common/jedi_common/logger/config_prod.py:55-59` — `infra` 的 `level: DEBUG`
- `jedi-common/jedi_common/logger/config_prod.py:80-84` — `sqlalchemy.orm` 的 `level: DEBUG`
- `config_stg.py:55-59`、`:80-84` — 測試環境同樣設定
- 對照：`config_dev.py:64-68` 的 `infra` 是 `INFO`、`:99-103` 的 `sqlalchemy.orm` 是 `ERROR`（**開發設定反而比正式設定保守**）

**怎麼修**

把 `config_prod.py` 與 `config_stg.py` 這兩處的 `DEBUG` 改成 `INFO`（`sqlalchemy.orm` 建議直接 `WARNING`，比照開發設定的 `ERROR` 也可以）。

**這條與 C2-5 要一起看**：修 C2-5（讓正式機真的套用正式設定）之前，得先修這條，否則正式設定一生效，log 量會突然暴增。

**首腦核對註記**：工具未報（**工具未報，runner 人工查證**）。卡片重點⑦指定要逐項對照三個設定檔，已完成（見第 5 節⑦的完整對照表）。**我特意把「`sqlalchemy.orm` 不等於 `sqlalchemy.engine`」寫清楚**——如果不區分，這條會被誇大成「SQL 全量進 log」，那不是事實。

---

### C2-8（無問題／死碼）log 上的操作者取自請求標頭——查了，這段程式從未被啟用

**（卡片認定「本棒最重要」；工具候選 C1，三票一致否決；我核對後同意否決）**

**這是什麼問題（白話）**

卡片指出：log 上標的「這是誰做的」，是從請求標頭 `X-UserId` 直接讀來的：

```python
user_id_var.set(request.headers.get("X-UserId", "System"))
```

標頭是**呼叫端自己送的**，任何人都能隨便填。如果稽核紀錄靠它標示操作者，那**任何人送一個 `X-UserId: admin` 就能讓紀錄寫成別人做的**。卡片把這條列為本棒最重要的疑點。

**查證結論：不成立，因為這段程式碼從來沒有被啟用過。**

那行程式包在一個叫 `logger_middleware(app)` 的函式裡。這種函式要「被呼叫」才會把行為掛到網站上。**我 grep 了整個 jedi monorepo（25 支套件）與主產品，找不到任何一處呼叫它**——只有定義本身。

同樣的情況也出現在 `decorator.py:11` 的 `jedi_logger`（另一支也讀 `X-UserId` 的裝飾器）：**同樣零呼叫者**。

**所以現況是**：`user_id_var` 這個變數從來沒有被設定過，永遠是預設值 `"System"`。

**那 log 上的操作者是哪來的？** 是另一套機制——`CustomFormatter.format()`（`custom_formatter.py:45-49`）呼叫 `get_user_context()`，從**登入憑證（JWT）**取得身分。那是後端自己驗證過的，**不可偽造**。

**但這件事留下了一個真的問題（不是資安問題）**：因為 `user_id_var` 永遠是 `"System"`，而螢幕與檔案的 log 格式字串用的正是它（`[%(userid)s]`），所以：

> **`log/app.log` 與螢幕輸出上的每一行 log，操作者欄位永遠顯示 `System`。**

真正的身分（`user_login_name`／`user_uid`／`user_nickname`）雖然有被算出來，卻**沒有被放進螢幕與檔案的格式字串裡**——只有寫進資料庫的那份用得到。**所以出事要查「誰做的」，只能查資料庫那份，看檔案是查不出來的。** 這是可維護性問題，值得記一筆。

**在哪裡**

- `jedi-common/jedi_common/logger/middleware.py:9` — `def logger_middleware(app):`，**零呼叫者**
- `jedi-common/jedi_common/logger/middleware.py:13` — 讀 `X-UserId` 的那行
- `jedi-common/jedi_common/logger/decorator.py:7` — `def jedi_logger(logger):`，**零呼叫者**
- `jedi-common/jedi_common/logger/decorator.py:11` — 同樣讀 `X-UserId`
- `custom_formatter.py:19` — `user_id_var` 定義，`default="System"`
- `custom_formatter.py:45-49` — **真正在用的身分來源**（`get_user_context()`，來自 JWT）
- `config_dev.py:12` — 格式字串 `[%(userid)s]`，用的是死掉的那個變數
- **全樹 grep 證據**：`logger_middleware`／`jedi_logger`／`user_id_var` 在 jedi monorepo ＋ 主產品的所有 `.py` 檔（排除 `.venv` 與 site-packages）中，除定義處外零命中

**怎麼修**

1. **刪掉 `middleware.py` 與 `decorator.py` 這兩支死碼**。留著的風險是：哪天有人看到「這裡有現成的中介層」就掛上去，那個可偽造的身分就活了。**這是真實的風險**——它看起來完全像是能用的東西
2. **順手把真實身分放進螢幕與檔案的格式字串**：`config_dev.py:12` 的 `[%(userid)s]` 改成 `[%(user_login_name)s]`（那個欄位已經被算出來了，只是沒被用）。這樣檔案 log 才查得出誰做的

**首腦核對註記**：**工具候選 C1，三位檢查員一致否決（0:3），三位都以「這是死碼」為理由，並各自獨立做了全樹搜尋。我重新獨立 grep 驗證過，確認結論正確。** 這是本輪工具表現最好的一條——**卡片（含首腦盤點）認定為「本棒最重要」的疑點，實際上是死碼，工具三票一致把它擋了下來**。如果沒有這道查證，這條會被當成 HIGH 寫進報告，然後有人花時間去修一段根本沒在跑的程式。**這正是三人面板存在的價值。**

---

## 5. 卡片「重點看什麼」八項逐項回覆

卡片（CM-1648）列了八項要追的點。逐項交代，**沒有留白**：

| # | 卡片的疑問 | 結論 |
|---|---|---|
| **①** | **log 上的操作者直接取自 request header `X-UserId`**（`middleware.py:13`、`decorator.py:11`）。這兩個 contextvar 最後流到哪？跟 `auth_context.py` 那個真正的登入身分是不是兩套？哪一套進了 `system_logs`？（卡片標為「本棒最重要」） | **不成立——兩支都是死碼，見 C2-8。** 全樹 grep 確認 `logger_middleware` 與 `jedi_logger` **零呼叫者**，所以 `user_id_var` 永遠是預設值 `"System"`。<br><br>**確實是兩套身分並存**，但可偽造的那套沒有生效：<br>• **可偽造的（死的）**：`X-UserId` → `user_id_var` → `record.userid` → **只出現在螢幕與檔案的格式字串** `[%(userid)s]`<br>• **不可偽造的（活的）**：JWT → `get_user_context()` → `record.user_uid`／`user_nickname` → **進 `system_logs` 表**<br><br>**所以進資料庫的是可信的那套。** 完整流向見下方「身分流向表」。<br><br>**但留下一個真問題**：檔案與螢幕 log 的操作者欄位**永遠顯示 `System`**，真實身分算出來了卻沒放進格式字串——**出事查「誰做的」只能查資料庫，查檔案查不到。** |
| **②** | **完整 URL 含 query string 進 log**（`decorator.py:13`）。哪些端點用了這個 decorator？有沒有 token／密碼走 query string 的端點？ | **這個 decorator 是死碼（零使用者），所以卡片問的那條路不存在。**<br><br>**但我在追這一項時找到了更嚴重的東西**——主產品有另一支**確實在跑**的中介層在做同樣性質的事，而且更糟：它印的不是 URL，是**完整標頭與完整請求內容**，也就是 **C2-1（本棒唯一的 HIGH）**。<br><br>`app_mw.py:45-46` 每個請求都印 `request.headers`（含 `Authorization: Bearer <JWT>`）與 `request.data`（含登入密碼），**完全沒有遮罩**，而同一支檔案 `:51` 寫另一張表時**有遮罩**。<br><br>**卡片問的方向是對的，只是問錯了檔案。** |
| **③** | **完整 traceback 落資料庫**（`db_handler.py:14-16`）。(a) 會不會夾帶區域變數值？(b) **`system_logs` 有沒有 RLS——請用 DEV 庫實查**；沒有的話誰能讀？ | **(a) 不會夾帶區域變數值**，但會夾帶內部路徑與函式名，見 **C2-3**。Python 標準的 `exc_text` 只含呼叫堆疊與例外訊息，**不含區域變數的值**（那需要 `traceback.format_exc` 搭配特殊設定或第三方套件才會有）。**所以「密碼從堆疊漏出去」這條路不成立**——密碼是從 C2-1 那條路漏的。<br><br>**(b) 實查完成，答案是完全沒有隔離**，見 **C2-2**：<br>• `relrowsecurity = f`（沒開）<br>• `pg_policies` **0 條規則**<br>• **表裡沒有 tenant 欄位**（12 個欄位全查過），所以現在想開也開不了<br>• 目前 **668,813 筆**，其中 ERROR 3,646 筆<br>• 誰能讀：`cm_app`（應用程式帳號）有完整讀寫權<br>• **對照：`api_logs` 同樣沒有 RLS**——不是漏了一張，是兩張 log 表都在隔離之外<br><br>**依卡片紀律，只查欄位與筆數，未讀取任何一列 message 原文。** |
| **④** | **OTLP 遠端追蹤 import 時就連線且寫死明文**（`custom_formatter.py:27`／`:35`）。正式環境有沒有 4317 在聽？沒有的話 exporter 會怎樣？有的話 span 含什麼？ | **見 C2-6（LOW）。全樹 grep `4317`／`otlp`／`OTLP`（含 compose、Dockerfile、env 檔）零命中——沒有任何地方部署接收端。**<br><br>**沒人聽的後果**：OTLP 匯出器把資料放進記憶體佇列、定期嘗試送出、失敗後丟棄。**是持續的無效重試與少量記憶體佔用，不會阻塞請求**（`BatchSpanProcessor` 是背景執行緒、有佇列上限）。**未實測**，這是依 OTLP SDK 的既定行為推論。<br><br>**有人聽的話**：span 內容是函式呼叫路徑與耗時，**不含 SQL 參數或 request body**（那要另外埋才會有）。敏感度低於 log 本身。<br><br>**額外指出**：`:22-40` 整段是 import 副作用，而**同一個套件的 `config_logger.py` 檔頭花了 34 行說明「不可用 import 副作用」**（CM-1513 的教訓）。同套件裡一邊立規矩一邊違反。 |
| **⑤** | **formatter 把登入身分印進每一行**（`custom_formatter.py:44-49`）。這跟①的 `X-UserId` 是兩套身分並存嗎？哪套可信？ | **是兩套並存，而且可信的那套（JWT）是唯一活著的。**<br><br>`CustomFormatter.format()` 每次格式化都呼叫 `get_user_context()` 取 JWT 身分，塞四個欄位進紀錄：`record.userid`（來自死掉的 contextvar，永遠 `System`）、`record.user_login_name`、`record.user_uid`、`record.user_nickname`（**三個都來自 JWT，可信**）。<br><br>**哪套可信：JWT 那套。** 它由後端驗章後取得（`auth_context`），前端偽造不了。<br><br>**但要指出兩件事**：<br>① **格式字串只用了不可信（且已死）的那個**——`config_dev.py:12` 的 `[%(userid)s]`，導致檔案 log 的操作者永遠是 `System`；<br>② **這個 formatter 每格式化一行就查一次身分**，屬效能上的小浪費（非資安問題）。 |
| **⑥** | **log handler 自己會不會炸**（`db_handler.py:19` 的 `strptime(record.asctime)`、`emit()` 無 try/except）。炸了會怎樣？有沒有「寫 log 失敗 → 例外 → 又要寫 log → 無限遞迴」？ | **會炸，而且炸了會把請求一起打死，見 C2-4（MEDIUM，非資安）。**<br><br>**卡片的兩個懷疑都成立**：<br>• `emit()` **確實沒有 try/except、也沒呼叫 `handleError`**，而 Python 標準庫的 `handle()` 只有 `try/finally` 不吞例外 → **錯誤真的會竄回業務程式**<br>• `record.asctime` **確實是格式化後才有的欄位**，而 `db` 這個 formatter 沒給格式字串、自己不會補 → **它能運作純粹因為 handler 排在第三個**（`['app','file','db']`），前兩個順手補上了<br><br>**無限遞迴的組合：不存在。** 我特別查了——寫 log 失敗時例外是往**呼叫端**傳（業務程式），不是在 handler 內再寫一次 log，所以不會自我遞迴。<br><br>**三位檢查員一致否決為「非資安漏洞」，我同意**：找不到攻擊者可控的觸發點（有長度上限的欄位都是系統自產，唯一受外部影響的 `message` 是無上限 `text`）。**我把它列為穩定性問題，不計入資安發現。** |
| **⑦** | **三環境設定差異**：DBLogHandler 掛在哪些 logger、prod 哪些是 DEBUG、`log/` 檔案權限、log 檔會不會進出貨 image。**逐項對照三個檔。** | **逐項對照完成（表在下方），並查出一個卡片沒預期到的問題：出貨настройки漏了 `RUN_ENV`，正式機實際套用的是開發設定——見 C2-5。**<br><br>**① DBLogHandler 掛在哪**：dev **7 個 logger**（api／app／infra／domain／common／middleware／error_handler，全 `propagate=False`）；**stg 與 prod 完全沒掛**（兩個設定檔連 import 都沒有）。<br>**② prod 哪些是 DEBUG**：`infra`（`:57`）與 `sqlalchemy.orm`（`:82`），stg 相同 → **C2-7（LOW）**。註：真正會印完整 SQL 的是 `sqlalchemy.engine`，**設定裡沒動到它**，故風險比乍看小。<br>**③ `log/` 檔案權限**：`config_logger.py:75` 用 `os.makedirs("log", exist_ok=True)`，**沒有指定權限**，取決於行程的 umask（容器內通常 0755）。RotatingFileHandler 只在 dev 設定有（`config_dev.py:35-42`，1MB×5），**stg／prod 都註解掉了**。<br>**④ log 檔會不會進 image**：**不會**——`Dockerfile:131` 註解明確寫「不含 log/」。但 compose **把主機目錄掛進去**（`docker-compose.yml:147`，`/srv/guidant-ai/log:/app/log`），所以 log 會落在主機上、重啟不遺失。<br><br>**🔴 但以上 stg／prod 的設定目前都沒生效**，因為 `guidant.env` 沒有 `RUN_ENV`（全檔 24 行，只有 `ENV=PRD`），程式退回預設值 `dev`。**客戶正式機跑的是上表 dev 那一欄。** |
| **⑧** | **遮罩層在哪。** 全站唯一的密碼遮罩是 `common_utils.py:150 mark_password`。logger 這一鏈有沒有呼叫它？沒有的話，request body 裡的密碼是不是原文進 log？ | **🔴 這一項的答案就是本棒的 HIGH（C2-1）：logger 鏈零遮罩，而 request body 裡的密碼確實原文進 log。**<br><br>**逐項回答**：<br>• **logger 鏈有沒有呼叫 `mark_password`** → **完全沒有。** 全樹 grep 確認 `jedi_common/logger/` 底下**零命中**。<br>• **誰在用它** → 只有主產品的 `app_mw.py:51`（寫 `api_logs` 前遮 request）與 `:101`（遮 response），加上兩支測試檔。<br>• **密碼是不是原文進 log** → **是。** 同一支 `app_mw.py` 的 `:46` 把 `request.data` 原文丟給 `logger.info()`，而那個 logger（`middleware`）在實際生效的 dev 設定裡掛著螢幕＋檔案＋資料庫三個輸出。**登入密碼因此原文寫進 `log/app.log` 與 `system_logs`。**<br>• **還不只密碼** → `:45` 印完整標頭，含 `Authorization: Bearer <JWT>`，**那是可直接冒用的登入憑證**。<br><br>**遮罩工具的能力邊界**（修的時候要知道）：`mark_password` 用正規表示式比對 **JSON 形式**的 `"key": "value"`，涵蓋 password／passphrase／secret／token／credential／authorization／api_key／private_key。**標頭不是 JSON 格式，它遮不到**——所以標頭那行必須另外用白名單處理，不能只加遮罩。 |

### 🔴 身分流向表（卡片指定要畫）

三個身分來源，各自流到哪個輸出：

| 身分來源 | 可不可信 | 存在哪 | 螢幕 (stdout) | 檔案 `log/app.log` | 資料庫 `system_logs` | OTLP 遠端 |
|---|---|---|---|---|---|---|
| **`X-UserId` 請求標頭** | ❌ **可偽造**（任何人自己填） | `user_id_var` → `record.userid` | ⚠️ **格式字串用它**（`[%(userid)s]`） | ⚠️ **格式字串用它** | ❌ 沒用到 | ❌ 沒用到 |
| **JWT 登入憑證** | ✅ **可信**（後端驗過章） | `get_user_context()` → `record.user_login_name`／`user_uid`／`user_nickname` | ❌ **沒放進格式字串** | ❌ **沒放進格式字串** | ✅ **`user_uid`＋`user_name` 進表** | ❌ 沒用到 |
| **`record.user_uid`** | ✅ 可信（就是上面那套） | 同上 | — | — | ✅ 寫入 `system_logs.user_uid` | — |

**怎麼讀這張表（三個結論）**：

1. **可偽造的那套（`X-UserId`）雖然佔著螢幕與檔案的格式位置，但它是死的**——設定它的程式從未被啟用，所以那個欄位**永遠印 `System`**，沒有偽造風險，也沒有任何資訊價值。
2. **可信的那套（JWT）只進了資料庫**，沒放進螢幕與檔案的格式字串。
3. **合起來的後果**：**唯一有真實操作者身分的輸出是資料庫那份**，而那張表**沒有客戶隔離**（C2-2）。查稽核只能查它，而它誰都看得到。

---

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

分兩層講，這兩件事的可信度差很多。

### 第一層：「這些問題真的存在嗎？」——高，但沒有一條做過實際攻擊驗證

**八條結論全部由我逐檔開啟核對**，每一條都能指到具體檔名與行號。其中三條有**程式碼以外的證據**：

- **C2-2** 是 DEV 資料庫的**實際查詢結果**（不是讀 schema 檔推論的）——RLS 關閉、0 條規則、668,813 筆、無 tenant 欄位
- **C2-5** 是實際打開 `guidant.env` 數過的（24 行內無 `RUN_ENV`）
- **C2-8** 的死碼判定做了兩次獨立全樹 grep（工具三位檢查員各做一次，我再做一次）

**但沒有任何一條做過實際攻擊驗證。** 依卡片紀律，這一棒**只讀不寫**：沒有對任何環境發過請求、沒有改過任何資料、資料庫只做 SELECT 與結構查詢，**沒有讀取任何一列 `system_logs` 的 message 原文**（那正是可能夾帶憑證的欄位）。

**C2-1 特別建議實測確認**：發一個登入請求，然後查 `log/app.log` 看密碼是否真的原文出現。這是**一分鐘就能做完、且能一槌定音**的驗證，我沒有做是因為那需要對環境發請求（超出「只讀」紀律）。**建議驗收時由決策者或下一棒補這個實測。**

### 第二層：「只有這些嗎？」——低，這一輪遠不能算掃透

**四個理由，前兩個是工具自己講的**：

1. **🔴 工具本輪正式發現為 0 條**，四個候選全被三票一致否決。**本報告的八條全部是我人工查證的結果**，工具的貢獻是「幫我擋掉一條假警報（C2-8）」與「間接提示了 C2-5」。這是連續第五個 arc 出現「工具產出遠低於人工」的情形。
2. **🔴 工具沒有交「逐檔閱讀帳本」**（`coverage.research: null`）——**所以無法證明 32 個檔每一支都被讀到結論**。「只有這些」這句話沒有證據支撐。
3. **`low` 是快篩不是徹查**：沒有清點階段、沒有威脅建模、沒有廣掃，就是「兩個研究員讀一輪 → 三個檢查員投一輪票」。
4. **範圍之外完全沒看**：`jedi_common/` 的其餘部分（interfaces／utils／enums／constants，那是 C3 的事）、以及 log 轉發鏈（`jedi-log-forwarding` 套件、主產品 `api/log_forwarding/`）**一個字都沒讀**。

### 🔴 越界說明（必讀）

**本報告最重要的一條發現（C2-1）在掃描範圍之外**——它的問題檔 `app_mw.py` 在主產品，不在 `jedi-common`。

**這是必要的追脈絡**：卡片重點⑧問「logger 鏈有沒有遮罩」，要回答就得知道「誰在往這條鏈寫東西」，而寫的人在主產品。不追出去，這一項只能答「套件裡沒有遮罩」，答不出「所以密碼真的原文進了 log」。C2-5（`guidant.env`）與 C2-2（DEV 資料庫）同理。

**但必須講清楚：主產品沒有被稽核。** 我讀 `app_mw.py` 是為了回答「jedi-common 的 log 鏈會不會出事」，**不是為了檢查主產品有沒有自己的問題**。主產品的完整檢查是別的 arc 的事。

**這裡乾淨不代表主產品乾淨。**

---

## 7. 執行概況

| 項目 | 數值 |
|---|---|
| 掃描範圍 | `jedi_common/logger/`，**32 個受版控檔**（與卡片指定一致，全為 `.py`） |
| 版本 | commit `8aa6f0609c4434c0fa5206d5e99e31cad249646e`（branch `feature/FR-075`） |
| 工具 | Claude Code `claude-security` plugin v0.11.0 |
| effort / focus / mode | `low` ／ attack-surface ／ codebase-scan |
| 研究員 | **派 2 個，回 2 個（零失敗、零中斷）** |
| 候選發現 | 提出 **4 條**，去重後仍 **4 條** |
| 檢查員投票 | 三個檢查員對 4 條候選各投一票，**12 票全數投出，沒有漏投、沒有一條候選沒人看** |
| 投票結果 | **4 條全部被三票一致否決（0:3）** |
| 被否決的 4 條 | C1 `X-UserId` 可偽造（死碼）／C2 系統 log 查詢無白名單（無呼叫者）／C3 handler 無 try/except（無可控觸發點）／C4 分頁參數被丟棄（無呼叫者） |
| **工具正式發現** | **0 條** |
| 驗證章（stamp） | `verification.status: **verified**`（完整跑完，未中斷、未撞額度、未遺失候選） |
| 驗證輪次 | 1 輪 |
| 逐檔閱讀帳本 | **無**（`coverage.research: null`）——無法證明 32 檔全讀完 |
| 執行時間 | 2,512 秒（約 **42 分鐘**） |
| 工具產物 | `jedi-common/CLAUDE-SECURITY-20260910-141953/`（含自身 `.gitignore`，不入版控） |
| **本報告發現** | **7 條**（1 HIGH／4 MEDIUM／2 LOW）＋ 1 條查證為死碼無問題。**全部為 runner 人工查證**；工具的貢獻是擋掉 C2-8 這條假警報 |

---

## 8. 建議的下一步

按投資報酬率排序：

1. **🔴 先修 C2-1（密碼與憑證原文進 log）**——這是唯一的 HIGH，而且**最小改動只要動兩行**（`app_mw.py:45-46`），遮罩函式已經 import 在同一支檔案裡。**建議先做一分鐘實測確認**（發一個登入請求，grep `log/app.log`），確認後直接修。
2. **接著決定 C2-5（`RUN_ENV` 漏設）怎麼處理**——它是 C2-1／C2-2／C2-3 的放大器。**但順序不能反**：先修 C2-1 再處理它，否則等於正式承認「要把密碼寫進正式機資料庫」。另建議把「沒設就當 dev」的預設改成安全的一邊。
3. **C2-4（handler 無防護）**——兩處小改動（包 try/except、不依賴 handler 順序）。非資安但會在最不方便的時候壞掉。
4. **決定 `system_logs` 該不該分客戶（C2-2）**——**先回答這個問題再動工**。若結論是「不分客戶」，最省力的修法是撤掉 `cm_app` 的 SELECT 權（它只需要寫）；若要分客戶，得先加欄位再回填 66 萬筆，是有成本的工程。
5. **刪掉 `middleware.py` 與 `decorator.py` 兩支死碼（C2-8）**，順手把真實身分放進檔案 log 的格式字串（`config_dev.py:12`）——**目前檔案 log 查不出誰做的**。
6. **C2-6（OTLP）與 C2-7（DEBUG 等級）**——都是小改動，可併入下次整理。
7. **考慮在 formatter 層加一道統一遮罩**——這是根治方向。目前「每個呼叫端自己記得遮罩」的設計已經漏了三次（CM-1606、FR-076 L3、本條 C2-1），**只要責任還在呼叫端，就會有第四次**。

---

*本報告依 `security-scan-lead` skill 第十節白話規則撰寫。所有發現均為讀程式碼、設定檔與資料庫結構的結果，**未在任何環境實際執行攻擊、未對任何環境發送請求、未修改任何資料、未印出任何金鑰或密碼值**。資料庫查詢全部唯讀且只對 DEV 庫，**未讀取任何一列 `system_logs` 的 message 原文**。工具產物目錄不入版控。*
