---
title: D2-1a 檢查結果：上傳入口守門（jedi-detection）
---

# D2-1a 檢查結果：上傳入口守門（jedi-detection）

> 檢查日期 2026-09-19｜對應卡片 CM-1874（D2 第 1 小棒的前半）｜檢查範圍 3 個檔案 1,058 行

## 🔴 一句話結論

**找到 2 條中風險，都三票一致通過。**

**第一條：產品宣告了「檢視掃描設定檔」這顆權限，但後端七支讀取功能沒有一支真的檢查它。**
管理員在權限矩陣把這一格取消勾選，以為資料關起來了，實際上那個帳號直接打 API 照樣拿得到
全租戶的掃描基準庫。同一個檔案裡十支寫入功能每一支都有檢查——**只有讀取這一半漏了**。

**第二條：手動重抽功能可以把主機打掛。** 每收一次請求就無條件開一條背景執行緒、把最大
50MB 的檔整包讀進記憶體、再開一支最長 900 秒的外部程序，**沒有併發上限、也沒有「這一版
已經在跑就別再排」的判斷**。連打幾百次就是幾百條執行緒同時存在。

## 這一棒在檢查什麼

客戶要做弱點掃描，得先在系統裡建立「掃描基準」——一包規則檔，可以上傳壓縮檔或給一個網址。
這一棒看的是**進入系統的那道門**（3 個檔）：

1. **`detection_profile_route.py`（549 行）** — 18 支 API 端點，是使用者能碰到的全部入口。
2. **`detection_profile.py`（290 行，serializer）** — 每支端點收什麼欄位、吐什麼欄位。
3. **`detection_profile_dto.py`（219 行）** — 回應的資料形狀。

**要驗的核心問題**：這 18 支門，誰能進？進去之後能拿到什麼、能做什麼？

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

## Coverage

`low` 強度：一位研究員讀完這 3 個檔案就提報候選，未做元件盤點、未做威脅建模，
`completenessCheckOutcome` 為 `not-applicable`（低強度本來就不跑盤點，且範圍是指定檔案不是整棵樹）。
驗證跑了 **1 輪**，2 個候選去重後仍是 2 個，**6 票全數投出**，兩條都 3:0 通過，
沒有候選遺失、沒有嚴重度被降低、沒有被駁回的候選。

研究員為了證明「外面的人真的打得到」讀了不少範圍外的檔：主專案的插件接線
（`core/plugins/detection.py`）、能力點契約（`plugin/contract.py`）、基準服務與抽取服務、
route 掛載表。**這些是必要的**——只看 route 檔無法判斷守門到底有沒有生效，必須追到宿主
那一層。這也正是這棒 context 膨脹的來源（見末段）。

研究員沒有回報「哪些檔案沒讀完」的自述（`coverage.research` 為 null），所以工具端沒有做
讀取完整度的交叉檢查。

**⚠️ 這一棒跑得很不順，要如實記下**：工具派了 **4 位研究員，前 3 位被砍掉重派**，
第 4 位才完成並提出這 2 條候選。所以**驗證章雖然是完整的（6 票全投），但它背後的研究
過程被中斷了三次**——這 3 個檔案的覆蓋程度不如票數看起來那麼均勻，不該當成「這三個檔
已經被徹底讀過」。

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

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - F1（七支讀取不檢查檢視權限）＝M03 第 8 條，✅ 已修（CM-2042）。
> - F2（重抽無併發上限）＝M03 第 10 條，✅ 已修（CM-2058）。

## 掃到什麼：總覽

| 編號 | 這是什麼問題 | 出事會怎樣 | 要先有什麼 | 在哪裡 | 嚴重度 |
|---|---|---|---|---|---|
| F1 | 七支讀取功能不檢查「檢視掃描設定檔」權限 | 被刻意拿掉權限的帳號照樣拿到全租戶基準庫、來源網址、完整規則清單 | 該租戶任一個正常帳號 | `detection_profile_route.py:142` 等七處 | MEDIUM |
| F2 | 重抽功能無上限開執行緒與外部程序 | 主機記憶體與 CPU 被吃光，同機所有客戶一起變慢或逾時 | 租戶管理員身分 | `detection_profile_route.py:548` | MEDIUM |

**零駁回**——兩條候選都通過，這在本系列少見（前面幾棒多半有一到兩條被否決）。

## Findings

### F1 — 宣告了讀取權限卻不執法，七支讀取功能只驗登入（MEDIUM，confidence medium）

**這是什麼問題。** 產品的能力點清單裡明明白白宣告了一顆「檢視掃描設定檔」
（`detection-profile.read`，`plugin/contract.py:38`），資料庫也 seed 了它、前端權限矩陣
也看得到這一格。**但後端七支讀取端點沒有一支去檢查它**——它們只驗兩件事：有沒有登入、
這個客戶有沒有買弱點掃描模組。

對照組很清楚：**同一個檔案裡十支寫入端點，每一支都掛了對應的能力點**
（新增、編輯、刪除、換來源、版更、fork、複製、停用、刪版、重抽）。所以這不是「這個套件
不做能力點」，而是**寫入做了、讀取漏了**。

**出事會怎樣。** 一個被刻意拿掉這個權限的帳號（例如只被指派專案參與者角色的約聘人員），
拿自己的 token 直接打 API，拿得到：

- 整個租戶有哪些掃描基準、名稱與分類
- 每一支基準的**來源檔名與外部網址**
- sha256 指紋
- 每一版**抽出來的完整規則清單**（控制項全量）
- 稽核欄位（誰建的、誰改的、什麼時候）

這是租戶內部的組態與安全基線外洩。**拿不到別家客戶的東西**（跨租戶由資料庫隔離擋住），
也**不能寫入**，所以是中風險不是高風險。

**最要命的地方是它會誤導管理員。** 前端選單因為 `route_capabilities` 有設定，所以那個帳號
看不到這個頁面——**管理員把權限取消勾選後，從畫面上看是生效的**。他沒有理由懷疑
後端根本沒在擋。

**要先有什麼才打得到。**
- 該租戶的**任何一個有效帳號**（不需要任何特殊權限）
- 該客戶的授權含弱點掃描模組（否則被「有沒有買」那道擋掉）
- 直接打 API，不經過前端選單

**在哪裡（七支全列）。**
- `jedi-detection/jedi_detection/api/routes/detection_profile_route.py:142` — 基準清單（**工具指的落點**）
- 同檔 `:163` — 下拉選單
- 同檔 `:211` — 單筆詳情
- 同檔 `:274` — 版本列表
- 同檔 `:303` — 主檔使用狀況
- 同檔 `:328` — 版本使用狀況
- 同檔 `:507` — **控制項全量清單**（外洩面最大的一支）
- 同檔 `:527` — 抽取狀態

對照組（有掛的十支寫入）：`:184`／`:230`／`:258`／`:359`／`:392`／`:410`／`:439`／`:460`／`:484`／`:542`。

相關座標：
- 能力點宣告：`jedi_detection/plugin/contract.py:38`
- 宿主只注入登入檢查：`compliance-manager-be/core/plugins/detection.py:302`（`auth_required=jwt_required()`）
- 資料庫 seed：`scripts/init/04-seed-core.sql:202`（能力點本身）、`:376`（給管理員角色）、
  `:491`（**掛在前端路由上——這就是「只有選單在認」的實證**）

**怎麼修。** 兩條路擇一，但**一定要選一條**，現況（宣告了不守）是最糟的：

1. **要守**：比照同檔寫入端的既有做法——在檔頭 `:54-56` 旁邊加一行
   `_profile_read = capability_required(lambda rt: rt.config.profile_read_capability)`，
   在 `DetectionConfig`（`plugin/contract.py:86`）補一個 `profile_read_capability` 欄位、
   預設值取已經宣告好的 `detection-profile.read`，再掛到七支 GET 上。**用的是現成機制，
   不必造新東西。**
2. **不守**：把 `detection-profile.read` 從能力點清單撤掉，或**在文件與權限矩陣旁明白寫出
   「這一格只影響前端選單、後端不擋」**。

⚠️ **修法一會擋掉現在能用的人**——今天所有登入者都讀得到，補了之後沒有這顆權限的帳號會開始
吃 403。這是產品決策不是純技術決定。

**驗證。** 3/3 三位檢查員確認成立（可達性、影響、既有防護三個角度）。三位各自獨立把鏈子
追完：宿主接線只給登入檢查 → route 只掛授權檢查 → service 直接回全庫。

**首腦核對註記（這條要更正我自己先前的說法）。**

D2-1b 那棒我推翻了卡片「15 條基準 route 一顆能力點都沒掛」的前提，當時的結論是
「**不應登記為第 80 項同題**」。**這句話講得太滿，要更正**：

- **寫入那半**：確實掛好了，卡片說「零能力點」是錯的，這部分維持原判。
- **讀取那半**：**工具這次查出來的形狀與第 80 項一模一樣**——宣告了 read 能力點、
  資料庫 seed 了、前端選單在認、**後端不守**。這半**就是**第 80 項同款的產品決策題。

所以正確說法是：**卡片的錯在範圍（說成全部），不在方向（讀取那半確實是同題）。**
第 80 項本身依首腦 09-19 裁決不撤（那是 jedi-asset 的事）；本項應登記為**與第 80 項同形的
新一筆**，因為受影響的資料與套件都不同（那邊是設備與資訊系統清冊，這邊是掃描基準庫）。

另外核對了工具沒講的一點：**抽取狀態那支 GET（`:527`）與重抽 POST（`:542`）共用同一個
route class**，POST 有掛 `_profile_create`、GET 沒掛。程式碼裡的註解（`:539-541`）明白寫了
「讀狀態的 GET 不設 capability，**與其他讀取端一致**」——**這是刻意的、不是疏漏**，
這句自陳正好證實 F1 是設計決定而非忘記，也讓「要不要改」成為產品面的問題。

### F2 — 重抽功能無上限開執行緒與外部程序（MEDIUM，confidence medium）

**這是什麼問題。** 掃描基準上傳後，系統會在背景解析出規則清單。如果解析失敗，使用者可以
按「重抽」重試一次。問題是**這支端點每收一次請求，就無條件開一條新的背景執行緒**——
沒有數量上限、也沒有「這一版已經在跑了就不要再排」的判斷。

每一條執行緒會做兩件很吃資源的事：把最大 50MB 的壓縮檔**整包讀進記憶體**、
再開一支外部程序（`cinc-auditor`）解析，**逾時設定是 900 秒**。

**出事會怎樣。** 租戶管理員先上傳一份接近 50MB 上限的基準拿到版本編號，然後寫個腳本對
重抽端點連打數百次。每一次都會：把狀態壓回 pending、開一條新執行緒、吃掉 50MB 記憶體、
再 fork 一支子程序。**數百條執行緒與數百支子程序會同時存在最長 900 秒**，主機記憶體與
CPU 被吃光，**同一台機器上所有客戶的 API 一起變慢或逾時**。

資料庫連線池不會被握住（設計上是三段式、背景工作不在請求交易內），所以影響面只有可用性，
沒有外洩也沒有竄改。

**要先有什麼才打得到。**
- 已登入且持有「新增掃描設定檔」能力點（一般是租戶管理員）
- 該客戶的授權含弱點掃描模組
- **主機上裝了 `cinc-auditor`**——沒裝的話每條執行緒會很快失敗，耗用大幅降低
- 有一個可重抽的版本編號（自己上傳一個即可）
- 那一版不能是公版（公版重抽只有平台管理員能做，`extraction_service.py:200-201` 有擋）

**在哪裡。**
- 端點：`jedi-detection/jedi_detection/api/routes/detection_profile_route.py:548`
- 無條件開執行緒：`jedi-detection/jedi_detection/app/service/detection_profile_extraction_service.py:166-173`
- 重抽沒有去重：同檔 `:202-203`（寫 pending 後直接 `schedule()`，中間沒有任何檢查）

**怎麼修。** 最便宜且最對症的是**去重**——而且**該有的資訊本來就在**：

這支服務**已經定義了 `running` 狀態**（`extraction_service.py:97`），也在真正開始跑的時候
寫入它（`:463`、`:472`）。**`retry()` 只是沒有去看它。** 所以修法是在 `retry()` 寫 pending
之前加一道判斷：**這一版目前已經是 pending 或 running 就直接回現況、不排第二條**。
三行程式碼。

另外兩層建議一併做（防的是不同的事）：
- **併發閘**：把 `schedule()` 的「每次請求開一條新執行緒」換成有上限的 worker pool 或
  `BoundedSemaphore`，超過上限排隊或回「目前解析工作已滿」。這防的是**多個版本同時被打**
  （去重只防同一版）。
- **節流**：同一版本 N 分鐘內只接受一次手動重抽。

**驗證。** 3/3 三位檢查員確認成立。三位都把路徑從 route 追到 `retry()` 再到 `schedule()` 的
執行緒建立處，確認中間兩道守門（`:538` 的授權、`:541` 的能力點）**都是在管「誰能按」、
不是在管「按幾次」**，下游也沒有任何節流。

**首腦核對註記。** 屬實。額外核對了三點工具沒講的：
① **公版有擋**——`retry()` 開頭會擋非平台管理員重抽公版（`:200-201`），且該處註解寫明
理由是「不擋的話 RLS 會讓背景更新 0 rows、使用者按了永遠停在 pending，比明確 403 糟得多」，
**這是正面案例**，工具沒提但值得記。
② **`running` 狀態確實已存在且有被寫入**，所以去重修法是「用既有資訊」不是「新增狀態機」，
成本極低。
③ **限額值在宿主端**：實際跑的 `max_entries` 是 10,000（`config/config.py:196`），
不是套件預設的 20,000（`contract.py:88`）——兩邊不一致但宿主覆寫是設計行為，不是缺陷；
**記下來是因為 D2-1b 的 F1 修法要用到這個值的真實來源**。

## 卡片重點逐項人工查證

首腦 09-19 把重點④改題為：「讀取 8 支不掛能力點**是不是刻意**（檔頭 docstring 自陳了）、
跟第 80 項**是不是同一個產品決策題**——答這題就好，不要再證『零能力點』。」

### ④ 讀取不掛能力點：是刻意的嗎？跟第 80 項同題嗎？ — ✅ 兩題都答了

**是刻意的，有三處自陳可證。**

1. **route 檔頭 docstring（`:3-6`）**：「三層防禦的**第 1 層**在這裡：route capability
   （軸④）。能力點皆 `is_platform=false`——租戶管理員必須能建自己的 TENANT 基準。
   **『能不能碰公版』是第 2 層（app service 的 `_guard_system_writable`）的事，
   不由 capability 表達**。」→ 作者對「哪一層管什麼」是有明確設計的。
2. **能力點契約檔的自陳（`plugin/contract.py:28-31`）**：「八項中 BE route **目前只真的守
   `plugin.update` 與 `detection-profile` 的三個寫入項**；**`read` 兩項前端選單與
   `route_capabilities` 認，漏了會是『選單看不到這個頁面』**。」→ **這段話直接承認了
   read 兩項後端不守**，而且說明了它的實際作用是控制選單可見性。
3. **route 內的個別註解（`:290-292`、`:539-541`）**：「GET 不設 capability，**與其他讀取端
   一致**——它回的是『這支能不能改／能不能刪』的事實，本身不構成任何寫入。」
   → 明確的一致性原則，不是隨手漏掉。

**判定：刻意的，不是疏漏。** 三處自陳互相吻合，設計意圖是「capability 管寫入，讀取靠
license 與 RLS」。

**跟第 80 項是不是同一個產品決策題？——是，形狀完全相同。**

| | 第 80 項（jedi-asset） | 本項（jedi-detection） |
|---|---|---|
| 宣告了 read 能力點 | ✅ | ✅ |
| 資料庫 seed 了 | ✅ | ✅（`04-seed-core.sql:202`） |
| 前端選單／`route_capabilities` 在認 | ✅ | ✅（`04-seed-core.sql:491`） |
| 後端 route 執法 | ❌ | ❌ |
| 套件在程式碼裡自陳 | ✅（`jedi_asset/plugin/contract.py:27`） | ✅（`jedi_detection/plugin/contract.py:28-31`） |
| 寫入類有守 | ✅ | ✅ |
| 外洩內容 | 設備清冊、資訊系統清冊 | 掃描基準庫、來源網址、規則清單 |

**判定：同形不同案。** 兩者是**同一個產品決策的兩次出現**，但**受影響的套件與資料不同**，
所以應登記為獨立一筆、並在總表標明「與第 80 項同形」。第 80 項本身不撤
（首腦 09-19 裁：那是 jedi-asset 的事）。

**⚠️ 更正我自己先前的說法**：D2-1b 那棒我說「不應登記為第 80 項同題」，**那句話錯在範圍**。
當時我證明的是寫入類有掛（正確），但由此推論「整條不是同題」是過度延伸——**讀取那半就是同題**。
正確結論見上表。

### ⑤ 版本檔案的取回 — ✅ 本棒補完

D2-1b 那棒只答了一半（確認 `DetectionProfileVersionSourceRoute` 是「就地換來源」不是下載），
本棒把另一半補上：

**這 18 支端點裡沒有任何一支是「下載原始壓縮檔」。** 逐支核對過，取得內容的只有三種：
基準的中繼資料（名稱、分類、來源網址、sha256）、抽出來的控制項清單、抽取狀態。
**原始檔的下載走的是主專案的通用檔案端點**（`/api/1.0/file/download/<uid>`），
不在本套件內，屬 FR-077 R2b 範圍。

⚠️ **但這產生一個本棒答不了的問題留給 D2-2**：`_read_source_bytes(file_id)` 用數字 id 查
`upload_files`——**那條路的歸屬驗證在 `detection_profile_service.py` 裡，屬 D2-2 範圍**。
本棒只能確認 route 這一層沒有直接開下載口。

### 附帶：serializer 與 dto 兩個檔（509 行）查證 — ✅ 沒有發現

工具對這兩個檔一條候選都沒提。人工過了一遍，記三點：

- **`scope` 欄位兩層寫法不一致**（D2-1b 已記錄的錨點）：新增基準的 schema
  （`detection_profile.py:216`）`scope` 預設 TENANT、**靠 service guard 擋 SYSTEM**；
  分享給子樹那支（`:250`）卻用 `validate.OneOf(["TENANT", "SHARED"])` **在 schema 層就把
  SYSTEM 排除**。兩種寫法防的是同一件事但層次不同。**本棒不判定對錯**——要驗第一層真的擋得住，
  得讀 `detection_profile_service.py`，屬 D2-2。
- **所有 request schema 都有 `unknown = EXCLUDE`**，前端多送的欄位會被丟掉而不是報錯或穿透。
  **判定：正確。**
- **dto 純粹是資料搬運**，沒有邏輯、沒有外部呼叫、沒有序列化陷阱。**判定：無攻擊面。**

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

分兩層講：

**「報出來的這兩條存在嗎」——可信度高。** 兩條都 3:0 通過，三位檢查員各自獨立把鏈子追完。
我逐條開檔核對過：F1 的七支端點與十支對照組全部逐支數過、能力點宣告與三處資料庫 seed 都找到了；
F2 的執行緒建立處、去重缺口、以及「`running` 狀態已存在」這個修法關鍵都確認過。

**「只有這兩條嗎」——可信度偏低，這棒有一個比平常更嚴重的限制。**

1. **🔴 四位研究員、三位陣亡。** 前三位被砍掉重派，第四位才完成。**驗證章（6 票全投）只證明
   「被提出來的那兩條經過了完整投票」，不證明「這三個檔被徹底讀過」**——三次中斷的研究
   過程沒有留下任何產出。這棒的覆蓋均勻度**明顯低於**前面幾棒。
2. **強度是 `low`**，一位研究員讀一遍，設計目的是快速篩不是窮盡。
3. **serializer 與 dto 兩個檔（509 行，佔本棒一半）工具零候選**，那部分的結論全是人工查的，
   沒有三票背書。
4. **下游不在範圍內**：F1 的實際外洩量、F2 的實際資源消耗，都要看 service 層才知道確切規模，
   那屬 D2-2／D2-3。

`verification.status` 為 **`verified`**：三位檢查員對 2 條候選各投一票，**6 票全數投出**，
票數由工具自己的程式碼統計、不是任何 agent 自報。

## 執行概況

| 項目 | 數字 |
|---|---|
| run ID | `wf_fdf9cd33-862` |
| 掃描 commit | `955e40964baa3cfc0e8766078db7d65075d3c50b`（工作區乾淨） |
| 範圍 | 3 檔 1,058 行 |
| 強度 | `low`（一位研究員 ＋ 三票面板） |
| 研究員 | **派 4 位、前 3 位被砍、第 4 位完成** |
| 面板 | 2 條候選 × 3 位檢查員 ＝ 6 票，全數投出 |
| 總耗時 | **約 9 小時 22 分**（18:33 啟動 → 次日 03:55 結果回來） |
| 候選 → 成立 | 2 → 2（**零駁回**） |
| 驗證輪數 | 1 |
| 驗證章 | `verified` |
| 工具原始產物 | `jedi-detection/CLAUDE-SECURITY-20260919-103320/`（不入版控） |

## ⚠️ 這一棒推翻了「行數決定成敗」的假設

把目前五個樣本擺在一起：

| 行數 | 檔案性質 | 結果 |
|---|---|---|
| 422 | 純函式模組（archive + ref） | ✅ 零重試 |
| 707 | domain service／repo／model | ✅ 零重試 |
| **1,058** | **route + serializer + dto** | ⚠️ **三位陣亡、第四位才完成** |
| 1,328 | service／handler／spec | ✅ 零重試 |
| 1,480 | route + serializer + dto + 兩個純函式模組 | ❌ 六位全滅 |

**1,058 比 1,328 小，卻慘得多。** 所以行數不是唯一變數——**檔案性質才是**。

**兩次出事的都含 route 檔。** 我的判讀：route 檔會把研究員引向 18 支端點各自的下游
（service、guard、DI 容器、宿主接線），要證明「這支端點誰打得到」就必須追到宿主那一層。
這次研究員確實讀了主專案的 `core/plugins/detection.py`、能力點契約、抽取服務——
**這些讀取是必要的，不是走神**，但 context 就是這樣膨脹起來的。

相對地，一支自包含的 service 或純函式模組，研究員讀完就能下判斷，不必往外追。

**給後續小棒的建議**：D2-2（service 主幹 1,632 行）、D2-3（解析與外抓 1,677 行）、
D2-4（分類與版本存取層 1,609 行）**都不含 route 檔**，性質接近跑得動的那幾棒，
可以照原切法派；**若日後還有含 route 的範圍，應該把 route 單獨切一棒**，不要跟其他檔混。

**另記**：本棒與 D2-2 兩線並行。D2-2 結果回來時，兩相對照可以進一步驗證「檔案性質 >
行數」這個判讀。
