D2-1a 檢查結果:上傳入口守門(jedi-detection)

D2-1a 檢查結果:上傳入口守門(jedi-detection)

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

§1

🔴 一句話結論

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

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

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

§2

這一棒在檢查什麼

客戶要做弱點掃描,得先在系統裡建立「掃描基準」——一包規則檔,可以上傳壓縮檔或給一個網址。 這一棒看的是進入系統的那道門(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。

§3

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)。
§4

掃到什麼:總覽

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

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

§5

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 修法要用到這個值的真實來源。

§6

卡片重點逐項人工查證

首腦 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 純粹是資料搬運,沒有邏輯、沒有外部呼叫、沒有序列化陷阱。判定:無攻擊面。
§7

這份結果可信到什麼程度

分兩層講:

「報出來的這兩條存在嗎」——可信度高。 兩條都 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 自報。

§8

執行概況

項目 數字
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/(不入版控)
§9

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

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

行數 檔案性質 結果
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 結果回來時,兩相對照可以進一步驗證「檔案性質 > 行數」這個判讀。