---
title: P2 檢查結果：選單字典全鏈（jedi-system-core）
---

# P2 檢查結果：選單字典全鏈（jedi-system-core）

> 檢查日期 2026-09-15｜耗時 10 分 38 秒｜對應卡片 CM-1806｜掃描版本 `8bc5d27`

---

## 🔴 一句話結論

**沒有找到問題，這是三棒裡唯一乾淨的一棒。** 工具提了一條候選、三個檢查員一致否決；首腦另外照卡片六個重點逐條追了一遍，**確認否決站得住**，但也查出一件要記下來的事：**那條被否決的缺口，是靠「目前沒有那種資料」擋著的，不是靠程式擋著**。

---

## 這一棒在檢查什麼

「選單字典」是一張存**下拉選單選項文字**的表——使用者狀態有哪幾種、問卷題型有哪幾種、設備類型有哪幾種。全站所有頁面的下拉選單都從這裡讀。

這一棒檢查這張表從網頁請求到資料庫的完整路徑（33 個檔案），要回答兩個問題：

1. **誰改得動這本字典？**
2. **一個客戶改一筆，是不是就改到全站所有客戶了？**

第二個問題的答案是**「是」，而且這是刻意的、正確的**——這張表本來就是全站共用的一本字典，隔離起來會壞掉所有頁面。所以這一棒看的不是「資料會不會外洩」（表裡沒有客戶資料、沒有個資），而是**「改得動的人對不對」**。

---

## 掃到什麼：0 個問題

| # | 嚴重度 | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 |
|---|---|---|---|---|---|
| — | — | **無** | — | — | — |

工具提了 1 條候選，**三個檢查員一致否決（0 票通過 / 3 票否決）**。首腦復核後**同意否決**——理由見下一段。

---

## 唯一那條候選：為什麼被否決，以及為什麼仍要記一筆

### 候選的內容

前台取下拉選項的那支端點（任何登入者都能打），回傳前**只過濾了「啟用」、沒有過濾「公開」**：

```python
# jedi_system_core/api/routes/system_menu_route.py:39
res = [r for r in res if r.enable == 1]
#                        ↑ 只看 enable，沒看 public
```

這張表有一個 `public` 欄位（1 = 公開、0 = 私有）。研究員的主張是：**標成「私有」的那些列，會被這支端點回給任何登入者**。

### 三個檢查員為什麼一致否決

**因為「私有」的列一列都不存在，而且沒有任何一條路會生出來。** 首腦逐項核對過，四個來源全部對上：

| 查了什麼 | 結果 |
|---|---|
| 隨套件出貨的預設資料（74 筆） | **全部是公開**（`migrations/004-system-menus-seed.sql:15-88`，沒有一列結尾不是 `, 1)`） |
| 欄位預設值 | **預設就是公開**——資料表、程式模型、資料物件三處都寫 `1` |
| 誰寫得動這個欄位 | **只有新增與修改兩支端點**，兩支都要**平台管理員**（`system_menu_route.py:93`、`:113`） |
| 開發環境資料庫實查（唯讀） | **74 筆，標成私有的 0 筆**。唯一三筆「停用」的是出貨時就刻意關掉的任務類型碼（`exec_tool`／`file_upload`／`task`），內容就是它們自己的選項文字 |

**所以今天打過去，那個沒過濾的欄位選不到任何東西。** 否決正確。

### 🔴 但這件事要記一筆：擋住它的是「現在沒那種資料」，不是程式

**同一支程式裡就有反例**：管理端的分頁查詢**有**強制過濾公開欄位（`app/service/system_menu_service.py:38` 寫死 `_filter.public = 1`），前台這支反而沒有。

意思是：**「私有」這個設計在管理端被當真，在前台端沒有被當真**。今天沒事，是因為**還沒有人用那支（有好好守門的）修改端點把某一列標成私有**。哪天平台管理員標了一列，那一列就會立刻出現在所有登入者的下拉選單裡。

**這不是今天打得到的漏洞**（要先有平台管理員去建立那種資料），所以不列為發現。**修法很小**：把第 39 行改成同時檢查兩個欄位即可。

---

## 卡片上六個重點的追查結果

首腦逐條開檔追過，**這段是工具沒回答、由首腦自己補的**：

| 卡上第幾點 | 結果 |
|---|---|
| **① 四條管理端點守門有沒有漏、順序會不會被跳過** | ✅ **沒有漏**。分頁查詢、分類清單、新增、修改／刪除四條，**每一條都疊了平台管理員檢查**。順序一律是「先要登入 → 再檢查能力 → 最後平台管理員」，平台管理員那道在最內層，跳不過去 |
| **② 前台端點會不會回出不該給的列** | ✅ **今天不會**，理由見上一段。但**缺口形狀是真的**，已記一筆 |
| **③ 讀選單會不會把整包寫進日誌** | ⚠️ **會，但這一半無機密**。`app/service/system_menu_service.py:22` 把整包選單寫進日誌檔，與第 1 棒設定那半是**同一個壞習慣的第二處**。差別在**這張表裡沒有密碼也沒有個資**，寫進去的就是下拉選單的文字，**影響低**。另外查到這支方法還被 AI 儀表板申報成可查詢的 API（`di_containers/dashboard_apis/system_menu.py`），**但它回的內容一樣無機密** |
| **④ 排序欄位有沒有白名單** | ✅ **有**。底層會先把模型的真實欄位列出來，**只有對得上的才拿去排序**，對不上直接忽略（`jedi-common` 的 `base_repository_impl.py:271`）。送任意欄位名進不去 |
| **⑤ 撞鍵的錯誤訊息會不會洩漏既有的鍵值** | ✅ **不會**。訊息是固定的一句「系統選單 group + key 已存在」，**不帶任何實際值**。另外確認：修改時**可以**把 A 分組的列改成 B 分組，但若撞到既有的鍵會被資料庫唯一約束擋下轉成 409，**覆蓋不掉別人的列** |
| **⑥ 兩支 SQL 有沒有塞不該公開的東西、重複執行會不會出事** | ✅ **都乾淨**。選單那支 74 筆全是下拉選項文字；路由那支塞的是選單管理頁自己的路由，綁的能力點全是平台層。**兩支都有防重複機制**（一支用「不存在才插入」、一支用「撞到就跳過」），重複執行不會塞兩份，**也不會把客戶改過的值蓋回預設** |

---

## 已知背景（掃到不要重報的兩件事）

這兩件在派工前就查證過，**本次掃描結果與它們一致**：

1. **這張表沒有客戶隔離，是正確的、不是漏洞。** 開發環境實查確認：沒開隔離、0 條規則、74 筆全是碼表。套件自己的說明也寫明兩張表的租戶語意刻意相反：「設定是每個客戶自己的參數，選單是全站共用的字典」。
2. **查選單的請求格式六個欄位全部非必填、送空條件就回全表**——歸「共用底層送空條件回全表」那條線（跨 arc 總表 §6 A 組第 15 項）。以這張表的內容而言影響有限，**不另計一條**。

---

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

分兩層，答案不一樣：

### 「沒有問題，是真的嗎」→ ✅ 可信度高

- **面板完整跑完**：驗證章 `verified`，1 條候選、3 票全投、沒有中斷、沒有人調低嚴重度。
- **否決理由具體**：三個檢查員各自打開檔案指出「出貨資料全部公開」「寫入端要平台管理員」，不是空話。
- **首腦沒有只採信工具**：四個來源（出貨資料、欄位預設、寫入守門、資料庫實查）逐項自己核過，**結論一致**。
- **六個重點另外自己追了一遍**，工具沒碰的四項（守門順序、排序白名單、錯誤訊息、SQL 冪等）由首腦補完。

### 「是不是只有這些」→ ⚠️ 有兩點要講清楚

1. **這是快篩模式**：單一研究員讀完 33 檔，**不做盤點、不做威脅建模、不跑密鑰專項**。找的是「最明顯的」，不是窮舉。
2. **工具沒有交代自己讀了哪些檔**。這一輪的研究員**沒有回報「哪些檔讀到結論、哪些沒讀」**，所以**無法證明 33 檔每一支都被讀過**。33 檔裡有 13 支是空的 `__init__.py`（無內容可讀），但**剩下 20 支的逐檔覆蓋率，這份結果給不出保證**。六個重點由首腦補追，涵蓋的是卡片點名的路徑，不等於全檔覆蓋。

**⚠️ 特別提醒**：零發現**不等於「這一半是乾淨的」**。工具的已知盲點是**會被程式碼註解說服**——這一半的檔案 docstring 寫了大量「這是刻意設計」的說明（例如 route 檔頭就寫明「消費端點刻意不疊守門」），研究員讀到就會接受。**唯一那條候選正是撞上這種註解的地方，而它被否決的理由經查屬實**——但這代表註解確實在影響判讀。

---

## 執行概況（技術細節）

| 項目 | 數字 |
|---|---|
| 檢查範圍 | 33 個檔案（其中 13 支為空的 `__init__.py`，實質約 842 行） |
| 掃描的程式版本 | **`8bc5d27`**，工作區乾淨（`dirty: false`） |
| 掃描 run ID | `wf_97ccbfe7-532` |
| 驗證章狀態 | **`verified`**（完整通過，沒有中途被拒收） |
| 候選問題 → 去重後 | 1 → 1 |
| 投票數 | **3**（1 條 × 3 個檢查員），全部投完 |
| 通過 / 否決 | **0 通過 / 1 否決**（一致票） |
| 未審候選 / 面板中斷 / 被降級 | 0 / 0 / 0 |
| 派出／回報的研究員 | 1 / 1 |
| 研究員自陳未讀清單 | **無**（工具本輪未回報，故無法逐檔佐證覆蓋率） |
| effort / focus | `low` / 未設（範圍小，整包讀） |
| 耗時 | 638 秒（10 分 38 秒） |
| 派出的 agent | 4（全數完成，0 失敗） |

**為什麼只跑 10 分鐘**：投票成本＝候選數 × 3 位檢查員。P1 有 4 條候選跑了 2 小時 39 分、H1 有 2 條跑了 13 分、這棒只有 1 條。**流程一步沒跳**，驗證章完整。

**產物位置**（套件 repo，已被該目錄自己的 `.gitignore` 擋住不入版控）：
`jedi-system-core/CLAUDE-SECURITY-20260915-095516/`
