D2-4b 版本存取層與規則清單 — 掃描報告

D2-4b 版本存取層與規則清單 — 掃描報告

這一棒掃了什麼

掃描目標 /Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-detection,revision dfe260a5ad0c(branch feature/review),mode scan,effort low,範圍 8 個檔案共 837 行(42KB):版本從檔的資料存取層 detection_profile_version_repo_impl.py(225 行)、三支資料表定義 detection_profile.py(69)/detection_profile_control.py(74)/detection_profile_version.py(102)、三支查詢條件物件 detection_profile_query_entity.py(25)/detection_profile_version_query_entity.py(18)/detection_profile_control_query_entity.py(17)、掃描目標語法與展開 scan_target_spec.py(307)。

工具沒有實際執行任何程式碼:沒跑測試、沒發請求、沒示範攻擊,所有判斷都是讀原始碼推出來的。

結論一句話

淨新增零條。 工具報出並通過三票驗證的那一條,與 D3-2 已登記的第 105 項是同一個缺口的同一條鏈(同樣的換工具繞過、同樣的展開端無上限),標重複、不另計。卡片重點⑥的三個問題逐項人工查證,都沒有發現新漏洞,但查出兩件值得記的事(一個脆弱設計、一個查詢陷阱),列在第 3 節。

現況(2026-10-01):本棒各條後來的處理結果如下(過程紀錄保留,不改)。

  • 工具報的那條重複第 105 項=M03 第 11 條,✅ 已修(CM-2058)。

1. 工具報的那一條 — 與第 105 項重複

工具報的:scan_target_spec.py:237 expand_spec() 把使用者填的網段整段展開成主機清單,自己不檢查台數;唯一的上限長在任務綁定處理器,而那道守門可以用「先綁豁免工具存大網段 → 再換成受管工具但不帶參數」繞過,執行時展開 1,670 萬個位址把後端吃垮。三票全數確認(可達性、影響、既有防護),嚴重度 MEDIUM、可信度 high。

首腦判定:重複,不另計。 逐項對照 D3-2 報告的 F1(scan-D3-2-job-binding.md,已登記為跨 arc 總表第 105 項):

環節 D3-2 F1 本棒 F1 相同?
豁免工具先存大網段 openvas 不在 SSH_ITERATED_TARGET_FIELDS 同 ✅
換工具不帶 params job.py:256 load_default=None 同 ✅
檢查被跳過 detection_job_binding_handler.py:201-204/:331-333 同 ✅
舊值不被覆蓋 base_repository_impl.py:424 value is not None 同 ✅
展開端無上限 detection_orchestration_service.py:555 → expand_spec 同 ✅
分派列那條路也成立 首腦補查(:536 提早 return) 工具這次自己提到了 ✅

一字不差的同一條。 本棒的價值在於獨立重現:換一組研究員、換一個範圍切法(這次 scan_target_spec.py 是被當作接縫參照放進來的),仍然走到同一個結論,且這次三票的可信度是 high(D3-2 那次是 medium)。修法不變、修正卡不重開,沿用第 105 項既有的兩層修法(寫入端用生效值重驗 + 展開端加硬上限)。

2. 卡片重點⑥逐項人工查證

工具一條都沒碰到卡片列的問題(與 D2-3 的觀察一致:「列清單」與「找洞」是兩種活動),以下全是開檔查的。

⑥-1 detection_profile_controls 零隔離是不是漏了 — ✅ 是刻意設計,且路徑成立

先講判準。 規則清單表確實不掛 RLS(migration 002-detection-rls-grants.sql 的 ENABLE ROW LEVEL SECURITY 清單裡沒有它),資料表定義的說明也寫明理由:隔離由上層版本從檔承擔,因為「控制項永遠是被帶出來的——任何查詢都得先有 version_id,而 version_id 只能從已受 RLS 保護的從檔取得」。

這句話是真的嗎?我把每一條讀取路徑都追了:

  • 資料存取層只開一個入口:detection_profile_control_repo_impl.py 只有 list_by_version_id(version_id) 一支讀法,它確實只吃 version_id。
  • 全套件只有三個呼叫端,每一個都先經過從檔:
    1. detection_profile_extraction_service.py:284(規則清單頁)— 先 get_version_by_uid(version_uid),查不到就丟 404,拿到的 version.id 才往下傳
    2. detection_profile_service.py:864(複製基準)— 先 _require_profile(uid) 再 get_current_version(source.id),兩關都在受保護的表上
    3. detection_profile_extraction_service.py:547(抽取落庫)— 先 get_version_by_uid
  • 對外端點收的是 uid 不是 id:唯一的規則清單端點 GET /detection-tool-profile-versions/<uid>/controls 收從檔 uid,路由層沒有任何地方能直接餵 version_id 進來。
  • 沒有「拿規則 uid 直接查」的路:get_by_uid 在 domain service 有定義,但全套件零呼叫端(grep 確認),沒有繞過版本層的第二條入口。

結論:現況沒有漏。 每一條路都先撞到掛 RLS 的從檔。

但要記一件事(不是漏洞、是脆弱):這道保護完全靠約定,沒有任何機制強制。list_by_version_id() 是公開方法,任何新寫的程式碼只要手上有一個 version_id 就能直接呼叫,資料庫層不會攔——因為那張表根本沒有 policy。今天成立是因為「目前只有三個呼叫端、三個都守規矩」,不是因為系統擋得住。第四個呼叫端寫錯的那天,不會有錯誤訊息,只會悄悄回別人租戶的資料。 建議:在資料存取層那支方法的說明加一行明講此約定(目前寫在應用層 _read_controls 的註解裡,寫錯的人不會看到),或更硬一點——讓它只收從檔實體而不收裸 version_id,把約定變成型別。

⑥-2 list_controls(version_uid) 有沒有先驗版本歸屬 — ✅ 有,靠 RLS

list_controls → _read_controls → get_version_by_uid(uid) → 查 detection_profile_versions,該表掛了 SELECT policy,跨租戶的 uid 查出來是空的、回 None、丟 404。歸屬檢查是資料庫做的,不是應用程式判斷的——這比程式判斷更可靠(沒有「忘了加 if」的空間)。

一個看起來像漏洞其實不是的點:SELECT policy 是 scope = 'SYSTEM' OR 超級管理員 OR 租戶符合,公版(SYSTEM)全租戶可讀是刻意的(公版的定義就是 root 維護、人人可讀),不是隔離破口。

同時發現一件重複的事:這支端點只有授權檢查(@require_license)、沒有能力點檢查。這正是 D2-1a 報過的第 113 項(「宣告了 detection-profile.read 但後端讀取端一支都不檢查」),套件自己的契約檔註解也寫明了這個現況。同一件事的又一個實例,標重複、不另計——修第 113 項時這支會一起好。

⑥-3 查詢條件物件的幽靈 WHERE — ✅ 三支都沒有

三支查詢條件物件每一欄都是 Optional[X] = None,to_dict() 過濾掉 None,底層 _gen_filters() 也獨立跳過 None。兩道都在,沒有任何欄位會在使用者沒指定時偷偷變成查詢條件。

但查到一個真陷阱(非安全性,是正確性):基準主檔的查詢條件物件在分頁列表的底層處理中,所有字串欄位的條件會被 OR 起來而不是 AND(_get_pager_list_query 的規則二:「多個字串(含跨欄位)→ 以 OR 連接」)。也就是說,同時傳 scope='TENANT' 與 name='foo' 得到的不是「私有的且叫 foo 的」,而是「私有的或叫 foo 的」——條件放寬而不是收緊。物件自己的說明已經標了 name 走模糊比對的陷阱,但沒提跨欄位 OR 這件事。

這不構成跨租戶外洩(租戶隔離靠 RLS,不靠這層條件),但會讓「我以為我在篩選」的呼叫端拿到超出預期的清單。建議在該物件說明補一行,或在需要 AND 語意的地方改用明確的查詢方法。

附帶:版本存取層其餘方法 — ✅ 沒問題

  • replace_source()(就地換來源):先用 uid 查(RLS 擋),四個來源欄在同一次 flush 內一起寫定——這是必要的,因為資料庫有行級約束要求「檔案型必有檔案編號且網址為空、網址型相反」,走一般更新只會寫上新的、留著舊的,當場撞約束。抽取衍生欄一併歸零也是對的(留著會讓頁面顯示上一份來源的內容數)。
  • sync_scope() / demote_current()(批次更新):走資料庫層的批次更新,UPDATE policy 會逐列擋——一般租戶打不到公版列。打不到時是回 0 列、靜默不報錯,而這個情況應用層已經知道並有處理(規則清單頁的註解寫明「壓不動就維持原狀態,前端靠另一個旗標顯示靜態提示」)。
  • get_with_profile()(主從合成):JOIN 寫在資料存取層是正確的分層;它解掉的兩個破口(拆表後單查從檔會讓停用守門靜默失效、通知信名稱退化成 UUID)也確實是真問題。

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

「報出來的那條存在嗎」——高,但它是舊的。三票獨立確認,且與三天前 D3-2 那棒的結論逐環對得上;兩棒不同研究員、不同範圍切法走到同一結論,是很強的交叉驗證。

「只有這些嗎」——中等,兩個限制。

  1. 強度 low,一位研究員讀一遍(且是第三次派才跑完,見下節)。
  2. 重點⑥三項的結論全是人工查的,沒有三票背書——工具連一格都沒碰。這與 D2-3 的觀察完全一致:卡片重點保證覆蓋,工具負責找卡片沒想到的東西,兩者不可互相取代。

這棒的實質產出是「證明沒有」而不是「找到有」——零隔離的那張表逐條路徑追完確認保護成立、三支查詢條件物件確認乾淨。這類結論不會進風險總表,但它正是掃描該做的事:沒查過的乾淨與查過的乾淨,不是同一件事。

4. 執行概況

項目 值
run ID wf_5da460c3-559
掃描 commit dfe260a5ad0c9d0c1b254b6ce4c86bcd4a575005(jedi-detection,branch feature/review)
報告目錄 jedi-detection/CLAUDE-SECURITY-20260920-043536/
範圍 8 檔 837 行 42KB
強度 low(一位研究員 + 三票面板)
研究員 派 3 支、回 1 支——前兩支被自動壓縮誤殺(上游 bug #92424),第三支正常寫出候選
面板 2 條候選去重成 1 條 × 3 位檢查員 = 3 票,全數投出零駁回
耗時 約 4 小時 53 分
候選 → 成立 2 → 1(去重)→ 淨新增 0(與第 105 項重複)
驗證章 verified
journal failed 數 全程 0(停損線未觸發)

5. 這一棒的觀察

① 切小到 837 行仍被砍兩次,但撐過去了。 這是切棒紀律第一次在「棒很小卻仍被砍」的情況下驗證有效:安全線約 1,000 行的建議沒錯,但行數小不代表不會被砍——看門狗誤殺是隨機的,小棒的價值在於被砍後重試還跑得完(D2-2 的 1,133 行主檔連砍 25 支都沒撐過)。判準要改成「小到重試會成功」而不是「小到不會被砍」。

② 重複的發現有價值,但要明確標掉。 本棒與 D3-2 撞同一條,如果不比對就登記,總表會多一筆假的新風險、修正卡會重開一張。跨棒重複比對必須是驗收的固定動作,不能靠印象。

③ 「證明沒有」的報告要寫得跟「找到有」一樣仔細。 重點⑥三項都是否定結論,但把追查路徑寫清楚,下一棒(或半年後的人)才知道這塊已經查過、查到什麼程度、以及那道保護為什麼脆弱。只寫「沒問題」等於沒查。