---
title: D2-4a 檢查結果：分類骨幹全鏈（jedi-detection）
---

# D2-4a 檢查結果：分類骨幹全鏈（jedi-detection）

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

## 🔴 一句話結論

**零發現。** 工具一條候選都沒提，所以三票檢查面板沒有東西可投。
卡片點名的重點⑥（三支寫入有沒有真的掛守門、零隔離的分類表會不會被拿來當探測管道、空條件回全表）
**我逐項開檔查證，三項都乾淨**——寫入三支守門確實都在、分類表是全站共用的字典（零隔離是對的設計）、
空條件回全表對字典表是正當行為。

**但查出一條體質問題**（不是漏洞，記在第 3 節）：`repo_impl.py` 的 `count_references()` 註解寫
「這個查詢不受 RLS 收斂是刻意的」，**實際上它受收斂**——只是恰好因為「能走到刪除的人」與
「RLS 眼中的超級管理員」判準同源，結果無害。**註解與事實反了，建議改註解。**

## 這一棒在檢查什麼

掃描基準（一包規則檔）在系統裡要標上兩個分類：「標的類型」（掃什麼，例如作業系統／資料庫）與
「基準體系」（依據哪套標準，例如 CIS／STIG）。這些分類值不是寫死在前端的常數，而是**存在資料庫、
由原廠統一維護**的一張字典表——新增一個標的類型不該需要發版。

這一棒看的就是**管這張字典表的那整條鏈**，從最外層的 API 一路到資料庫模型，八個檔案 772 行：

| 層 | 檔案 | 行數 |
|---|---|---|
| API 路由 | `api/routes/detection_profile_taxonomy_route.py` | 155 |
| API 收送格式 | `api/serializers/detection_profile_taxonomy.py` | 54 |
| 資料傳輸物件 | `app/dto/detection_profile_taxonomy_dto.py` | 55 |
| 應用服務 | `app/service/detection_profile_taxonomy_service.py` | 237 |
| 領域服務 | `domain/.../service/detection_profile_taxonomy_domain_service.py` | 70 |
| 資料存取 | `infra/.../repository/detection_profile_taxonomy_repo_impl.py` | 124 |
| 資料庫模型 | `infra/.../model/detection_profile_taxonomy.py` | 58 |
| 查詢條件 | `domain/.../entity/detection_profile_taxonomy_query_entity.py` | 19 |

**要驗的核心問題**（卡片重點⑥）：這張表**刻意不掛資料庫層的租戶隔離**（它是全站共用的字典，
每個租戶看到的內容本來就該一樣）。既然資料庫層不擋，**寫入的保護就全靠程式碼那一道守門**——
那道守門是不是真的在每一支寫入上？而「人人都讀得到」這件事，會不會被拿來打探別人的東西？

掃描目標 `/Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-detection`，
revision `dfe260a5ad0c`（branch `feature/review`，工作區乾淨），mode `scan`，effort `low`。

## Coverage

`low` 強度：一位研究員讀完這 8 個檔就提報候選，未做元件盤點、未做威脅建模，
`completenessCheckOutcome` 為 `not-applicable`。8 檔在門檻（5 檔）之上，所以跑的是完整管線形狀
而不是小範圍快捷路徑。驗證跑了 **1 輪**，**0 個候選、0 票**——沒有候選遺失、沒有嚴重度被降低、
沒有被駁回的候選（因為根本沒有候選）。

**這一棒跑得很順**：派 1 位研究員、回 1 位、**零重試**（journal 只有一筆 `started`），
**約 33 分鐘**完成，56 次指令呼叫、10 次開檔。

### 🔴 零發現要分清楚兩件事：「讀過但沒報」與「根本沒讀」

零發現最危險的讀法是把它當成「這塊沒問題」。實際上它可能是「沒人看」。這一棒是前者，證據如下。

**讀過而且讀完的**：八個檔全部讀過（研究員的操作紀錄裡八個檔的絕對路徑都在）。
為了判斷守門到底有沒有生效，它還追出範圍外去讀了：`api/routing.py`（守門是怎麼掛上去的）、
`api/guards.py`、`plugin/assembly.py` 與 `plugin/contract.py`（哪些接線是必填的）、
jedi-iam 的 `authz/platform.py`（平台管理員這個判斷實際上在算什麼）、
主專案的 `core/plugins/detection.py`（宿主餵進去的是什麼）、
jedi-common 的 `base_repository_impl.py` 與 `session/database/db.py`、
以及主專案的 `scripts/init/02-schema.sql`（基準表的 RLS 政策）。
**這些外追是必要的**——只看範圍內八個檔，根本判不出「路由上看不到認證裝飾器」是不是漏洞。

**確實沒讀的**：範圍外的一切，包含同一條分類鏈上**沒有排進這一棒**的三個檔——
`detection_profile_taxonomy_entity.py`（領域實體）、`detection_profile_taxonomy_mapper.py`（模型轉換）、
`domain/.../repository/detection_profile_taxonomy.py`（存取介面）。
`api/routing.py` 雖然讀了，但是**當背景資料讀的、不是當檢查目標**——這個差別在第 4 節有實際影響。

**工具沒有實際執行任何程式碼**：沒跑測試、沒發請求、沒示範攻擊。
唯一的例外是我（首腦側復核）跑了兩條唯讀 `SELECT` 查 DEV 資料庫的表擁有者與角色屬性，見第 2 節第③項。

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - 本棒範圍內沒有資安發現；體質項（`count_references()` 註解寫反）登記為跨 arc 總表 §3.2 第 30 項（註解錯誤，屬非資安），查不到單獨處理紀錄，保留待核。

## 1. 工具看過但判定安全的兩處（記下來，免得後續重做一遍）

工具沒提任何候選，但研究員的紀錄留下兩處「看起來像問題、追到底不是」的地方。
**這兩處都很容易被下一棒或下一個人重新當成漏洞報一次**，所以寫進來。

### ① 讀取的那支 API 看起來沒有「必須登入」的保護 — 追到底：有，只是不在這個檔

`detection_profile_taxonomy_route.py` 檔頭的說明表格寫著 `GET` 端點有 `@jwt_required()`，
**但整個檔案裡找不到這個裝飾器**。文件與程式碼不一致，第一眼就像漏掛。

追下去是這樣：認證是**從外面套上來的**。`plugin/assembly.py:79` 把宿主給的認證裝飾器傳進
`mount_routes(bp, auth_required=adapters.auth_required)`，`api/routing.py:166-183` 的
`_guarded_factory` 用**動態子類**把它套到每一個動詞上。宿主餵進去的是
`auth_required=jwt_required()`（主專案 `core/plugins/detection.py:302`），
而且 `auth_required` 列在 `REQUIRED_WIRING`（`plugin/contract.py:175-182`）裡，
**缺了就拒絕掛載整個藍圖**，不是靜默放行。

**所以認證是真的，只是位置反直覺。** 唯一該改的是那段檔頭說明——它寫「`GET` 有 `@jwt_required()`」
容易讓人以為裝飾器就在旁邊，下一個人找不到會以為是漏的。

### ② 算引用數的兩條查詢沒有租戶條件 — 追到底：有 RLS 在管，而且結果剛好對

`count_references()`（`repo_impl.py:100`）與 `map_reference_counts()`（`:117`）都是對
`config.detection_profiles` 做 `COUNT`，**兩條都沒有任何租戶條件**。
第一眼像是「我查得到別人租戶有幾支基準用了這個分類」。

追下去：那張基準表**有掛 RLS**，而且是租戶子樹的讀取政策
（`scripts/init/02-schema.sql:24785` 開啟、`:24805` 政策本體），應用程式連的帳號是 `cm_app`。
**資料庫是先過濾列、才做加總**，所以租戶使用者數出來的只有「原廠公版（本來就人人可讀）＋ 自己子樹」，
**沒有比基準列表端點本來就會回給他的更多東西**。詳細的查證見第 2 節第③項。

## 2. 卡片重點⑥逐項人工查證（工具一條都沒提，全是開檔查的）

**結論：三項都乾淨，沒有破口。** 逐項如下。

### ① 三支寫入是不是真的掛了 `require_platform_admin_route` — ✅ 三支全掛

開檔直接數，五支端點的守門長這樣：

| 動作 | 行號 | 守門 |
|---|---|---|
| 讀清單 `GET` | `:61` | 只有 `@require_license("detection-profile")` |
| **新增 `POST`** | `:82-83` | `@require_license` ＋ **`@require_platform_admin_route`** |
| **改排序／停用 `PUT`** | `:109-110` | `@require_license` ＋ **`@require_platform_admin_route`** |
| **刪除 `DELETE`** | `:133-134` | `@require_license` ＋ **`@require_platform_admin_route`** |
| 讀引用數 `GET` | `:149` | 只有 `@require_license` |

**三支寫入全部有，兩支讀取刻意沒有**（每個租戶都要拿分類選項來渲染下拉，限制讀取等於讓分類功能對租戶失效）。

**那道守門也不是有名無實**：`require_platform_admin_route` → `IdentityGuardAdapter` →
jedi-iam 的 `authz/platform.py` 的 `require_platform_admin()`，判準是「這個人的可存取租戶路徑裡有根」。
**那個路徑是從資料庫查出來的**（`jedi_iam/middleware/context.py:128` 取 `tenant.path`），
使用者送的 `X-Tenant-ID` 標頭要先過成員資格檢查才被採信——**攻擊者改不動它**。

**沒有第四條寫入路徑**。把整個套件的 taxonomy service 與 repo 的呼叫端全部 grep 過：
除了上面三支端點，唯一的其他消費者是 `ensure_profile_taxonomy_values()`
（被 `detection_profile_service.py:891` 呼叫），**那是讀的**（查某個 key 存不存在、有沒有停用）。
**這件事對這張表特別重要**：它刻意不掛 RLS（`model/detection_profile_taxonomy.py:11-14`），
**程式碼那一道就是唯一的寫入保護**，多一條沒守門的旁路就是全開。

### ② 零隔離的分類表，讀取路徑會不會被拿來當探測管道 — ✅ 不會

這張表零隔離是**正確的設計**：它是全站字典，每個租戶看到的內容本來就該一樣
（原廠新增一個「網通設備」分類，所有租戶都該看到）。分類本身沒有任何一筆是「屬於某個租戶」的資料，
**所以讀到全部不等於讀到別人的東西**。

真正要驗的是**那條可疑的旁路**：管理頁用的 `?with_counts=true` 會帶回「有幾支基準在用這個分類」，
而那個數字是數**基準表**（那張表是有主人的）。如果這個數字是全站的，就等於一支
「我用分類表當鏡子，照出別的租戶有多少基準」的探測管道。

查證結果是**照不出來**：

- 基準表有 RLS，政策是租戶子樹讀取（`02-schema.sql:24805`）
- 應用程式連 `cm_app`，我實查 DEV 確認：**`cm_app` 的 `rolbypassrls = false`、`rolsuper = false`，
  而且不是那張表的擁有者**（擁有者是 `cmmgr`）——擁有者會自動繞過 RLS，這是唯一的漏法，不成立
- PostgreSQL 是**先套 RLS 過濾列、才做 `COUNT`**，所以數出來的只涵蓋「公版 ＋ 自己子樹」

也就是說租戶使用者拿到的引用數，和他打基準列表端點自己數一遍是同一個數字——**沒有新資訊**。

**反方向也要驗**（更容易出事的那一邊）：刪除保護靠這個數字判斷「還有人在用就不准刪」，
如果數字被 RLS 縮小，會不會**低估**成 0 而誤放行？不會——能走到 `DELETE` 的人必須過
`require_platform_admin_route`，而 `session_scope` 給 RLS 的 `app.is_super_admin='t'`
**用的是同一個「路徑裡有根」判準**（jedi-common `db.py:193-198`）。
**過得了守門的人，在 RLS 眼裡就是超管、看得到全部的列**，兩邊同源所以不會錯位。

**順帶驗了失去身分時的行為**：`session_scope` 在沒有使用者情境時設
`app.is_super_admin = 'f'` 且不設 `app.allowed_tenant_paths`，而政策函式
`public.app_tenant_allowed_for_session` 在路徑為空時回 `false`（`02-schema.sql:631`）。
**沒有身分＝什麼都看不到**，不是什麼都看得到——方向是對的。

### ③ 空條件回全表 — ✅ 對字典表是正當行為，不是第 70 項那條病

`list_by_axis(axis=None)` 不帶軸就回兩軸全部，`query_entity` 的 `to_dict()` 濾掉 `None`
所以不帶條件就是不加 `WHERE`。形狀上確實是「空條件回全表」。

**但這裡不是缺陷**，理由有二：

1. **這張表回全部是需求**。前端開管理頁或編輯對話框時，一次就要兩軸的選項；分兩次打 API 沒有收益。
   而且分類值人人相同，「全表」對每個使用者都是同一份內容——**沒有任何一列是別人的資料**。
2. **表的量級是個位數到十幾筆**（原廠維護的分類骨幹，seed 的 `sort_order` 是 10~80），
   不是會長到數萬筆的業務表。

**與總表第 70 項的差別**：那條講的是「有主人的業務資料，空條件會跨主人撈出來」。
這裡是無主的全站字典，**兩者形狀像、語意完全不同**，不要合併。

### 附帶：查證時另外核對的三件（都沒問題）

- **改不到 `key`**：`key` 是既有基準引用的值（存字串不存 id），改掉等同刪掉再新增、會製造懸空孤兒。
  防法是**寫入 schema 根本沒有 `key` 這個欄位**（`serializers:43-54`），
  而且 `BaseRepositoryImpl.update()` 會排除 `id`/`uid`/`created_at`/`updated_at`/`created_user`、
  跳過 `None` 值，餵進去的實體又是從資料庫重讀的——**`axis` 與 `key` 只能被寫回它本來的值**。
  **靠契約上沒有這個欄位，不是靠一個可以被繞過的 `if`**，這是對的做法。
- **沒有注入面**：`axis` 對白名單 `CONTROLLED_AXES` 比對，`key` 對 `^[a-z0-9][a-z0-9_]*$`
  （有錨定、線性，不會災難性回溯），所有過濾都是 SQLAlchemy 欄位比較不是拼字串。
  八個檔裡沒有 `eval`／`exec`／`subprocess`／`pickle`／`yaml.load`／`text()`。
- **多送欄位不會炸**：兩支寫入 schema 都是 `unknown = EXCLUDE`，前端把整個實體回送（含 `created_at`）
  不會變成 422。

## 3. 查證時發現的一條體質問題（不是漏洞，但註解寫反了）

### `count_references()` 的註解與事實相反

`repo_impl.py:92-95` 的註解寫：

> ⚠️ 這個查詢**不受 RLS 收斂**的影響是刻意的：判斷「這個 key 還有沒有人在用」
> 必須看全站，只看得到自己租戶的資料會誤判成 0 而放行刪除。

**前半句是錯的。** 這個查詢**確實受 RLS 收斂**——基準表有 RLS、`cm_app` 沒有任何繞過能力
（實查確認：`rolbypassrls = false`、非表擁有者）。註解描述的「看全站」不是現況。

**但註解的擔憂（誤判成 0 而放行刪除）不會發生**，理由是它沒寫到的那一層：
能走到 `DELETE` 的人必須過平台管理員守門，而那個判準與 RLS 的超管判準同源，
**所以真正要用這個數字做決定的那個人，看到的本來就是全部的列**。
結論是對的，**理由寫錯了**——而且註解後半句自己也繞回來了（「本 repo 走 `cm_app` 身分時仍受 RLS 擋」），
**同一段註解前後矛盾**。

**為什麼要修**：這種註解比沒有註解更危險。
下一個人讀到「不受 RLS 收斂」，可能會把這支方法當成「可以用來看全站」的工具搬去別的地方用——
搬到一個**沒有平台管理員守門**的呼叫端，它就會沉默地只回自己租戶的數字，
而呼叫端以為拿到的是全站數字。**這條保護目前成立的真正原因是「呼叫端的身分」，不是「查詢本身繞過 RLS」**，
註解沒把這件事講對，就等於沒有記錄真正的前提條件。

**建議改法**（只改註解，不動程式碼）：寫明「這個查詢受 RLS 收斂；它之所以夠用，是因為唯一的呼叫端
（刪除保護與管理頁）都在平台管理員守門後面，而平台管理員在 RLS 眼中是超管、看得到全部的列。
**若要在沒有守門的路徑上呼叫它，這個數字會是該租戶可見範圍內的數字，不是全站數字。**」

順帶一條同類的：檔頭那段「`GET` 有 `@jwt_required()`」的說明（第 1 節第①項），
裝飾器實際上是外面套的。建議也一併改成指向 `_guarded_factory`，省得下一個人找不到而誤判成漏掛。

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

**「零發現＝這八個檔沒有漏洞嗎」——中高，但不要讀成「保證乾淨」。**
八個檔全部被讀過（不是沒人看），而且兩處疑點都追到了外部接線與資料庫政策才收手，
不是讀完表面就下判斷。我自己復核了守門三支（開檔直接數）與 RLS 那條鏈（含跑兩條唯讀 SQL 查
表擁有者與角色屬性——這是研究員明說「讀原始碼定不了、要查實際資料庫」的那一項），結論一致。

**限制有三個**：
① 強度 `low`，一位研究員讀一遍，**沒有第二個人獨立讀過同一份程式碼**。
② **零候選代表面板沒有投過票**——這不是「三票判定安全」，是「沒有東西可判」。
   換句話說這一棒**完全沒有用到對抗性檢查那一層**，全靠研究員自己的判斷加我的復核。
③ 這一棒的八個檔**每支都很小**（最大 237 行），而且是教科書式的分層 CRUD，
   本來就是最不容易藏洞的形狀。零發現與「範圍選得容易」有關，不能外推到別的範圍。

**第八個樣本，再次印證「切棒保證跑得完，不保證找得更多」。**
772 行零重試 33 分鐘跑完，產出 0 條。

## 5. 被駁回的候選

**零候選，所以也零駁回。** 也沒有越界候選——研究員的外追都是為了判定範圍內那兩處疑點的下游，
沒有被鄰近大檔吸走（D2-1b 與 D3-4b 都出現過這個現象，這棒沒有）。

## 6. 記下來的兩個體質項（不是漏洞，不進總表）

| # | 在哪 | 是什麼 | 建議 |
|---|---|---|---|
| 1 | `repo_impl.py:92-95` | 註解寫「不受 RLS 收斂」，實際受收斂；結論對但理由錯，同段前後矛盾 | 改註解，寫明真正的前提是「呼叫端身分」（見第 3 節） |
| 2 | `detection_profile_taxonomy_route.py:3-8` | 檔頭說 `GET` 有 `@jwt_required()`，實際是 `_guarded_factory` 從外面套的 | 改說明，指向掛載處 |

**兩個共通點：都是文件／註解與程式碼不一致，不是程式碼本身有錯。**
CLAUDE.md 的「註解只寫為什麼與陷阱」那條在這裡有直接對應——
**一段寫錯的「為什麼」，比沒有註解更容易把下一個人帶到坑裡。**

另外兩個**範圍外**的隱患，附帶記錄（在 `api/routing.py`，不屬這一棒的檢查目標）：

- `mount_routes()` 的 `auth_required` 預設值是 `None`，而 `_guarded_factory` 對 `None` 回的是
  「什麼都不做」的包裝——**真的用預設值呼叫，35 支路由全部無認證掛上去**。
  目前沒有任何呼叫端這樣用（`register()` 與 `create_blueprint()` 都先過 `_assert_wiring`），
  **是一個目前碰不到的不安全預設值**。
- `key` 與 `sort_order` 送進資料庫前沒有長度／範圍上限（只有那個 slug 格式檢查）。
  超長的 `key` 會換到一句 PostgreSQL 的 `DataError`，最多洩漏一點資料庫內部字串，
  **而且對象是平台管理員**（系統裡權限最高的人），**影響可忽略**。

## 7. 執行概況

| 項目 | 值 |
|---|---|
| run ID | `wf_3998e771-ddb` |
| 報告 | `docs/features/FR-108-2609-detection-security-scan/scan-D2-4a-taxonomy-chain.md` |
| 掃描 commit | `dfe260a5ad0c9d0c1b254b6ce4c86bcd4a575005`（jedi-detection，branch `feature/review`，工作區乾淨） |
| 範圍 | 8 檔 772 行（36KB） |
| 強度 | `low`（一位研究員 ＋ 三票面板） |
| 研究員 | 派 1 位、回 1 位、**零重試**（journal 一筆 `started`） |
| 面板 | **0 條候選 ＝ 0 票**（沒有東西可投） |
| 耗時 | 約 33 分鐘 |
| 候選 → 成立 | 0 → 0 |
| 驗證章 | `verified` |
| 工具產物 | `jedi-detection/CLAUDE-SECURITY-20260920-043628/`（含 `CLAUDE-SECURITY-REVISION-dfe260a5ad0c.json`） |

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

**① 掃描 commit 換了，要記一筆。**
前面 D2-1b／D2-1a／D2-3 掃的是 `955e409`、D2-2a 掃的是 `ac5a0d6`，**這一棒是 `dfe260a5`**——
套件在期間有兩個 commit 進來（`c86952b` 拆 bpmn_uilts、`dfe260a` 碼表鏡像，都屬 FR-112、與 detection 無關）。
**分類鏈這八個檔一行都沒動**，所以跨棒比較仍然成立，但基準線要記對。

**② 第八個樣本：「不含 route 就跑得動」這個判讀要修正成「小檔就跑得動」。**
這一棒**含 route**（155 行）卻零重試 33 分鐘跑完，
而 D2-1a 也含 route（1,058 行）卻死了三位。**所以主變數不是「有沒有 route」，是 token 量**——
route 檔本身不是毒，大的 route 檔才是。772 行／36KB 遠低於安全線（約 1,000 行或 60KB），
與首腦手冊記的判準一致。

**③ 零發現的報告要寫得比有發現的更用力。**
有發現時，發現本身就是價值；零發現時，**讀者唯一能判斷的就是「你到底看了沒有」**。
所以這份報告把「讀過但沒報」與「根本沒讀」明確分開（Coverage 段），
並且把研究員看過卻判定安全的兩處疑點完整寫出來——
**否則下一個人會把同樣的兩處疑點重新當成漏洞報一次，或者反過來，把「零發現」讀成「這塊不用再看」。**

**④ 人工查證的產出又一次不是「有／沒有」。**
重點⑥三項查下來全部乾淨，但同一輪比對抓到 `count_references()` 的註解寫反了。
**那不是工具會報的東西**（它不是漏洞），卻正是日後把這支方法搬去無守門路徑時會踩的坑。
與 D2-2a 的觀察④一致：**逐項查證的產出應該包含「現況落差」，不是只回答有／沒有。**
