檢查日期 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。
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 項(註解錯誤,屬非資安),查不到單獨處理紀錄,保留待核。
工具沒提任何候選,但研究員的紀錄留下兩處「看起來像問題、追到底不是」的地方。 這兩處都很容易被下一棒或下一個人重新當成漏洞報一次,所以寫進來。
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()」 容易讓人以為裝飾器就在旁邊,下一個人找不到會以為是漏的。
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 節第③項。
結論:三項都乾淨,沒有破口。 逐項如下。
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 會帶回「有幾支基準在用這個分類」, 而那個數字是數基準表(那張表是有主人的)。如果這個數字是全站的,就等於一支 「我用分類表當鏡子,照出別的租戶有多少基準」的探測管道。
查證結果是照不出來:
02-schema.sql:24805)cm_app,我實查 DEV 確認:cm_app 的 rolbypassrls = false、rolsuper = false, 而且不是那張表的擁有者(擁有者是 cmmgr)——擁有者會自動繞過 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)。 沒有身分=什麼都看不到,不是什麼都看得到——方向是對的。
list_by_axis(axis=None) 不帶軸就回兩軸全部,query_entity 的 to_dict() 濾掉 None 所以不帶條件就是不加 WHERE。形狀上確實是「空條件回全表」。
但這裡不是缺陷,理由有二:
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()。unknown = EXCLUDE,前端把整個實體回送(含 created_at) 不會變成 422。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,省得下一個人找不到而誤判成漏掛。
「零發現=這八個檔沒有漏洞嗎」——中高,但不要讀成「保證乾淨」。 八個檔全部被讀過(不是沒人看),而且兩處疑點都追到了外部接線與資料庫政策才收手, 不是讀完表面就下判斷。我自己復核了守門三支(開檔直接數)與 RLS 那條鏈(含跑兩條唯讀 SQL 查 表擁有者與角色屬性——這是研究員明說「讀原始碼定不了、要查實際資料庫」的那一項),結論一致。
限制有三個: ① 強度 low,一位研究員讀一遍,沒有第二個人獨立讀過同一份程式碼。 ② 零候選代表面板沒有投過票——這不是「三票判定安全」,是「沒有東西可判」。 換句話說這一棒完全沒有用到對抗性檢查那一層,全靠研究員自己的判斷加我的復核。 ③ 這一棒的八個檔每支都很小(最大 237 行),而且是教科書式的分層 CRUD, 本來就是最不容易藏洞的形狀。零發現與「範圍選得容易」有關,不能外推到別的範圍。
第八個樣本,再次印證「切棒保證跑得完,不保證找得更多」。 772 行零重試 33 分鐘跑完,產出 0 條。
零候選,所以也零駁回。 也沒有越界候選——研究員的外追都是為了判定範圍內那兩處疑點的下游, 沒有被鄰近大檔吸走(D2-1b 與 D3-4b 都出現過這個現象,這棒沒有)。
| # | 在哪 | 是什麼 | 建議 |
|---|---|---|---|
| 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,最多洩漏一點資料庫內部字串, 而且對象是平台管理員(系統裡權限最高的人),影響可忽略。| 項目 | 值 |
|---|---|
| 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) |
① 掃描 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 的觀察④一致:逐項查證的產出應該包含「現況落差」,不是只回答有/沒有。