---
title: FR-118 V4a 掃描報告——控制項目錄、基準線、框架
---

# V4a 掃描報告 — 控制項目錄、基準線、框架（CM-2127）

> 範圍：套件 `jedi-oscal-v2`，14 檔／1,202 行（目錄服務、目錄與基準線複製、基準線解析、框架服務、9 支資料存取實作）。
> 掃描工具：Claude Code 官方 `claude-security` plugin，effort low，scoped 掃描。
> 基準 commit：jedi monorepo `eafc7ae511d5`（`feature/review`；本套件目錄乾淨，monorepo 其他目錄有平行 session 的未提交改動，所以 stamp 標 dirty）。
> 驗證章：**verified，工具零候選**（沒有東西送進三人面板）。只掃不修。

---

## 1. 一句話結論

**卡片最擔心的「複製給客戶的目錄還指著公版」不成立**：四層（群組、控制項、部分、參數）加增強項，所有「用數字編號指向別筆資料」的欄位都有換成新編號，跟 V1 那種漏換的情況不一樣。DEV 實查全庫零筆跨目錄亂指。

但追呼叫端時查出兩件事，要請首腦裁：

- **第 133 項的修法前提不成立**（非新洞，是更正）。總表寫「刪框架前照叫 `_require_no_references()` 檢查還有沒有人在用」。可是那支檢查看的是「有沒有基準線直接引用公版目錄」，而客戶的資源庫一律先**複製**一份目錄，再引用自己的副本。所以這支檢查**永遠不會擋**：DEV 591 筆基準線引用，指向公版目錄的是 0 筆。真正記錄「誰在用這個版本」的，是資源庫表上的一個文字欄位 `module_frames.oscal_framework_version_uid`。另外，刪框架**不會**刪到客戶的控制項，第 133 項「整份標準憑空消失、不可逆」的後果描述要改寫（見 5.2）。
- **V4a-1（低）**：建資源庫時不檢查框架版本「發佈了沒」。任何登入帳號都能用一份平台管理員還在編的**草稿版本**建資源庫，拿到還沒公開的控制項內容。跟第 121 項（建資源庫沒權限檢查）是同一個入口，建議併同一張卡修。

---

## 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，唯讀）。資料庫本身分不出哪份是公版、哪份是哪個客戶的副本。所以安全全靠兩件事：

1. 複製時每一層都換成新編號，不能有子物件還掛在公版底下。
2. 呼叫端只把「自己的」目錄編號交給套件。

套件本身沒有網址入口（全套件零個 route），它只是被主專案呼叫的函式庫。所以「誰打得到」一律追到主專案。

---

## 3. 掃到什麼：總覽

| 編號 | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度＋為什麼 | 來源 |
|---|---|---|---|---|---|---|
| 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 條**。

---

## 4. 工具報的

零候選。1 名研究員讀完 14 檔，另跑 1 次密鑰專項，都沒提出任何候選，三人面板沒有東西可投。驗證章 verified。

零發現不等於沒事：這批的風險幾乎都在「呼叫端傳什麼進來」。範圍檔本身是很薄的函式庫，工具只看範圍內，自然看不到主專案那一側。下面第 5 節是 runner 照卡片逐條開檔追的結果。

---

## 5. 卡片點名的疑點，逐條回答（runner 自行開檔核對，未經三人面板投票）

### 5.1 目錄樹複製有沒有把每一層的父層編號都換新：成立「有換」，不是洞（下次不用重查）

`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` 一樣改成留空。
- DEV 這 4 份公版目錄**沒有任何增強項，也沒有部分套部分**（0 筆）。「兩輪補 parent_id」這段在 DEV 從沒被真資料跑過，只能靠讀程式碼確認。

**基準線複製**（`:251-314`）：`source_catalog_id` 有依對照表換成新目錄。`source_profile_id`（基準線引用基準線）照舊保留，但 DEV 0 筆，而且解析那邊遇到它會直接丟 `NotImplementedError`，目前打不到。

**目錄本體的 `framework_version_id` 刻意保留**，當作「從哪個版本來的」紀錄（`:30-32` 註解）。這不是漏換：它指向公版版本是對的，不是子物件。DEV 596 份目錄這欄全是空的。

### 5.2 框架刪除、版本刪除、版本發佈：套件沒有第二個入口；但第 133 項的修法前提不成立（V4a-2）

**現況**：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 筆**。

所以：

- 照第 133 項修法補上 `_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` **沒有**連鎖刪除，所以公版目錄和它的控制項都留著，只是變成沒人掛的孤兒。客戶資源庫和專案用的是各自的副本，**控制項一筆都不會少**。實際後果是：

- 資源庫上那個版本 uid 變成指向不存在的東西，列表的框架名稱會空掉（`resource_library_app_service.py:117-133`）。
- 用 Word 更新既有資源庫時，`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`** 不檢查目前狀態（已發佈的再發佈一次，只會更新發佈時間），呼叫端只有平台管理員。不是洞（下次不用重查）。

### 5.3 基準線的 `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`）只來自資料庫裡已存的基準線，這條路上使用者送不進來。

### 5.4 `list_catalogs()`、`list_frameworks()` 不帶條件回全部：不是洞（下次不用重查）

- `list_catalogs()` 只有一個呼叫端：`framework_version_app_service.py:50` 的 `_catalog_presence()`。它把全部目錄撈回來，只為了用編號找出**這個框架版本自己那份**公版目錄的 uid，回給前端的也只有 `{uid, id}`。A 客戶的副本**不會**列給 B 客戶看。
- 但它每顯示一個版本就把全庫 596 份目錄整張撈一次。這是效能問題，不是資安問題，修的人順手改成 `get_catalog` 查一筆即可。
- `list_frameworks()` 四個呼叫端（`framework_app_service.py:90/142`、`framework_version_app_service.py:165/179`、`ssp_import_template_app_service.py:625`）撈的都是框架本身。框架是全平台共用，本來就該全部看得到。

### 5.5 `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`）。

### 5.6 V4a-1 的來龍去脈：草稿版本能被拿去建資源庫

**現況**：已修（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`。**補在這一支，三個入口一次蓋到。**
- 草稿版本的 uid 拿得到：版本列表與選單（`framework_version_app_service.py:102/160/176`）沒有平台管理員檢查、不過濾草稿，只要登入就能查。
- DEV 目前 4 個版本全是 published，沒有草稿可以實際重現。以上都是讀程式碼推論。

這和總表第 88 項（流程範本沒發佈也能啟動稽核）是同一種病：只在「發佈」那一步檢查，拿來用的時候不看狀態。

### 5.7 整份通讀的其他觀察

- 9 支資料存取實作都繼承 `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` 裡，中途出錯整批撤回，不會留半份副本。
- 密鑰專項零發現，範圍內沒有寫死的帳密。

---

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

**「這幾條存在嗎」**：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` 只讀了建立流程。
- 複製的「兩輪補 parent_id」在 DEV 沒有真資料跑過（沒有增強項，也沒有巢狀部分），正確性只靠讀程式碼。

資料庫隔離現況以本次 DEV 唯讀實查為準（2026-09-24 18:11～18:18 +08）。平行的 RLS 線之後可能改變這個事實。

---

## 7. 執行概況（數字，給工程師看）

| 項目 | 值 |
|---|---|
| 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`，未寫入） |

---

## 8. 待首腦裁決

1. **V4a-1 登不登、登什麼等級**：我定低。建議併進第 121 項的修正卡（同一支 `create_resource_library`，補一行狀態檢查就能蓋住三個入口）。
2. **第 133 項改寫**：
   - 修法把「照叫 `_require_no_references()`」改成「系統層查 `module_frames.oscal_framework_version_uid` 有沒有未刪除的資源庫在用」。
   - 後果把「整份標準憑空消失、不可逆」改成「公版目錄變孤兒、客戶資源庫的版本連結斷掉」。
   - 嚴重度要不要因此下修，請首腦定。
3. **編輯路徑與匯入覆蓋那兩道「有沒有被引用」檢查**從來沒擋過東西，要不要跟第 133 項同一張卡改成查同一個欄位。
4. **刪主版本、刪有子版本的版本會吐 500**（5.2 末段）：非資安，要不要順手記進第 133 項同卡。
