D2-2b 檢查結果:基準領域層與存取層(jedi-detection)

D2-2b 檢查結果:基準領域層與存取層(jedi-detection)

檢查日期 2026-09-20|對應卡片 CM-1874(D2 第 2 小棒的後半,原 D2-2 重切)|檢查範圍 4 個檔案 499 行

🔴 一句話結論

範圍內 0 條新發現。 工具的研究員讀完四個檔、追完每一條路的上下游,一條候選都沒提報, 所以驗證面板沒有東西可以投票。

但這個「乾淨」有前提,而且前提不在這四個檔裡面:這四個檔自己完全沒有租戶隔離的程式碼—— 隔離是靠資料庫的 RLS 政策擋下來的。我實際連 DEV 資料庫查過政策與帳號,確認目前真的擋得住; 但這代表這幾個檔的安全性完全外包給資料庫,任何一天有人改動政策或換了連線帳號, 這裡不會有任何一行程式碼發出警告。這一點記在第 2 節,不是漏洞、是要知道的事實。

這一棒在檢查什麼

客戶要建立「掃描基準」(一包掃描規則)。D2-2a 看的是那支 1,133 行的服務本體(業務判斷、守門、 公版 vs 租戶的 scope),這一棒看的是它下面那兩層:

層 檔 行數 做什麼
領域層 detection_profile_domain_service.py 190 基準主檔+版本從檔的協調(同一個聚合)
領域層 detection_profile_control_domain_service.py 48 控制項(某一版裡面的規則清單)
存取層 detection_profile_repo_impl.py 177 主檔的實際查詢
存取層 detection_profile_control_repo_impl.py 84 控制項的實際查詢

這兩層都是薄層:領域層幾乎全是一行轉呼叫,真正組查詢的只有存取層那兩支。

卡片指定的重點④:查詢條件與 RLS 的實際落點、空條件會不會回全表(跨 arc 總表第 70 項)。

掃描目標 /Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-detection, revision dfe260a5ad0c(branch feature/review,工作區乾淨),mode scan,effort low。 與 D2-4a 同一個 commit;與更早幾棒的 955e409 相比,這四個檔一行都沒變(已用 git diff 核對), 所以跨棒比較成立。

Coverage

low 強度:一位研究員讀完這 4 個檔就提報候選,未做元件盤點、未做威脅建模, completenessCheckOutcome 為 not-applicable。驗證跑了 1 輪,0 個候選、面板 0 票 (沒有候選就沒有東西可投),沒有候選遺失、沒有被駁回的候選、沒有嚴重度被降低。

研究員為了判斷這四個檔安不安全,讀了範圍外的檔:上游呼叫端(detection_profile_service.py、route 層)、 守門(能力點 decorator、_guard_system_writable、assert_scope_writable)、 資料庫政策(套件的 002-detection-rls-grants.sql、主專案的出貨基線), 以及執行時實際用哪個帳號連資料庫(.env、install.sh)。 這些讀取是必要的——這四個檔裡面沒有任何一行在做租戶隔離,不往外追就無法判斷「那到底誰在擋」。

這一棒跑得普通:派了 2 位研究員,第一位在約 50 分鐘後被換掉,第二位完成,總耗時約 1 小時 45 分。 ⚠️ 與 D2-2a 完全一致的現象:journal 從頭到尾沒有出現過 failed 這個字, 研究員被換掉的呈現方式是同一個 key 再出現一筆 started(D2-2a 的報告已更正過這個判準,本棒再次印證)。

工具沒有實際執行任何程式碼:沒跑測試、沒發請求、沒示範攻擊。 但我自己連了 DEV 資料庫查政策與帳號(唯讀 SELECT,屬環境鐵律允許的讀取),第 2 節的結論是實查來的。

現況(2026-10-01):本棒各條後來的處理結果如下(過程紀錄保留,不改)。

  • 本棒範圍內沒有資安發現;體質項登記為跨 arc 總表 §3.2 第 31 項(前提說明,非要改的程式),無修正卡。

1. 卡片重點④逐項人工查證

結論:查詢條件沒有破口,空條件回全表的問題在這四個檔不成立。 逐項如下。

① 查詢條件會不會漏掉租戶 — ✅ 兩支列表都顯式收斂,理由成立

_visible_scope_filter(detection_profile_repo_impl.py:47-53)把可見範圍定義成三種的聯集: 公版 + 本租戶自有 + 上游分享下來的。兩支列表(list_profiles:75、list_menu:120)都掛了它。

為什麼不能只靠 RLS:root 租戶的可存取路徑是 /1/,前綴涵蓋所有子租戶—— 只靠 RLS 的話,平台管理員在管理頁會看到全站每一個租戶的私有基準。 程式碼的註解寫明了這件事,而且註解是對的(我查了 RLS 政策確認)。

② 那個「無條件放行 SHARED」看起來很可疑 — ✅ 實查確認擋得住

第 52 行的 DetectionProfile.scope == _SCOPE_SHARED 沒有任何附帶條件。 單看這一行,任何租戶的任何 SHARED 基準都會被撈出來——這是整個範圍裡最像漏洞的一行。

實查結果:擋得住,但擋的人不在這個檔裡。 我連 DEV 資料庫核對:

-- 目前 detection_profiles_select 政策的實際內容(DEV 實查)
scope = 'SYSTEM'
  OR app.is_super_admin = 't'
  OR app_tenant_allowed_for_session(tenant_id)
  OR (scope = 'SHARED' AND app_tenant_is_ancestor_of_session(tenant_id))   ← 這一段

最後那一段要求「資料的租戶必須是我的祖先」——平輩租戶的 SHARED 資料照不出來。 程式碼的註解也寫明「祖孫關係由 RLS policy 判定,這裡不重算租戶樹,重算等於第二套真相」, 這個取捨是對的(兩邊各算一次,漂移時無人察覺)。

⚠️ 但這段政策不在套件自己的 migration 裡。套件的 002-detection-rls-grants.sql:75 那份沒有 SHARED 分支, 是 2026-09-14 主專案的 FR-094 migration(2026-09-14-fr094-cm1790-shared-scope-subtree.sql)後來蓋上去的。 D2-2a 已經報過這個落差(套件自帶建表腳本停在舊值域),這裡是同一件事的另一面: 套件的 RLS 腳本也停在舊版。新裝的客戶若只跑套件那份、沒跑主專案那支, 第 52 行就會變成真正的跨租戶外洩。這屬「出貨基線待重產」,只回報不自行處理。

③ 寫入路徑有沒有驗「這是不是我的東西」 — ✅ 沒有,但資料庫擋得住(要知道的是它怎麼擋)

clear_fields(:130)與領域層的 update / delete_profile 都沒有任何 tenant_id 比對—— 拿到 uid 就直接改/刪。上游 detection_profile_service.py 也只在 scope 有變動時才多判一道。

實查 DEV 的政策,UPDATE/DELETE 的判準是 app_tenant_allowed_for_session(tenant_id):

方向 結果
平輩租戶(A 改 B 的) ❌ 擋下,零列被改(表現為更新失敗,不是靜默成功)
子租戶改母租戶的 ❌ 擋下
母租戶改子租戶的 ✅ 會成功
任何人改公版 ❌ 擋下(政策明寫 scope <> 'SYSTEM')

母改子是刻意的:FR-094 那支 migration 的註解寫明「update/delete 一律不加 SHARED 分支—— 子租戶對分享資源唯讀」,而母對子本來就在可存取路徑內。這是設計不是缺口。

另外要打到這些寫入,得先知道那支基準的 uid,而列表與下拉都已經用 _visible_scope_filter 收斂過, 看不到的東西拿不到 uid。

④ 空條件會不會回全表(總表第 70 項)— ✅ 這四個檔不成立

第 70 項講的是「query entity 每一欄都沒填 → 條件是空的 → 回整張表」。這裡查了三處:

  • 兩支 query entity 的預設值全是 None(detection_profile_query_entity.py、 detection_profile_control_query_entity.py),to_dict() 濾掉 None,不會產生幽靈 WHERE
  • 共用基底 _gen_filters() 的行為確認:value is not None 才加條件,全空就是空條件
  • 但這四個檔的查詢沒有一支走那條路:兩支列表是自己手寫的 query,且第一個條件就是 _visible_scope_filter(不是可選的、不是從 query entity 來的);控制項的三支方法 全部強制帶 version_id,沒有「不帶條件」的入口

唯一走共用基底的是 list_all_versions()(領域層 :121),它傳的 profile_id 是必填參數, 呼叫端不可能不給。所以這四個檔沒有「條件全空」的路徑。

⑤ 控制項表沒有 RLS,那它靠什麼隔離 — ✅ 靠上層,而且四個入口都守住了

detection_profile_controls 刻意不掛 RLS(套件 migration 檔頭寫明,tests/test_migrations.py 焊死了這件事), 隔離由上層的版本從檔承擔。這種設計只要有一個入口直接拿 version_id 進來就破功,所以我把四個呼叫點全查了:

入口 怎麼拿到 version_id 判定
看控制項清單 _read_controls() 先用 uid 查從檔(受 RLS)再用 version.id ✅
抽取完寫回 worker 先取得從檔物件再用 version.id ✅
複製基準 先 get_current_version(source.id),而 source 是 RLS 過的 ✅
領域層的 get_by_uid 全專案零呼叫端(死碼,不構成入口) ✅

_read_controls() 的 docstring 甚至寫明「直接拿 version_id 當入口就是把那道保護拆了」—— 寫的人知道這件事,而且四個入口都照做了。

2. 這一棒最該知道的一件事:安全性完全外包給資料庫

這不是漏洞,是讀這四個檔時必須知道的前提,也是為什麼「0 條發現」需要但書。

這四個檔(499 行)裡面,沒有任何一行在做租戶隔離的判斷。 唯一沾到邊的 _visible_scope_filter 是在做「可見範圍」(避免平台管理員看到全站私有資料), 不是在做安全隔離——真正擋住跨租戶存取的是資料庫的 RLS 政策。

我實查確認目前這個外包是成立的:

  • 執行時連資料庫的帳號是 cm_app(.env:21),DEV 實查 rolsuper=f、rolbypassrls=f ——它繞不過 RLS,也不是那三張表的 owner(owner 是 cmmgr)
  • 三張表的 RLS 狀態實查:主檔與版本從檔 relrowsecurity=t(有開),控制項 f(刻意不開,見上)
  • 政策內容實查,與 FR-094 migration 寫的一致

但這代表三件事同時成立才安全:① 政策還在且內容正確、② 連線帳號沒有 bypass 權限、③ 上層每一個入口 都先經過受保護的表。任何一項被改動,這四個檔不會有任何反應——不會報錯、不會有測試變紅, 只會安靜地開始回傳別人的資料。

這不是要求改程式碼(在 repo 層重算租戶樹就是 FR-094 註解明確反對的「第二套真相」)。 是要知道:這幾個檔的安全審查結論,有效期等同於那份 RLS 政策的有效期。

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

「範圍內真的沒有嗎」——中高。 四個檔合計 499 行、是本 arc 最小的一棒,研究員讀得完;它逐項交代了每個候選為什麼不成立, 不是沉默的空手而歸(與 D2-2 那 25 位一句話都沒寫就死掉的情況完全不同)。 我另外自己把卡片重點④四項全部開檔查過,並實連 DEV 資料庫核對政策與帳號—— 第 1 節的結論不是轉述研究員,是查證過的。

「只有這樣嗎」——中等,三個限制。 ① 強度 low,一位研究員讀一遍(第一位被換掉,實際只有第二位的一遍)。 ② 重點④的結論全是人工查的,沒有三票背書——工具零候選,面板零票, 本棒的驗證章 verified 只代表「流程跑完沒有斷」,不代表有三個人同意過什麼。 ③ 這是第八個樣本,再次印證「切棒保證跑得完,不保證找得更多」:499 行出 0 條。

這四個檔本來就不太可能藏洞:它們是薄層(領域層幾乎全是一行轉呼叫), 沒有外部輸入的解析、沒有子程序、沒有網路、沒有加解密、沒有檔案路徑處理—— 攻擊面本來就在上一層(D2-2a 的服務本體)與更上層的 route。

4. 被駁回的候選

零候選、零駁回,也沒有越界候選。研究員的範圍外讀取都是為了判斷這四個檔的安全性,沒有被鄰近大檔吸走。

5. 手測清單(給驗收用)

⚠️ 只在 DEV 做,不要碰 STG/POC。 以下全部是唯讀查詢。

驗「隔離真的靠資料庫」這件事(第 2 節的前提):

  1. 連 DEV 查帳號權限,確認 cm_app 兩個旗標都是 f(繞不過 RLS): SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname IN ('cm_app','cmmgr');
  2. 查 SELECT 政策內容,確認有 SHARED 那一段(沒有這一段,第 52 行就是跨租戶外洩): SELECT pg_get_expr(polqual, polrelid) FROM pg_policy WHERE polname='detection_profiles_select';
  3. 查三張表的 RLS 開關,確認主檔與版本從檔是 t、控制項是 f: SELECT relname, relrowsecurity FROM pg_class WHERE relname LIKE 'detection_profile%';

驗可見範圍(要兩個平輩租戶):

  1. 租戶 A 建一支基準、把 scope 設成 SHARED
  2. 用租戶 B(平輩、不是 A 的子孫)登入看基準列表
  3. 預期:看不到 A 那一支 —— 看得到就是 RLS 政策沒套到,回頭查上面第 2 步

驗控制項的隔離(要兩個租戶):

  1. 租戶 A 建基準並成功抽取,記下那一版的 uid
  2. 用租戶 B 登入,直接打看控制項的端點、帶 A 的版本 uid
  3. 預期:404(查不到那一版),不是回出控制項清單

6. 這一棒的觀察(給後續小棒)

① 「0 條發現」有兩種,要分清楚。 D2-2 那次是 25 位研究員一句話都沒寫就被砍,沒有任何結論;這一棒是研究員讀完、 逐條交代為什麼每個候選不成立。前者是失敗、後者是結果,報告上都寫「0 條」但可信度天差地別。 判準:看 journal 有沒有 result 事件、看研究員的最後一則有沒有推論內容。

② 薄層的安全結論會指向別的地方。 這四個檔查完,所有安全結論都落在兩個地方:資料庫的 RLS、上一層的服務。 薄層本身沒有可攻擊的表面——後續若還有純領域層/存取層的小棒(如 D2-4b 的 query entity 與 model), 可以預期同樣的形狀:工具報不出東西,價值在人工確認「上下游的假設有沒有成立」。

③ 監看判準再次印證 D2-2a 的更正。 本棒 journal 從頭到尾 failed 為 0,但實際換過一次研究員——呈現方式是同一個 label 重複出現 started。 「看 failed 數第二個就停」這條原始指令在這個工具版本上永遠不會觸發, 正確判準是「failed 數 + 同 label 重複 started 數,任一到第二次就停」。