範圍:套件
jedi-oscal-v2,14 檔/1,202 行(目錄服務、目錄與基準線複製、基準線解析、框架服務、9 支資料存取實作)。 掃描工具:Claude Code 官方claude-securityplugin,effort low,scoped 掃描。 基準 commit:jedi monorepoeafc7ae511d5(feature/review;本套件目錄乾淨,monorepo 其他目錄有平行 session 的未提交改動,所以 stamp 標 dirty)。 驗證章:verified,工具零候選(沒有東西送進三人面板)。只掃不修。
卡片最擔心的「複製給客戶的目錄還指著公版」不成立:四層(群組、控制項、部分、參數)加增強項,所有「用數字編號指向別筆資料」的欄位都有換成新編號,跟 V1 那種漏換的情況不一樣。DEV 實查全庫零筆跨目錄亂指。
但追呼叫端時查出兩件事,要請首腦裁:
_require_no_references() 檢查還有沒有人在用」。可是那支檢查看的是「有沒有基準線直接引用公版目錄」,而客戶的資源庫一律先複製一份目錄,再引用自己的副本。所以這支檢查永遠不會擋:DEV 591 筆基準線引用,指向公版目錄的是 0 筆。真正記錄「誰在用這個版本」的,是資源庫表上的一個文字欄位 module_frames.oscal_framework_version_uid。另外,刪框架不會刪到客戶的控制項,第 133 項「整份標準憑空消失、不可逆」的後果描述要改寫(見 5.2)。控制項目錄(catalog)是 CMMC、ISO 27001 這類標準的控制項清單,全平台共用一份「公版」。平台管理員在「框架(framework)→ 版本(framework version)」底下維護它。客戶建「資源庫」時,系統會把公版目錄整份複製一份,客戶之後用的是自己那份副本。
基準線(profile)記錄「從哪份目錄(source_catalog_id)挑哪些控制項」。
這一批表的處境:公版和客戶副本放在同一張表,都沒有客戶欄位,資料庫也沒開隔離(DEV 實查 9 張表 relrowsecurity 全是 false、pg_policies 0 條,2026-09-24 18:11 +08,唯讀)。資料庫本身分不出哪份是公版、哪份是哪個客戶的副本。所以安全全靠兩件事:
套件本身沒有網址入口(全套件零個 route),它只是被主專案呼叫的函式庫。所以「誰打得到」一律追到主專案。
| 編號 | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度+為什麼 | 來源 |
|---|---|---|---|---|---|---|
| V4a-1 | 建資源庫時不檢查框架版本是不是已發佈,草稿版本也能拿來複製 | 客戶拿到平台管理員還沒公開的控制項內容,並用它建資源庫、開專案;之後管理員改草稿,客戶的副本不會跟著改 | 任何登入帳號,加一個草稿版本的編號。版本列表(GET)只要登入就能查,拿得到 | 主專案 app/oscal/service/resource_library_app_service.py:460-464,get_version 之後補「publish_status 必須是 published」 |
低:不跨客戶、不改公版,外洩的是「還沒發佈的標準條文」,敏感度有限。跟第 121 項同一入口,一起修成本最低 | runner 追呼叫端,未經面板 |
| V4a-2 | 第 133 項的修法前提不成立:_require_no_references() 檢查的連結在正常流程裡永遠不存在 |
照總表修法補上後,刪框架/刪版本依舊不會被擋,會誤以為修好了 | — | 見 5.2:要改成查 module_frames.oscal_framework_version_uid |
非新洞,是更正:第 133 項的修法與後果描述要改寫 | runner 開檔+DEV 唯讀實查,未經面板 |
工具正式清單:0 條。
零候選。1 名研究員讀完 14 檔,另跑 1 次密鑰專項,都沒提出任何候選,三人面板沒有東西可投。驗證章 verified。
零發現不等於沒事:這批的風險幾乎都在「呼叫端傳什麼進來」。範圍檔本身是很薄的函式庫,工具只看範圍內,自然看不到主專案那一側。下面第 5 節是 runner 照卡片逐條開檔追的結果。
oscal_clone_service.py:90-248 逐層對照。每一層「用數字指向別筆」的欄位,以及它用哪張對照表換:
| 層 | 指向別筆的欄位 | 怎麼換 | 位置 |
|---|---|---|---|
| 目錄本體 | metadata_id |
另外複製一份 metadata 再指過去 | :106-112 |
| 群組 | catalog_id、parent_id(群組套群組) |
先全部插入、parent_id 留空,第二輪用群組對照表補 |
:150-171 |
| 控制項(含增強項) | catalog_id、catalog_group_id、parent_id(增強項指向本體控制項) |
catalog_group_id 用群組對照表;parent_id 同樣兩輪補 |
:185-208 |
| 部分(評估目標等) | catalog_control_id、parent_id(部分套部分) |
控制項對照表+每個控制項各自一張部分對照表 | :220-238 |
| 參數 | catalog_control_id |
控制項對照表 | :241-248 |
增強項有涵蓋。增強項在資料表裡就是 parent_id 不空的控制項,每一筆都帶 catalog_id(欄位 NOT NULL)。所以 get_by_catalog 一次撈出本體和增強項,兩輪補 parent_id 時一起換掉。複製流程不走 get_enhancements(),那支只在基準線解析時用。
跟 V1 不同型:V1 漏換的是「用編號互指、卻沒進對照表」的欄位。這裡我把 5 張 model 的全部欄位列過一遍。剩下的 props、links、params、parts、depends_on 都是 OSCAL 文字代號(像 ac-1_prm_1),不是資料庫編號,複製後照樣對得上。這一層沒有漏網的數字欄位。
DEV 唯讀實查(2026-09-24 18:12 +08):全庫 596 份目錄裡,控制項指到別份目錄的群組 0 筆、增強項指到別份目錄的本體 0 筆、群組指到別份目錄的父群組 0 筆、部分指到別個控制項的父部分 0 筆。
兩個留給修的人的小陷阱,都不是洞,打不到:
:193 group_id_map.get(c.catalog_group_id, c.catalog_group_id):查不到時退回原本的公版群組編號。這是整支檔唯一一處「換不到就留舊值」的寫法。群組是用同一份目錄撈的,正常資料一定查得到;只有資料本身已經壞掉(控制項掛在別份目錄的群組)時才會觸發。建議改成查不到就報錯,或至少跟 parent_id 一樣改成留空。基準線複製(:251-314):source_catalog_id 有依對照表換成新目錄。source_profile_id(基準線引用基準線)照舊保留,但 DEV 0 筆,而且解析那邊遇到它會直接丟 NotImplementedError,目前打不到。
目錄本體的 framework_version_id 刻意保留,當作「從哪個版本來的」紀錄(:30-32 註解)。這不是漏換:它指向公版版本是對的,不是子物件。DEV 596 份目錄這欄全是空的。
現況:V4a-2 更正已納入第 133 項修法:已修(M11-27,FR-114 CM-2180,commit 7262b74c8,1.21.0 出貨)
入口:套件 framework_service.py:61-120 那三支只收 uid。全 monorepo 與主專案搜尋 delete_framework、delete_version、publish_version,只有主專案 framework_app_service.py:185/242、framework_version_app_service.py:224 三處,另加 framework_parse_job_service.py:295 的 update_version。四支第一行都是 require_platform_admin()。套件自己零個網址入口,沒有第二條路(下次不用重查)。
「還有沒有客戶在用」該擋在哪一層:放在主專案,不該放套件。套件不知道「資源庫」這個概念,資源庫表 compliance.module_frames 屬於主專案。
🔴 但第 133 項寫的修法擋不住。總表建議刪除前比照編輯路徑呼叫 framework_version_edit_service.py:258-266 的 _require_no_references()。這支查的是「有沒有基準線的 source_catalog_id 指向這個版本的公版目錄」。可是建資源庫的流程(resource_library_app_service.py:467 先 clone_catalog_tree,:489 再 source_catalog_id=new_catalog_id)讓基準線永遠指向複製出來的副本;開專案時(project_start_app_service.py:345)又從資源庫的副本再複製一次。正常流程裡沒有任何基準線會指向公版目錄。
DEV 唯讀實查(2026-09-24 18:16 +08):基準線引用共 591 筆,指向任何框架版本目錄的 0 筆。
所以:
_require_no_references() 之後,刪框架/刪版本照樣一律放行,修了跟沒修一樣。_require_no_references()(framework_version_edit_service.py:207/218/230)和匯入覆蓋的 _guard_version_replaceable()(framework_parse_job_service.py:386-403)其實也從來沒擋過東西。真正在擋的是旁邊那道「版本必須是草稿」。compliance.module_frames.oscal_framework_version_uid(resource_library_app_service.py:512 寫入)。這是純文字欄位、沒有外鍵,表有開客戶隔離。要檢查「還有沒有人在用」,得用系統層查詢(跨客戶)查這一欄。第 133 項的後果描述也要改寫。DEV 實查外鍵:刪框架只會連鎖刪到版本(framework_versions_framework_id_fkey ON DELETE CASCADE)。版本指向目錄的 framework_versions_catalog_id_fkey 沒有連鎖刪除,所以公版目錄和它的控制項都留著,只是變成沒人掛的孤兒。客戶資源庫和專案用的是各自的副本,控制項一筆都不會少。實際後果是:
resource_library_app_service.py:117-133)。ssp_docx_import_app_service.py:593-612 用這個 uid 找目錄會找不到、回 None。還是該擋,但後果不是「正在稽核的標準憑空消失、不可逆」。
順帶兩個穩定性問題(非資安,給修的人參考):刪掉「主版本」時,frameworks.main_version_id 的外鍵沒有 ON DELETE,會直接吐資料庫錯誤 500。刪掉「有子版本的版本」時,framework_versions_parent_id_fkey 同樣會吐 500。
publish_version 不檢查目前狀態(已發佈的再發佈一次,只會更新發佈時間),呼叫端只有平台管理員。不是洞(下次不用重查)。
source_catalog_id 只由伺服器端寫入:成立,不是洞(下次不用重查)全 monorepo 與主專案搜尋 source_catalog_id= 的寫入,只有兩處:
oscal_clone_service.py:305:依對照表換成新目錄。resource_library_app_service.py:489:值是 :467 剛複製出來的 new_catalog_id。使用者輸入都碰不到這一欄。profile_resolution_service.py 只讀不寫,而且只在 source_catalog_id 那份目錄裡挑控制項(:98)。所以就算 include_controls 裡塞了別份目錄的代號,也只會比對不到,不會跨目錄撈。
使用者可以控制的是 profile_spec.include_controls(建資源庫時前端送的控制項數字編號)。resource_library_app_service.py:479-483 用 SELECT control_id FROM oscal.catalog_controls WHERE id = ANY(:ids) 把數字轉成文字代號,沒有限定是這份目錄的控制項。所以使用者能把任意目錄的控制項代號存進自己的基準線。不過代號就是公開標準的條號(像 AC.L2-3.1.1),解析時也只在自己的目錄裡比對,沒有實質外洩。記錄在此,不另立條。
matching 裡的萬用字元比對規則(fnmatch)只來自資料庫裡已存的基準線,這條路上使用者送不進來。
list_catalogs()、list_frameworks() 不帶條件回全部:不是洞(下次不用重查)list_catalogs() 只有一個呼叫端:framework_version_app_service.py:50 的 _catalog_presence()。它把全部目錄撈回來,只為了用編號找出這個框架版本自己那份公版目錄的 uid,回給前端的也只有 {uid, id}。A 客戶的副本不會列給 B 客戶看。get_catalog 查一筆即可。list_frameworks() 四個呼叫端(framework_app_service.py:90/142、framework_version_app_service.py:165/179、ssp_import_template_app_service.py:625)撈的都是框架本身。框架是全平台共用,本來就該全部看得到。import_catalog_from_pdf/excel 寫入時的歸屬依首腦指示不重追:V4b 已查過,這兩支和 add_catalog 在主專案零入口(scan-V4b.md 5.8)。補一句寫入歸屬:公版目錄沒有客戶欄位,寫進去就是全平台共用。唯一會寫公版目錄的活路是 framework_parse_job_service.py:466 _persist_catalog,前面有 require_platform_admin()(:259)。
現況:已修(M11-30,FR-114 CM-2179,commit 94275d72f,1.21.0 出貨)
resource_library_app_service.py:460-464 只檢查「版本存在」和「版本有目錄」,沒看 publish_status,就直接 clone_catalog_tree。resource_library_route.py:50(只要登入,第 121 項)、Excel 匯入 ssp_excel_import_app_service.py:556、Word 匯入 ssp_docx_import_app_service.py:347。補在這一支,三個入口一次蓋到。framework_version_app_service.py:102/160/176)沒有平台管理員檢查、不過濾草稿,只要登入就能查。這和總表第 88 項(流程範本沒發佈也能啟動稽核)是同一種病:只在「發佈」那一步檢查,拿來用的時候不看狀態。
BaseRepositoryImpl。自訂方法(get_by_catalog、get_by_control、get_enhancements、get_children、get_roots、get_by_profile、list_aos)全部帶了範圍條件,沒有手拼 SQL。profile_resolution_service.py:157-163)如果資料裡有 parent_id 形成迴圈,會無限遞迴。但 parent_id 只由複製流程和平台管理員的編輯寫入,一般使用者碰不到,列為資料健全性問題。@transaction 裡,中途出錯整批撤回,不會留半份副本。「這幾條存在嗎」:V4a-1、V4a-2 都是 runner 開檔追出來的,沒經三人面板投票。V4a-2 的核心數字(591 筆基準線引用、0 筆指向公版)是 DEV 唯讀實查的,可信度高。V4a-1 在 DEV 沒有草稿版本可以重現,是讀程式碼推論,請首腦開檔核對 resource_library_app_service.py:460-467。
「只有這幾條嗎」:套件側 14 檔我逐支通讀過,工具研究員也讀完了,範圍內應該沒有漏。沒涵蓋到的是:
framework_version_edit_service.py(公版目錄的編輯)只看了守門那幾支,其餘沒通讀。resource_library_app_service.py 只讀了建立流程。資料庫隔離現況以本次 DEV 唯讀實查為準(2026-09-24 18:11~18:18 +08)。平行的 RLS 線之後可能改變這個事實。
| 項目 | 值 |
|---|---|
| run ID | wf_ea7f895e-25c |
| 報告目錄 | 套件 repo jedi-oscal-v2/CLAUDE-SECURITY-20260924-100531/(不入版控) |
| stamp | CLAUDE-SECURITY-REVISION-eafc7ae511d5-dirty.json |
| verification.status | verified(reason_kind 無;候選 0、面板票 0) |
| 形狀 | low:1 名研究員讀 14 檔+1 次密鑰專項(focus=attack-surface) |
| 候選 → 通過 | 0 → 0 |
| agent 數/失敗 | 2/0(journal.jsonl 無 failed) |
| 耗時 | 約 4.7 分鐘(stamp duration_s 279) |
| DEV 唯讀實查 | 9 張表 relrowsecurity/pg_policies、外鍵定義、跨目錄亂指計數、基準線引用公版計數、版本狀態(cm_app,BEGIN READ ONLY … ROLLBACK,未寫入) |
create_resource_library,補一行狀態檢查就能蓋住三個入口)。_require_no_references()」改成「系統層查 module_frames.oscal_framework_version_uid 有沒有未刪除的資源庫在用」。