---
title: D2-2b 檢查結果：基準領域層與存取層（jedi-detection）
---

# D2-2b 檢查結果：基準領域層與存取層（jedi-detection）

> 檢查日期 2026-09-20｜對應卡片 CM-1874（D2 第 2 小棒的後半，原 D2-2 重切）｜檢查範圍 4 個檔案 499 行

## 🔴 一句話結論

**範圍內 0 條新發現。** 工具的研究員讀完四個檔、追完每一條路的上下游，**一條候選都沒提報**，
所以驗證面板沒有東西可以投票。

**但這個「乾淨」有前提，而且前提不在這四個檔裡面**：這四個檔自己**完全沒有租戶隔離的程式碼**——
隔離是靠資料庫的 RLS 政策擋下來的。我實際連 DEV 資料庫查過政策與帳號，**確認目前真的擋得住**；
但這代表**這幾個檔的安全性完全外包給資料庫**，任何一天有人改動政策或換了連線帳號，
這裡不會有任何一行程式碼發出警告。這一點記在第 2 節，**不是漏洞、是要知道的事實**。

## 這一棒在檢查什麼

客戶要建立「掃描基準」（一包掃描規則）。**D2-2a 看的是那支 1,133 行的服務本體**（業務判斷、守門、
公版 vs 租戶的 scope），這一棒看的是**它下面那兩層**：

| 層 | 檔 | 行數 | 做什麼 |
|---|---|---|---|
| 領域層 | `detection_profile_domain_service.py` | 190 | 基準主檔＋版本從檔的協調（同一個聚合） |
| 領域層 | `detection_profile_control_domain_service.py` | 48 | 控制項（某一版裡面的規則清單） |
| 存取層 | `detection_profile_repo_impl.py` | 177 | 主檔的實際查詢 |
| 存取層 | `detection_profile_control_repo_impl.py` | 84 | 控制項的實際查詢 |

**這兩層都是薄層**：領域層幾乎全是一行轉呼叫，真正組查詢的只有存取層那兩支。

**卡片指定的重點④**：查詢條件與 RLS 的實際落點、空條件會不會回全表（跨 arc 總表第 70 項）。

掃描目標 `/Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-detection`，
revision `dfe260a5ad0c`（branch `feature/review`，工作區乾淨），mode `scan`，effort `low`。
**與 D2-4a 同一個 commit**；與更早幾棒的 `955e409` 相比，這四個檔一行都沒變（已用 `git diff` 核對），
所以跨棒比較成立。

## Coverage

`low` 強度：一位研究員讀完這 4 個檔就提報候選，未做元件盤點、未做威脅建模，
`completenessCheckOutcome` 為 `not-applicable`。驗證跑了 **1 輪**，**0 個候選、面板 0 票**
（沒有候選就沒有東西可投），沒有候選遺失、沒有被駁回的候選、沒有嚴重度被降低。

研究員為了判斷這四個檔安不安全，讀了範圍外的檔：上游呼叫端（`detection_profile_service.py`、route 層）、
守門（能力點 decorator、`_guard_system_writable`、`assert_scope_writable`）、
資料庫政策（套件的 `002-detection-rls-grants.sql`、主專案的出貨基線），
以及**執行時實際用哪個帳號連資料庫**（`.env`、`install.sh`）。
**這些讀取是必要的**——這四個檔裡面沒有任何一行在做租戶隔離，不往外追就無法判斷「那到底誰在擋」。

**這一棒跑得普通**：派了 2 位研究員，第一位在約 50 分鐘後被換掉，第二位完成，**總耗時約 1 小時 45 分**。
⚠️ 與 D2-2a 完全一致的現象：**journal 從頭到尾沒有出現過 `failed` 這個字**，
研究員被換掉的呈現方式是**同一個 key 再出現一筆 `started`**（D2-2a 的報告已更正過這個判準，本棒再次印證）。

**工具沒有實際執行任何程式碼**：沒跑測試、沒發請求、沒示範攻擊。
**但我自己連了 DEV 資料庫查政策與帳號**（唯讀 `SELECT`，屬環境鐵律允許的讀取），第 2 節的結論是實查來的。

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - 本棒範圍內沒有資安發現；體質項登記為跨 arc 總表 §3.2 第 31 項（前提說明，非要改的程式），無修正卡。

## 1. 卡片重點④逐項人工查證

**結論：查詢條件沒有破口，空條件回全表的問題在這四個檔不成立。** 逐項如下。

### ① 查詢條件會不會漏掉租戶 — ✅ 兩支列表都顯式收斂，理由成立

`_visible_scope_filter`（`detection_profile_repo_impl.py:47-53`）把可見範圍定義成三種的聯集：
**公版 ＋ 本租戶自有 ＋ 上游分享下來的**。兩支列表（`list_profiles:75`、`list_menu:120`）都掛了它。

**為什麼不能只靠 RLS**：root 租戶的可存取路徑是 `/1/`，前綴涵蓋**所有**子租戶——
只靠 RLS 的話，平台管理員在管理頁會看到全站每一個租戶的私有基準。
程式碼的註解寫明了這件事，**而且註解是對的**（我查了 RLS 政策確認）。

### ② 那個「無條件放行 SHARED」看起來很可疑 — ✅ 實查確認擋得住

第 52 行的 `DetectionProfile.scope == _SCOPE_SHARED` **沒有任何附帶條件**。
單看這一行，任何租戶的任何 SHARED 基準都會被撈出來——**這是整個範圍裡最像漏洞的一行**。

**實查結果：擋得住，但擋的人不在這個檔裡。** 我連 DEV 資料庫核對：

```sql
-- 目前 detection_profiles_select 政策的實際內容（DEV 實查）
scope = 'SYSTEM'
  OR app.is_super_admin = 't'
  OR app_tenant_allowed_for_session(tenant_id)
  OR (scope = 'SHARED' AND app_tenant_is_ancestor_of_session(tenant_id))   ← 這一段
```

最後那一段要求「資料的租戶必須是我的祖先」——**平輩租戶的 SHARED 資料照不出來**。
程式碼的註解也寫明「祖孫關係由 RLS policy 判定，這裡不重算租戶樹，重算等於第二套真相」，
**這個取捨是對的**（兩邊各算一次，漂移時無人察覺）。

⚠️ **但這段政策不在套件自己的 migration 裡**。套件的 `002-detection-rls-grants.sql:75` 那份**沒有 SHARED 分支**，
是 2026-09-14 主專案的 FR-094 migration（`2026-09-14-fr094-cm1790-shared-scope-subtree.sql`）後來蓋上去的。
**D2-2a 已經報過這個落差**（套件自帶建表腳本停在舊值域），這裡是同一件事的另一面：
**套件的 RLS 腳本也停在舊版**。新裝的客戶若只跑套件那份、沒跑主專案那支，
第 52 行就會變成真正的跨租戶外洩。**這屬「出貨基線待重產」，只回報不自行處理。**

### ③ 寫入路徑有沒有驗「這是不是我的東西」 — ✅ 沒有，但資料庫擋得住（要知道的是它怎麼擋）

`clear_fields`（`:130`）與領域層的 `update` / `delete_profile` **都沒有任何 `tenant_id` 比對**——
拿到 uid 就直接改／刪。上游 `detection_profile_service.py` 也只在 scope 有變動時才多判一道。

實查 DEV 的政策，UPDATE／DELETE 的判準是 `app_tenant_allowed_for_session(tenant_id)`：

| 方向 | 結果 |
|---|---|
| 平輩租戶（A 改 B 的） | ❌ 擋下，零列被改（表現為更新失敗，不是靜默成功） |
| 子租戶改母租戶的 | ❌ 擋下 |
| 母租戶改子租戶的 | ✅ **會成功** |
| 任何人改公版 | ❌ 擋下（政策明寫 `scope <> 'SYSTEM'`） |

**母改子是刻意的**：FR-094 那支 migration 的註解寫明「update／delete 一律不加 SHARED 分支——
子租戶對分享資源唯讀」，而母對子本來就在可存取路徑內。**這是設計不是缺口。**

另外要打到這些寫入，得先知道那支基準的 uid，而列表與下拉都已經用 `_visible_scope_filter` 收斂過，
**看不到的東西拿不到 uid**。

### ④ 空條件會不會回全表（總表第 70 項）— ✅ 這四個檔不成立

第 70 項講的是「query entity 每一欄都沒填 → 條件是空的 → 回整張表」。這裡查了三處：

- **兩支 query entity 的預設值全是 `None`**（`detection_profile_query_entity.py`、
  `detection_profile_control_query_entity.py`），`to_dict()` 濾掉 None，**不會產生幽靈 WHERE**
- **共用基底 `_gen_filters()` 的行為確認**：`value is not None` 才加條件，全空就是空條件
- **但這四個檔的查詢沒有一支走那條路**：兩支列表是自己手寫的 query，且**第一個條件就是
  `_visible_scope_filter`**（不是可選的、不是從 query entity 來的）；控制項的三支方法
  **全部強制帶 `version_id`**，沒有「不帶條件」的入口

唯一走共用基底的是 `list_all_versions()`（領域層 `:121`），它傳的 `profile_id` 是**必填參數**，
呼叫端不可能不給。**所以這四個檔沒有「條件全空」的路徑。**

### ⑤ 控制項表沒有 RLS，那它靠什麼隔離 — ✅ 靠上層，而且四個入口都守住了

`detection_profile_controls` **刻意不掛 RLS**（套件 migration 檔頭寫明，`tests/test_migrations.py` 焊死了這件事），
隔離由上層的版本從檔承擔。**這種設計只要有一個入口直接拿 `version_id` 進來就破功**，所以我把四個呼叫點全查了：

| 入口 | 怎麼拿到 version_id | 判定 |
|---|---|---|
| 看控制項清單 | `_read_controls()` 先用 uid 查從檔（受 RLS）再用 `version.id` | ✅ |
| 抽取完寫回 | worker 先取得從檔物件再用 `version.id` | ✅ |
| 複製基準 | 先 `get_current_version(source.id)`，而 source 是 RLS 過的 | ✅ |
| 領域層的 `get_by_uid` | **全專案零呼叫端**（死碼，不構成入口） | ✅ |

`_read_controls()` 的 docstring 甚至寫明「直接拿 `version_id` 當入口就是把那道保護拆了」——
**寫的人知道這件事，而且四個入口都照做了。**

## 2. 這一棒最該知道的一件事：安全性完全外包給資料庫

這不是漏洞，是**讀這四個檔時必須知道的前提**，也是為什麼「0 條發現」需要但書。

**這四個檔（499 行）裡面，沒有任何一行在做租戶隔離的判斷。** 唯一沾到邊的
`_visible_scope_filter` 是在做「可見範圍」（避免平台管理員看到全站私有資料），
**不是在做安全隔離**——真正擋住跨租戶存取的是資料庫的 RLS 政策。

我實查確認**目前這個外包是成立的**：

- 執行時連資料庫的帳號是 `cm_app`（`.env:21`），DEV 實查 `rolsuper=f`、`rolbypassrls=f`
  ——**它繞不過 RLS**，也不是那三張表的 owner（owner 是 `cmmgr`）
- 三張表的 RLS 狀態實查：主檔與版本從檔 `relrowsecurity=t`（有開），控制項 `f`（刻意不開，見上）
- 政策內容實查，與 FR-094 migration 寫的一致

**但這代表三件事同時成立才安全**：① 政策還在且內容正確、② 連線帳號沒有 bypass 權限、③ 上層每一個入口
都先經過受保護的表。**任何一項被改動，這四個檔不會有任何反應**——不會報錯、不會有測試變紅，
只會安靜地開始回傳別人的資料。

**這不是要求改程式碼**（在 repo 層重算租戶樹就是 FR-094 註解明確反對的「第二套真相」）。
**是要知道：這幾個檔的安全審查結論，有效期等同於那份 RLS 政策的有效期。**

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

**「範圍內真的沒有嗎」——中高。**
四個檔合計 499 行、是本 arc 最小的一棒，研究員讀得完；它逐項交代了每個候選為什麼不成立，
**不是沉默的空手而歸**（與 D2-2 那 25 位一句話都沒寫就死掉的情況完全不同）。
我另外自己把卡片重點④四項全部開檔查過，**並實連 DEV 資料庫核對政策與帳號**——
第 1 節的結論不是轉述研究員，是查證過的。

**「只有這樣嗎」——中等，三個限制。**
① 強度 `low`，一位研究員讀一遍（第一位被換掉，實際只有第二位的一遍）。
② **重點④的結論全是人工查的，沒有三票背書**——工具零候選，面板零票，
   **本棒的驗證章 `verified` 只代表「流程跑完沒有斷」，不代表有三個人同意過什麼**。
③ 這是第八個樣本，再次印證「切棒保證跑得完，不保證找得更多」：499 行出 0 條。

**這四個檔本來就不太可能藏洞**：它們是薄層（領域層幾乎全是一行轉呼叫），
沒有外部輸入的解析、沒有子程序、沒有網路、沒有加解密、沒有檔案路徑處理——
**攻擊面本來就在上一層（D2-2a 的服務本體）與更上層的 route**。

## 4. 被駁回的候選

**零候選、零駁回**，也沒有越界候選。研究員的範圍外讀取都是為了判斷這四個檔的安全性，沒有被鄰近大檔吸走。

## 5. 手測清單（給驗收用）

⚠️ **只在 DEV 做，不要碰 STG／POC。** 以下全部是唯讀查詢。

**驗「隔離真的靠資料庫」這件事（第 2 節的前提）：**
1. 連 DEV 查帳號權限，確認 `cm_app` 兩個旗標都是 `f`（繞不過 RLS）：
   `SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname IN ('cm_app','cmmgr');`
1. 查 SELECT 政策內容，確認有 SHARED 那一段（**沒有這一段，第 52 行就是跨租戶外洩**）：
   `SELECT pg_get_expr(polqual, polrelid) FROM pg_policy WHERE polname='detection_profiles_select';`
1. 查三張表的 RLS 開關，確認主檔與版本從檔是 `t`、控制項是 `f`：
   `SELECT relname, relrowsecurity FROM pg_class WHERE relname LIKE 'detection_profile%';`

**驗可見範圍（要兩個平輩租戶）：**
1. 租戶 A 建一支基準、把 scope 設成 SHARED
1. 用租戶 B（**平輩、不是 A 的子孫**）登入看基準列表
1. **預期：看不到 A 那一支** —— 看得到就是 RLS 政策沒套到，回頭查上面第 2 步

**驗控制項的隔離（要兩個租戶）：**
1. 租戶 A 建基準並成功抽取，記下那一版的 uid
1. 用租戶 B 登入，直接打看控制項的端點、帶 A 的版本 uid
1. **預期：404（查不到那一版）**，不是回出控制項清單

## 6. 這一棒的觀察（給後續小棒）

**① 「0 條發現」有兩種，要分清楚。**
D2-2 那次是 25 位研究員一句話都沒寫就被砍，**沒有任何結論**；這一棒是研究員讀完、
逐條交代為什麼每個候選不成立。**前者是失敗、後者是結果**，報告上都寫「0 條」但可信度天差地別。
判準：看 journal 有沒有 `result` 事件、看研究員的最後一則有沒有推論內容。

**② 薄層的安全結論會指向別的地方。**
這四個檔查完，所有安全結論都落在兩個地方：資料庫的 RLS、上一層的服務。
**薄層本身沒有可攻擊的表面**——後續若還有純領域層／存取層的小棒（如 D2-4b 的 query entity 與 model），
可以預期同樣的形狀：工具報不出東西，價值在人工確認「上下游的假設有沒有成立」。

**③ 監看判準再次印證 D2-2a 的更正。**
本棒 journal 從頭到尾 `failed` 為 0，但實際換過一次研究員——**呈現方式是同一個 label 重複出現 `started`**。
「看 `failed` 數第二個就停」這條原始指令**在這個工具版本上永遠不會觸發**，
正確判準是「`failed` 數 ＋ 同 label 重複 `started` 數，任一到第二次就停」。
