# V1 掃描報告 — SSP 本體：服務、複製與凍結、子表 repo（CM-2124）

> 範圍：套件側 `jedi-oscal-v2` 21 檔／1,503 行（SSP 服務層、深複製、凍結快照、metadata 複製、13 支 SSP 子表 repo、4 支共用 metadata repo）。
> 掃描工具：Claude Code 官方 `claude-security` plugin，effort low，scoped 掃描（指定檔案清單）。
> 基準 commit：`eafc7ae511d5`（monorepo 主 checkout，工作區有平行 session 的未提交改動，標 dirty）。
> 驗證章：**verified，0 findings**。只掃不修。

---

## 1. 一句話結論

**工具沒找到問題；人工把主專案 25 處呼叫端逐處開檔核對，也都沒有洞。卡片最擔心的「凍結的 SSP 被事後竄改」不成立。** 每一處都先在同一份 SSP 底下找到那筆資料，才把編號交給套件；改資料時也都是「從資料庫讀出來、改幾個欄位、存回去」，沒有拿使用者送來的內容重新組一筆。凍結的 SSP 在主專案有擋：所有寫入 SSP 的網址都要通過 `SspPermissionChecker.require_manager`，其中那一步「計畫還能不能改」只要發現這份 SSP 是某一輪稽核的凍結快照，就一律拒絕。

**本棒淨新增 1 條低（V1-1，資料完整性，不是越權）。** 複製 SSP 時，有兩種「用編號指向同一份 SSP 裡另一筆資料」的欄位沒有跟著換成新編號，複製出來的 SSP 仍然指向來源 SSP 的資料：
- 沿用授權的「提供者」欄位（`party_uuid`）
- 元件上記的「沿用授權編號」

DEV 實查：**47 筆凍結快照的沿用授權，提供者全部指向別份 SSP 的人員**。目前沒有任何程式拿這個編號去全表找人，所以不會讀到別家的資料；結果只是凍結的稽核基準裡，「提供者」這一欄對不上任何人。

---

## 2. 這一棒在檢查什麼

SSP（系統安全計畫）是客戶最核心的合規文件：系統有哪些元件、資產、人員，每個控制項怎麼實作。這支套件負責把 SSP 存進資料庫、讀出來、複製一份（開專案時從資源庫範本複製），以及凍結成快照（開始稽核時把當下的 SSP 鎖成稽核基準）。

這支套件管的 SSP 相關 17 張表**都沒有客戶欄位，也沒有資料庫隔離**（DEV 以 `cm_app` 唯讀實查，2026-09-24 14:22：隔離開啟 0 張、隔離規則 0 條、客戶欄位 0 個）。換句話說，程式只要漏一步，就能直接讀寫到別家客戶的 SSP，資料庫不會再擋一次。

套件本身沒有網址入口，也不知道使用者是誰，所以它「不問資料屬於誰」是設計上的預期。但它把「只要給編號就能讀、改、刪」的方法開放給主專案用，**檢查歸屬的責任就全落在呼叫端**。這一棒要回答三件事：

1. 呼叫端把編號交進套件之前，是不是每一處都先確認那筆資料屬於正確的 SSP？
2. 改資料時，交給套件的是從資料庫讀出來的那筆，還是用使用者輸入重新組的一筆？如果是重新組的，使用者就能改掉「屬於哪份 SSP」的欄位，把資料搬到別份 SSP 底下。
3. 凍結的 SSP 有沒有辦法被修改？

---

## 3. 掃到什麼：總覽

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度 | 來源 |
|---|---|---|---|---|---|---|
| V1-1 | 複製 SSP 時，「提供者」和「元件的沿用授權編號」這兩種內部參照沒有換成新編號 | 凍結快照與新專案的 SSP 裡，沿用授權的「提供者」指向來源 SSP 的人員，本份 SSP 裡找不到這個人；元件的「沿用授權」同樣可能指錯地方。稽核基準裡這一欄對不上任何人 | 不用打，每次複製都會發生。要變成「讀到別家資料」，還需要將來有人寫出「拿人員編號全表找人」的程式，目前全庫沒有 | 套件 `ssp_clone_service.py:170-176`（複製沿用授權時要換 `party_uuid`）、`:184-190`（複製元件時要換 props 裡的 `leveraged-authorization-uid`）；人員新編號在 `metadata_clone_service.py:99-111` 產生，要把「舊編號→新編號」對照表傳給 SSP 複製 | **低**：影響的是資料正確性，不是越權。今天沒有任何路徑會因此讀到別份 SSP 的內容。不是「資訊」等級，是因為凍結快照是稽核證據，裡面的欄位錯了會影響稽核基準的可信度 | runner 開檔＋DEV 唯讀實查，未經三人面板投票 |

工具的正式清單是空的（見第 4 節）。V1-1 是人工追卡片「複製時子物件有沒有帶錯家」這條時找到的，**沒有經過三人審查小組投票**。

---

## 4. 工具報的：一條都沒有

工具派了 2 個研究員（1 個讀完整個範圍，1 個專門找密鑰），2 個都正常回報，都沒有提出候選。所以審查小組沒有東西可以投票，驗證章蓋 `verified`。整個過程沒有失敗。

研究員沒提 V1-1，合理的推測是：它只讀套件這 21 檔，看到的是「元件的 id 有換、人員的編號也有換」，但人員是另一支檔案（metadata 複製）換的，看不出兩支檔案之間的對照表沒有傳過去。這個問題要把兩支檔案放在一起看，還要查資料庫裡的實際資料才看得出來。

---

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

### 5.1 🔴 主專案 25 處呼叫端：全部先找父層，也全部用讀出來的那筆改 → 不成立，下次不用重查

作法：每一處都開檔，從進入點往下追到套件呼叫，確認兩件事。①編號是不是在「同一份 SSP 的清單」裡比對找到的；②交給 `update_*` 的物件是不是讀出來的那筆。

| 呼叫處 | SSP 從哪來（權限） | 找父層的方式 | 改的物件 |
|---|---|---|---|
| `ssp_components_app_service.py:123,135` | `require_manager(ssp_uid)` | `_resolve` `:138-142`：列這份 SSP 的元件，比對 uid，找不到就 404 | 讀出來的 `existing`，只改 title／description／purpose／type／status／props |
| `ssp_resources_context_service.py:106,111` | 兩個入口：專案 `ssp_resources_app_service.py:45-52`（`require_manager`）；資源庫 `module_frame_ssp_resources_service.py:66-77`（模組的 `template_ssp_id`） | `_fetch_item_in_scope` `:117-123`，同上，另外限定 type | 讀出來的 `item` |
| `module_frame_components_service.py:115,125` | `_template_ssp_id(module_frame_uid)` | `_resolve` `:128-132` | 讀出來的 `existing` |
| `ssp_inventory_items_app_service.py:121,130` | `require_manager` | `_resolve` `:180-184` | 讀出來的 `existing`，只改 description／props／implemented_components |
| `module_frame_inventory_service.py:116,125` | `_template_ssp_id` | `_resolve` `:128-132` | 同上 |
| `ssp_leveraged_app_service.py:125,134` | `require_manager` | `_resolve`（檔尾，列本 SSP 的沿用授權比對 uid） | 讀出來的 `existing`，只改 title／party_uuid／日期／remarks／props |
| `module_frame_leveraged_service.py:127,134` | `_template_ssp_id` | `_resolve` `:137-141` | 同上 |
| `ssp_party_app_service.py:108,118` | `require_manager` → `_metadata_id` | `_resolve(metadata_id, uid)`：列這份 SSP 的人員比對 | 讀出來的 `existing`，只改 email／props（姓名、類型鎖住不改） |
| `module_frame_party_service.py:132,141` | `_template_ssp_id` → `_metadata_id` `:144-157` | `_resolve` `:166-170` | 同上 |
| `ssp_control_implementation_service.py:90` | `require_manager` | `_get_or_create_ir(ssp_id, control_id)` `:928-936`：列這份 SSP 的控制項實作比對 | 讀出來的 `ir`，只改 props／remarks |
| `ssp_control_implementation_service.py:860,878` | `require_manager` | 先 `_get_or_create_ir`，再 `_statement_for(ir.id, …)` `:938-942`（只在這筆控制項底下找） | 讀出來的 `stmt` |
| `module_frame_control_default_service.py:209,223` | `_resolve_template_ssp_id` | `_ir_for_control` `:118-122` | 讀出來的 `ir` |
| `module_frame_control_objective_default_service.py:223,240` | `_resolve_template_ssp_id` `:78-83` | `:223` 先 IR 再 statement（`:138-167`）；`:240` 是把這份範本的所有控制項和敘述一層一層走過，比對 uid | 讀出來的 `stmt` |

補充說明：
- **為什麼「用讀出來的那筆改」這麼重要**：套件底層的 `update()`（`jedi_common` `base_repository_impl.py:406-427`）會把物件上所有不是空值的欄位寫回資料庫，包括 `ssp_id`、`system_implementation_id` 這種「屬於哪份 SSP」的欄位。25 處都是讀出來再改，而且都沒有把使用者輸入整包塞進物件，所以這些歸屬欄位維持資料庫裡的原值，資料搬不了家。
- 卡片列出「零呼叫者」的 11 支方法（`update_ssp`、`delete_implemented_requirement`、`delete_statement`、`update_by_component`、`delete_by_component`，以及 6 支 `get_*`）：我用 grep 搜了主專案（`api`／`app`／`common`／`domain`／`infra`）和 compliance-audit，**確實都沒有人呼叫**。打不到，可以併入死碼清理。

### 5.2 🔴 凍結的 SSP 能不能被事後修改：主專案擋得住 → 不成立，下次不用重查

套件層確實**沒有**保護：`oscal_snapshot_service.py:87` 只是把 `status` 設成 `frozen`，`ssp_service` 的 `update_*` 一支都不看 status。**保護在主專案**：

- `SspPermissionChecker.require_manager`（`common/authz/ssp.py:98-103`）→ `_require_ap_editable`（`:131-142`）→ `SspProjectResolver.is_writable`（`domain/oscal/service/ssp_project_resolver.py:111-133`）。
- `is_writable` 的第一條規則：如果這份 SSP 是透過「稽核輪次的凍結快照」（`project_audit_rounds.ssp_id`）找到的，就**一律回 False，拒絕寫入**。就算是還在用的 SSP，也只有「目前這一輪還在規劃階段」才能改。
- 這個判斷**看的是「凍結快照接在哪一輪上」，不是 status 那個字串**，所以有人把 status 改掉也繞不過去。凍結快照如果找不到所屬的輪次，會直接回「找不到」（`:91-92`），不會放行，也就是出狀況時預設拒絕。
- **每一條寫入 SSP 的網址都會經過這一關**：上表所有專案 SSP 的寫入；系統特性（`ssp_system_characteristic_app_service.py:54`）；程序書池（`ssp_document_pool_service.py:64,118,155,163,184,192`）；控制項實作與程序書（`ssp_control_implementation_service.py:79,845,865,883,889,907`）；Word／Excel 匯入到專案 SSP（`ssp_docx_import_app_service.py:497`→`:581`、`ssp_excel_import_app_service.py:520`→`:782`）。資源庫範本那一側走的是模組編號，凍結快照不會被當成範本。
- compliance-audit 對 SSP 只做兩件事：開始稽核時叫 `snapshot_ssp`（`audit_round_app_service.py:397`，前面有「必須是 manager」和「狀態必須是 planning」兩道檢查），以及讀取（`project_current_ssp_service.py:51`）。它沒有任何修改 SSP 子表的呼叫。

**附帶提一個小地方（不列發現）**：同一支檔案裡另外兩個入口 `require_write_access`／`require_read_access`（`common/authz/ssp.py:164-209`）全庫零呼叫者。而且它們底下的 `SspContextResolver._try_resolve_module_frame` 讀的是 `ssp.profile_id`，但 SSP 物件上的欄位名叫 `import_profile_id`，所以「範本 SSP」那條分支永遠不會成立。現在沒人用，不影響任何東西，建議跟死碼一起清。

### 5.3 深複製：包含關係都換對了，但有兩種用編號互指的欄位沒換 → 部分成立（V1-1）

**現況**：已修（M11-38，FR-114 CM-2185，套件 commit `1d3a59c8`；舊快照依裁定不動，1.21.0 出貨）

`ssp_clone_service.py:103-245` 逐張子表看：
- **包含關係都有換**：每一張子表的「父層編號」（`ssp_id`、`system_characteristics_id`、`system_implementation_id`、`control_implementation_id`、`implemented_requirement_id`、`statement_id`）都換成新複製出來的那筆。by-component 的 `component_id` 也照對照表換掉了（`:225`、`:242`）。**卡片①問的「by-component 有沒有留著指向舊 SSP 的元件」不成立**。
- 只有一個邊角：`component_id_map.get(bc.component_id, bc.component_id)`。如果某筆 by-component 指向的元件不在這份 SSP 底下，就沿用舊的編號。前提是來源資料本身已經錯了，正常流程產生不出這種資料，只記錄不列發現。
- **沒有換的（V1-1）**：這些欄位不是資料庫的關聯欄，而是「寫成 OSCAL 編號字串、指向同一份 SSP 裡另一筆資料」：
  - 沿用授權的 `party_uuid` → 指向人員。人員在 `metadata_clone_service.py:105` 換了新編號，但沿用授權在 `ssp_clone_service.py:171-176` 是原樣照抄。
  - 元件 props 裡的 `leveraged-authorization-uid` → 指向沿用授權。沿用授權在 `:174` 換了新編號，但元件在 `:185-190` 是 props 原樣照抄。
  - 資產清單的 `implemented_components[].component-uuid` → 指向元件。問題同上，但 DEV 目前 0 筆資料有填這個欄位。

  DEV 唯讀實查（`cm_app`，2026-09-24 14:2x，查完 ROLLBACK）：

  | 參照 | SSP 狀態 | 總數 | 指向同一份 SSP | 指向別份 SSP |
  |---|---|---|---|---|
  | 沿用授權 → 提供者 | 凍結 | 47 | 0 | **47** |
  | 沿用授權 → 提供者 | 草稿（含專案的 SSP） | 541 | 25 | 515 |
  | 元件 → 沿用授權 | 草稿 | 10 | 8 | 2 |

  前端顯示這些欄位時，是拿「本份 SSP」的清單去對照（例如 `build_leveraged_title_map` `module_frame_components_service.py:195-203`），所以指錯的那些只會顯示成空白，不會帶出別份 SSP 的內容。我用 grep 搜了主專案，沒有任何程式拿 `party_uuid` 去全表找人，所以不會跨份讀取。定為低。
- **卡片②「來源 SSP 編號是誰給的」**：只有兩個地方會觸發複製。一是 `project_start_app_service.py:345` `clone_resource_library`，來源三件組由 `_resolve_source_triple` 從已發布的資源庫解析出來（FR-095 H1 掃過）；二是 compliance-audit `audit_round_app_service.py:397` `snapshot_ssp(living_ssp_id)`，`living_ssp_id` 從專案擴充表取，不是使用者傳進來的（FR-116 C2a 範圍）。主專案沒有其他地方直接叫 `deep_clone_ssp`。

### 5.4 `list_ssps` 不帶條件就回全部 SSP：不成立，下次不用重查

全庫有 **5 處**呼叫（盤點寫 6 處，實查少一處）：`ssp_party_app_service.py:128`、`module_frame_party_service.py:151`、`ssp_import_template_app_service.py:1063`、`export/ssp_v2_content_loader.py:85`、compliance-audit `project_current_ssp_service.py:51`。全部都有帶 `id=`，而且這個 id 在前面都已經確認不是空的（權限檢查回來的 SSP、模組的 `template_ssp_id` 空值會先擋、`living_ssp_id` 空值會先擋、匯出兩個入口都先確認 id 存在）。沒有任何一處會拿空條件去查。

### 5.5 複製人員時個資一起複製：是預期行為，不成立

`metadata_clone_service.py:99-111` 會把人員整筆複製（含 email、電話），只換 `id`、`metadata_id`、`uuid`。兩個觸發點：專案成立時從資源庫範本複製（範本是同一家客戶自己建的，或是平台公版；公版本來就是要給客戶用的），以及凍結快照（同一個專案內複製）。目的地都是同一家客戶，而且凍結快照本來就該保存當下的聯絡資訊，才能當稽核基準。

### 5.6 13 支子表 repo 與 4 支 metadata repo：自己寫的方法都有父層範圍，不成立

17 支 repo 我逐支讀完。自己寫的方法全部都用父層編號過濾（`get_by_ssp`、`get_by_system_implementation`、`get_by_system_characteristics`、`get_by_control_implementation`、`get_by_implemented_requirement`、`get_by_statement`）。`ssp_repo_impl.py` 和 4 支 metadata repo 沒有自己寫任何方法，只有從基底繼承來的。繼承來的 `get_by_id`／`update`／`delete_by_id` 只看編號，但它們只有在服務層開放出去時才有意義，這部分 5.1 已經逐處追完。

### 5.7 卡片以外，逐檔通讀的結論

21 檔全部讀過。`ssp_service.py` 剩下沒被點名的方法：`upsert_system_characteristics` `:135-147`（用 `sc.ssp_id` 找到既有那筆、帶入它的 id）、`get_or_create_*`、`list_*`，全部都用 SSP 編號限定範圍。`oscal_snapshot_service.clone_resource_library` `:98-179`：目錄和基準線的複製在 V4a 範圍，SSP 這一段跟 5.3 相同。除了 V1-1，沒有找到其他問題。

---

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

**「這幾條存在嗎」：高。** V1-1 是開檔讀程式碼、再用 DEV 實際資料對過數字確認的（凍結快照 47/47 指錯）。5.1 和 5.2 的「不成立」，是 25 處呼叫加上所有寫入 SSP 的網址逐一追到權限檢查那一行得出的結論，不是抽查。

**「只有這幾條嗎」：中。**
- 工具用 effort low，只派 1 位研究員讀全部範圍，沒有做威脅建模，也沒有做多輪掃描；研究員自己也沒有留下「讀到哪裡」的紀錄。
- 我這一棒把 21 檔逐支通讀，主專案呼叫端也逐處追完，所以「套件被怎麼用」這一層的覆蓋是完整的。
- 打折的地方：
  - ①DEV 只能用 `cm_app` 帳號查。「稽核輪次」這張表有資料庫隔離，查詢沒帶客戶身分就看不到任何一列（回 0 筆），所以「71 份凍結快照都接在某一輪上」這件事**沒辦法用資料驗證**，只驗證了程式邏輯（找不到輪次就預設拒絕，所以就算資料不齊也不會變成可以寫入）。
  - ②Word／Excel 匯入寫進 SSP 的細節（`import_ssp`、`clear_ssp_body`）屬 V3 範圍，本棒只確認了匯入到專案 SSP 的那條路會經過凍結判斷。

---

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

| 項目 | 數值 |
|---|---|
| 範圍 | 套件側 21 檔／1,503 行，effort low，scoped，focus attack-surface |
| 基準 commit | `eafc7ae511d5`（dirty：工作區有平行 session 的未提交改動） |
| 研究員 | 派 2（1 位讀全範圍、1 位專找密鑰），回 2，失敗 0 |
| 原始候選 | 0 |
| 審查小組投票 | 0（沒有候選可投） |
| 驗證輪次 | 1 輪，正常完成 |
| 耗時 | 約 4 分鐘（2 個 agent） |
| 驗證章 | **verified，0 findings**，`reason_kind` 空白（`CLAUDE-SECURITY-REVISION-eafc7ae511d5-dirty.json`） |
| 工具 run ID | `wf_6fdd8b35-b85` |
| 工具原始報告 | 套件 repo `jedi-oscal-v2/CLAUDE-SECURITY-20260924-061613/`（未入版控） |
| 人工核對 | 主專案 25 處呼叫＋5 處 `list_ssps`＋所有寫入 SSP 的入口；DEV 唯讀查詢 4 組 |

---

## 8. 待首腦裁決

1. **V1-1 要不要開修正卡**：修法是在套件的複製流程裡，把 `clone_metadata` 產生的「人員舊編號→新編號」和複製沿用授權時產生的「舊編號→新編號」兩張對照表傳給 SSP 複製，然後換掉 `party_uuid`、元件 props 的 `leveraged-authorization-uid`、資產清單的 `component-uuid` 三處。已經產生的 47 份凍結快照要不要修補資料，是另一個問題：修補等於改動稽核證據，本身就需要決策。
2. **死碼清理**：`ssp_service` 的 11 支零呼叫方法，加上 `SspPermissionChecker.require_write_access`／`require_read_access`（零呼叫，而且範本分支因為欄位名寫錯，永遠不會成立），建議併入 FR-092。
3. **凍結保護只有一層**：這次確認的是主專案擋得住，但套件本身完全沒有保護。將來如果有新的呼叫端（例如新套件、背景工作）直接叫 `ssp_service.update_*`，又沒有經過 `SspPermissionChecker`，就能修改稽核證據。要不要在套件層也加一道檢查（`status == frozen` 就拒絕寫入），請裁決。

沿革見 LOG 與 git log。
