D2-2a 檢查結果:基準服務本體(jedi-detection)

D2-2a 檢查結果:基準服務本體(jedi-detection)

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

🔴 一句話結論

找到 1 條中風險,三票一致通過:填網址建基準時,系統允許填明文 http:// 的網址,而且網址型來源不記指紋。 遠端代理程式拿到這個網址後會直接叫外部程式去下載並執行裡面的 Ruby 規則——中間任何一個能動手腳的網路節點 (同一段區網、上游路由、被控的 DNS)換掉那包檔案,就等於在代理程式容器裡執行自己的程式碼, 而那個容器手上握有它要去掃描的每一台主機的 SSH 憑證。後端自己的下載器只准 https,這裡卻放行 http,兩邊不一致。

卡片指定的重點④(公版 vs 租戶的 scope 守門)工具一條候選都沒提,我逐項開檔查證,守門本身沒有破口—— 但查出兩個工具與卡片都沒問到的現況落差,記在下方第 3 節:資料庫已經認得第三種 scope(SHARED), 而這支服務的程式碼還停在兩種的世界觀。這不是漏洞,是會長成漏洞的地方。

這一棒在檢查什麼

客戶要做弱點掃描,得先在系統裡建立「掃描基準」——一包規則檔。這一棒看的是管這件事的那支服務本體: detection_profile_service.py(1,133 行),基準的建立、改名、改分類、版更、改來源、刪版、刪基準、停用、 複製成自己的(fork)、複製到另一個工具池(copy-to-tool),全部在這一支裡。

要驗的核心問題(卡片重點④): 「原廠公版」與「租戶自己的」這兩種基準,誰能改誰?複製出來的那一份,算誰的?

掃描目標 /Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-detection, revision ac5a0d6c564f(branch feature/review,工作區乾淨),mode scan,effort low。 與前一棒 D2-3 掃的 955e409 相比,這個檔一行都沒變(已用 git diff 核對),所以兩棒讀的是同一份程式碼。

Coverage

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

研究員為了證明這條真的打得到,讀了範圍外的檔:serializer 與 route(誰能送這個欄位)、 detection_profile_ref.py(送給代理程式的封包長什麼樣)、代理程式端的 profile_cache.py 與 inspec.py (它拿到之後做什麼)。這些讀取是必要的——只看服務層無法判斷「網址最後會被誰拿去做什麼」。

這一棒跑得普通:派了 2 位研究員,第一位在 33 分鐘、寫了 1.3MB 思考紀錄後被砍掉,第二位 38 分鐘完成。 總耗時約 1 小時 42 分。相較原 D2-2(五檔一起掃,17 小時、25 位研究員全滅),切成單檔是對的。

⚠️ 監看判準要更正:卡片寫「看 journal.jsonl 的 failed 事件」,實際上這個工具不寫 failed 這個字。 研究員被換掉的呈現方式是同一個 key 再出現一筆 started。往後監看請數 started 筆數,第三筆就停。

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

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

  • 明文網址且不記指紋=M03 第 5 條,✅ 已修(CM-2057)。
  • 同棒提到的 SHARED 值域落差=跨 arc 總表 §3.2 第 28 項,✅ 已修(CM-2221,1.21.0 出貨)。

1. 找到的那一條:明文網址 + 沒有指紋 = 中途被換掉也沒人知道

這是什麼問題

建立基準時可以選「上傳檔案」或「填網址」。填網址這條路,全部的檢查只有一行:

# detection_profile_service.py:952
if not cleaned.lower().startswith(("http://", "https://")):
    raise BadRequestError(...)
return None, None, cleaned   # ← 第二個 None 就是 sha256,網址型永遠是空的

兩個問題疊在一起:

  1. http:// 被放行。後端自己的下載器 common/safe_http_fetch.py:68 寫得很清楚——ALLOWED_SCHEMES = ("https",), 同一份內容它只肯走 https。同一個系統的兩個地方對同一件事有兩套標準。
  2. 網址型不記 sha256。上傳檔案那條路會算指紋並寫進資料庫(代理程式拿到後會對帳), 網址這條路填的是 None,送給代理程式的封包裡就沒有指紋這一格可以比對。

出事會怎樣

代理程式收到封包後,直接把網址交給外部程式:cinc-auditor exec <網址>。 掃描規則包是 Ruby 程式,exec 的意思就是下載回來跑它。 所以攻擊者只要在那條網路路徑上換掉檔案, 裡面的 controls/*.rb 寫什麼就執行什麼——沒有 TLS 要破解,因為根本沒有 TLS。

拿到的是代理程式容器的權限,而代理程式是專門派去各台主機上掃描的,它身上帶著那些主機的 SSH 憑證, 而且它就住在客戶的內網裡。同時,被換過的規則包跑出來的掃描報告也是攻擊者寫的——稽核軌跡跟著一起假掉。

要先有什麼才打得到

  • 系統裡至少有一支基準是用 http:// 網址登記的。租戶管理員(有「新增檢測基準」能力點)就建得出來, 而且介面完全不會警告。
  • 攻擊者要能動到「代理程式 → 那個網址主機」這段路(同段區網、上游某一跳、或能改 DNS)。
  • 有一個掃描任務綁著那一版基準被派出去。

⚠️ 一個容易被誤以為「已經擋住了」的地方

後端自己抓那個網址會失敗(它只准 https),那一版的抽取狀態會停在 failed。 但派工不看抽取狀態——_load_profile() 只檢查主檔的 is_active(detection_orchestration_service.py:651)。 所以一支「後端自己都抓不到」的基準,照樣派得出去,代理程式照樣會去跑它。

怎麼修

  1. _resolve_source 只收 https://,與 safe_http_fetch.ALLOWED_SCHEMES 對齊。 後端自己都不肯抓的網址,不該讓它登記成功。
  2. 給網址型一個指紋錨點:第一次抽取成功時把算出來的 sha256 記下來, 並放進 build_payload(),讓代理程式執行前能對帳——上傳檔案那條路本來就是這樣做的, 網址這條只是沒補上。

⚠️ 與既有第 116 項不是同一條(那條是 D2-3 報的「網址型跳過壓縮檔驗證器、可以用解壓炸彈打爆記憶體」)。 同一條網址路徑上的兩種不同的病:第 116 項是「檔案太大沒人管」,這條是「檔案是不是原來那一份沒人管」。 修法不同,不要合併成一條。

2. 卡片重點④逐項人工查證(工具一條都沒提,全是開檔查的)

結論:公版與租戶的守門,該有的都有,沒有破口。 逐項如下。

① _guard_system_writable 是不是每支寫入都有掛 — ✅ 八支全掛

動作 行號 判的是誰的 scope
建立基準 :336 使用者指定的目標 scope(先正規化再判)
改基準 :422 這支基準現在的 scope
停用 :501(deactivate_profile) 這支基準的 scope
改某一版的來源 :581 那一版的 scope
刪某一版 :617 那一版的 scope
刪整支基準 :645 這支基準的 scope
複製到另一個工具池 :706 來源的 scope
版更(新增一版) :732 這支基準的 scope

判定邏輯本身(:903-910)也對:只有 scope 是 SYSTEM 時才要求是平台管理員, 而且是走統一的 viewer_is_platform_admin(),沒有硬寫租戶編號(硬寫的話日後 root 租戶編號一改就是看不見的地雷)。

未接線時會炸而不是放行(common/guard.py:41-47)——這點特別值得記一筆。這支套件的守門實作是由宿主注入的, 如果宿主忘了接線,預設回 True 會讓「忘記接線」變成無聲的權限破口:任何租戶管理員都能改跨租戶共用的公版, 而服務照常起得來、健康檢查照樣綠燈。套件選擇直接拋例外拒絕執行,這是對的。

② fork 與 copy-to-tool 的租戶編號從哪來 — ✅ 兩者刻意不同,而且都對

這兩支長得很像但語意相反,差異正是重點④問的那一格:

fork(:677) copy-to-tool(:696)
意圖 公版 → 我自己的可編輯副本 公版 → 還是公版,只是換一個工具池
要不要過公版守門 不用 要(:706)
新的 scope 硬寫 TENANT 沿用來源
新的租戶編號 硬寫當前使用者的租戶 沿用來源的租戶

兩邊都是對的,而且理由不對稱:

  • fork 不需要公版守門,因為它寫的是一列全新的租戶資料,沒有動到公版本身。 如果這裡沿用來源的 scope,公版 fork 出來還是公版——任何人 fork 一次就多一份全站可見的資料。
  • copy-to-tool 需要公版守門,因為複製出來的仍然是公版(受眾沒變,只是換池), 等於在製造新的公版,那當然只有平台管理員能做。

兩支共用同一個實作 _clone_profile()(:777),差異收斂成三個參數傳進去—— 這是好的寫法,兩套各寫一遍才是日後漂移的來源。

③ 建立基準時,公版的租戶編號填什麼 — ✅ 填 root(1),不是填 NULL

:346 一行三元判斷:目標是 SYSTEM 就填常數 _ROOT_TENANT_ID(1),否則填當前使用者的租戶。 這與「tenant_id IS NULL 永遠是壞資料」的既有拍板一致——公版也要有明確的擁有者, 否則所有靠租戶欄位過濾的查詢都會對它失效。

④ 版本從檔的 scope/tenant 有沒有跟著主檔 — ✅ 顯式沿用,沒有靠自動填

_create_version()(:759-760)與 _clone_profile()(:827-828)都顯式把主檔的 scope/tenant_id 抄進從檔。 這一點非常關鍵:版本從檔自己掛 RLS(資料庫層的租戶隔離不會沿外鍵繼承), 如果靠 jedi-common 的自動填欄位機制,只有租戶情境碰巧正確—— 公版會被填成「操作者的租戶」,於是公版變成只有那個人看得到的私有版。程式碼裡的註解也寫明了這點。

改 scope 時也有同步(update_profile:434 的 sync_version_scope), 不同步的症狀是「子租戶在列表看得到這支基準、點進去卻讀不到任何版本」——因為派工走的是版本表。

⑤ 改「分享設定」為什麼要另外一道守門 — ✅ 有掛,而且理由成立

update_profile:425 在 scope 有變動時額外呼叫 assert_scope_writable。 查宿主實作(common/authz/sharing.py)確認它判的是**「作用租戶=資源擁有租戶」**, 而且明確拒絕把 scope 改成 SYSTEM。

為什麼光靠資料庫的 RLS 不夠:RLS 的更新政策判準是「資料的租戶在我的可存取路徑內」, 而母租戶的可存取路徑涵蓋整個子樹——母租戶的管理員對子租戶的資源也在範圍內。 能力點只答「這個人有沒有編輯權」,不答「編輯的是不是自己租戶的東西」。 scope 決定資源對誰可見,改錯了是把整個子樹的可見性拱手讓人。這一刀切得對。

⑥ 列表為什麼不只靠 RLS — ✅ 顯式收斂,理由成立

detection_profile_repo_impl.py:40-55 的可見範圍條件是「公版 + 本租戶自有 + 分享下來的」。 理由:root 租戶的可存取路徑是 /1/,前綴涵蓋所有子租戶—— 只靠 RLS 的話,平台管理員在管理頁會看到全站每個租戶的私有基準。可見範圍是業務語意,RLS 只是兜底。

⑦ 資料庫層是不是也擋 — ✅ 三層防禦完整

002-detection-rls-grants.sql:65-84 四條政策:

  • SELECT:公版人人可讀,其餘看租戶
  • INSERT/DELETE:scope <> 'SYSTEM' AND 租戶符合——資料庫層直接禁止任何人新增或刪除公版
  • UPDATE:讀取條件寬(自己租戶的都能改),寫入檢查條件要求 scope <> 'SYSTEM'

也就是說,就算服務層的守門被繞過,資料庫這一層還是擋得住對公版的寫入(超級管理員除外)。 與程式裡寫的「第 1 層 route 能力點/第 2 層服務守門/第 3 層 RLS 兜底」完全對得上。

3. 查證時發現的兩個落差(不是漏洞,但會長成漏洞)

這兩點工具沒提、卡片也沒問,是我比對程式碼與資料庫時看出來的,記下來給後續決策。

落差一:資料庫已經認得三種 scope,這支服務還停在兩種

2026-09-14 的 FR-094(CM-1790)把 SHARED(母租戶分享給子樹)加進了 detection_profiles 與 detection_profile_versions 的欄位值域與查詢政策。但這支服務的程式碼還是兩種的世界觀:

  • _normalize_scope()(:897-901)只收 SYSTEM 與 TENANT,填 SHARED 會被當成壞請求擋掉
  • 服務裡的常數只定義了 SCOPE_SYSTEM 與 SCOPE_TENANT,沒有 SHARED
  • 但 update_profile 的 scope 變更路徑是直接把值傳給 assert_scope_writable(:424-425), 沒有先過 _normalize_scope——而宿主那支只收 TENANT/SHARED

現況是:建立時無法指定 SHARED,但改的時候可以改成 SHARED。 兩條路徑對同一個欄位有兩套值域認知。 存取層那邊 _visible_scope_filter 已經認得 SHARED 了(repo_impl:52), 所以改成 SHARED 之後查詢行為是對的——目前不會出事,但這是典型的「半套遷移」形狀: 一個欄位、三個地方、各自認得不同的值域。日後任何一邊改動都可能踩空。

建議:把 SHARED 補進服務層的常數與 _normalize_scope,讓建立與修改兩條路徑對值域有同一套認知。 這件事屬 FR-094 的收尾,不是這棒能自行決定的範圍,列為待裁示。

落差二:套件自帶的建表腳本還是舊的值域

jedi_detection/migrations/001-detection-tables.sql:176/203 的 CHECK 仍然只允許 SYSTEM/TENANT。 主專案的 scripts/sql/packages/jedi_detection/001-detection-tables.sql 同樣是舊的。 FR-094 的那支 migration 在主專案的 scripts/sql/ 下單獨存在。

症狀:既有環境跑過 FR-094 那支 migration 所以沒事,但新裝的客戶拿到的是舊值域—— 資料庫會拒絕任何 SHARED 的值,而且是在寫入當下才炸。 這正是 CLAUDE.md 講的「出貨基線待重產」那一類,屬決策者裁示範圍,這裡只回報。

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

「報出來的這條存在嗎」——高。 :952 放行 http://、:954 回傳 sha256=None,這兩行我自己開檔核對過,是可直接查證的事實不是推論。 三位檢查員各自從可達性、影響面、既有防線三個角度獨立查證,都投成立,其中防線那位逐一檢查了每一個可能的 緩解措施並說明為何都不適用。沒有實際造一個假的規則包送進去驗過——與 D2-3 的第 115 項同樣是讀出來的結論。

「只有這一條嗎」——中等,三個限制。 ① 強度 low,一位研究員讀一遍(第一位被砍,實際只有第二位的一遍)。 ② 重點④的結論全是人工查的,沒有三票背書——工具那條候選與卡片重點完全沒有交集。 ③ 這是第七個樣本,再次印證「切棒保證跑得完,不保證找得更多」:1 檔 1,133 行只出 1 條。

5. 被駁回的候選

零駁回,也沒有越界候選(研究員的讀取都是為了追這一條的下游,沒有被鄰近大檔吸走)。

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

⚠️ 只在 DEV 做,不要碰 STG/POC。 這是會讓代理程式真的去下載東西的測試。

這一條(明文網址):

  1. DEV 用租戶管理員身分建一支新基準,來源選「網址」,填一個 http://(明文)開頭的網址
  2. 預期現況:建立成功、完全沒有警告 —— 成功即這條成立的前半段
  3. 去資料庫看那一列:SELECT source_type, url, sha256 FROM config.detection_profile_versions WHERE ... 預期 sha256 是空的 —— 這是後半段(代理程式沒有東西可以對帳)
  4. 把這支基準綁進一個掃描任務派出去,看代理程式那邊收到的封包裡有沒有指紋欄位
  5. 修好後重跑第 1 步,應該在建立當下就被擋下並回明確錯誤

對照組(證明兩條路標準不一致):

  1. 同一個 http:// 網址,拿去讓後端自己抓(版更後會自動排抽取)
  2. 預期:後端抓不到、那一版停在 failed —— 但基準仍然派得出去(_load_profile 只看 is_active)
  3. 這個落差本身就是這條的核心:後端自己都不肯抓的東西,卻放行讓代理程式去執行

重點④(守門)的回歸驗證(確認我的查證沒錯,選做):

  1. 用租戶管理員(非平台管理員)身分,試著改一支公版基準的名字 → 預期 403
  2. 同一個身分 fork 那支公版 → 預期成功,且新的那支 scope 是 TENANT、租戶是自己
  3. 同一個身分對那支公版做 copy-to-tool → 預期 403(這支要公版守門,fork 不用)

7. 執行概況

項目 值
run ID wf_bff1ea28-4de
報告 docs/features/FR-108-2609-detection-security-scan/scan-D2-2a-profile-service.md
掃描 commit ac5a0d6c564f1951b00b67834651672150ec53ad(jedi-detection,branch feature/review,工作區乾淨)
範圍 1 檔 1,133 行
強度 low(一位研究員 + 三票面板)
研究員 派 2 位、回 1 位(第一位 33 分鐘被砍)
面板 1 條候選 × 3 位檢查員 = 3 票,全數投出,3:0 通過
耗時 約 1 小時 42 分
候選 → 成立 1 → 1(零駁回)
驗證章 verified

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

① 切成單檔是對的,但收穫沒有變多。 原 D2-2 五檔一起掃打了 17 小時、25 位研究員全滅;切出這支 1,133 行的主檔單獨跑,1 小時 42 分完成。 切棒解決的是「跑不完」,不是「找不到」——1,133 行只出 1 條,與 D2-1b、D2-3、D3-4b 的觀察一致。

② 監看判準要改:這個工具不寫 failed。 卡片寫「看 journal 的 failed 事件」,實際 journal 裡沒有這個字。 研究員被換掉的呈現是同一個 key 再出現一筆 started。 本棒 2 筆 started = 死了 1 位。往後數 started,第三筆就停。

③ 工具找到的與卡片要查的,第二次完全沒有交集。 D2-3 已經出現過一次(卡片問重點②③,工具報的兩條都不在裡面)。這棒一樣: 卡片重點④問 scope 守門,工具報的是網址協定。 給後續:卡片重點仍要逐項人工查(它保證覆蓋),但不要期待工具照清單走。

④ 人工查證的價值不只是「確認沒問題」。 重點④查下來守門本身是乾淨的,但同一輪比對讓我看到 SHARED 這個值域在三個地方各認一套—— 那不是工具會報的東西(它不是漏洞),卻是日後長出漏洞的地方。 逐項查證的產出應該包含這類「現況落差」,不是只回答有/沒有。