---
title: FR-118 掃描盤點——jedi-oscal-v2 套件本體＋主專案側 Word 解析器：範圍界定與切棒
---

# FR-118 掃描盤點：jedi-oscal-v2（控制項目錄、SSP、稽核計畫與結果、改善計畫的資料層）

> 盤點日期 2026-09-24｜盤點卡 CM-2123（母卡 CM-2122）｜這份文件只盤點、不掃描——切好棒等首腦開卡派工。
> 行數全部是 `wc -l` 逐檔實算。基準：BE `feature/review` commit `a7f403856`；jedi monorepo `feature/review` commit `eafc7ae5`（`jedi-oscal-v2/` 工作區乾淨）。每棒段落附兩側各自的驗證指令。

名詞先講清楚（讀者是 PM 與新手工程師）：

- **套件**：`jedi-oscal-v2`，放在另一個 repo（`~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2/`）的一包程式，負責把合規文件存進資料庫、再從資料庫組回來。它**沒有網址入口**，所有功能都要靠主專案或 `jedi-compliance-audit` 套件呼叫。
- **OSCAL**：美國 NIST 訂的一套合規文件格式。這支套件處理其中五種文件：**控制項目錄（catalog）**、**基準線（profile，從目錄挑出要做的控制項）**、**系統安全計畫（SSP）**、**稽核計畫（AP）與稽核結果（AR）**、**改善計畫（POA&M）**。
- **repo（資料存取方法）**：程式裡專門負責「去資料庫查／改／刪」的那一層。
- **範圍條件**：資料庫查詢時除了「編號是多少」之外，有沒有再加上「而且要屬於某份計畫／某份 SSP／某家客戶」。只有編號、沒有範圍，就是總表第 118／119 項那種「檢查甲、刪乙」的洞。
- **資料庫隔離（RLS）**：資料庫這一層自己擋「你只能看到你們公司的資料」。程式層漏檢查時，它是最後一道防線。
- **判斷邏輯 vs 純宣告**：有 if、迴圈、查詢組裝的程式碼叫「判斷邏輯」，可能出錯、要掃；只列欄位名稱、或欄位一對一搬家的程式碼叫「純宣告」，本身不會出錯、不掃。

---

## 這批最該先看的四件事

1. **這支套件本身「不問歸屬」是設計，不是漏洞——但它把整份責任丟給呼叫它的人**。套件 54 支 repo 自己寫的查詢方法**全部都有帶上一層的編號**（例如「某份 SSP 底下的元件」）；但每一支 repo 都另外**從共用基底類別繼承了「只用編號」的查、改、刪**（`get_by_id`／`get_by_uid`／`update`／`delete_by_id`／`delete_by_uid`）。套件的服務層有 **33 支方法直接拿呼叫者給的編號去讀、改或刪**，沒有任何範圍檢查（見第 4 節）。主專案目前的呼叫端**都先在正確的父層底下找到那筆、才把編號交進去**（形狀對），所以這不是現成的洞，而是「**下一個寫呼叫端的人漏一步，就是第 118／119 項**」的地雷區。掃描要做的是**逐支確認呼叫端都有先找父層**。
2. **整個 oscal 資料區 45 張本套件的表：沒有一張有客戶欄位、沒有一張開了資料庫隔離**（出貨 `02-schema.sql` 與 DEV 實查一致）。第 1 點講的「靠呼叫端先找父層」，**底下完全沒有第二道防線**。這是總表第 128 項的全貌，本棒補齊清單（第 5 節）。
3. **「XML 解析攻擊」在這支套件不成立**（第 6 節）。全套件正式碼**零支** import 任何 XML 解析器（xml／lxml／defusedxml／ElementTree），也**沒有 OSCAL XML 格式的匯入匯出**——`oscal_io_service.py` 只做 JSON 匯出、以及「把主專案已經組好的 Python 字典寫進資料庫」的匯入。真正會碰到 XML 的是主專案那側讀 Word（`python-docx`，底層已關掉外部實體解析）與讀 Excel（`openpyxl`，主專案已裝 `defusedxml`，會自動套用防護）。**掃描重點從「XML 攻擊」換成「超大／超深的字典」與「Word／PDF 壓縮炸彈」**。
4. **Word 解析失敗會把原始錯誤訊息原封不動寫進解析工作、並直接回給前端**（`app/oscal/service/ssp_docx_import_app_service.py:220`、`:226` 都是 `str(e)`）。這跟總表第 132 項（PDF 解析失敗吐伺服器路徑）同一個形狀，**但這支檔在 FR-113 O4 範圍內**，本批不重掃；列給 W1 研究員當「解析器丟出來的例外會長什麼樣」的背景。

> ⚠️ 以上是盤點時開檔看到的「疑點」與「形狀」，**不是掃描結論**。

---

## 1. 範圍界定

### 1.1 套件側

```bash
cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2
git ls-files -- 'jedi_oscal_v2/*.py' | xargs wc -l | tail -1     # 363 支、15,852 行
```

**正式程式 363 支／15,852 行**，與卡片數字一致（tests 不掃）。另有兩個非 `.py` 目錄：`assets/`（兩份 CMMC 官方 PDF＋一份對照檔，當範例與種子資料用）、`migrations/001-cmmc-2-catalog-seed.sql`（CMMC 目錄的種子資料）——都不是程式邏輯，不掃。

363 支的去向：

| 分類 | 檔數 | 行數 | 為什麼 |
|---|---:|---:|---|
| 排進掃描棒 | 69 | 7,117 | 見第 2 節（去重後；含 2 支空的 adapter `__init__.py`） |
| domain/entity 純資料結構 | 99 | 2,195 | 全部是 `@dataclass` 欄位宣告，0 個方法、0 個 if（見附錄 A） |
| infra/model 資料表宣告 | 54 | 2,921 | 全部是 SQLAlchemy 欄位宣告，0 個方法、0 個驗證器／事件（附錄 A） |
| infra/mapper 欄位搬家 | 54 | 2,671 | 每支只有 `if model is None: return None` 一個判斷，其餘一欄一欄搬；**沒有任何一支做 `json.loads` 或遞迴組裝**（JSON 欄位由資料庫驅動直接給 Python 物件）（附錄 A） |
| domain/repository 介面 | 54 | 802 | 抽象方法宣告，沒有實作（附錄 A） |
| 錯誤碼與列舉常數 | 2 | 103 | `common/code/oscal_v2_error_code.py`（41）、`common/enum/oscal_enums.py`（62） |
| `jedi_oscal_v2/__init__.py` | 1 | 30 | 只有 docstring |
| 其餘 `__init__.py`（0～1 行） | 30 | 13 | |
| **合計** | **363** | **15,852** | ※ 不掃的共 294 支／8,735 行，明細見附錄 A |

**與卡片說法的差異**：卡片寫「infra/repository 54」是指 54 張表對應 54 支 repo，但實際 `*_repo_impl.py` 是 **45 支**，另外 9 支是 `__init__.py`。要列查詢條件的就是這 45 支（全部排進 V1／V2／V4a）。

### 1.2 主專案側

```bash
cd ~/Projects/Billows/Audit-Manager/compliance-manager-be
git ls-files -- domain/oscal/parser domain/oscal/adapter | xargs wc -l | tail -1   # 11 支（含 2 支空檔）、2,986 行
```

⚠️ **比卡片多 2 支**：卡片列 7 支／2,946 行（取自 FR-113 盤點第 196～210 行），但目錄實際還有：

| 檔 | 行數 | 為什麼補進來 |
|---|---:|---|
| `domain/oscal/parser/framework_patterns.py` | 19 | 四組「控制項編號長什麼樣」的正規表示式（regex）。Word 解析器每一段文字都會拿它去比對；**regex 寫法不好會被特製文字卡死 CPU**（ReDoS），要一起看。併入 W1 |
| `domain/oscal/adapter/adapter_registry.py` | 21 | 「框架代號 → 用哪支轉接器」的對照表，DI 在用。併入 W2 |

兩支 `__init__.py` 是空檔。所以主專案側實際掃 **9 支／2,986 行**。

### 1.3 排除清單（已被別的掃描棒掃過，本批不重掃）

套件本體**一支都沒被掃過**，零重疊。以下是「呼叫套件的主專案檔」，全部在 FR-113 範圍內：

| 主專案檔 | 呼叫套件的什麼 | 哪一棒掃過 |
|---|---|---|
| `app/oscal/service/ssp_docx_import_app_service.py` | `import_ssp`／`clear_ssp_body`／`build_ssp_snapshot` | FR-113 O4 |
| `app/oscal/service/ssp_excel_import_app_service.py` | 同上 | FR-113 O3a |
| `app/oscal/service/oscal_export_app_service.py` | `export_oscal` | FR-113 O2 |
| `app/oscal/service/framework_app_service.py` | `import_catalog_from_pdf／excel` | FR-113 O8b |
| `app/oscal/service/framework_parse_job_service.py` | PDF 解析工作 | FR-113 O8a |
| `app/oscal/service/ssp_components／inventory_items／leveraged／party_app_service.py` | `ssp_service` 的改／刪 | FR-113 O5 |
| `app/oscal/service/ssp_control_implementation_service.py` | `update_implemented_requirement`／`update_statement` | FR-113 O1 |
| `app/module_frame/service/module_frame_*_service.py`（party／components／inventory／leveraged／control_default／control_objective_default） | 同上 | FR-113 B 系列 |
| `app/project/service/project_start_app_service.py` | `clone_resource_library` | FR-095 H1 |

`jedi-compliance-audit` 套件是第二個大呼叫端（`assessment_result_app_service`／`poam_app_service`／`audit_round_app_service` 直接用本套件的 repo 與 service），在 **FR-116 C2a／C3** 範圍內。

⚠️ **卡片提到「O1／O6 報告有引用 `ssp_reference_document_repo_impl.py`」——查證屬實，那支檔在 `jedi-compliance-audit`、不是本套件**，本套件沒有程序書相關的表或程式。第 118／119／129 項的根因**不在本套件**。

⚠️ **一支例外沒被任何人掃過**：`app/flow_control/service/assessment_plan_app_service.py` 呼叫本套件 `generate_draft` 與 `clone_metadata`（`:313`、`:354`），它不在 FR-113 任何一棒、也不在 FR-116 盤點的主專案清單。本批**不收**（它是主專案業務層、不是 Word 解析器），列在第 7 節請首腦決定歸屬。

### 1.4 最終要掃的範圍

| | 檔數 | 行數 |
|---|---:|---:|
| 套件側 | 69 | 7,117 |
| 主專案側 | 9 | 2,986 |
| **合計（去重）** | **78** | **10,103** |

---

## 2. 切棒（7 棒，每棒 ≤2,000 行、≤30 檔；按 OSCAL 文件類型切）

切法原則：**按文件類型縱切**——同一種文件的「服務 → repo 實作」放同一棒，因為這支套件要找的是「子物件有沒有被綁在正確的父文件底下」，拆開就看不出來。`oscal_io_service.py` 1,660 行一支獨立成棒。主專案 Word 解析器拆兩棒，**跟套件 CMMC PDF 解析器（V4b）配對**：兩邊都是「吃外部檔案、解析成內部格式」，風險形狀一樣（大小上限、壓縮炸彈、錯誤訊息外洩、regex 卡死）。

**接縫檔**（刻意在兩棒重複列）：`catalog_service.py`（V4a 管資料、V4b 管解析入口）。

| 棒 | 做什麼 | 檔數 | 行數 | 側 |
|---|---|---:|---:|---|
| V1 | 🔴 SSP 本體：服務、複製與凍結、SSP 子表與 metadata 的 repo | 21 | 1,503 | 套件 |
| V2 | 🔴 稽核計畫、稽核結果、改善計畫：服務與 repo | 26 | 1,880 | 套件 |
| V3 | 🔴 OSCAL 匯入匯出整份文件（`oscal_io_service.py`） | 1 | 1,660 | 套件 |
| V4a | 控制項目錄、基準線、框架：服務、目錄複製、repo | 14 | 1,202 | 套件 |
| V4b | CMMC PDF／Excel 解析器 | 8 | 1,110 | 套件 |
| W1 | 主專案 Word 內容抽取器 | 5 | 1,658 | 主專案 |
| W2 | 主專案 CMMC 轉接器與中介格式 | 4 | 1,328 | 主專案 |
| **逐棒加總（含接縫重複）** | | **79** | **10,341** | |

逐棒加總扣掉接縫 `catalog_service.py`（238 行，重複 1 次）＝ **78 檔、10,103 行**，與 1.4 一致。

### 驗收時的配對建議

- **V4b 與 W1／W2 同一個 runner 連做**：三棒都是「外部檔案解析」，研究重點（大小、頁數、壓縮炸彈、錯誤訊息、regex）完全相同，連做省一次建立脈絡。
- **V1 與 V3 共用 SSP 的子表結構**：V3 的匯入寫的就是 V1 那批表。不必同一人做，但 V3 研究員遇到「寫進哪張表、用哪個鍵認定是同一筆」時可越界讀 V1 的 repo。

### V1 — 🔴 SSP 本體（服務、複製與凍結、子表 repo）

**這一棒要回答**：`ssp_service.py` 那一堆「只收編號就改／刪」的方法，每一個呼叫端是不是都先在正確的 SSP 底下找到那筆；SSP 複製／凍結時，子物件的歸屬欄位有沒有全部換成新 SSP、有沒有漏帶或帶到別份 SSP 的東西。

```
套件側（cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2）
  350 jedi_oscal_v2/app/service/ssp/ssp_service.py                          ← 🔴 21 支只收編號就讀／改／刪的方法
  245 jedi_oscal_v2/app/service/ssp/ssp_clone_service.py                    ← 深複製 12 張子表
  192 jedi_oscal_v2/app/service/snapshot/oscal_snapshot_service.py          ← 凍結、資源庫三件組複製
  129 jedi_oscal_v2/app/service/snapshot/metadata_clone_service.py          ← metadata（角色、人員、資源）複製
   48 jedi_oscal_v2/infra/repository/ssp/ssp_by_component_repo_impl.py
   36 jedi_oscal_v2/infra/repository/ssp/ssp_component_repo_impl.py
   47 jedi_oscal_v2/infra/repository/ssp/ssp_control_implementation_repo_impl.py
   31 jedi_oscal_v2/infra/repository/ssp/ssp_diagram_repo_impl.py
   50 jedi_oscal_v2/infra/repository/ssp/ssp_implemented_requirement_repo_impl.py
   40 jedi_oscal_v2/infra/repository/ssp/ssp_information_type_repo_impl.py
   38 jedi_oscal_v2/infra/repository/ssp/ssp_inventory_item_repo_impl.py
   46 jedi_oscal_v2/infra/repository/ssp/ssp_leveraged_authorization_repo_impl.py
   17 jedi_oscal_v2/infra/repository/ssp/ssp_repo_impl.py
   38 jedi_oscal_v2/infra/repository/ssp/ssp_statement_repo_impl.py
   47 jedi_oscal_v2/infra/repository/ssp/ssp_system_characteristics_repo_impl.py
   43 jedi_oscal_v2/infra/repository/ssp/ssp_system_implementation_repo_impl.py
   38 jedi_oscal_v2/infra/repository/ssp/ssp_system_user_repo_impl.py
   17 jedi_oscal_v2/infra/repository/base/metadata_repo_impl.py
   17 jedi_oscal_v2/infra/repository/base/party_repo_impl.py
   17 jedi_oscal_v2/infra/repository/base/resource_repo_impl.py
   17 jedi_oscal_v2/infra/repository/base/role_repo_impl.py
```
共 **21 檔／1,503 行**。

驗證：
```bash
cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2 && P=jedi_oscal_v2 && git ls-files -- $P/app/service/ssp/ssp_service.py $P/app/service/ssp/ssp_clone_service.py $P/app/service/snapshot/oscal_snapshot_service.py $P/app/service/snapshot/metadata_clone_service.py "$P/infra/repository/ssp/*_repo_impl.py" "$P/infra/repository/base/*_repo_impl.py" | xargs wc -l | tail -1   # 21 檔、1503
```

### V2 — 🔴 稽核計畫、稽核結果、改善計畫

**這一棒要回答**：稽核結果與改善計畫的服務收到「風險編號＋一串判定編號」「矯正措施編號」這類輸入時，有沒有驗證它們屬於同一份稽核結果；`update_poam_item`、`link_findings` 這種只收編號的方法被誰呼叫。

```
套件側（cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2）
  215 jedi_oscal_v2/app/service/ap/assessment_plan_service.py               ← 用 ap_uid 找 AP 再整批換任務／受評對象
  156 jedi_oscal_v2/domain/service/ap/ap_draft_service.py                   ← 從 SSP 產生 AP 草稿
  184 jedi_oscal_v2/app/service/ar/assessment_result_service.py
  143 jedi_oscal_v2/app/service/ar/assessment_risk_service.py               ← 🔴 link_findings 不驗判定屬於誰
  186 jedi_oscal_v2/domain/service/ar/ar_finding_matrix_service.py
  144 jedi_oscal_v2/app/service/poam/poam_service.py                        ← 🔴 update_poam_item 只收編號
  146 jedi_oscal_v2/app/service/poam/remediation_service.py                 ← upsert 用 risk_id＋uuid 定位（有範圍）
   38 jedi_oscal_v2/infra/repository/ap/ap_assessment_activities_repo_impl.py
   51 jedi_oscal_v2/infra/repository/ap/ap_assessment_subjects_repo_impl.py
   36 jedi_oscal_v2/infra/repository/ap/ap_local_objectives_repo_impl.py
   17 jedi_oscal_v2/infra/repository/ap/ap_repo_impl.py
   35 jedi_oscal_v2/infra/repository/ap/ap_reviewed_controls_repo_impl.py
   47 jedi_oscal_v2/infra/repository/ap/ap_task_participants_repo_impl.py
   45 jedi_oscal_v2/infra/repository/ap/ap_task_subjects_repo_impl.py
   52 jedi_oscal_v2/infra/repository/ap/ap_tasks_repo_impl.py
   38 jedi_oscal_v2/infra/repository/ar/ar_assessment_subjects_repo_impl.py
   45 jedi_oscal_v2/infra/repository/ar/ar_finding_repo_impl.py
   68 jedi_oscal_v2/infra/repository/ar/ar_finding_risk_repo_impl.py         ← link／unlink 只用 (finding_id, risk_id)
   34 jedi_oscal_v2/infra/repository/ar/ar_observation_repo_impl.py
   29 jedi_oscal_v2/infra/repository/ar/ar_remediation_repo_impl.py
   17 jedi_oscal_v2/infra/repository/ar/ar_repo_impl.py
   29 jedi_oscal_v2/infra/repository/ar/ar_results_repo_impl.py
   29 jedi_oscal_v2/infra/repository/ar/ar_risk_repo_impl.py
   29 jedi_oscal_v2/infra/repository/poam/poam_item_repo_impl.py
   39 jedi_oscal_v2/infra/repository/poam/poam_milestone_repo_impl.py        ← get_by_assignee 零呼叫者
   28 jedi_oscal_v2/infra/repository/poam/poam_repo_impl.py
```
共 **26 檔／1,880 行**。

驗證：
```bash
cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2 && P=jedi_oscal_v2 && git ls-files -- "$P/app/service/ap/*_service.py" "$P/app/service/ar/*_service.py" "$P/app/service/poam/*_service.py" "$P/domain/service/ap/*_service.py" "$P/domain/service/ar/*_service.py" "$P/infra/repository/ap/*_repo_impl.py" "$P/infra/repository/ar/*_repo_impl.py" "$P/infra/repository/poam/*_repo_impl.py" | xargs wc -l | tail -1   # 26 檔、1880
```

### V3 — 🔴 OSCAL 匯入匯出（單獨一棒）

**這一棒要回答**：整份文件進來時，內容裡的 uuid、名稱、陣列長度、巢狀深度有沒有上限；「更新模式」用什麼鍵認定「是同一筆」，能不能被拿去覆蓋別份 SSP 的資料；匯出時會不會把不屬於這份文件的東西組進去；整份匯入失敗時會不會寫一半。

```
套件側（cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2）
 1660 jedi_oscal_v2/app/service/io/oscal_io_service.py
```
共 **1 檔／1,660 行**。

驗證：
```bash
cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2 && git ls-files -- jedi_oscal_v2/app/service/io/oscal_io_service.py | xargs wc -l   # 1 檔、1660
```

### V4a — 控制項目錄、基準線、框架

**這一棒要回答**：目錄複製（資源庫套用框架時用）有沒有把子物件的父層編號全部換掉；基準線解析時「從哪份目錄挑控制項」這個來源是不是伺服器端決定；框架刪除、版本發佈這幾支「只收 uid」的方法被誰呼叫、有沒有平台管理員檢查。

```
套件側（cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2）
  238 jedi_oscal_v2/app/service/catalog/catalog_service.py                  ← 接縫（V4b 也帶）
  333 jedi_oscal_v2/app/service/snapshot/oscal_clone_service.py             ← 目錄樹、基準線複製
   72 jedi_oscal_v2/app/service/profile/profile_service.py
  163 jedi_oscal_v2/domain/service/profile/profile_resolution_service.py
  120 jedi_oscal_v2/app/service/framework/framework_service.py              ← delete／publish 只收 uid
   28 jedi_oscal_v2/infra/repository/catalog/catalog_control_param_repo_impl.py
   57 jedi_oscal_v2/infra/repository/catalog/catalog_control_part_repo_impl.py
   41 jedi_oscal_v2/infra/repository/catalog/catalog_control_repo_impl.py
   41 jedi_oscal_v2/infra/repository/catalog/catalog_group_repo_impl.py
   17 jedi_oscal_v2/infra/repository/catalog/catalog_repo_impl.py
   17 jedi_oscal_v2/infra/repository/framework/framework_repo_impl.py
   26 jedi_oscal_v2/infra/repository/framework/framework_version_repo_impl.py
   32 jedi_oscal_v2/infra/repository/profile/profile_import_repo_impl.py
   17 jedi_oscal_v2/infra/repository/profile/profile_repo_impl.py
```
共 **14 檔／1,202 行**。

驗證：
```bash
cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2 && P=jedi_oscal_v2 && git ls-files -- $P/app/service/catalog/catalog_service.py $P/app/service/snapshot/oscal_clone_service.py "$P/app/service/profile/*_service.py" "$P/domain/service/profile/*_service.py" "$P/app/service/framework/*_service.py" "$P/infra/repository/catalog/*_repo_impl.py" "$P/infra/repository/profile/*_repo_impl.py" "$P/infra/repository/framework/*_repo_impl.py" | xargs wc -l | tail -1   # 14 檔、1202
```

### V4b — CMMC PDF／Excel 解析器

**這一棒要回答**：上傳的 PDF 有沒有頁數與大小上限；解析出錯時錯誤訊息往上丟什麼（總表第 132 項同形）；Excel 版本走 pandas＋openpyxl，有沒有公式或壓縮炸彈的防護。

```
套件側（cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2）
  238 jedi_oscal_v2/app/service/catalog/catalog_service.py                  ← 接縫：import_catalog_from_pdf／excel 入口
  117 jedi_oscal_v2/infra/adapter/base_parser_adapter.py                    ← 頁碼範圍計算
   34 jedi_oscal_v2/infra/adapter/oscal_parser_factory.py
  639 jedi_oscal_v2/infra/adapter/cmmc/cmmc2_lv2_parser_adapter.py         ← pdfplumber＋pandas＋openpyxl（中文 docstring 最多的檔）
   55 jedi_oscal_v2/domain/ports.py                                         ← 解析器介面
   27 jedi_oscal_v2/common/code/parser_adapter_type.py                      ← 宣告了 YAML／JSON 匯入類型、但沒有實作（見第 6 節）
    0 jedi_oscal_v2/infra/adapter/__init__.py
    0 jedi_oscal_v2/infra/adapter/cmmc/__init__.py
```
共 **8 檔／1,110 行**（含 2 支空檔）。

驗證：
```bash
cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2 && P=jedi_oscal_v2 && git ls-files -- $P/app/service/catalog/catalog_service.py "$P/infra/adapter/*.py" "$P/infra/adapter/cmmc/*.py" $P/domain/ports.py $P/common/code/parser_adapter_type.py | xargs wc -l | tail -1   # 8 檔、1110
```

### W1 — 主專案 Word 內容抽取器（原 FR-113 O7a ＋ 1 支）

**這一棒要回答**：Word 檔打開前有沒有看大小、解壓後的大小與段落數有沒有上限；逐段比對控制項編號的 regex 會不會被特製文字卡死；模糊比對（rapidfuzz）對超長段落的耗時。

```
主專案側（cd ~/Projects/Billows/Audit-Manager/compliance-manager-be）
  892 domain/oscal/parser/docx_section_extractors.py
  555 domain/oscal/parser/docx_parser_core.py                    ← python-docx Document() 開檔點（:170、:192）
  120 domain/oscal/parser/control_id_matcher.py                  ← rapidfuzz 模糊比對
   72 domain/oscal/parser/docx_intermediate.py
   19 domain/oscal/parser/framework_patterns.py                  ← 補入：四組控制項編號 regex
```
共 **5 檔／1,658 行**。

驗證：
```bash
cd ~/Projects/Billows/Audit-Manager/compliance-manager-be && git ls-files -- domain/oscal/parser/docx_section_extractors.py domain/oscal/parser/docx_parser_core.py domain/oscal/parser/control_id_matcher.py domain/oscal/parser/docx_intermediate.py domain/oscal/parser/framework_patterns.py | xargs wc -l | tail -1   # 5 檔、1658
```

### W2 — 主專案 CMMC 轉接器與中介格式（原 FR-113 O7b ＋ 1 支）

**這一棒要回答**：Word 解析出來的中介資料轉成 OSCAL 字典時，uuid 與名稱是不是由伺服器產生（還是直接沿用檔案裡寫的）；這份字典之後會餵給 V3 的 `import_ssp`——**檔案裡的 uuid 能不能對上別份 SSP 已存在的資料**。

```
主專案側（cd ~/Projects/Billows/Audit-Manager/compliance-manager-be）
  860 domain/oscal/adapter/cmmc_ssp_adapter.py
  369 domain/oscal/parser/ssp_intermediate.py
   78 domain/oscal/adapter/i_ssp_docx_adapter.py
   21 domain/oscal/adapter/adapter_registry.py                   ← 補入：框架代號 → 轉接器對照
```
共 **4 檔／1,328 行**。

驗證：
```bash
cd ~/Projects/Billows/Audit-Manager/compliance-manager-be && git ls-files -- domain/oscal/adapter/cmmc_ssp_adapter.py domain/oscal/parser/ssp_intermediate.py domain/oscal/adapter/i_ssp_docx_adapter.py domain/oscal/adapter/adapter_registry.py | xargs wc -l | tail -1   # 4 檔、1328
```

---

## 3. 每棒重點看什麼

### 每一棒開工前都先做的兩件事

**① 找「只收編號就改／刪」的方法，逐一追呼叫端**：
```bash
# 套件服務層：哪些方法把呼叫者給的編號直接交給 repo 改／刪
grep -nE "\.(get_by_id|get_by_uid|update|delete_by_id|delete_by_uid)\(" <檔>
# 主專案與 compliance-audit：誰呼叫它
grep -rn "\.<方法名>(" --include='*.py' ~/Projects/Billows/Audit-Manager/compliance-manager-be/{api,app} ~/Projects/Jedicogy/module/jedi-python-package/jedi-compliance-audit/jedi_compliance_audit
```
套件本身沒有守門是**預期的**（它沒有網址入口、不知道使用者是誰）。要答的是「**呼叫端在把編號交進來之前，有沒有先在正確的父文件底下找到它**」。主專案目前已知的正確寫法：`ssp_components_app_service.py:138-142` 的 `_resolve(ssp_id, item_uid)`——先列出這份 SSP 的所有元件、比對 uid、找不到就 404，再把找到的 `id` 交給套件。

**② 找「呼叫端傳什麼、套件就寫什麼」**：套件多支方法收 `**fields`，然後逐欄 `setattr` 寫進物件（例如 `remediation_service.py:77-78`、`poam_service.py:141-142`）。如果呼叫端把使用者的整包輸入直接傳進去，使用者就能改到 `risk_id`、`ar_result_id` 這種**歸屬欄位**，把資料搬到別份文件底下。要確認每個呼叫端傳進 `**fields` 的鍵是白名單。

### 各棒個別重點

**V1（SSP 本體）🔴**
- 🔴 `ssp_service.py:125-344`：21 支「收編號就讀／改／刪」的方法（`update_ssp`，以及元件、資產清單、沿用授權、控制項實作、實作敘述、by-component、人員各一組 get／update／delete）。盤點時追到主專案 25 處呼叫（第 4.2 節），分屬 10 支方法；`update_ssp`、`delete_implemented_requirement`、`delete_statement`、`update_by_component`、`delete_by_component`、六支 `get_*` 在兩個呼叫端 repo **零呼叫者**。掃描要確認：①25 處都真的先在同一份 SSP 底下找到那筆（盤點只抽了 `ssp_components_app_service.py:138-142`，形狀對，其餘待掃）；②`update_*` 傳進來的物件是從資料庫讀出來再改欄位，**不是用使用者輸入重新組一個**（重新組的話 `ssp_id`／`system_implementation_id` 就能被改掉）。
- `ssp_service.py:121-123` `list_ssps(query)`：查詢條件全部選填（`query or SspQueryEntity()`），不帶條件就**回全部客戶的全部 SSP**（表沒有隔離）。盤點時找到 6 處呼叫，**全部都帶 `id=`**，預期不是洞，確認即可。
- `ssp_clone_service.py:103-245` `deep_clone_ssp`：複製 12 張子表，每張都用 `replace(src, id=None, uid=None, ssp_id=new_ssp_id, ...)`。要確認：①所有「指向別張表」的欄位（例如 by-component 的 `component_id`）都有換成新複製出來的那筆，**沒有留著指向舊 SSP 的元件**；②來源 SSP 編號（`src_ssp_id`）是誰給的——主專案呼叫端 `project_start_app_service.py`（FR-095 H1 已掃）與 `jedi-compliance-audit` 的開輪流程。
- `oscal_snapshot_service.py:52-96` `snapshot_ssp`：凍結後把 `status` 改成 `frozen`。**套件內沒有任何地方檢查「凍結的 SSP 不能改」**——`ssp_service.update_*` 都不看 status。確認主專案有沒有擋（第 7 節）。
- `metadata_clone_service.py:58-129`：複製角色／人員／資源時，人員的 `email_addresses`、`telephone_numbers` 等個資會一起複製到新的 SSP。確認這是預期行為。

**V2（稽核計畫、結果、改善計畫）🔴**
- 🔴 `assessment_risk_service.py:109-125` `link_findings(risk_id, finding_ids)`：**逐一把判定編號接到風險上，完全不驗判定屬於哪份稽核結果**。底下 `ar_finding_risk_repo_impl.py:42-55` `link()` 也只查 `(finding_id, risk_id)`。唯一呼叫端是 `jedi-compliance-audit` 的 `assessment_result_app_service.py:792`，它傳入的 `desired` 是從 uid 轉出來的——**轉換時有沒有限定在本輪的稽核結果底下，是 FR-116 C3 的範圍**；本棒只要確認套件這支「完全信任呼叫端」的形狀，並在報告寫明「兩側要一起看」。
- `assessment_risk_service.py:132-143` `get_risk_findings`：用 `get_by_id(link.finding_id)` 撈判定，不驗判定跟風險在同一份結果。如果 `link_findings` 被塞進別份結果的判定，這支就會把它讀出來。
- 🔴 `poam_service.py:126-144` `update_poam_item(item_id, **fields)`：只收編號、`**fields` 全部照寫。**主專案與 compliance-audit 零呼叫者**，目前打不到；確認後列「零呼叫者但對外公開」。
- `remediation_service.py:49-94` `upsert_remediation`：用 `risk_id` 限定範圍再比 uuid，形狀對。但 `**fields` 逐欄照寫——確認呼叫端（compliance-audit `poam_app_service.py:317`、`assessment_result_app_service.py:661`）傳的是白名單。
- `assessment_plan_service.py:113-215` 三支 `set_*`：用 `ap_uid` 找計畫、再整批刪掉舊的子物件重建。刪除時用 `delete_by_id(existing.id)`，`existing` 來自 `get_by_ap(ap.id)`，**有範圍**。確認 `ap_uid` 由呼叫端驗過歸屬。
- `poam_milestone_repo_impl.py:31-39` `get_by_assignee(user_id)`：**只用使用者編號、沒有任何文件範圍**，會回「這個人在所有客戶、所有改善計畫裡被指派的里程碑」。盤點時**找不到任何呼叫者**，列「零呼叫者」。

**V3（匯入匯出）🔴**
- **大小與形狀的防線在哪一層**：`import_ssp`（`:321-430`）收的是**已經是 Python 字典**的 `oscal_ssp`，本身沒有 `json.loads`。字典的來源只有兩條：主專案 Word 匯入（`ssp_docx_import_app_service.py`，上傳限 20MB，`:50`）與 Excel 匯入（`ssp_excel_import_app_service.py`，限 10MB，`:47`），全站請求上限 50MB（`config/config.py:105`）。**所以「從外面丟一份超深的 OSCAL JSON 進來」這條路目前不存在**；要看的是：Word／Excel 解析出來的字典裡，陣列長度（例如 5 萬個元件）有沒有上限、`import_ssp` 逐筆 `add()` 會不會把資料庫卡住。
- 🔴 **更新模式用「自然鍵」認定同一筆**（docstring `:356-365`）：人員用 `(metadata_id, type, name)`、元件用 `(system_implementation_id, title, type)`。因為範圍都帶了 `metadata_id`／`system_implementation_id`，**跨 SSP 覆蓋應該不可能**；但人員的 `uuid` 是**照檔案裡寫的直接存**（`:984` `uuid=p.get("uuid") or self._gen_uuid()`），oscal 區的 `parties.uuid` 只有索引、**沒有唯一限制**。掃描要確認：同一個 uuid 出現在兩份 SSP 時，主專案有沒有任何地方「用 party uuid 找人」而會找到別份 SSP 的那一筆（主專案 `ResponsiblePartyEntity` 就是用 `party_uuid` 串起來的，見 CLAUDE.md「OSCAL Parties」段）。
- `clear_ssp_body`（`:281-319`）：刪掉整份 SSP 的內容。參數是 SSP 數字編號，由呼叫端決定。確認兩個呼叫端（`ssp_excel_import_app_service.py:478`、`ssp_docx_import_app_service.py:384`）的編號都來自已驗權的範本。
- `export_oscal`（`:229-251`）與四個 `_export_*`：用 uid 撈根文件，然後**所有子物件都用根文件的數字編號往下撈**，範圍是對的。入口 `oscal_export_app_service.py` 只給了目錄匯出（`api/oscal/routes/framework/oscal_framework_version_route.py`，短效憑證綁版本 uid，FR-113 O8b 已看過）；**SSP／稽核結果／改善計畫的 JSON 匯出在主專案目前沒有網址入口**，列「有能力、沒入口」。
- **交易（transaction）**：套件自己不開交易、由呼叫端的 `@transaction` 包住，所以整份匯入是**一個交易**，中途出錯會整份撤回。確認兩個呼叫端都沒有在 `import_ssp` 中間自己 commit。
- 錯誤處理：套件一律 `raise ValueError(f"... id={...!r}")`，訊息帶內部數字編號。確認主專案有沒有把 `str(e)` 回給前端（`ssp_excel_import_app_service.py:621` `msg = str(e)` 要看去向）。

**V4a（目錄、基準線、框架）**
- `oscal_clone_service.py:90-315` 目錄樹與基準線複製：群組、控制項、部分、參數四層，每層複製後用 `get_by_id(new_id)` 讀回、改 `parent_id` 再 `update`。確認「舊編號 → 新編號」的對照表有涵蓋所有層，沒有子物件的 `parent_id` 還指向**來源目錄**的群組。
- `framework_service.py:61-120`：`delete_framework(uid)`、`delete_version(uid)`、`publish_version(uid)` 只收 uid。框架是**全平台共用資源**（主專案 `framework_app_service.py:202` 有 `require_platform_admin()`），確認所有呼叫端都有這道檢查（FR-113 O8b 掃過呼叫端，本棒確認套件側沒有第二個入口）。
- `profile_resolution_service.py`：基準線「從哪份目錄挑控制項」的來源欄位 `source_catalog_id`。確認這個欄位只由伺服器端的複製流程寫入，沒有從使用者輸入寫入的路徑。
- `catalog_service.py:161-163` `list_catalogs()`、`framework_service.py:50-54` `list_frameworks()`：不帶條件＝回全部。目錄與框架**本來就是全平台共用**，預期不是洞。

**V4b（CMMC PDF／Excel 解析器）**
- `cmmc2_lv2_parser_adapter.py:232-268` `pdfplumber.open(pdf_stream)` 後**逐頁**讀文字與字型資訊。頁碼範圍由 `base_parser_adapter.py:37-58` `_resolve_pages` 算，**有夾在總頁數之內**，但沒有「總頁數上限」。確認主專案 `framework_parse_job_route.py:81-83` 只量了檔案大小、**沒有擋大小**（只記錄），唯一的上限是全站 50MB。一份 50MB、上萬頁的 PDF 會解析多久要實測或估算。
- 錯誤訊息：套件解析器本身沒有 `try/except`，例外直接往上丟；主專案 `framework_parse_job_service.py:226-228` 寫成 `f"{error_message}: {e}"`——**這就是總表第 132 項**，已知、不重報，本棒只確認套件丟出來的例外內容（pdfplumber／pandas 的例外會不會帶暫存檔路徑）。
- `excel_parser`（`:450-472`）走 `pandas.read_excel`：openpyxl 在主專案環境會自動用 `defusedxml` 防 XML 炸彈（第 6 節）；但**套件自己的 `pyproject.toml` 沒有宣告 `defusedxml`**，換個主機環境防護就會消失。
- `parser_adapter_type.py:23-27` 宣告了 `YAML`、`JSON` 兩種匯入類型，**全套件沒有任何一處實作**；`pyproject.toml` 也宣告了 `pyyaml` 依賴卻零 import。確認不是「預留入口、某天被接上 `yaml.load`」的地雷（第 7 節）。

**W1（Word 內容抽取器）**
- `docx_parser_core.py:170`、`:192` `Document(file_path)`：python-docx 打開 Word 檔（Word 檔本質是 zip 壓縮檔裝一堆 XML）。python-docx 的 XML 解析器已設 `resolve_entities=False`（不解析外部實體），**XML 外部實體攻擊不成立**；但**解壓縮沒有大小上限**——一個 20MB 的 Word 檔解壓後可以是好幾 GB（壓縮炸彈）。確認主專案有沒有在打開前看 zip 內各檔的解壓大小。
- `framework_patterns.py`：四組 regex 都是固定長度的數字與字母組合，**盤點時目測沒有「巢狀重複」這種會卡死的寫法**（例如 `(a+)+`），但 `iso27001` 那組 `A\.\d+(\.\d+)*` 有一層重複，請研究員用超長 `A.1.1.1.1…` 字串實測。
- `control_id_matcher.py:50-120` `fuzzy_match_control`：每一段文字都拿去跟**所有候選控制項**做模糊比對，耗時是「段落數 × 控制項數 × 文字長度」。CMMC L2 有 110 項，一份幾千段的 Word 可能要跑很久。確認有沒有段落長度或段落數上限。
- `docx_section_extractors.py` 892 行：逐表格、逐儲存格讀文字。確認合併儲存格、巢狀表格這種特殊結構不會讓迴圈爆掉。

**W2（CMMC 轉接器與中介格式）**
- `cmmc_ssp_adapter.py` 把中介格式轉成 `import_ssp` 吃的 OSCAL 字典。🔴 要確認字典裡的 `uuid`（人員、元件、實作項目）是**轉接器自己產生**，還是**沿用 Word 內容**——如果 Word 裡能寫 uuid 而轉接器照抄，就接上 V3 講的「同一個 party uuid 出現在兩份 SSP」。
- 轉接器輸出的 `component-uuid` 決定 by-component 掛在哪個元件底下（V3 `:1342-1397` 用 `comp_map` 查，**查不到就跳過**，範圍對）。確認轉接器不會輸出「別份 SSP 的元件 uuid」。
- `adapter_registry.py`：框架代號 → 轉接器。代號來自使用者選的框架，確認查不到時是回錯誤而不是落到某個預設轉接器。

---

## 4. repo 方法範圍條件對照表

「範圍條件」＝除了編號之外，查詢有沒有再加上「而且要屬於某份文件」。

### 4.1 45 支 repo 實作自己寫的查詢方法：全部有範圍

盤點時逐支讀過 45 支 `*_repo_impl.py`。**自己寫的方法共 41 個，每一個的查詢條件都有上一層的編號**：

| 文件類型 | 方法（查詢條件） |
|---|---|
| SSP（13 支 repo） | `get_by_ssp(ssp_id)` ×3、`get_by_system_implementation(system_implementation_id)` ×4、`get_by_system_characteristics(system_characteristics_id)` ×2、`get_by_control_implementation(control_implementation_id)`、`get_by_implemented_requirement(implemented_requirement_id)` ×2、`get_by_statement(statement_id)` |
| AP（8 支） | `get_by_ap(assessment_plan_id)` ×5、`get_included(assessment_plan_id)`、`get_roots(assessment_plan_id)`、`get_children(parent_id)`、`get_by_task(task_id)` ×2、`delete_by_task(task_id)` ×2 |
| AR（8 支） | `get_by_ar_result(ar_result_id)` ×4、`get_by_target(ar_result_id, …)`、`get_by_assessment_result(assessment_result_id)`、`get_by_risk(risk_id)` ×2、`get_by_finding(finding_id)`、`link／unlink(finding_id, risk_id)` |
| POA&M（3 支） | `get_by_poam(poam_id)`、`get_by_remediation(remediation_id)`、🟡 `get_by_assignee(user_id)`、`get_by_uid(uid)` |
| 目錄、基準線（5 支） | `get_by_catalog(catalog_id)`、`get_roots(catalog_id)`、`get_children(parent_id)`、`get_enhancements(parent_id)`、`get_by_control(catalog_control_id)`、`list_aos(catalog_control_id)`、`get_by_profile(profile_id)` |
| 框架、metadata（6 支） | 無自訂方法 |

例外兩支：
- 🟡 `PoamMilestoneRepoImpl.get_by_assignee(user_id)`（`poam_milestone_repo_impl.py:31-39`）：只用使用者編號、跨所有文件。**零呼叫者**。
- 🟡 `PoamRepoImpl.get_by_uid(uid)`（`poam_repo_impl.py:21-28`）：只用 uid，是根文件的查法，**唯一呼叫者是 V3 的 `_export_poam`**，而那條匯出目前沒有網址入口。

### 4.2 🔴 從基底類別繼承、只有編號的方法（45 支 repo 全部都有）

每一支 repo 都繼承 `jedi_common` 的 `BaseRepositoryImpl`，自動多出這些方法（`jedi-common/jedi_common/session/database/repository/base_repository_impl.py`）：

| 方法 | 位置 | 查詢條件 |
|---|---|---|
| `get_by_id`／`get_by_uid` | `:356-383` | 只有編號 |
| `update(entity)` | `:406-427` | 只有 `entity.uid` 或 `entity.id` |
| `delete_by_id`／`delete_by_uid` | `:429-443` | 只有編號 |
| `delete_by_ids`／`delete_by_uids` | `:445-453` | 只有編號清單 |
| `get_all_by_fields(query)` | `:286` | 查詢條件的非空欄位；**全空＝整張表** |

這是全專案共用的基底、不是本套件的錯，**本身不是發現**。重點是**本套件的服務層把這些方法原封不動開放出去**：

| 套件服務方法 | 做什麼 | 主專案／compliance-audit 呼叫端 | 呼叫端有沒有先找父層 |
|---|---|---|---|
| `SspService.update_component`／`delete_component` | 改／刪元件 | `ssp_components_app_service.py:123,135`、`ssp_resources_context_service.py:106,111`、`module_frame_components_service.py:115,125` | ✅ 抽查第一支有 `_resolve(ssp_id, uid)`；其餘 V1 確認 |
| `update_inventory_item`／`delete_inventory_item` | 改／刪資產清單 | `ssp_inventory_items_app_service.py:121,130`、`module_frame_inventory_service.py:116,125` | V1 確認 |
| `update_leveraged_authorization`／`delete_…` | 改／刪沿用授權 | `ssp_leveraged_app_service.py:125,134`、`module_frame_leveraged_service.py:127,134` | V1 確認 |
| `update_party`／`delete_party` | 改／刪人員 | `ssp_party_app_service.py:108,118`、`module_frame_party_service.py:132,141` | V1 確認 |
| `update_implemented_requirement` | 改控制項實作 | `ssp_control_implementation_service.py:90`、`module_frame_control_default_service.py:209,223` | V1 確認 |
| `update_statement` | 改實作敘述 | `ssp_control_implementation_service.py:860,878`、`module_frame_control_objective_default_service.py:223,240` | V1 確認 |
| `update_ssp`、`delete_implemented_requirement`、`delete_statement`、`update_by_component`、`delete_by_component`、`get_component`／`get_inventory_item`／`get_leveraged_authorization`／`get_implemented_requirement`／`get_statement`／`get_party` | | **零呼叫者** | — |
| `AssessmentRiskService.update_risk`／`delete_risk` | 改／刪風險 | compliance-audit `assessment_result_app_service.py:747,765` | FR-116 C3 範圍 |
| 🔴 `AssessmentRiskService.link_findings` | 判定接到風險 | compliance-audit `assessment_result_app_service.py:792` | FR-116 C3 範圍；**套件側完全不驗** |
| `AssessmentRiskService.get_risk_findings` | 讀風險的判定 | **零呼叫者** | 跟著 `link_findings` |
| `PoamService.get_poam(poam_id)` | 讀改善計畫 | compliance-audit（FR-116 C3） | |
| 🔴 `PoamService.update_poam_item(item_id, **fields)` | 改改善計畫條目 | **零呼叫者** | — |
| `FrameworkService.delete_framework`／`delete_version`／`publish_version`／`update_*` | 框架增刪改 | `framework_app_service.py` 等（FR-113 O8b 已掃） | 平台管理員檢查 |
| `OscalIoService.clear_ssp_body(ssp_id)` | 清空整份 SSP 內容 | `ssp_excel_import_app_service.py:478`、`ssp_docx_import_app_service.py:384` | V3 確認 |
| 各服務 `get_ssp`／`get_ap`／`get_ar`／`get_catalog`／`get_profile`／`get_framework`（by uid） | 讀根文件 | 多處 | 根文件本來就只能用 uid 找，由呼叫端決定能不能看 |

**標紅統計**：
- 套件 repo 自己寫的方法：**0 支**只有編號沒範圍（41 支都有範圍；2 支例外都零呼叫者或無網址入口）。
- 套件服務層把「只有編號」的讀／改／刪開放出去：**33 支**（`ssp_service` 21、`assessment_risk_service` 4、`poam_service` 2、`framework_service` 5、`oscal_io_service.clear_ssp_body` 1）。其中**有活呼叫端的 20 支**要逐一確認呼叫端先找父層；**零呼叫者 13 支**列第 7 節清理建議。另外 `remediation_service.upsert_remediation` 有範圍，但 `**fields` 全開（第 3 節②）。
- 最該先看：🔴 `link_findings`（套件完全不驗、要跟 FR-116 C3 兩側對看）、🔴 `update_poam_item`（零呼叫者但 `**fields` 全開）。

---

## 5. 資料庫隔離對照表

對照來源：①出貨版 `scripts/init/02-schema.sql`（靜態快照）；②DEV 實查（唯讀 SELECT，**2026-09-24 12:49**，`cm_app` 帳號）。

### 5.1 本套件的 45 張表

| 群組 | 表 | 客戶欄位 | 出貨 02-schema | DEV |
|---|---|---|---|---|
| 共用 | `metadata`、`parties`、`roles`、`resources` | 無 | 關 | 關 |
| 框架 | `frameworks`、`framework_versions` | 無 | 關 | 關 |
| 目錄 | `catalogs`、`catalog_groups`、`catalog_controls`、`catalog_control_parts`、`catalog_control_params` | 無 | 關 | 關 |
| 基準線 | `profiles`、`profile_imports` | 無 | 關 | 關 |
| 🔴 SSP | `system_security_plans`、`ssp_system_characteristics`、`ssp_information_types`、`ssp_diagrams`、`ssp_system_implementations`、`ssp_system_users`、`ssp_components`、`ssp_inventory_items`、`ssp_leveraged_authorizations`、`ssp_control_implementations`、`ssp_implemented_requirements`、`ssp_statements`、`ssp_by_components` | 無 | 關 | 關 |
| 🔴 稽核計畫 | `assessment_plans`、`ap_reviewed_controls`、`ap_assessment_subjects`、`ap_assessment_activities`、`ap_local_objectives`、`ap_tasks`、`ap_task_subjects`、`ap_task_participants` | 無 | 關 | 關 |
| 🔴 稽核結果 | `assessment_results`、`ar_results`、`ar_assessment_subjects`、`assessment_observations`、`assessment_findings`、`assessment_risks`、`assessment_finding_risks`、`assessment_remediations` | 無 | 關 | 關 |
| 🔴 改善計畫 | `poams`、`poam_items`、`poam_milestones` | 無 | 關 | 關 |

**45 張：客戶欄位 0 張、隔離開啟 0 張、規則 0 條。**

### 5.2 oscal 區其他 7 張表（不是本套件建的，列出來對照）

DEV 的 oscal 區共 52 張表，開了隔離的**只有 5 張**，全部是主專案的解析工作紀錄表，而且**只有這 5 張有客戶欄位**：

| 表 | 客戶欄位 | 隔離 |
|---|---|---|
| `framework_parse_jobs`、`ap_docx_parse_jobs`、`ar_xlsx_parse_jobs`、`ssp_docx_parse_jobs`、`ssp_excel_parse_jobs` | 有 | 開 |
| `ssp_reference_documents`、`ssp_reference_document_mappings`（compliance-audit 的程序書，FR-116 已列） | 無 | 關 |

### 5.3 白話結論

- **這支套件管的所有合規文件內容，在資料庫層完全沒有客戶分隔**。A 公司的 SSP、稽核判定、改善計畫與 B 公司的放在同一張表、沒有欄位標示屬於誰。能不能跨客戶讀寫，**百分之百取決於程式層有沒有先從「有隔離的表」（例如專案表）往下找**。
- 這就是總表第 128 項，**本批不重報**；列在這裡是讓 V1～V3 的研究員知道：只要發現任何一條「使用者能直接指定 SSP／稽核結果編號」的路，**沒有第二道防線**。
- 共用區（目錄、框架、基準線）沒有隔離是**設計**：這些是全平台公版。但注意「資源庫套用框架」會把公版目錄**複製一份給客戶**（V4a `clone_catalog_tree`），複製出來的那份跟公版在同一張表、同樣沒有客戶欄位——**無法從資料庫區分哪份是公版、哪份是某客戶的副本**。

---

## 6. XML 支援確認

**結論：OSCAL XML 格式未支援，套件層 XML 攻擊面不存在。交接文件寫的「XML 解析攻擊」在本套件不成立。**

查證（2026-09-24，jedi monorepo `eafc7ae5`）：

```bash
cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2
grep -rnE "import (xml|lxml|defusedxml|yaml)|from (xml|lxml|defusedxml|yaml)|etree|ElementTree|minidom|sax" jedi_oscal_v2 --include='*.py'
# → 0 筆
grep -rniE "xml|yaml" jedi_oscal_v2 | grep -v Binary
# → 只有 common/code/parser_adapter_type.py:26  YAML = "YAML"（一個沒有實作的常數）
```

| 項目 | 結果 |
|---|---|
| 套件正式碼 import XML 解析器 | **0 支**（xml、lxml、defusedxml、ElementTree、minidom、sax 全無） |
| 套件正式碼 import YAML | **0 支**。但 `pyproject.toml` 宣告了 `pyyaml` 依賴（沒用到的依賴） |
| `oscal_io_service.py` 支援的格式 | 匯出只有 JSON（`:239-240`，其他格式直接 `raise ValueError`）；匯入收的是 Python 字典，不解析任何文字格式 |
| `parser_adapter_type.py` 的 `ImportType` | 宣告了 `PDF`／`EXCEL`／`YAML`／`JSON`，**只有 PDF、EXCEL 有實作**（`catalog_service.py:118-155`） |

**真正會碰到 XML 的地方（都不在套件、而且都已有防護）**：

| 在哪 | 用什麼讀 | XML 防護 | 還剩什麼風險 |
|---|---|---|---|
| 主專案 Word 匯入（W1 `docx_parser_core.py:170,192`） | python-docx → lxml | python-docx 建解析器時設 `resolve_entities=False`（`.venv/.../docx/oxml/parser.py:19`），外部實體不會被解析 | **壓縮炸彈**（Word 是 zip，解壓沒上限）、段落數與模糊比對耗時 |
| 套件 CMMC Excel 匯入（V4b `excel_parser`） | pandas → openpyxl | openpyxl 偵測到 `defusedxml` 就自動使用（`openpyxl/xml/__init__.py:29-42`）；主專案 `pyproject.toml:93` 有宣告 `defusedxml>=0.7.1` | 套件自己沒宣告 `defusedxml`，**防護依賴主機環境**；壓縮炸彈 |
| 套件 CMMC PDF 匯入（V4b） | pdfplumber（pdfminer） | 不是 XML | 頁數沒上限、惡意 PDF 物件結構 |

**掃描重點因此換成**：①V3——Word／Excel 解析出的字典，陣列長度、字串長度有沒有上限；②W1／V4b——Word／Excel 的 zip 壓縮炸彈、PDF 頁數；③沒有任何地方把 JSON 當輸入的「深度炸彈」，因為**目前沒有 OSCAL JSON 匯入入口**（主專案只有三處 `json.loads`，都是解析表單裡的小欄位：`ssp_excel_import_route.py:48`、`ssp_scoped_excel_import_route.py:55`、`framework_parse_job_route.py:74`，受全站 50MB 上限約束）。

---

## 7. 盤點時發現、需要首腦裁決的事

1. **`app/flow_control/service/assessment_plan_app_service.py` 沒被任何一棒掃過**：它呼叫本套件的 `generate_draft`（`:313`）與 `clone_metadata`（`:354`，覆核子輪複製稽核計畫用）。FR-113 十棒、FR-116 盤點的主專案清單都沒有它。它不是 Word 解析器，本批不收。要歸 FR-116（稽核計畫相關）還是另開，由首腦決定。
2. **零呼叫者但對外公開的套件方法 13 支**（第 4.2 節）：`update_poam_item`（`**fields` 全開）、`get_risk_findings`、`ssp_service` 的 `update_ssp`／`delete_implemented_requirement`／`delete_statement`／`update_by_component`／`delete_by_component` 與六支 `get_*`；另加 repo 層的 `get_by_assignee`（跨全部客戶）。現在打不到，**哪天有人接上網址就是現成的「只認編號」**。建議併入 FR-092（死碼清理）後續，或在套件裡改成要求父層編號的版本——這是清理建議，不是資安發現。
3. **套件宣告了 `pyyaml` 依賴與 `ImportType.YAML`／`JSON` 常數，但零實作**。如果未來要做 YAML 匯入，**必須用 `yaml.safe_load`**（`yaml.load` 可以執行任意程式）。建議拿掉 `pyyaml` 依賴，或在常數旁寫明這個陷阱。
4. **套件自己沒宣告 `defusedxml`**：Excel 解析的 XML 防護目前靠主專案環境剛好有裝。要不要在套件 `pyproject.toml` 補宣告，由首腦決定（改套件要走發版流程）。
5. **凍結的 SSP 在套件層沒有「不可修改」的保護**（V1）：`snapshot_ssp` 把 status 設成 `frozen`，但 `ssp_service.update_*` 都不看 status。主專案有沒有擋要等 V1 確認；如果沒有，這是**稽核證據可被事後竄改**的問題，嚴重度會比一般的越權高。
6. **派工順序建議**：V2（`link_findings` 要跟 FR-116 C3 對看，C3 已派）→ V1 → V3 → W2 → W1 → V4b → V4a。V4b、W1、W2 建議同一個 runner 連做（第 2 節）。

---

## 附錄 A：不掃的宣告檔（294 支／8,735 行）

驗證指令（套件側）：
```bash
cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2/jedi_oscal_v2
# entity：0 個方法、0 個 if／for
grep -rnE "    def |^\s+(if|for)\b" domain/entity | wc -l          # → 0
# model：0 個方法、驗證器、事件監聽
grep -rnE "def |@validates|event\.listen|hybrid|column_property" infra/model | wc -l   # → 0
# mapper：除了「model 是 None 就回 None」之外沒有任何 if／for／try
grep -rnE "^\s+(if|for|while|try)\b" infra/mapper | grep -v "is None" | wc -l        # → 0
grep -rn "json\|loads" infra/mapper | wc -l                         # → 0
# repository 介面：抽象宣告，沒有實作
grep -rnE "^\s+(if|for)\b" domain/repository | wc -l                # → 0
```

| 目錄 | 檔數 | 行數 | 為什麼不掃 |
|---|---:|---:|---|
| `domain/entity/**` | 99 | 2,195 | `@dataclass` 純欄位宣告，沒有方法、沒有判斷 |
| `infra/model/**` | 54 | 2,921 | SQLAlchemy 欄位與索引宣告；沒有 `@validates`、沒有事件監聽、沒有計算欄位 |
| `infra/mapper/**` | 54 | 2,671 | `to_entity`／`to_model` 一欄一欄搬；唯一判斷是「傳入 None 回 None」；JSON 欄位由 SQLAlchemy JSONB 型別直接轉成 Python 物件，**mapper 沒有自己做 `json.loads` 或遞迴組裝**，所以不算判斷邏輯 |
| `domain/repository/**` | 54 | 802 | 抽象介面（含 9 支空 `__init__.py`） |
| `common/**` 常數 | 2 | 103 | 錯誤碼與列舉常數 |
| `jedi_oscal_v2/__init__.py` | 1 | 30 | 只有 docstring |
| 其他 `__init__.py` | 30 | 13 | |
| **合計** | **294** | **8,735** | |

⚠️ 例外提醒：`infra/model/base/oscal_party.py` 的 `uuid` 欄位**只有索引、沒有唯一限制**（`:17`、`:30`）。這不是判斷邏輯、本身不掃，但它是 V3「同一個 party uuid 出現在兩份 SSP」那條疑點的根據，V3 研究員要知道。

---

## 附：與交接說明的差異

- **主專案側**：卡片「7 檔／2,946 行」→ 實數 **9 支有內容的檔／2,986 行**（補 `framework_patterns.py` 19 行、`adapter_registry.py` 21 行）。
- **repo 實作支數**：卡片「infra/repository 54」→ 實際 `*_repo_impl.py` **45 支**，另 9 支是 `__init__.py`。
- **XML 攻擊**：交接寫「XML 解析攻擊」→ **確認不成立**（第 6 節），套件零 XML import、沒有 OSCAL XML 格式。
- **io service 的 `json.loads`**：卡片寫 io service「`import json`」並要求查「`json.loads` 之前有沒有看大小」→ 查證 io service **只用 `json.dumps`（匯出）**，匯入收的是字典、沒有 `json.loads`。大小防線在主專案上傳層（Word 20MB／Excel 10MB／全站 50MB）。
- **「54 支 repo 方法範圍條件」**：預期會找到一批「只有 uid」的 repo 方法 → 實際套件 repo **自己寫的 41 個方法全部有範圍**；問題形狀在**服務層把基底類別的只認編號方法原封不動開放出去**（第 4.2 節）。
- **FR-113 O1／O6 引用的 `ssp_reference_document_repo_impl.py`**：確認屬 `jedi-compliance-audit`，不在本套件。
