# FR-085.C3 掃描報告：輸出面與打包（jedi-common）

- **卡片**：[CM-1649](https://app.notion.com/p/FR-085-C3-util-env-30-jedi-monorepo-Opus-1M-low-3d7346da4cd081939e4bfbe7ed36df88)（母卡 [CM-1646](https://app.notion.com/p/FR-085-jedi-common-3d7346da4cd081f18862dd193cf355c1)）
- **掃描範圍**：`jedi-common` 的 `interfaces/`＋`utils/`＋`enums/`＋`constants/`＋`.env`＋`publish.sh`，共 30 檔
- **版本**：commit `8aa6f060`（jedi monorepo，branch `feature/FR-075`）
- **日期**：2026-09-11
- **工具**：claude-security plugin，`low` effort，focus `attack-surface`
- **面板**：6 條候選 × 3 位檢查員 = **18 票全數投出**，stamp `verified`

---

## 1. 🔴 一句話結論

**我們安裝套件的管道是不加密的，只要有人能插進公司內網那條線，就能在我們安裝時把惡意程式掉包進來，直接拿下開發機與打包機**——而打包機產出的東西是要送到客戶手上的。

第二件事：**全站唯一那道「密碼遮罩」，遇到含有雙引號的密碼會遮一半、剩下半截原文寫進資料庫**，而那張表要留 90 天。

好消息有一個：卡片原本列為「本棒最重要」的那條（信封組裝失敗把錯誤原文當資料回前端）**經查不成立**，那段程式進不去。詳見第 4 節 C3-5。

---

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

`jedi-common` 是所有 jedi 套件與主產品共用的底層。這一棒看的是它的**「輸出面」**——資料整形完之後，要交出去的那一段：

- 回給前端的**信封**長什麼樣（`{"status": ..., "data": ...}` 那層）
- 前端送來的**分頁參數**怎麼收（要第幾頁、一頁幾筆）
- 物件怎麼變成 **JSON**（會不會夾帶不該給的欄位）
- **密碼遮罩**怎麼做（寫 log 之前把密碼蓋掉的那道）
- 套件本身怎麼**打包發布**（會不會把憑證一起包出去）

要回答的是四個問題：**回給前端的東西會不會夾帶不該有的欄位、分頁參數能不能拿來拖垮資料庫、共用工具裡有沒有不該存在的東西、打包腳本會不會把憑證帶出去。**

**只找問題、不修問題**——所有修法都只寫建議，這一棒不動任何程式碼。

---

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

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 嚴重度 | 來源 |
|---|---|---|---|---|---|---|
| **C3-1** | 安裝套件走的是**沒加密的連線**，別人可以中途掉包 | 惡意程式裝進開發機與打包機，**打包機產出的是要給客戶的東西** | 能插進內網那條線（公司 LAN、被入侵的網路設備） | `pyproject.toml:50` | **MEDIUM**<br>（一位檢查員評 HIGH） | 工具 3/3 票 |
| **C3-2** | 分頁的「一頁幾筆」**沒有上限** | 一個請求就能叫資料庫把整張表撈出來，重複幾次服務就癱 | 任何登入者 | `interfaces/schema/common.py:13` | **MEDIUM** | 工具 3/3 票 |
| **C3-3** | 密碼遮罩遇到**含雙引號的密碼只遮一半**，剩下原文進資料庫 | LDAP 綁定密碼、SSH 密語等強密碼的後半段明文留 90 天 | 密碼裡有 `"`；能讀到 log 表或備份的人 | `utils/common_utils.py:160` | **MEDIUM** | 工具 3/3 票 |
| **C3-4** | 同一道遮罩，**JSON 跳脫形式的引號**也一樣漏 | 同上，且 log 紀錄變成壞掉的 JSON、後續解析不可靠 | 同上 | `utils/common_utils.py:159` | **LOW** | 工具 3/3 票 |
| **C3-5** | 信封組裝失敗把錯誤原文當資料回前端 | **經查不成立**——該分支進不去 | — | `utils/response_util.py:26` | **無問題** | runner 人工查證 |
| **C3-6** | 一支**開發輔助工具混在正式套件裡**，且會把整個檔案覆寫成空 | 目前無人呼叫，但它**跟著出貨**，且拖著 `openai` 成為產品必需依賴 | 需有人真的去執行它 | `utils/gen_comment.py:49` | **LOW** | runner 人工查證 |
| **C3-7** | 序列化工具**原樣吐出整個物件**，沒有欄位過濾 | 物件上有什麼就回什麼，前端會拿到內部欄位 | 呼叫端沒有自己收斂欄位 | `utils/serialization_util.py:18` | **LOW**（套件層）<br>**個案在別處** | runner 人工查證 |
| **C3-8** | `.env` 受版控（CM-1598 已知） | **經查：值是真的可連線設定，但密碼與 DEV 主庫不同** | — | `.env` | **LOW**（維持原評） | 密鑰專項＋人工比對 |

**沒有發現的項目**：⑥ savepoint 靜默吞例外——查了，結論寫在第 4 節 C3-9。⑦ 兩套信封並存——查了，舊信封**零使用者**，結論同節。

---

## 4. 每條發現的詳述

### C3-1（MEDIUM）安裝套件走沒加密的連線，別人可以中途掉包

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

我們所有的套件（包含 25 支 `jedi-*` 自製套件，以及 Flask、SQLAlchemy 這些外部套件）都從公司內部的一台套件伺服器下載。問題是這條下載連線用的是 **`http://` 而不是 `https://`**——白話說就是**沒有加密、也沒有驗對方身分**。

這代表：只要有人能插進「我們的機器」到「那台套件伺服器」中間的網路路徑上（術語叫**網路中間人**，例如同一個辦公室網段、一台被入侵的交換器、或假冒的 DHCP／DNS 伺服器），他就可以**假裝自己是套件伺服器**，回一個被動過手腳的套件給我們，而我們的工具**完全分辨不出來**。

更要緊的是這一行還標了 `priority = "primary"`（`:51`），意思是**優先用它**——所以不只是自家套件，連外部套件都走這條線。

**出事會怎樣**

被掉包的套件一旦裝上去，程式碼在 `import` 的當下就會執行。所以：

- **開發人員的電腦**被拿下
- **打包機（188）被拿下**——而打包機產出的 image 與安裝包是**要送到客戶機房**的，等於惡意程式跟著出貨
- 執行 `publish.sh` 發版時，**發版用的帳密也走同一條沒加密的線**，一併被看光

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

- 攻擊者要能插進內網那條路徑（例如接進公司網路、或拿下一台網路設備）
- 有人跑 `poetry install`、`poetry update` 或 `publish.sh`——這是每天都在發生的事

**在哪裡**

- `pyproject.toml:50` — `url = "http://192.168.50.171:8082/repository/pypi-group/simple"`
- `pyproject.toml:51` — `priority = "primary"`（所以所有套件都走這條）
- **同樣的設定在其他 24 支 `jedi-*` 套件與主專案的 `pyproject.toml` 內都有一份**

**怎麼修**

1. 讓那台 Nexus 伺服器**支援 HTTPS**（架 TLS 憑證）
2. 把這 25 支套件與主專案 `pyproject.toml` 內的 `http://` 全部改成 `https://`
3. 如果用的是公司自簽憑證，**把根憑證發到每台開發機與打包機**，而不是因為「憑證有問題」就退回用 `http://`

**嚴重度說明**：評 MEDIUM 而非 HIGH，是因為需要先取得內網中間人位置這個前提。三位檢查員中有一位評 HIGH（理由是打包機的產出會流向客戶），工具取三票的中位數記為 MEDIUM。**以我們的實際情境看，這條的投報率最高**——修法明確、影響面最大、而且是一次改完 25 支就永久解決。

**首腦核對註記**：`pyproject.toml` 不在本棒 30 檔的 scope 內（scope 只含 `.env` 與 `publish.sh` 兩支根目錄檔）。工具是為了追 `publish.sh` 的發版行為而越界讀到它。**判定為真實且應報**——`publish.sh` 的內容就是 `poetry publish --build -r nexus`，那個 `nexus` 指的正是 `pyproject.toml:50` 這一行，兩者是同一件事的兩半，不算離題。

---

### C3-2（MEDIUM）分頁的「一頁幾筆」沒有上限

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

所有列表 API（專案列表、稽核列表、POA&M 列表……）都吃同一組分頁參數：「要第幾頁」和「一頁幾筆」。「第幾頁」有檢查（至少要 1），但**「一頁幾筆」完全沒有上限**。

所以任何登入的人都可以說「給我一頁一億筆」，系統就真的會去資料庫撈一億筆。

**出事會怎樣**

一個請求就能讓資料庫把整張表讀進記憶體、再轉成物件、再轉成 JSON。同時送幾個這種請求，**應用程式的記憶體與資料庫連線就被吃光**，其他客戶跟著一起卡住。

另一個角度：分頁本來也有「限制一次能撈走多少資料」的作用，沒上限等於這個限制不存在。

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

- 只要有**任何一個有效登入帳號**即可，不需要特殊權限
- 影響的是所有繼承 `RequestMetaSchema` 的列表端點（feedback、module_frame、information_system、audit、poam 等）
- 已確認**沒有任何呼叫端自己補上限**（整個主專案只有一支 flow_engine 的 schema 有上限，但它用的是不同的欄位名 `size`，救不到這裡）

**在哪裡**

- `jedi_common/interfaces/schema/common.py:13` — `page_size = fields.Int(missing=25)`
- 對照上一行 `:12` — `page = fields.Int(missing=1, validate=validate.Range(min=1))`，有檢查

**🔴 這個上限是被刻意拿掉的，不是忘了寫**

我查了 git 紀錄，commit `a79d002`（2026-03-03，標題「update pager max limit」）把這一行從：

```python
page_size = fields.Int(missing=25, validate=validate.Range(min=1, max=100))
```

改成：

```python
page_size = fields.Int(missing=25)
```

**原本有 max=100，被整個移除了。** 這件事很重要：修的時候要先弄清楚**當初為什麼拿掉**（很可能是某個頁面需要一次撈超過 100 筆），否則改回去會把那個功能弄壞。

**怎麼修**

1. 在 `schema/common.py:13` 加回上限，但值要放寬到實際需要的量：
   `page_size = fields.Int(missing=25, validate=validate.Range(min=1, max=200))`
2. **另外在 `base_repository_impl.py` 的分頁實作裡再夾一層保險**——在 `.limit()` 之前把值壓到上限內。這樣即使將來又有人把 schema 的檢查拿掉，資料庫也不會被打爆
3. 先查清 `a79d002` 當初為何移除，確認真正需要的上限值

---

### C3-3（MEDIUM）密碼遮罩遇到含雙引號的密碼只遮一半

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

系統會把每個 API 請求與回應的內容寫進資料庫的 `api_logs` 表存 90 天，方便除錯。為了不把密碼一起寫進去，中間有一道**遮罩**：看到欄位名稱像 `password`、`secret`、`token` 這類，就把值換成 `***`。

問題出在這道遮罩是用**文字比對**做的，它認的格式是 `"欄位名":"值"`，而它判斷「值到哪裡結束」的方式是**「找到下一個雙引號就算結束」**。

所以如果密碼本身含有雙引號——例如 `ab"cdSUPERSECRET`——遮罩會**在密碼中間那個引號就停住**，只遮掉前半段，**後半段 `cdSUPERSECRET` 原文寫進資料庫**。

**諷刺的地方**：越是強度高、含特殊符號的密碼，越容易踩到這個洞。

**出事會怎樣**

`api_logs.request` 與 `api_logs.response` 這兩個欄位會留著密碼的後半段明文，**保留 90 天**。會受影響的包括 LDAP 綁定帳號密碼、SSH 私鑰密語、各種偵測工具的憑證。

能看到的人：資料庫管理者、任何有 log 匯出權限的人、以及拿到資料庫備份的人（含 90 天內的任何一份備份）。

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

- 密碼裡有 `"` 這個字元。**目前 LDAP 的 `secret` 欄位與工具憑證欄位都沒有限制可用字元**，所以使用者完全可能設出這種密碼
- 請求經過 `common/middleware/app_mw.py`——這是全域註冊的，**每個請求都會過**
- 有人能讀到 `api_logs` 表、備份或匯出檔

**在哪裡**

- `jedi_common/utils/common_utils.py:160` — 那一行的值比對寫成 `"[^"]*"`，`[^"]` 的意思就是「不是引號的字元」，所以碰到引號就停
- 遮罩函式：同檔 `:150` `mark_password`
- 呼叫端：`compliance-manager-be/common/middleware/app_mw.py:51`（request）與 `:101`（response）

**怎麼修**

**不要用文字比對來遮密碼。** 正確做法是把內容當成 JSON **解析成結構**，然後逐層走訪，看到欄位名是密碼類的就整個值換掉，最後再轉回文字。解析不了的就整包不要寫。

這個改法同時修掉同一道遮罩的另外兩個漏遮情形（見下方 C3-4 與補充）：

- **值不是字串就完全不遮**：`"token": 123456789`（數字）、`"secret": ["abc"]`（陣列）都會原封不動寫進去，因為比對式只認 `"值"` 這種字串形態
- **非 JSON 格式完全不遮**：表單格式的 `password=xxx` 整包漏掉

**首腦核對註記**：這兩個補充情形（非字串值、表單格式）在面板被單獨列為一條候選（C5／F6），**三位檢查員以 2:1 否決**。否決理由是：目前系統裡所有標記為 secret 的欄位在資料庫 seed 裡都是字串型別、前端也只送字串；而表單格式的請求在到達遮罩之前內容已被讀空，實際寫進 log 的是空的。**所以那條不成立**——但既然遮罩要重寫，順手把這兩種形態一起處理掉，成本是零。

---

### C3-4（LOW）JSON 跳脫形式的引號也一樣漏

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

這是 C3-3 的同一個根因，差別在密碼裡的引號**寫進 JSON 之後會變成 `\"` 這種形式**（前面多一個反斜線）。那道遮罩同樣不認識這種寫法，一樣在那裡停住。

分開記錄是因為它多一個副作用：**產生出來的紀錄變成壞掉的 JSON**，後續要解析 log 的程式會出錯。

**出事會怎樣**

密碼後半段留在 log 裡（同 C3-3），加上 log 紀錄本身格式壞掉、不能可靠地被解析。影響範圍比 C3-3 窄——只有引號之後那一段會漏。

**在哪裡**

- `jedi_common/utils/common_utils.py:159` — `return re.sub(` 那一段的值比對式

**怎麼修**

同 C3-3，改成結構化解析。如果基於某些理由一定要保留文字比對當備援，值的比對式要改成能認得跳脫字元的形式：`"(?:\\.|[^"\\])*"`。

**嚴重度說明**：評 LOW 而非 MEDIUM，是因為只有「引號之後」那一段會漏，且要密碼剛好含引號。C3-3 評 MEDIUM 是因為它涵蓋的情境更廣（包含未跳脫的原始形態，以及整個密碼以引號開頭時**全部**外漏的情形）。

---

### C3-5（無問題）信封組裝失敗把錯誤原文當資料回前端——查了，進不去

**卡片把這條列為「本棒最重要」，結論是不成立。**

**卡片的疑慮是什麼**

`utils/response_util.py:23-27` 有一段：

```python
except Exception as e:
    status = False
    current_app.logger.exception(e)
    data = e.args[0]
    return {"status": status, "data": data}
```

擔心的是：如果信封組裝過程出錯，`e.args[0]`（錯誤的原文，可能含檔案路徑、資料表名稱）會被當成 `data` 回給前端。

**為什麼不成立**

我把 `try` 區塊裡的程式逐行看過，裡面只有三件事：

1. `isinstance(data, dict)` — 型別判斷，不會拋例外
2. `data.keys()` — 前一行已確認是 dict 才會執行
3. `data.get(...)` 與組一個新的 dict — 都不會拋例外

**這個 `try` 區塊裡沒有任何會失敗的操作**，所以 `except` 分支實際上進不去。真正的序列化（把 dict 轉成 JSON）發生在這個函式**回傳之後**，由 Flask 處理，不在這個 `try` 的範圍內——那裡出錯會走 Flask 自己的錯誤處理，不會走到這一行。

**還是值得做的小事**

這段程式碼**本身是個誤導**——它看起來像在防某件事，實際上什麼也沒防，反而讓後來的人（包含開卡的首腦）以為有風險。建議直接把 `try/except` 拿掉，或者改成重新拋出例外。**這不是資安問題，是可讀性問題**，優先度低。

---

### C3-6（LOW）一支開發輔助工具混在正式套件裡

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

`jedi_common/utils/gen_comment.py` 是一支**開發時用來自動產生程式註解的小工具**——它把程式碼送給 OpenAI，請 AI 寫註解，再把結果**寫回原始檔案**。

這種東西不該放在正式出貨的套件裡。它現在造成三個具體問題：

**① 它會把檔案覆寫成空的。** `:49` 那一行寫 `with open(file_path, "w")`，但 `file_path` 這個變數**在那個函式裡根本沒有定義**（函式收到的參數叫 `path`，不是 `file_path`）。`"w"` 的意思是「開啟並清空」——所以真的有人去執行它，**檔案會先被清空，然後才因為變數不存在而出錯**，內容就沒了。

**② 錯誤被整個吞掉。** `:54` `except Exception as e: return f"Error: {str(e)}"`，出了任何事都只回一個字串，不會有人發現。

**③ 它讓 `openai` 變成產品的必要依賴。** `pyproject.toml:24` 把 `openai` 列在正式依賴裡，而**整個套件只有這支檔案用到它**。等於每個客戶的機器都裝了一套 OpenAI 用戶端，只為了一支沒人呼叫的開發工具。

**出事會怎樣**

目前**沒有立即的外洩風險**——我已確認（grep 過整個 monorepo 25 支套件與主專案）**零個地方 import 它**，而且它需要 `OPENAI_API_KEY` 這個環境變數才會動，客戶機上不會有。

實際的問題是：**它會跟著出貨**。它放在 `jedi_common/utils/` 底下，而打包設定 `pyproject.toml:44` 寫的是 `packages = [{ include = "jedi_common" }]`——整個目錄都包進去，所以這支檔案**確實會進到客戶機器上的 wheel 裡**。加上多一個沒必要的依賴、多一份攻擊面。

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

- 要有人真的去執行這支程式（目前沒有任何自動路徑會執行它）
- 才會踩到「覆寫成空」那個問題

**在哪裡**

- `jedi_common/utils/gen_comment.py:8` — 模組載入時就讀 `OPENAI_API_KEY`
- `jedi_common/utils/gen_comment.py:49` — `with open(file_path, "w")`，用未定義的變數開檔覆寫
- `jedi_common/utils/gen_comment.py:54` — 吞光所有錯誤
- `pyproject.toml:24` — `"openai (>=2.8.0,<3.0.0)"` 列在正式依賴

**怎麼修**

**直接把 `gen_comment.py` 從套件刪掉**，同時把 `openai` 從 `pyproject.toml` 的正式依賴移除。如果這支工具還有人要用，搬到 monorepo 的 `scripts/` 或開發工具目錄，不要放在會出貨的套件內。

這跟 CM-1515 當初把 pytest／testcontainers 從正式依賴搬進 dev group 是**同一類問題**（`pyproject.toml:29-35` 的註解有記錄那次），只是這次漏了 `openai`。

---

### C3-7（LOW，套件層）序列化工具原樣吐出整個物件，沒有欄位過濾

**卡片點名要回答的問題：「`to_serializable` 是套件層的系統性問題，還是那一支的個案？」**

**答案：是「套件提供的工具本身不做過濾」（套件層事實），但目前只有一支套件在用它（使用面是個案）。**

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

`to_serializable` 是一支把任意物件轉成 JSON 的工具。它的轉換方式是：**物件上有什麼欄位，就全部吐出來**——只跳過三個資料庫框架的內部欄位（`metadata`、`registry`、`sa_instance_state`）和底線開頭的私有欄位。

換句話說，**它沒有「哪些欄位不該給前端」的概念**。如果丟一個資料庫物件給它，物件上有密碼鹽值、有內部識別碼，它就原樣一起吐出來。

**使用面的實際情形（grep 全 monorepo 25 支套件 ＋ 主專案的結果）**

| 範圍 | 使用處數 |
|---|---:|
| 主專案 `compliance-manager-be` | **0 處** |
| 其他 24 支 jedi 套件 | **0 處** |
| `jedi-ai-dashboard` | **2 處** |

兩處都在 `jedi-ai-dashboard`：

1. `app/service/ai_dashboard_app_service.py:191` — 取樣本資料**送給 AI 當提示詞**。這裡吐出的整包內容會進到送往 AI 模型的訊息裡
2. `domain/service/dashboard_generation_domain_service.py:249` — 在 `_build_dynamic_table()` 內，把每一筆資料轉好之後**直接放進要回給前端的表格資料**（`table_data.append(serialized)`），而且下一行 `_auto_extract_columns(table_data[0])` 是**拿第一筆有什麼欄位就自動生成表格欄位**

**所以 FR-083 D1 F2（AI 儀表板回傳夾帶 `salt`）的成因是什麼**

是這兩件事**相乘**的結果：

- **套件層**：`to_serializable` 不做欄位過濾（這是事實，但它是一支通用工具，不過濾本身是合理的設計）
- **呼叫端**：`jedi-ai-dashboard` 拿它的輸出**未經收斂就直接當回應資料**，且欄位還是自動推導的

**修哪一邊？建議修呼叫端，不要修套件。** 因為 `to_serializable` 是通用工具，加上寫死的欄位黑名單會讓它在別的情境失準；而且「哪些欄位不能給前端」是業務知識，屬於呼叫端的責任。

**怎麼修**

在 `dashboard_generation_domain_service.py:249` 附近，**改成白名單**：只把 `columns_config` 指定的欄位放進 `table_data`，而不是整包塞進去再自動推導欄位。`ai_dashboard_app_service.py:191` 送 AI 的樣本同理，先挑欄位再送。

**若要在套件層補一層保險**（非必要，可選）：給 `to_serializable` 加一個選用參數 `exclude_keys`，讓呼叫端可以指定要排除哪些欄位，預設行為維持不變。

---

### C3-8（LOW，CM-1598 已知）`.env` 受版控——值是真的，但不是主庫密碼

**卡片要求：確認值是真憑證還是佔位符，只做相等性比對、不印值。**

**查了，結果如下（全程未印出任何值）：**

| 項目 | 結果 |
|---|---|
| 是否受版控 | **是**，`git ls-files .env` 有列出 |
| 共幾個設定 | 6 個：`DB_HOST`／`DB_PORT`／`DB_NAME`／`DB_SCHEMA`／`DB_USERNAME`／`DB_PASSWORD` |
| 值是空的嗎 | **都不是空的**，六個都有實際內容（用字元長度確認，未讀取內容） |
| 密碼是否等於 DEV 主庫密碼 | **不相等**——與主專案 `.env` 的 `DB_PASSWORD` 做相等性比對，結果為「不同」 |

**判讀**：這是一組**真的可以拿來連線的設定**（不是 `xxx`、`changeme` 這種佔位符），但**密碼與主專案 DEV 主庫用的那組不同**。合理推測是這支套件跑測試時用的獨立資料庫帳號。

**是否會被打包出去**：**不會。** 打包設定 `pyproject.toml:44` 只收 `jedi_common/` 這個目錄（`packages = [{ include = "jedi_common" }]`），而 `.env` 在套件根目錄、不在 `jedi_common/` 裡面，**不會進到 wheel**。所以它的外洩途徑只有一條：**任何拿得到這個 git repo 的人都看得到**。

**維持 CM-1598 原本的 LOW 評級，本棒不重報。** 建議的處置照舊：從版控移除、加進 `.gitignore`、改該帳號密碼，並在 `README` 註明要自建 `.env`。

---

### C3-9（無問題）savepoint 靜默吞例外——查了，行為正確

**卡片的疑慮**：`utils/savepoint.py:30-35` 的 rollback 失敗時 `except Exception: pass` 靜默吞掉，擔心資料庫連線會停在壞掉的交易狀態，連累後續請求。

**查了，這個寫法是對的。** 完整程式碼是：

```python
try:
    yield
    sp.commit()
except Exception:
    try:
        sp.rollback()
    except Exception:
        pass
    raise          # ← 關鍵在這一行
```

理由：

1. **原本的錯誤沒有被吞掉**——最後那個 `raise` 會把它往外拋，呼叫端一定會知道出事了
2. 被吞掉的只有「**回滾這個動作本身又失敗**」這件事。這種情況下連線已經壞了，再拋一個新例外只會**蓋掉原本那個真正有用的錯誤訊息**，讓除錯更難
3. 外層的 `@transaction` 會在請求結束時關閉整個 session，不會把壞掉的連線留給下一個請求

**這是處理巢狀交易的標準寫法，不需要改。** 唯一可以做的小改善：在 `except Exception: pass` 那裡補一行 `logger.warning`，讓回滾失敗這件事至少留下痕跡，方便日後追查。**優先度很低。**

---

### C3-10（無問題）兩套信封並存——查了，舊的那套零使用者

**卡片的疑慮**：`utils/response_util.py`（新，`status/data`）與 `utils/common_response.py`（舊，`code/msg/data`）兩套信封並存，擔心兩套的錯誤處理不一致（一套遮、一套不遮）。

**查了，舊信封目前沒有任何人在用。**

grep 全 monorepo 25 支套件與主專案的結果：`common_response` 只出現在 **2 個地方**，而且**兩處都是 `response_util.py:7` 與 `:9` 的註解文字**（那份註解正是在說明「這兩支不是同一支、不要搞混」），**沒有任何一行實際的 import 或呼叫**。

**所以不存在「兩套行為不一致」的風險**——只有一套在跑。

**建議**：既然零使用者，可以直接刪掉 `common_response.py`，連同 `response_util.py` 檔頭那段說明註解一起清掉。**這是整理工作不是資安修補**，優先度低。

---

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

卡片列了八個觀察點，逐項回答如下（**不留白**）：

| # | 卡片的觀察點 | 查證結果 |
|---|---|---|
| **①** | 信封組裝失敗把例外訊息當 data 回前端（卡片標「本棒最重要」） | **不成立**。`try` 區塊內沒有任何會拋例外的操作，`except` 分支進不去。詳見 C3-5。**（工具未報，runner 人工查證）** |
| **②** | `gen_comment.py` 不該進 runtime 套件 | **成立**。零 importer（已 grep 25 支套件＋主專案），但**確實會被打包進 wheel**（在 `jedi_common/` 目錄內），且拖著 `openai` 成為正式依賴。詳見 C3-6。**（工具未報，runner 人工查證）** |
| **③** | 分頁 `page_size` 沒有上限 | **成立，且是被刻意移除的**——commit `a79d002` 把 `max=100` 拿掉。工具三票一致確認。詳見 C3-2 |
| **④** | 序列化不濾敏感欄位；**卡片指定要答「系統性還是個案」** | **答案：套件層工具確實不過濾（系統性事實），但使用面只有 `jedi-ai-dashboard` 2 處（個案）。主專案與其餘 24 支套件皆 0 處。** FR-083 D1 F2 是「工具不過濾」×「呼叫端未收斂」相乘的結果，**建議修呼叫端**。詳見 C3-7。**（工具未報，runner 人工查證）** |
| **⑤** | 密碼遮罩只吃一種格式 | **成立，且比卡片預期的更嚴重**——不只漏掉非字串與表單格式，連**字串值本身含引號都會遮一半**。工具報了兩條（C3-3／C3-4，各 3/3 票）。卡片原本擔心的非字串／表單情形被面板 2:1 否決，理由見 C3-3 末段 |
| **⑥** | `savepoint.py` rollback 失敗靜默 | **不成立**。最後有 `raise`，原始例外照常往外拋；被吞的只有「回滾本身失敗」，那是標準寫法。詳見 C3-9。**（工具未報，runner 人工查證）** |
| **⑦** | 兩套並存的回應信封 | **不成立**。舊信封 `common_response` **零使用者**，只在註解裡被提到。不存在行為不一致風險。詳見 C3-10。**（工具未報，runner 人工查證）** |
| **⑧** | 打包與發布：`.env` 是否真憑證、會否打進 wheel | **值是真的可連線設定**（六項皆非空），**但密碼與主專案 DEV 主庫不同**；**不會打進 wheel**（不在 `jedi_common/` 內）。另外查出**發版腳本走的是沒加密的連線**（C3-1，本棒最高投報率的發現）。詳見 C3-8 與 C3-1 |

---

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

**分兩層回答，這兩層的可信度差很多，不要混在一起看。**

### 第一層：「這些問題真的存在嗎？」——**高**

- **工具報的 4 條（C3-1～C3-4）**：每一條都經過**三位獨立檢查員投票，全部 3:3 一致通過**。18 票全數投出，沒有漏投，stamp 記為 `verified`
- **runner 人工查證的 6 條（C3-5～C3-10）**：每一條我都自己打開檔案看過那幾行，行號逐一核對過。其中「不成立」的四條（C3-5、C3-9、C3-10 與 ⑤ 的部分）是**讀完程式邏輯後的判斷**，不是猜的
- **git 紀錄的證據是硬事實**：C3-2 的 `a79d002` 移除上限、C3-7 的 grep 計數，這些是可以重現的查詢

**但要講清楚：沒有任何一條做過實際攻擊驗證。** 掃描過程**完全沒有執行任何程式碼**，所有結論都來自讀程式碼。例如 C3-1 沒有真的架中間人測試、C3-2 沒有真的送 `page_size=一億` 打看看。要實測的話需要另外開一棒。

### 第二層：「只有這些嗎？」——**中等，不能說這個範圍掃透了**

- **面板完整跑完**（這一點比前幾棒好）：6 條候選 × 3 票 = 18 票全投出，沒有中途陣亡。所以「工具看過的部分」是有結論的
- **但工具本來就報得保守**：它只報「能講出完整攻擊路徑」的問題。C3-5～C3-10 六條全是**卡片指定要追、但工具沒報**的——其中兩條（C3-6、C3-7）是真的有問題，四條查完是沒問題。這代表**工具的沉默不等於乾淨**
- **`enums/` 與 `constants/` 這兩個目錄沒有任何發現**。這符合預期（純常數定義），但我沒有逐檔讀完，只能說「工具讀過、沒報」
- **這一棒是 `low` effort**：只派一位研究員讀完整個範圍，沒有威脅建模、沒有廣度掃描。這是為了讓面板跑得完而刻意選的設定（見首腦手冊第一節），代價就是研究深度

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

**C3-1 的 `pyproject.toml:50` 不在本棒 30 檔的 scope 內。**

工具是為了追 `publish.sh`（在 scope 內）的發版行為而讀到它的。我判定**應該保留這條**，理由是 `publish.sh` 的全部內容就是 `poetry publish --build -r nexus`，那個 `nexus` 指向的正是 `pyproject.toml:50`——兩者是同一件事的兩半，拆開來看沒有意義。

**但要提醒首腦**：這個問題**不是 jedi-common 專有的**，同樣的 `http://` 設定在其餘 24 支套件與主專案的 `pyproject.toml` 內都有一份。開修正卡時應該當成**一張跨全 monorepo 的卡**，不要當成 FR-085 的子問題。

其餘 9 條（C3-2～C3-10）**全部落在 scope 內**，無越界。

---

## 7. 執行概況

| 項目 | 數字 |
|---|---|
| 掃描範圍 | 30 檔（`git ls-files` 實測，與卡片一致） |
| 版本 | commit `8aa6f060` |
| effort／focus | `low`／`attack-surface`（含密鑰專項） |
| 研究員派出／回報 | **2／2**（零陣亡） |
| 候選發現 | 6 條 |
| 面板票數 | **18 票**（6 條 × 3 位檢查員），**全數投出，零漏投** |
| 通過（3:3 一致） | **4 條** |
| 否決 | **2 條**（一條 0:3、一條 1:2） |
| 嚴重度被面板下修 | **0 條** |
| stamp `verification.status` | **`verified`** |
| 驗證輪數 | 1 輪（無候選遺失、無待驗） |
| 工具耗時 | 約 41 分鐘 |
| runner 人工追加 | 6 條（卡片八項中工具未答的部分） |
| 產物位置 | `jedi-common/CLAUDE-SECURITY-20260911-034053/` |

**被否決的兩條是什麼（值得記錄，這是三人面板的價值）**：

1. **「巢狀物件裡的密碼不會被遮」**——0:3 全數否決。檢查員指出遮罩作用在**攤平的文字**上而不是解析後的結構，所以不管巢狀多深都照遮，並引用主專案自己的測試 `test_api_log_secret_masking.py:14-37` 為證（那個測試就是在驗巢狀密碼有被遮掉）
2. **「非字串值與表單格式的密碼會漏」**——1:2 否決。理由是目前所有 secret 欄位在資料庫 seed 裡都是字串型別、前端也只送字串；表單格式的內容在到達遮罩前已被讀空

**這正是面板的用處**：沒有它，會有人花時間去修兩個實際上不存在的問題。

---

## 8. 建議的下一步

按投報率排序（**這是建議，開卡與排序由首腦裁決**）：

1. **C3-1（換 HTTPS）投報率最高**——修法明確（架 TLS ＋ 改 25 支 `pyproject.toml`），一次解決永久有效，而且它威脅的是打包機，產出要送客戶。**建議開成跨 monorepo 的獨立卡，不掛在 FR-085 底下**
2. **C3-3／C3-4（重寫密碼遮罩）綁一起修**——同一個根因、同一支函式，順手把非字串與表單格式一起處理掉，成本是零
3. **C3-2（分頁上限）修前要先查 `a79d002` 為何移除**——直接改回去可能弄壞某個需要大量撈資料的頁面
4. **C3-7（AI 儀表板欄位白名單）與 FR-083 D1 F2 是同一件事**——建議併卡，修呼叫端不修套件
5. **C3-6（刪 `gen_comment.py` ＋ 移除 `openai` 依賴）** — 低風險的清理，可以順手做
6. **C3-5／C3-9／C3-10 三條「無問題」不需開卡**，但 C3-5 的死 `try/except` 與 C3-10 的零使用者舊信封建議順手清掉，避免下一個人又被誤導（C3-5 這次就誤導了開卡的首腦）

**這一棒到此結束，FR-085 三棒全部掃完。**
