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

# 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](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 撞同一條，如果不比對就登記，總表會多一筆假的新風險、修正卡會重開一張。**跨棒重複比對必須是驗收的固定動作**，不能靠印象。

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