---
title: FR-118 V3 掃描報告——OSCAL 整份文件匯入匯出
---

# V3 掃描報告 — OSCAL 整份文件匯入匯出（CM-2126）

> 範圍：套件 jedi-oscal-v2 的 1 檔／1,660 行：`jedi_oscal_v2/app/service/io/oscal_io_service.py`。
> 掃描工具：Claude Code 官方 `claude-security` plugin，effort low，scoped 掃描。
> 基準 commit：套件 repo `eafc7ae511d5`（`feature/review`，工作區有平行 session 的未提交改動，stamp 標 dirty；範圍內這一支檔乾淨）。
> 驗證章：**verified，0 條**（研究員沒提出任何候選，面板沒有票可投）。只掃不修。

---

## 1. 一句話結論

**這一棒沒有新的資安問題。** 工具零發現；卡片點名的七件事，runner 逐條跨 repo 開檔追完，全部不成立，或是總表已登記的舊案。

- **人員 uuid**：查清了，**目前沒有任何入口能讓使用者直接交一份 OSCAL 字典進來**。Excel 那條 W2 沒追完的路，這次補追完：人員、元件、外部服務、設備、實作項目的 uuid 全部由伺服器產生。套件「給什麼 uuid 就存什麼」維持**地雷、不是洞**。
- **資料量**：寫入次數實測是「元件數 × 7」左右，一萬筆元件級的資料約十幾秒內寫完，碰不到 120 秒逾時；Excel 每張工作表又已有 5,000 列上限。不成立。
- **蓋掉別份 SSP**：套件本身範圍都對；真正的缺口是主專案「誰可以指定要蓋哪份範本」，就是總表第 123／125 項，不另計。

另外追出 **1 條非資安的真 bug（V3-1）**：專案 SSP「再匯入一次」時，人員、實作項目的 uuid 每次都被換成新的，外部服務與設備每次都多一份重複。不會跨份讀寫，但會讓舊的內部參照指到不存在的人。

---

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

這支檔是整個套件唯一的「整份進、整份出」：

```
Word／Excel 上傳 ─(W1/W2/Excel 解析)─→ 解析單 ─使用者按確認─→ import_adapter 組成 OSCAL 字典
    ─→ import_ssp（本檔）逐筆寫進 45 張沒有客戶隔離的表

資料庫裡的一份文件 ─→ export_oscal（本檔）─→ OSCAL JSON（給外部工具）
```

它是 45 張零隔離表的批次寫入口，所以要回答四件事：

1. 進來的字典有多大、有沒有人管。
2. 「更新模式」用什麼認定「這是同一筆」，能不能被拿去蓋掉別份 SSP 的資料。
3. 匯出會不會把不屬於這份文件的東西組進去。
4. 匯到一半失敗會不會留下一半。

背景：這 45 張表**沒有客戶欄位、沒有資料庫隔離**（總表第 128 項，盤點檔第 5 節；本棒 DEV 唯讀重查 `oscal.system_security_plans`、`oscal.parties` 兩張隔離仍為關，2026-09-24 18:58）。能不能跨客戶讀寫，百分之百看呼叫端有沒有先從有隔離的表往下找。

---

## 3. 掃到什麼：總覽

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度＋為什麼 | 來源 |
|---|---|---|---|---|---|---|
| V3-1 | **非資安真 bug**：專案 SSP 再匯入時，uuid 每次換新、外部服務與設備每次重複 | 人員的 uuid 被換掉，原本指著它的「外部服務提供者」變成指向不存在的人（DEV 604 筆外部服務有 17 筆提供者查無此人）；外部服務、設備清單每匯一次多一份 | 專案負責人對同一份專案 SSP 用 Word 或 Excel 再匯入一次（正常操作，不是攻擊） | 套件 `oscal_io_service.py:998-1000`（人員）、`:1292-1295`（實作項目）、`:1326-1329`（實作敘述）：更新時已配對到舊列就**一律沿用舊 uuid**，不看字典有沒有帶；`:1183-1235` 外部服務與設備改用自然鍵（標題／描述）配對，或由主專案在組字典前先套上現有 uuid | **不是資安**：不跨份、不跨客戶、沒有權限繞過；是資料正確性問題 | runner 開檔追出、**未經三人面板投票**；DEV 唯讀佐證 |

**工具報的：0 條。** 研究員讀完整支檔、密鑰專項也跑完，兩者都沒有提出候選。

---

## 4. 工具報的（經三人面板投票）

無。零發現**不等於**乾淨，下一節是 runner 照卡片逐條追的結果。

---

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

### 5.1 🔴 有沒有「使用者能直接給 OSCAL 字典」的入口：沒有（下次不用重查）

這一條依首腦補充改問：套件信任呼叫端給的 uuid（`:982` `uuid=p.get("uuid") or self._gen_uuid()`），**只有在使用者能把 uuid 放進字典時**才是洞。追了全部呼叫端：

**① `import_ssp` 只有兩個呼叫者，都在主專案。**

- `app/oscal/service/ssp_docx_import_app_service.py:361`、`:385`、`:509`（Word）。
- `app/oscal/service/ssp_excel_import_app_service.py:614`（Excel）。
- 其他 jedi 套件（含 compliance-audit）零呼叫。
- 主專案**沒有任何網址收 OSCAL JSON 匯入**：唯一接到本檔的網址是框架版本的目錄下載（見 5.4），是匯出。

**② 兩條路組字典的地方，uuid 全部是伺服器產生。**

- Word：W2 已查（`docx_to_oscal_ssp.py:41／69／97`）。
- **Excel（W2 沒追完的，這次補完）**：
  - `excel_to_oscal_ssp.py:36`（組織）、`:51`（個人）、`:84`（實作項目）、`:98`（實作敘述），全部 `_common.gen_uuid()`。
  - 共用層 `_common.py`：
    - 元件：`:281`、`:331`；
    - 外部服務與它的提供者：`:401-404`；
    - 設備：`:466`、`:497`；
    - 實作說明掛點：`:579`。
  - 全部是 `gen_uuid()`＝`uuid.uuid4()`（`:24-25`）。

**③ 使用者在確認時能改的東西，改不到人員 uuid。**

Excel 確認時的 `content_overrides` 比 Word 寬：`_apply_content_overrides`（`ssp_excel_import_app_service.py:694-695`）對六張工作表的任一列可以蓋**任意欄位**（`rows[idx][k] = v`）。逐一看它能影響什麼：

| 使用者塞的欄位 | 到了組字典那一層 | 結論 |
|---|---|---|
| `uuid`（任何一張表） | 組字典時不讀這個鍵，一律 `gen_uuid()` | 改不到 |
| 人員的 `matched_user_id` | 存成人員的 `matched-user-id` 屬性（`_common.py:121-123`） | 跟手動編輯人員（`module_frame_party_service.py:317-319`）是同一種能力；讀回時查 `users` 表，而 `users` 表 DEV 隔離為開，反查不到別家 |
| 設備的 `implemented_components` 塞成 `[{"component-uuid": 任意值}]` | `_resolve_implemented_components` 原樣放行（`_common.py:519-520`） | 跟手動編輯設備（`ssp_inventory_items_app_service.py:91`）同一種能力；讀回時只在**本份 SSP 的元件清單**裡對標題（`module_frame_inventory_service.py:134-135`），對不到就顯示空白，讀不到別份 |

**④ 就算 uuid 重複，也沒有地方「用 uuid 全表找人」。**

- 主專案每一支「用 uuid 找人員／元件／外部服務／設備」的程式，都先列出**本份** SSP 的清單再比對：
  - `ssp_party_app_service.py:136-140`；
  - `module_frame_party_service.py:166-170`；
  - `ssp_components_app_service.py:140`；
  - `ssp_leveraged_app_service.py:139`；
  - `ssp_inventory_items_app_service.py:182`；
  - 以及 module_frame 同名四支。
- 全庫 grep 沒有 `PartyQueryEntity(uuid=…)` 這種全表查詢。
- DEV 唯讀：`oscal.parties` 裡「同一個 uuid 出現在兩份以上 metadata」＝ **0 筆**。

**結論**：地雷還在，但目前沒有引信。將來如果開放「上傳 OSCAL JSON」，要在組字典前把使用者給的 uuid 全部換掉，或在套件 `import_ssp` 一律自產。

### 5.2 陣列長度沒上限、逐筆寫入會不會卡住資料庫：不成立（下次不用重查）

套件確實**不管大小**：`import_ssp` 對每個陣列逐筆 `add()`，而共用底層 `BaseRepositoryImpl.add` 每筆都 `flush()`（`jedi_common/.../base_repository_impl.py:401-403`），等於每筆一次資料庫來回。所以做了三組實測：

**① 寫入次數（假資料庫，只數次數）**：一份字典放 N 個人員、N 個元件、N 個設備、N 個實作項目（每項各一個實作敘述、兩個說明掛點）。

| N | 模式 | 寫入次數 | 讀取次數 | 純 Python 耗時 |
|---|---|---|---|---|
| 1,000 | 建立 | 7,003 | 4 | 0.01 秒 |
| 5,000 | 建立 | 35,003 | 4 | 0.06 秒 |
| 20,000 | 建立 | 140,003 | 4 | 0.34 秒 |
| 20,000 | 更新 | 140,003 | 60,009 | 6.5 秒（假資料庫每次回全部列，真資料庫只回同層幾筆，實際會更快） |

寫入次數是線性的，沒有平方級。

**② 單筆成本（DEV 本機，暫存表，最後 ROLLBACK）**：

- 純來回：0.05 毫秒；
- 逐筆 `INSERT … RETURNING`：0.07 毫秒；
- 照套件的寫法「ORM `add` 再 `flush`」：**0.11～0.12 毫秒**，2 萬筆 2.3 秒。

**③ 換算**：14 萬筆寫入約 16 秒。資料庫在另一台機器、來回慢十倍，也在 120 秒逾時的量級附近，而且這還是 N＝2 萬的極端值。

**實際上 N 到不了 2 萬**：

- **Excel**：每張工作表讀到第 5,000 列就停（`excel_parser/sheet_handlers.py:115-118` `max_row_cap=5000`），上傳限 10MB（`ssp_excel_import_app_service.py:47`）。
- **Word**：一份能產出幾萬個控制項或元件的 Word，在解析階段就先被總表第 173 項那六個慢點卡到逾時了。

**結論**：大小的防線在主專案那一層（每表 5,000 列＋解析逾時），套件這層沒有、也不需要另加。若將來開放 OSCAL JSON 匯入，要在那個入口自己限陣列長度。

### 5.3 `clear_ssp_body` 的 SSP 編號是誰給的：套件範圍對，缺口在主專案＝總表第 123／125 項（既有案，不另計）

**現況**：既有案，不另計；V3-1 再匯入換新編號已修（M11-39，FR-114 CM-2185，套件 commit `1d3a59c8`，1.21.0 出貨）

`clear_ssp_body`（`:281-319`）只刪這份 SSP 的三大塊，加上這份 metadata 底下的人員與角色：

- `get_by_ssp(ssp_id)`；
- `PartyQueryEntity(metadata_id=…)`；
- `RoleQueryEntity(metadata_id=…)`。

子表靠外鍵連鎖刪除，**不會刪到別份**。

兩個呼叫端的編號來源：

- Excel `ssp_excel_import_app_service.py:464-478`：`job.source_uid` → `get_resource_library` → `ssp_template_id`。
- Word `ssp_docx_import_app_service.py:334-337` → `:384`：`job.source_uid or payload.get("source_uid")` → 同上。

`get_resource_library` 只問「存在嗎」，不問「你能不能改」。資源庫表 DEV 隔離為開，但讀取規則放行原廠公版（`scope='SYSTEM'`）與母公司分享（`SHARED`），所以讀得到的範圍包含不屬於你的範本。這就是：

- **總表第 123 項**（Excel，`_verify_source_exists` 與 `_confirm_update_module_frame` 無守門）；
- **第 125 項**（Word，含「確認時再換一次目標編號」）。

本棒補一句「套件丟出來的長什麼樣」：套件照單全收呼叫端給的編號、先清再建，**第二道防線不存在**。修法照第 123／125 項，在主專案補守門即可，套件不用動。

### 5.4 匯出會不會組進不屬於這份文件的東西：不會；SSP／稽核結果／改善計畫三種「有能力、沒入口」

**① 範圍都對。** 四支 `_export_*` 都先用 uid 撈根文件，之後所有子物件都用根文件的數字編號往下撈：

- 目錄：`:531-649`，`catalog.id`；
- SSP：`:652-670`，`ssp.id`／`ssp.metadata_id`；
- 稽核結果：`:1398-1417`，`ar.id` → 每個 result 的 `result.id` → risk 的 `risk.id`；
- 改善計畫：`:1557-1613`，`poam.id`。

`by-component` 的 `component-uuid` 用本份 SSP 的元件對照表反查（`:844-852`），沒有全表查詢。

**② 唯一有網址的是目錄下載**：`api/oscal/routes/framework/oscal_framework_version_route.py:118-143`，短效憑證綁版本 uid，FR-113 O8b 已看過。

**③ 另外三種沒有入口。** `OscalExportAppService.export`（`app/oscal/service/oscal_export_app_service.py:29`）能接四種文件，但主專案只有上面那一處呼叫它、固定傳 `'catalog'`。

它的檔頭註解寫「本期權限：jwt 認證即可；per-project 參與者授權為 follow-up」。**將來若有人為 SSP／稽核結果／改善計畫加匯出網址，不能直接接這支**——它不檢查文件屬於誰，而底下的表沒有隔離。記地雷，不另計。

**④ `build_ssp_snapshot`（`:257-278`）**：同樣以 SSP 編號往下組，呼叫端的編號來源同 5.3。預覽時把目標範本現有內容回傳給使用者，這是第 125 項「動手前還能先讀」那一半，既有案。

### 5.5 交易：整份一個交易，中途失敗整份撤回（下次不用重查）

- **套件沒有自己開交易**：全套件 grep `.commit()` 零筆；本檔也沒有。
- **兩個呼叫端的確認入口都有 `@transaction`**：
  - Excel `ssp_excel_import_app_service.py:302`；
  - Word `ssp_docx_import_app_service.py:303`。
  - 兩支檔全檔零 `.commit()`、零 `session_scope()`。
- **`clear_ssp_body` → `import_ssp` → 還原程序書關聯三步在同一個交易裡**。還原那支（`ssp_document_pool_service`）雖然也標了 `@transaction`，但共用底層在「已經在交易裡」時直接沿用、不另開（`jedi_common/.../db.py:230-241`）。
- **「範本已經有內容」的檢查排在任何寫入之前**（`:393-401`）。Word 那條接住這個錯誤、改走「清空再建」時，前面沒有半筆寫入要撤。

### 5.6 錯誤訊息：套件的內部編號沒有回到前端（下次不用重查）

套件一律 `raise ValueError(f"... id={...!r}")`，訊息帶內部數字編號，例如：

- `:389` `target ssp not found: id=…`；
- `:277`、`:534`、`:655`。

追三個接收點：

| 接收點 | 怎麼處理 `ValueError` | 前端看到什麼 |
|---|---|---|
| `ssp_excel_import_app_service.py:620-630` | `msg = str(e)` **只用來判斷**是不是「already has a body」，然後寫日誌 | 固定錯誤碼 `GRC_IMPORT_SSP_TEMPLATE_NOT_EMPTY` 或 `GRC_EXCEL_INVALID_FILE`，不帶原文 |
| `ssp_docx_import_app_service.py:363-366`、`:515-520` | 寫日誌 | 固定錯誤碼 `GRC_DOCX_PARSE_FAILED` |
| `oscal_export_app_service.py:43-45` | 寫日誌（`info`） | 固定錯誤碼 `GRC_EXPORT_DOC_NOT_FOUND` |

卡片提到的 `ssp_docx_import_app_service.py:220,226` 寫進解析單並回前端，是**解析階段**的例外（第 132／174 項同形、O4 範圍），不是本檔丟的。

### 5.7 `by_component` 掛在哪個元件底下：只會掛到本次寫入的這份 SSP 的元件（下次不用重查）

`_import_by_component`（`:1342-1395`）用 `comp_map.get(bc["component-uuid"])` 找元件編號，查不到就跳過、寫日誌。`comp_map` 只有三種來源，全部限定在本份 SSP 的系統實作 `new_si.id` 底下：

- 本次寫入的元件（`:1166`）；
- 同一筆元件的來源 uuid（`:1174-1175`）；
- 更新模式下這份 SSP 原本的元件（`:1179-1181`，來自 `get_by_system_implementation(new_si.id)`）。

別份 SSP 的元件 uuid 就算被塞進字典，也對不到，會被跳過。

### 5.8 整支檔通讀的其他觀察

- **更新模式的自然鍵**（`:362-372`）：每一種都帶父層編號。
  - 人員、角色：`metadata_id`；
  - 元件、外部服務、設備：`system_implementation_id`；
  - 實作項目：`control_implementation_id`；
  - 敘述：`implemented_requirement_id`；
  - 資訊類型：`system_characteristics_id`。
  
  「更新」時先用這些鍵在本份底下撈既有列，再把 `entity.id` 設成撈到的那筆。**不會跨份配對**。
- **共用底層的 `update` 只看 `id` 找列**（`base_repository_impl.py:413-417`，`entity.uid` 在這些實體上不存在時走 `id`）。本檔設進去的 `id` 全部來自上一行的「本份底下撈到的列」，沒有從字典讀 `id`。字典裡就算塞了 `"id": 123` 也不會被讀（本檔所有建構實體的地方都沒有 `.get("id")`，角色的 `r.get("id")` 是 OSCAL 的角色代號、存成 `role_id`）。
- **所有 JSON 欄位原樣存**（`props`、`links`、`remarks`、`responsible-parties` 等）：沒有深度或長度檢查。來源是主專案組好的字典，巢狀深度由組字典的程式決定、使用者控制不了結構；長度受 Excel 每格內容與 10MB 上限約束。匯出時原樣吐回，公式注入的問題在匯出 Excel 那端（第 126／148 項），不在 JSON 匯出。
- **`_poam_owned_query`（`:1621-1645`）用類別名稱字串判斷要建哪種查詢條件**：寫法脆弱（改名就壞），但三個呼叫都傳固定的 repo，不是輸入。不算問題。

---

## 6. V3-1 詳述（非資安真 bug）

**情境**：專案負責人在「專案 SSP」頁把同一份 Word 或 Excel 再匯入一次（`source_type='ssp'`，Model C，更新模式）。

**發生什麼**：組字典的那一層每次都產生全新的 uuid（5.1 ②）。套件更新模式配對到舊列之後，對 uuid 的處理不一致：

| 物件 | 配對用的鍵 | 配對到舊列時 uuid 怎麼處理 | 結果 |
|---|---|---|---|
| 元件 | 標題＋類型 | **一律沿用舊的**（`:1157-1162`，註解寫明是為了讓參照不斷） | ✅ 穩定 |
| 人員 | 類型＋名稱 | **字典有帶就換成新的**（`:999-1000` 只在字典沒帶時沿用） | ❌ 每次換新 |
| 實作項目、實作敘述、說明掛點、資訊類型 | 控制項代號／敘述代號／元件／標題 | 同人員（`:1294`、`:1328`、`:1391`、`:1092`） | ❌ 每次換新 |
| 外部服務、設備 | **uuid 本身**（`:1186`、`:1213`） | 新字典的 uuid 永遠對不到 | ❌ 每次**多一份** |

**後果**：

- **提供者對不到人**：外部服務的「提供者」存的是人員 uuid（`party_uuid`，軟參照、沒有外鍵）。人員 uuid 被換掉後，舊的外部服務指向不存在的人。DEV 唯讀（2026-09-24 19:0x）：604 筆外部服務有 **17 筆**提供者在整張人員表查無此 uuid。
  - 這個數字**混著 V1-1**（複製 SSP 時沒換參照）的效果，不能全算在這條上；
  - 但 V1-1 的指錯是「指到別份 SSP 的人」（人還在），查無此人更像是本條的形狀。
- **清單重複**：DEV 設備有 5 組、外部服務有 2 組「同一份 SSP 底下描述／標題相同」的重複列。DEV 目前**沒有任何一筆完成的專案 SSP 匯入**（兩張解析單表 `source_type='ssp'` 的 completed 都是 0），所以這些重複的來源無法證實是這條路。**本條以讀程式碼推論為主，DEV 無法直接重現。**
- **不影響資源庫範本**：範本的再匯入走「先清空再建立」（5.3），不走更新模式。

**為什麼不是資安**：全部發生在同一份 SSP 裡，沒有跨份讀寫、沒有權限繞過；觸發的是有權限的負責人做正常操作。

**修法方向**：

- 套件更新模式「配對到舊列就沿用舊 uuid」一律比照元件的寫法。
- 外部服務與設備改用自然鍵配對（外部服務：標題；設備：描述），跟其他物件一致。
- 或主專案在組字典前先套上現有 uuid。

建議修在套件，一處修好、Word 與 Excel 兩條都受益。

---

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

**「這幾條存在嗎」：高。**

- 零發現是工具讀完整支檔的結果。
- 七條卡片疑點每條都有檔名行號可查；5.2 有實測數字。
- V3-1 是開檔推論，加上 DEV 唯讀的間接佐證，DEV 無法直接重現，這點已在第 6 節講明。

**「只有這幾條嗎」：中偏高。**

- 本檔 1,660 行 runner 逐行通讀完。
- 呼叫端跨 repo 追到主專案兩支匯入服務的確認路徑、兩個組字典的轉接器、決策合併、匯出服務與唯一網址，以及其他 jedi 套件（零呼叫）。
- **打折處**：
  - 5.2 的實測是「假資料庫數次數＋DEV 本機量單筆成本」再乘起來，**沒有用真的 14 萬筆去打真的匯入流程**。本機資料庫與 Python 在同一台，來回比落地版（同機 docker）快或相當，換算值可能偏樂觀一個量級。
  - `import_diff/` 目錄沒有逐支通讀（FR-113 O9 範圍），只讀了跟 uuid 與決策合併有關的部分。

---

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

| 項目 | 值 |
|---|---|
| run ID | `wf_94fad4ad-8c6` |
| 報告目錄 | 套件 repo `CLAUDE-SECURITY-20260924-105441/`（不入版控） |
| stamp | `CLAUDE-SECURITY-REVISION-eafc7ae511d5-dirty.json` |
| verification.status | **verified**（`reason_kind` 無；候選 0，面板 0 票） |
| 形狀 | low：1 名研究員讀 1 檔＋1 次密鑰專項（focus=attack-surface） |
| 候選 → 通過 | 0 → 0 |
| agent 數／失敗 | 2／0（`journal.jsonl` 無 `failed`，沒撞看門狗） |
| 耗時 | 掃描約 2 分 20 秒；人工追查與實測另計 |
| 人工實測 | 假資料庫數寫入次數（N＝1k／5k／20k，建立與更新）；DEV 本機暫存表量單筆成本（5,000 次來回、3.5 萬筆 INSERT、2 萬筆 ORM add＋flush，全部 ROLLBACK） |
| DEV 唯讀實查 | 2026-09-24 18:58～19:08：oscal 兩表與資源庫表隔離狀態、`module_frames` 規則原文、人員 uuid 跨 metadata 0 筆、外部服務提供者查無 17／604、設備與外部服務同份重複 5／2 組、兩張解析單表完成的專案 SSP 匯入 0 筆；全程 `BEGIN READ ONLY … ROLLBACK` |

---

## 9. 待首腦裁決

1. **V3-1 要不要登總表 §3.2（非資安真 bug）**：
   - 跟 V1-1（第 36 項，複製 SSP 沒換參照）是同一族——都是「用 uuid 字串互指的欄位，uuid 一換就斷」。
   - 建議同一張修正卡，修法都落在套件。
   - DEV 無法直接重現（沒有完成的專案 SSP 再匯入），需要修的人或首腦用一份 Word 在 DEV 跑兩次確認。
2. **「有能力、沒入口」兩個地雷要不要記進 §3.4**：
   - ① 套件 `import_ssp` 信任字典裡的 uuid 與陣列長度；
   - ② `OscalExportAppService.export` 對 SSP／稽核結果／改善計畫不檢查文件歸屬。
   - 兩者現在都打不到，但將來開放「上傳 OSCAL JSON」或「匯出 SSP JSON」時會直接變成洞。建議記一句「開這兩種入口前必須先補守門」。
3. **FR-118 八棒全收**：V3 是最後一棒。本棒無資安新增，第 123／125 項（主專案匯入覆蓋範本無守門）仍是這條匯入鏈上唯一的真缺口。
