P2 檢查結果:選單字典全鏈(jedi-system-core)

P2 檢查結果:選單字典全鏈(jedi-system-core)

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


§1

🔴 一句話結論

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


§2

這一棒在檢查什麼

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

這一棒檢查這張表從網頁請求到資料庫的完整路徑(33 個檔案),要回答兩個問題:

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

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


§3

掃到什麼:0 個問題

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

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


§4

唯一那條候選:為什麼被否決,以及為什麼仍要記一筆

候選的內容

前台取下拉選項的那支端點(任何登入者都能打),回傳前只過濾了「啟用」、沒有過濾「公開」:

# 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 行改成同時檢查兩個欄位即可。


§5

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

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

卡上第幾點 結果
① 四條管理端點守門有沒有漏、順序會不會被跳過 ✅ 沒有漏。分頁查詢、分類清單、新增、修改/刪除四條,每一條都疊了平台管理員檢查。順序一律是「先要登入 → 再檢查能力 → 最後平台管理員」,平台管理員那道在最內層,跳不過去
② 前台端點會不會回出不該給的列 ✅ 今天不會,理由見上一段。但缺口形狀是真的,已記一筆
③ 讀選單會不會把整包寫進日誌 ⚠️ 會,但這一半無機密。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 筆全是下拉選項文字;路由那支塞的是選單管理頁自己的路由,綁的能力點全是平台層。兩支都有防重複機制(一支用「不存在才插入」、一支用「撞到就跳過」),重複執行不會塞兩份,也不會把客戶改過的值蓋回預設

§6

已知背景(掃到不要重報的兩件事)

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

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

§7

這份結果可信到什麼程度

分兩層,答案不一樣:

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

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

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

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

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


§8

執行概況(技術細節)

項目 數字
檢查範圍 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/