檢查日期 2026-09-19|對應卡片 CM-1874(D2 第 1 小棒的前半)|檢查範圍 3 個檔案 1,058 行
找到 2 條中風險,都三票一致通過。
第一條:產品宣告了「檢視掃描設定檔」這顆權限,但後端七支讀取功能沒有一支真的檢查它。 管理員在權限矩陣把這一格取消勾選,以為資料關起來了,實際上那個帳號直接打 API 照樣拿得到 全租戶的掃描基準庫。同一個檔案裡十支寫入功能每一支都有檢查——只有讀取這一半漏了。
第二條:手動重抽功能可以把主機打掛。 每收一次請求就無條件開一條背景執行緒、把最大 50MB 的檔整包讀進記憶體、再開一支最長 900 秒的外部程序,沒有併發上限、也沒有「這一版 已經在跑就別再排」的判斷。連打幾百次就是幾百條執行緒同時存在。
客戶要做弱點掃描,得先在系統裡建立「掃描基準」——一包規則檔,可以上傳壓縮檔或給一個網址。 這一棒看的是進入系統的那道門(3 個檔):
detection_profile_route.py(549 行) — 18 支 API 端點,是使用者能碰到的全部入口。detection_profile.py(290 行,serializer) — 每支端點收什麼欄位、吐什麼欄位。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。
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 |
零駁回——兩條候選都通過,這在本系列少見(前面幾棒多半有一到兩條被否決)。
這是什麼問題。 產品的能力點清單裡明明白白宣告了一顆「檢視掃描設定檔」 (detection-profile.read,plugin/contract.py:38),資料庫也 seed 了它、前端權限矩陣 也看得到這一格。但後端七支讀取端點沒有一支去檢查它——它們只驗兩件事:有沒有登入、 這個客戶有沒有買弱點掃描模組。
對照組很清楚:同一個檔案裡十支寫入端點,每一支都掛了對應的能力點 (新增、編輯、刪除、換來源、版更、fork、複製、停用、刪版、重抽)。所以這不是「這個套件 不做能力點」,而是寫入做了、讀取漏了。
出事會怎樣。 一個被刻意拿掉這個權限的帳號(例如只被指派專案參與者角色的約聘人員), 拿自己的 token 直接打 API,拿得到:
這是租戶內部的組態與安全基線外洩。拿不到別家客戶的東西(跨租戶由資料庫隔離擋住), 也不能寫入,所以是中風險不是高風險。
最要命的地方是它會誤導管理員。 前端選單因為 route_capabilities 有設定,所以那個帳號 看不到這個頁面——管理員把權限取消勾選後,從畫面上看是生效的。他沒有理由懷疑 後端根本沒在擋。
要先有什麼才打得到。
在哪裡(七支全列)。
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:38compliance-manager-be/core/plugins/detection.py:302(auth_required=jwt_required())scripts/init/04-seed-core.sql:202(能力點本身)、:376(給管理員角色)、 :491(掛在前端路由上——這就是「只有選單在認」的實證)怎麼修。 兩條路擇一,但一定要選一條,現況(宣告了不守)是最糟的:
: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 上。用的是現成機制, 不必造新東西。detection-profile.read 從能力點清單撤掉,或在文件與權限矩陣旁明白寫出 「這一格只影響前端選單、後端不擋」。⚠️ 修法一會擋掉現在能用的人——今天所有登入者都讀得到,補了之後沒有這顆權限的帳號會開始 吃 403。這是產品決策不是純技術決定。
驗證。 3/3 三位檢查員確認成立(可達性、影響、既有防護三個角度)。三位各自獨立把鏈子 追完:宿主接線只給登入檢查 → route 只掛授權檢查 → service 直接回全庫。
首腦核對註記(這條要更正我自己先前的說法)。
D2-1b 那棒我推翻了卡片「15 條基準 route 一顆能力點都沒掛」的前提,當時的結論是 「不應登記為第 80 項同題」。這句話講得太滿,要更正:
所以正確說法是:卡片的錯在範圍(說成全部),不在方向(讀取那半確實是同題)。 第 80 項本身依首腦 09-19 裁決不撤(那是 jedi-asset 的事);本項應登記為與第 80 項同形的 新一筆,因為受影響的資料與套件都不同(那邊是設備與資訊系統清冊,這邊是掃描基準庫)。
另外核對了工具沒講的一點:抽取狀態那支 GET(:527)與重抽 POST(:542)共用同一個 route class,POST 有掛 _profile_create、GET 沒掛。程式碼裡的註解(:539-541)明白寫了 「讀狀態的 GET 不設 capability,與其他讀取端一致」——這是刻意的、不是疏漏, 這句自陳正好證實 F1 是設計決定而非忘記,也讓「要不要改」成為產品面的問題。
這是什麼問題。 掃描基準上傳後,系統會在背景解析出規則清單。如果解析失敗,使用者可以 按「重抽」重試一次。問題是這支端點每收一次請求,就無條件開一條新的背景執行緒—— 沒有數量上限、也沒有「這一版已經在跑了就不要再排」的判斷。
每一條執行緒會做兩件很吃資源的事:把最大 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:548jedi-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,超過上限排隊或回「目前解析工作已滿」。這防的是多個版本同時被打 (去重只防同一版)。驗證。 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 項是不是同一個產品決策題——答這題就好,不要再證『零能力點』。」
是刻意的,有三處自陳可證。
:3-6):「三層防禦的第 1 層在這裡:route capability (軸④)。能力點皆 is_platform=false——租戶管理員必須能建自己的 TENANT 基準。 『能不能碰公版』是第 2 層(app service 的 _guard_system_writable)的事, 不由 capability 表達。」→ 作者對「哪一層管什麼」是有明確設計的。plugin/contract.py:28-31):「八項中 BE route 目前只真的守 plugin.update 與 detection-profile 的三個寫入項;read 兩項前端選單與 route_capabilities 認,漏了會是『選單看不到這個頁面』。」→ 這段話直接承認了 read 兩項後端不守,而且說明了它的實際作用是控制選單可見性。: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 這一層沒有直接開下載口。
工具對這兩個檔一條候選都沒提。人工過了一遍,記三點:
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。unknown = EXCLUDE,前端多送的欄位會被丟掉而不是報錯或穿透。 判定:正確。分兩層講:
「報出來的這兩條存在嗎」——可信度高。 兩條都 3:0 通過,三位檢查員各自獨立把鏈子追完。 我逐條開檔核對過:F1 的七支端點與十支對照組全部逐支數過、能力點宣告與三處資料庫 seed 都找到了; F2 的執行緒建立處、去重缺口、以及「running 狀態已存在」這個修法關鍵都確認過。
「只有這兩條嗎」——可信度偏低,這棒有一個比平常更嚴重的限制。
low,一位研究員讀一遍,設計目的是快速篩不是窮盡。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 結果回來時,兩相對照可以進一步驗證「檔案性質 > 行數」這個判讀。