---
title: D2-2a 檢查結果：基準服務本體（jedi-detection）
---

# D2-2a 檢查結果：基準服務本體（jedi-detection）

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

## 🔴 一句話結論

**找到 1 條中風險，三票一致通過：填網址建基準時，系統允許填明文 `http://` 的網址，而且網址型來源不記指紋。**
遠端代理程式拿到這個網址後會直接叫外部程式去下載並**執行**裡面的 Ruby 規則——中間任何一個能動手腳的網路節點
（同一段區網、上游路由、被控的 DNS）換掉那包檔案，就等於在代理程式容器裡執行自己的程式碼，
而那個容器手上握有它要去掃描的每一台主機的 SSH 憑證。**後端自己的下載器只准 https，這裡卻放行 http，兩邊不一致。**

**卡片指定的重點④（公版 vs 租戶的 scope 守門）工具一條候選都沒提**，我逐項開檔查證，**守門本身沒有破口**——
但查出**兩個工具與卡片都沒問到的現況落差**，記在下方第 3 節：資料庫已經認得第三種 scope（`SHARED`），
而這支服務的程式碼還停在兩種的世界觀。這不是漏洞，是會長成漏洞的地方。

## 這一棒在檢查什麼

客戶要做弱點掃描，得先在系統裡建立「掃描基準」——一包規則檔。這一棒看的是**管這件事的那支服務本體**：
`detection_profile_service.py`（1,133 行），基準的建立、改名、改分類、版更、改來源、刪版、刪基準、停用、
複製成自己的（fork）、複製到另一個工具池（copy-to-tool），全部在這一支裡。

**要驗的核心問題**（卡片重點④）：
「原廠公版」與「租戶自己的」這兩種基準，誰能改誰？複製出來的那一份，算誰的？

掃描目標 `/Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-detection`，
revision `ac5a0d6c564f`（branch `feature/review`，工作區乾淨），mode `scan`，effort `low`。
**與前一棒 D2-3 掃的 `955e409` 相比，這個檔一行都沒變**（已用 `git diff` 核對），所以兩棒讀的是同一份程式碼。

## Coverage

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

研究員為了證明這條真的打得到，讀了範圍外的檔：serializer 與 route（誰能送這個欄位）、
`detection_profile_ref.py`（送給代理程式的封包長什麼樣）、代理程式端的 `profile_cache.py` 與 `inspec.py`
（它拿到之後做什麼）。**這些讀取是必要的**——只看服務層無法判斷「網址最後會被誰拿去做什麼」。

**這一棒跑得普通**：派了 2 位研究員，第一位在 33 分鐘、寫了 1.3MB 思考紀錄後被砍掉，第二位 38 分鐘完成。
**總耗時約 1 小時 42 分**。相較原 D2-2（五檔一起掃，17 小時、25 位研究員全滅），切成單檔是對的。

⚠️ **監看判準要更正**：卡片寫「看 `journal.jsonl` 的 `failed` 事件」，**實際上這個工具不寫 `failed` 這個字**。
研究員被換掉的呈現方式是**同一個 key 再出現一筆 `started`**。往後監看請數 `started` 筆數，第三筆就停。

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

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - 明文網址且不記指紋＝M03 第 5 條，✅ 已修（CM-2057）。
> - 同棒提到的 SHARED 值域落差＝跨 arc 總表 §3.2 第 28 項，✅ 已修（CM-2221，1.21.0 出貨）。

## 1. 找到的那一條：明文網址 ＋ 沒有指紋 ＝ 中途被換掉也沒人知道

### 這是什麼問題

建立基準時可以選「上傳檔案」或「填網址」。填網址這條路，全部的檢查只有一行：

```python
# detection_profile_service.py:952
if not cleaned.lower().startswith(("http://", "https://")):
    raise BadRequestError(...)
return None, None, cleaned   # ← 第二個 None 就是 sha256，網址型永遠是空的
```

兩個問題疊在一起：

1. **`http://` 被放行**。後端自己的下載器 `common/safe_http_fetch.py:68` 寫得很清楚——`ALLOWED_SCHEMES = ("https",)`，
   同一份內容它只肯走 https。**同一個系統的兩個地方對同一件事有兩套標準。**
2. **網址型不記 sha256**。上傳檔案那條路會算指紋並寫進資料庫（代理程式拿到後會對帳），
   網址這條路填的是 `None`，送給代理程式的封包裡就沒有指紋這一格可以比對。

### 出事會怎樣

代理程式收到封包後，直接把網址交給外部程式：`cinc-auditor exec <網址>`。
**掃描規則包是 Ruby 程式，`exec` 的意思就是下載回來跑它。** 所以攻擊者只要在那條網路路徑上換掉檔案，
裡面的 `controls/*.rb` 寫什麼就執行什麼——**沒有 TLS 要破解，因為根本沒有 TLS**。

拿到的是代理程式容器的權限，而代理程式是專門派去各台主機上掃描的，**它身上帶著那些主機的 SSH 憑證**，
而且它就住在客戶的內網裡。同時，被換過的規則包跑出來的掃描報告也是攻擊者寫的——**稽核軌跡跟著一起假掉**。

### 要先有什麼才打得到

- 系統裡至少有一支基準是用 `http://` 網址登記的。租戶管理員（有「新增檢測基準」能力點）就建得出來，
  **而且介面完全不會警告**。
- 攻擊者要能動到「代理程式 → 那個網址主機」這段路（同段區網、上游某一跳、或能改 DNS）。
- 有一個掃描任務綁著那一版基準被派出去。

### ⚠️ 一個容易被誤以為「已經擋住了」的地方

後端自己抓那個網址會失敗（它只准 https），那一版的抽取狀態會停在 `failed`。
**但派工不看抽取狀態**——`_load_profile()` 只檢查主檔的 `is_active`（`detection_orchestration_service.py:651`）。
所以一支「後端自己都抓不到」的基準，**照樣派得出去**，代理程式照樣會去跑它。

### 怎麼修

1. **`_resolve_source` 只收 `https://`**，與 `safe_http_fetch.ALLOWED_SCHEMES` 對齊。
   後端自己都不肯抓的網址，不該讓它登記成功。
2. **給網址型一個指紋錨點**：第一次抽取成功時把算出來的 sha256 記下來，
   並放進 `build_payload()`，讓代理程式執行前能對帳——**上傳檔案那條路本來就是這樣做的**，
   網址這條只是沒補上。

⚠️ **與既有第 116 項不是同一條**（那條是 D2-3 報的「網址型跳過壓縮檔驗證器、可以用解壓炸彈打爆記憶體」）。
**同一條網址路徑上的兩種不同的病**：第 116 項是「檔案太大沒人管」，這條是「檔案是不是原來那一份沒人管」。
修法不同，不要合併成一條。

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

**結論：公版與租戶的守門，該有的都有，沒有破口。** 逐項如下。

### ① `_guard_system_writable` 是不是每支寫入都有掛 — ✅ 八支全掛

| 動作 | 行號 | 判的是誰的 scope |
|---|---|---|
| 建立基準 | `:336` | 使用者指定的目標 scope（先正規化再判） |
| 改基準 | `:422` | 這支基準現在的 scope |
| 停用 | `:501`（`deactivate_profile`） | 這支基準的 scope |
| 改某一版的來源 | `:581` | 那一版的 scope |
| 刪某一版 | `:617` | 那一版的 scope |
| 刪整支基準 | `:645` | 這支基準的 scope |
| 複製到另一個工具池 | `:706` | **來源**的 scope |
| 版更（新增一版） | `:732` | 這支基準的 scope |

**判定邏輯本身（`:903-910`）也對**：只有 scope 是 `SYSTEM` 時才要求是平台管理員，
而且是走統一的 `viewer_is_platform_admin()`，**沒有硬寫租戶編號**（硬寫的話日後 root 租戶編號一改就是看不見的地雷）。

**未接線時會炸而不是放行**（`common/guard.py:41-47`）——這點特別值得記一筆。這支套件的守門實作是由宿主注入的，
如果宿主忘了接線，預設回 `True` 會讓「忘記接線」變成無聲的權限破口：任何租戶管理員都能改跨租戶共用的公版，
**而服務照常起得來、健康檢查照樣綠燈**。套件選擇直接拋例外拒絕執行，這是對的。

### ② fork 與 copy-to-tool 的租戶編號從哪來 — ✅ 兩者刻意不同，而且都對

這兩支長得很像但語意相反，**差異正是重點④問的那一格**：

| | fork（`:677`） | copy-to-tool（`:696`） |
|---|---|---|
| 意圖 | 公版 → **我自己的**可編輯副本 | 公版 → **還是公版**，只是換一個工具池 |
| 要不要過公版守門 | **不用** | **要**（`:706`） |
| 新的 scope | 硬寫 `TENANT` | 沿用來源 |
| 新的租戶編號 | 硬寫**當前使用者**的租戶 | 沿用**來源**的租戶 |

**兩邊都是對的，而且理由不對稱**：
- fork **不需要**公版守門，因為它寫的是一列全新的租戶資料，沒有動到公版本身。
  如果這裡沿用來源的 scope，公版 fork 出來還是公版——**任何人 fork 一次就多一份全站可見的資料**。
- copy-to-tool **需要**公版守門，因為複製出來的仍然是公版（受眾沒變，只是換池），
  等於在製造新的公版，那當然只有平台管理員能做。

兩支共用同一個實作 `_clone_profile()`（`:777`），差異收斂成三個參數傳進去——
**這是好的寫法**，兩套各寫一遍才是日後漂移的來源。

### ③ 建立基準時，公版的租戶編號填什麼 — ✅ 填 root（1），不是填 NULL

`:346` 一行三元判斷：目標是 `SYSTEM` 就填常數 `_ROOT_TENANT_ID`（1），否則填當前使用者的租戶。
**這與「`tenant_id IS NULL` 永遠是壞資料」的既有拍板一致**——公版也要有明確的擁有者，
否則所有靠租戶欄位過濾的查詢都會對它失效。

### ④ 版本從檔的 scope／tenant 有沒有跟著主檔 — ✅ 顯式沿用，沒有靠自動填

`_create_version()`（`:759-760`）與 `_clone_profile()`（`:827-828`）都顯式把主檔的 scope／tenant_id 抄進從檔。
**這一點非常關鍵**：版本從檔自己掛 RLS（資料庫層的租戶隔離不會沿外鍵繼承），
如果靠 jedi-common 的自動填欄位機制，**只有租戶情境碰巧正確**——
公版會被填成「操作者的租戶」，於是公版變成只有那個人看得到的私有版。程式碼裡的註解也寫明了這點。

改 scope 時也有同步（`update_profile:434` 的 `sync_version_scope`），
不同步的症狀是「子租戶在列表看得到這支基準、點進去卻讀不到任何版本」——因為派工走的是版本表。

### ⑤ 改「分享設定」為什麼要另外一道守門 — ✅ 有掛，而且理由成立

`update_profile:425` 在 scope 有變動時額外呼叫 `assert_scope_writable`。
查宿主實作（`common/authz/sharing.py`）確認它判的是**「作用租戶＝資源擁有租戶」**，
而且明確拒絕把 scope 改成 `SYSTEM`。

**為什麼光靠資料庫的 RLS 不夠**：RLS 的更新政策判準是「資料的租戶在我的可存取路徑內」，
而**母租戶的可存取路徑涵蓋整個子樹**——母租戶的管理員對子租戶的資源也在範圍內。
能力點只答「這個人有沒有編輯權」，不答「編輯的是不是自己租戶的東西」。
scope 決定資源對誰可見，改錯了是把整個子樹的可見性拱手讓人。這一刀切得對。

### ⑥ 列表為什麼不只靠 RLS — ✅ 顯式收斂，理由成立

`detection_profile_repo_impl.py:40-55` 的可見範圍條件是「公版 ＋ 本租戶自有 ＋ 分享下來的」。
**理由**：root 租戶的可存取路徑是 `/1/`，前綴涵蓋**所有**子租戶——
只靠 RLS 的話，平台管理員在管理頁會看到全站每個租戶的私有基準。可見範圍是業務語意，RLS 只是兜底。

### ⑦ 資料庫層是不是也擋 — ✅ 三層防禦完整

`002-detection-rls-grants.sql:65-84` 四條政策：

- **SELECT**：公版人人可讀，其餘看租戶
- **INSERT／DELETE**：`scope <> 'SYSTEM'` **AND** 租戶符合——**資料庫層直接禁止任何人新增或刪除公版**
- **UPDATE**：讀取條件寬（自己租戶的都能改），**寫入檢查條件要求 `scope <> 'SYSTEM'`**

也就是說，就算服務層的守門被繞過，**資料庫這一層還是擋得住對公版的寫入**（超級管理員除外）。
與程式裡寫的「第 1 層 route 能力點／第 2 層服務守門／第 3 層 RLS 兜底」完全對得上。

## 3. 查證時發現的兩個落差（不是漏洞，但會長成漏洞）

這兩點工具沒提、卡片也沒問，是我比對程式碼與資料庫時看出來的，**記下來給後續決策**。

### 落差一：資料庫已經認得三種 scope，這支服務還停在兩種

2026-09-14 的 FR-094（CM-1790）把 `SHARED`（母租戶分享給子樹）加進了 `detection_profiles` 與
`detection_profile_versions` 的欄位值域與查詢政策。但**這支服務的程式碼還是兩種的世界觀**：

- `_normalize_scope()`（`:897-901`）只收 `SYSTEM` 與 `TENANT`，**填 `SHARED` 會被當成壞請求擋掉**
- 服務裡的常數只定義了 `SCOPE_SYSTEM` 與 `SCOPE_TENANT`，沒有 `SHARED`
- 但 `update_profile` 的 scope 變更路徑是**直接把值傳給 `assert_scope_writable`**（`:424-425`），
  沒有先過 `_normalize_scope`——**而宿主那支只收 `TENANT`／`SHARED`**

**現況是：建立時無法指定 `SHARED`，但改的時候可以改成 `SHARED`。** 兩條路徑對同一個欄位有兩套值域認知。
存取層那邊 `_visible_scope_filter` 已經認得 `SHARED` 了（`repo_impl:52`），
所以改成 `SHARED` 之後查詢行為是對的——**目前不會出事**，但這是典型的「半套遷移」形狀：
一個欄位、三個地方、各自認得不同的值域。日後任何一邊改動都可能踩空。

**建議**：把 `SHARED` 補進服務層的常數與 `_normalize_scope`，讓建立與修改兩條路徑對值域有同一套認知。
這件事屬 FR-094 的收尾，不是這棒能自行決定的範圍，**列為待裁示**。

### 落差二：套件自帶的建表腳本還是舊的值域

`jedi_detection/migrations/001-detection-tables.sql:176/203` 的 `CHECK` 仍然只允許 `SYSTEM`／`TENANT`。
主專案的 `scripts/sql/packages/jedi_detection/001-detection-tables.sql` 同樣是舊的。
FR-094 的那支 migration 在主專案的 `scripts/sql/` 下單獨存在。

**症狀**：既有環境跑過 FR-094 那支 migration 所以沒事，**但新裝的客戶拿到的是舊值域**——
資料庫會拒絕任何 `SHARED` 的值，而且是在寫入當下才炸。
這正是 CLAUDE.md 講的「出貨基線待重產」那一類，**屬決策者裁示範圍，這裡只回報**。

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

**「報出來的這條存在嗎」——高。**
`:952` 放行 `http://`、`:954` 回傳 `sha256=None`，這兩行我自己開檔核對過，是可直接查證的事實不是推論。
三位檢查員各自從可達性、影響面、既有防線三個角度獨立查證，都投成立，其中防線那位逐一檢查了每一個可能的
緩解措施並說明為何都不適用。**沒有實際造一個假的規則包送進去驗過**——與 D2-3 的第 115 項同樣是讀出來的結論。

**「只有這一條嗎」——中等，三個限制。**
① 強度 `low`，一位研究員讀一遍（第一位被砍，實際只有第二位的一遍）。
② **重點④的結論全是人工查的，沒有三票背書**——工具那條候選與卡片重點完全沒有交集。
③ 這是第七個樣本，再次印證「切棒保證跑得完，不保證找得更多」：1 檔 1,133 行只出 1 條。

## 5. 被駁回的候選

**零駁回**，也沒有越界候選（研究員的讀取都是為了追這一條的下游，沒有被鄰近大檔吸走）。

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

⚠️ **只在 DEV 做，不要碰 STG／POC。** 這是會讓代理程式真的去下載東西的測試。

**這一條（明文網址）：**
1. DEV 用租戶管理員身分建一支新基準，來源選「網址」，填一個 `http://`（明文）開頭的網址
1. **預期現況：建立成功、完全沒有警告** —— 成功即這條成立的前半段
1. 去資料庫看那一列：`SELECT source_type, url, sha256 FROM config.detection_profile_versions WHERE ...`
   **預期 `sha256` 是空的** —— 這是後半段（代理程式沒有東西可以對帳）
1. 把這支基準綁進一個掃描任務派出去，看代理程式那邊收到的封包裡有沒有指紋欄位
1. **修好後重跑第 1 步**，應該在建立當下就被擋下並回明確錯誤

**對照組（證明兩條路標準不一致）：**
1. 同一個 `http://` 網址，拿去讓後端自己抓（版更後會自動排抽取）
1. **預期：後端抓不到、那一版停在 `failed`** —— 但基準仍然派得出去（`_load_profile` 只看 `is_active`）
1. **這個落差本身就是這條的核心**：後端自己都不肯抓的東西，卻放行讓代理程式去執行

**重點④（守門）的回歸驗證**（確認我的查證沒錯，選做）：
1. 用**租戶管理員**（非平台管理員）身分，試著改一支公版基準的名字 → **預期 403**
1. 同一個身分 fork 那支公版 → **預期成功，且新的那支 scope 是 `TENANT`、租戶是自己**
1. 同一個身分對那支公版做 copy-to-tool → **預期 403**（這支要公版守門，fork 不用）

## 7. 執行概況

| 項目 | 值 |
|---|---|
| run ID | `wf_bff1ea28-4de` |
| 報告 | `docs/features/FR-108-2609-detection-security-scan/scan-D2-2a-profile-service.md` |
| 掃描 commit | `ac5a0d6c564f1951b00b67834651672150ec53ad`（jedi-detection，branch `feature/review`，工作區乾淨） |
| 範圍 | 1 檔 1,133 行 |
| 強度 | `low`（一位研究員 ＋ 三票面板） |
| 研究員 | 派 2 位、回 1 位（**第一位 33 分鐘被砍**） |
| 面板 | 1 條候選 × 3 位檢查員 ＝ 3 票，全數投出，3:0 通過 |
| 耗時 | 約 1 小時 42 分 |
| 候選 → 成立 | 1 → 1（**零駁回**） |
| 驗證章 | `verified` |

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

**① 切成單檔是對的，但收穫沒有變多。**
原 D2-2 五檔一起掃打了 17 小時、25 位研究員全滅；切出這支 1,133 行的主檔單獨跑，**1 小時 42 分完成**。
切棒解決的是「跑不完」，不是「找不到」——**1,133 行只出 1 條**，與 D2-1b、D2-3、D3-4b 的觀察一致。

**② 監看判準要改：這個工具不寫 `failed`。**
卡片寫「看 journal 的 `failed` 事件」，實際 journal 裡**沒有這個字**。
研究員被換掉的呈現是**同一個 key 再出現一筆 `started`**。
本棒 2 筆 `started` ＝ 死了 1 位。**往後數 `started`，第三筆就停。**

**③ 工具找到的與卡片要查的，第二次完全沒有交集。**
D2-3 已經出現過一次（卡片問重點②③，工具報的兩條都不在裡面）。這棒一樣：
卡片重點④問 scope 守門，**工具報的是網址協定**。
**給後續：卡片重點仍要逐項人工查（它保證覆蓋），但不要期待工具照清單走。**

**④ 人工查證的價值不只是「確認沒問題」。**
重點④查下來守門本身是乾淨的，但同一輪比對讓我看到 `SHARED` 這個值域在三個地方各認一套——
**那不是工具會報的東西**（它不是漏洞），卻是日後長出漏洞的地方。
逐項查證的產出應該包含這類「現況落差」，不是只回答有／沒有。
