# C5 掃描報告 — 程序書文件池的資料層＋兩支懸案檔（CM-2101）

> 範圍：12 檔／1,213 行，跨兩個 repo。套件側 `jedi-compliance-audit` 9 檔／582 行（程序書池的資料實體、兩支資料存取介面與實作、對照器、兩支資料表定義）；主專案側 3 檔／631 行（`ssp_reference_document_dto.py`，加上從 FR-113 O9 就掛著沒人讀過的兩支懸案檔 `oscal_containers.py`、`ssp_catalog_title_query.py`）。
> 掃描工具：Claude Code 官方 `claude-security` plugin，effort low。**兩側各掃一次**，因為工具只認它所在那個 repo 的檔案。
> 掃描基準：套件側 `eafc7ae511d5`（monorepo 主 checkout，`feature/review`）；主專案側 `18e190371b25`（`feature/review`）。兩邊工作區都有其他 session 還沒 commit 的改動。
> 驗證章：兩次都是 **verified**。只掃不修，兩個 repo 都沒改程式碼。

---

## 1. 一句話結論

**淨新增 0。** 工具在兩側各報了刪除程序書的漏洞，全是總表第 118／119 項（根因在套件側 `delete_by_uid` 只認編號），已知、不重報。第 129 項的後半段 `add_mappings` 被工具提出來，三人面板 0:3 否決，照卡片說法也不另計。

卡片點名的新東西逐一開檔查完，**全部不成立**，理由如下：

- `list_mappings`、`remove_mapping`、`get_mapping_doc_names` 三支：用來查的那個「掛在哪個控制項底下」的編號，每一個呼叫端都是伺服器自己從「網址上那份計畫」解析出來的，使用者塞不進別份計畫的編號。
- 兩支 DI 組裝：oscal 與 module_frame 各建一份文件池，守門零件兩邊都接齊。oscal 那側 12 支 SSP 服務，每支都有注入權限檢查器。
- `ssp_catalog_title_query`：查控制項標題時有帶「這份計畫用哪套框架」的範圍，不是全庫撈，呼叫端也都先過了權限檢查。

有一件事要首腦知道：兩側工具都順手查到了，**第 118／119 項的修法（CM-2041，commit `15d4efd3c`）已寫在 `fix/security-b1` 分支，還沒合回 `feature/review`**；第 129 項在該分支上**還沒有修法**，我開檔核對過。

---

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

「程序書池」是每份系統安全計畫（SSP）底下的一個文件櫃：專案經理把公司的程序書、規章上傳進去，再逐一「掛」到某個控制項或某個查核項目底下，當作「這條控制我們是靠這份文件做到的」的佐證。資源庫的範本（module_frame）也有一份同樣的文件櫃。

這一棒掃的是這個文件櫃的**資料層**：文件怎麼存、怎麼查、怎麼刪、掛載關係怎麼建。卡片交代，第 118／119／129 項的根因就在這一層（刪除和掛載只認文件編號、不問屬於哪份計畫），**已知不重報**；本棒要找的是**同一批檔案裡還沒被報過的方法**，以及兩支從 FR-113 O9 就只列不掃的懸案檔。

背景說明：資料層（repo）本來就不負責權限檢查，檢查放在上面的服務層，這是本專案允許的做法。所以這裡問的是「每支查詢有沒有帶上『屬於哪份計畫』這個範圍」，以及「呼叫它的服務層有沒有把範圍守住」。

---

## 3. 掃到什麼：總覽

| # | 這是什麼問題 | 出事會怎樣 | 該補檢查的位置 | 哪一側發現 | 跟總表的關係 |
|---|---|---|---|---|---|
| 工具-套件 F1 | 刪除程序書只認文件編號，不問屬於哪份計畫 | 管理任一份計畫的人能刪掉別專案、別客戶的程序書及其所有掛載 | 套件側 `ssp_reference_document_repo_impl.py:66`（`delete_by_uid` 起點） | 套件側掃描（面板 3:0，中） | **＝第 118／119 項根因，已知不重報** |
| 工具-主 F1 | 刪除「控制項底下的佐證文件」時沒核對文件歸屬 | 同上 | 主專案側 `ssp_control_implementation_service.py:888`（`delete_reference_document` 起點） | 主專案側掃描（面板 3:0，中） | **＝第 118 項，已知不重報** |
| 工具-主 F2 | 從文件庫刪除時沒核對文件歸屬 | 同上，外加所有掛載連鎖刪除 | 主專案側 `ssp_document_pool_service.py:104`（`delete_from_pool` 起點） | 主專案側掃描（面板 3:0，中） | **＝第 119 項，已知不重報** |
| （被否決） | 掛載程序書時換算編號不限計畫範圍 | — | 套件側 `ssp_document_pool_query.py:144`（`add_mappings`） | 套件側掃描（面板 **0:3 否決**） | ＝第 129 項後半段，卡片已說不另計 |

**本棒淨新增：0 條。**

說明一下為什麼工具會報到本棒範圍外的檔案：主專案側的範圍是 DI 組裝檔，研究員順著「這個組裝檔把沒帶範圍的刪除零件接給了誰」追到了服務層，所以兩條發現都落在 `app/oscal/service/` 底下。這是正確的追法，但結論跟 FR-113 O1 已報的第 118／119 項完全相同。

---

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

### 4.1 套件側 F1：刪除只認編號（＝第 118／119 項根因）

**場景**：某甲自己開了一個專案，於是自動成為它的負責人；他同時是另一個專案的唯讀成員，在那邊的文件櫃清單上看得到每份程序書的編號。他送出「刪除文件」的請求，網址寫的是自己那份計畫、文件編號填的卻是別人那份的。系統只確認「你是網址上那份計畫的負責人」就放行，那份別人的程序書連同掛在各控制項底下的關係，全部無聲消失。

**為什麼會這樣**：套件側 `ssp_reference_document_repo_impl.py:66-76` 的 `delete_by_uid`，找資料的條件只有文件編號。存文件的那張表沒有客戶欄位，資料庫隔離也沒開（見第 6 節），所以底下沒有第二道關卡。

**面板**：3 票全數認定成立，三票都評中。**這是卡片說的「已知、不重報」。**工具另外提醒一件事：修法已經寫在 `fix/security-b1` 分支（CM-2041），只是沒合回。

### 4.2 主專案側 F1／F2：兩支刪除入口都沒核對歸屬（＝第 118／119 項）

- F1：主專案側 `ssp_control_implementation_service.py:888-890` 的 `delete_reference_document`。守門的回傳值沒接住，下一行就直接用編號刪。
- F2：主專案側 `ssp_document_pool_service.py:118-129` 的 `delete_from_pool`。第 125 行已經把那筆文件抓在手上了，卻沒檢查它是不是屬於 `ctx.ssp.id` 那份計畫。

兩條面板都是 3:0，其中 F1 有一票評低，最終仍是中。**跟總表第 118、119 項逐字對得上，已知不重報。** 我開檔核對過 `fix/security-b1`：兩支都已補上「文件屬於本計畫才准刪」的檢查（`ssp_document_pool_service.py:125-126`、`ssp_control_implementation_service.py:892-894`），跟面板建議的修法一致。

### 4.3 被否決的：`add_mappings` 用呼叫端給的編號建掛載（＝第 129 項後半段）

研究員把「換算文件編號不限範圍 → 直接拿去建掛載」提成一條，面板 **0:3 全數否決**，理由是「攻擊者拿不到他原本拿不到的東西」。

這跟總表第 129 項的判斷**有落差**：第 129 項指出，掛上去之後「列出掛載」會把檔案代號回傳出來，而檔案代號可以拿去下載，所以其實拿得到。本棒的面板只看得到套件這一側，看不到主專案側的列表回傳和下載網址，所以才判不成立。**以總表第 129 項為準，面板這一票不推翻它**；照卡片說法，也不另計。

---

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

### 5.1 `list_mappings`／`remove_mapping`／`get_mapping_doc_names` 的查詢編號是不是都由伺服器解析：**是，全部成立，不是漏洞**

這三支只用「掛在哪一類（控制項或查核項目）」加上「那一項的內部編號」來查，本身沒有帶計畫範圍。所以關鍵在於：那個內部編號，使用者塞得進別份計畫的嗎？我把主專案裡每一個呼叫端都找出來了（跨側接縫，兩側研究員都看不到）：

| 呼叫端（主專案側） | 內部編號從哪來 | 結論 |
|---|---|---|
| `ssp_document_pool_service.py:151`、`:180`（列出控制項／查核項目的掛載） | `_resolve_ir_id(ctx.ssp.id, …)`／`_resolve_stmt_id(ctx.ssp.id, …)`：在守門回傳的那份計畫底下，用控制項代號去找 | 伺服器解析，只會落在自己這份計畫 |
| `ssp_document_pool_service.py:171`、`:200`（移除掛載） | 同上。文件編號雖然是全系統換算（第 129 項那支），但刪除條件同時綁著這個控制項的內部編號，所以只刪得到**自己這份計畫**控制項上的掛載 | 刪不到別份計畫的東西 |
| `ssp_document_pool_service.py:221`、`:229`（覆蓋匯入前快照） | 由 `list_implemented_requirements_for_ssp(ssp_id)` 逐一列出，`ssp_id` 是上層匯入流程過完守門後傳進來的 | 伺服器解析 |
| `ssp_control_impl_import_service.py:141`、`:165`（Excel 匯出的程序書欄） | `_existing_view(ssp_id)` 算出來的，`ssp_id` 來自 `:97` 的 `require_participant` | 伺服器解析 |
| module_frame `module_frame_reference_document_service.py:174`、`:194`（範本移除掛載） | `_ir_for_control(ssp_id, …)`／`_statement_or_404(ssp_id, …)`，`ssp_id` 是範本自己的；文件先過 `_get_doc_or_404`，核對了「屬於這個範本」 | 伺服器解析，而且多核對了文件歸屬 |
| module_frame `module_frame_control_default_service.py:236`、`module_frame_control_objective_default_service.py:254` | 範本 SSP 自己的 IR／statement 列表 | 伺服器解析 |
| module_frame `ssp_import_template_app_service.py:1364`、`:1373`，`module_frame_template_import_service.py:770`、`:779` | 範本匯出時從範本 SSP 列出的既有項目 | 伺服器解析 |

**附帶一個對照**：module_frame 那一側的掛載／移除（`module_frame_reference_document_service.py:155-194`），在建掛載前會先用 `_get_doc_or_404` 確認「這份文件屬於這個範本」，所以範本那邊**連第 129 項的洞都沒有**。第 129 項只存在於專案 SSP 那一側（`ssp_document_pool_service.py:157`、`:186`）。這個正確範本修第 129 項時可以直接照抄。

### 5.2 兩支 DI 組裝有沒有一邊少接守門零件：**沒有，查證不成立**

FR-113 O9b 證實過「DI 漏接，守門就靜默失效」，所以這件事要實查。先說結論：兩邊都接齊了。

- **oscal 那側**（主專案側 `oscal_containers.py:344-359`）：文件池服務 `SspDocumentPoolService` 注入了 `ssp_permission_checker`，而且這支服務的建構子**不給預設值**，漏接的話開機就會炸，不會靜默放行。我另外把這個檔裡所有需要守門的 SSP 服務都點過一輪：12 支都在建構子裡收權限檢查器（其中 11 支預設值是 `None`），組裝檔裡 `ssp_permission_checker=ssp_permission_checker` 也剛好出現 **12 次**，一支不漏。
- **module_frame 那側**（主專案側 `module_frame_containers.py:145`、`:173-188`）：各自建了一份文件池查詢和文件資料存取零件，但 module_frame 的守門本來就不靠這些零件，而是掛在路由上的 `require_capability("module-frame.create／update／delete")`（`module_frame_reference_document_route.py:84`、`:117`、`:146`、`:179`、`:205`、`:241`、`:267`）。
- **兩邊建出來的東西一樣嗎**：一樣。都是同一支套件的同一個類別，而且這兩支零件本身沒有任何建構參數，不存在「一邊少注入」的可能。另外 `module_frame_containers.py:210`、`:254` 兩處直接借用 oscal 那份，`flow_control_containers.py:359` 也借用 oscal 的文件池服務。
- **會不會有人拿到沒守門的那份**：`oscal_containers.py:345-349` 的資料存取零件與 domain service 是可以直接拿的，但我逐一查過直接呼叫它們的服務，只有三支：`ssp_document_pool_service`（有守門）、`ssp_control_implementation_service`（有守門，但就是第 118 項）、`module_frame_reference_document_service`（有核對歸屬）。沒有第四個沒守門的呼叫者。

**附帶一處過期註解（不是漏洞）**：`oscal_containers.py:343` 寫「mapping 走 AP control/task id」，其實已經改成走 v2 的 IR／statement；`ssp_reference_document.py` 的資料表定義註解也還寫著舊的 context 說明。註解過期可能誤導下一個讀的人，不影響安全，留給動到這兩支檔的人順手修。

### 5.3 `ssp_catalog_title_query.py` 查標題有沒有帶範圍：**有，查證不成立**

這支查的是「某份計畫裡每個控制項的標題、每個查核項目的標題」，給 Excel 匯出匯入和 SSP 文件匯出用。

- **有帶範圍**：主專案側 `ssp_catalog_title_query.py:41-52` 的 `_catalog_controls` 先用計畫編號找到那份計畫，再只拿「這份計畫選用的那套框架」解析出來的控制項；後面查查核項目（`:73`）也是逐一照這些控制項去查。**不是全庫撈。**計畫沒選框架就回空。
- **呼叫端都先過了守門**：
  - `ssp_control_impl_import_service.py` 的匯出、驗證、重新驗證、確認匯入四支（`:97`、`:199`、`:313`、`:356`），都先 `require_participant` 或 `require_manager`，再把守門回傳的 `ctx.ssp.id` 傳進來。
  - SSP 文件匯出 `ssp_export_app_service.py:74-81`：專案那條先 `require_participant`；範本那條（`:51-65`）的路由 `mf_ssp_export_route.py:51-52` 掛了 `require_capability("module-frame.read")`。
- **一個形狀要留意（不是漏洞）**：`ssp_export_app_service.py:74` 寫的是「**有**注入權限檢查器才檢查」，組裝檔沒接的話守門就會靜默跳過（O9b 那種形狀）。我查過 `oscal_containers.py:421` 有接，所以現在沒問題。但這支是全 app 唯一「零件在才守、不在就放行」的寫法，將來重構組裝檔時容易踩到，建議首腦考慮改成「沒接就報錯」。
- **唯讀、沒有寫入**，也沒有吃使用者給的字串去組查詢，無注入風險。

### 5.4 `get_pool_name_to_uid_map` 與 `clone_pool_docs`（同檔，順手核對）

- `get_pool_name_to_uid_map`（套件側 `ssp_document_pool_query.py:211-233`）：條件是 `context_type='ssp'` 加上 `context_id=ssp_id`，**有範圍**。它換出來的文件編號只會屬於這份計畫，所以 Excel 匯入走這條時第 129 項打不到（匯入是先用名稱比對本計畫的池子，才換編號）。
- `clone_pool_docs`（`:78-110`）：只有 `clone_pool_for_snapshot` 在呼叫，源頭跟目標計畫編號都是啟動稽核時由伺服器傳入。**不是使用者可控的**。

### 5.5 資料實體、對照器、資料表定義、DTO：沒有安全相關邏輯

`ssp_reference_document_entity.py`、`ssp_reference_document_mapper.py`、兩支資料表定義、主專案側 `ssp_reference_document_dto.py`，全是純資料搬運。DTO 對外只回 `uid`、`file_id`、`file_uid`、`file_name`、`file_size`、`description`，其中 `file_uid` 可以拿去下載，這正是第 129／139 項的利用鏈，已記在總表。

---

## 6. 資料庫隔離現況（DEV 唯讀實查，2026-09-24 15:25，查完 ROLLBACK）

| 表 | Schema | DEV 隔離開了嗎 | 隔離規則數 |
|---|---|---|---|
| `ssp_reference_documents`（程序書池） | `oscal` | **關** | 0 |
| `ssp_reference_document_mappings`（程序書掛載） | `oscal` | **關** | 0 |

白話說明：這兩張表**沒有客戶欄位，也沒開資料庫隔離**，連「只看得到自己公司的」這一層都沒有，完全靠程式層的檢查頂著。這就是第 118／119／129 項能一路跨到別的客戶的原因。跟盤點檔 §5「四張表完全沒有資料庫防線」的說法一致。

---

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

**「工具報的條目存在嗎」：可信度高。**兩次掃描共 4 個候選、12 票全數投出，3 條 3:0 成立、1 條 0:3 否決。成立的 3 條都跟總表第 118／119 項逐字對得上。

**「卡片點名要追的事」：逐一開檔核對過，全部排除。**

- 三支 mapping 方法：呼叫端 16 處逐一追過，編號都是伺服器解析的。
- 兩支 DI 組裝：守門零件 12 支全接齊。
- 標題查詢：有帶框架範圍，呼叫端都先過了守門。

**「只有這些嗎」：不保證。**

1. 用的是最快的檔位（effort low），只跑一個研究員加一輪投票，沒有威脅建模和廣度掃描。
2. 兩側研究員彼此看不到對方；跨側接縫（套件方法 ↔ 主專案呼叫端）是我手動逐一核對的，範圍只限這批 12 檔裡的方法。
3. 全程沒有實際打過任何網址，所有結論都是讀程式邏輯推導的。

---

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

| 項目 | 套件側 | 主專案側 |
|---|---|---|
| 掃描範圍 | 9 檔／582 行 | 3 檔／631 行 |
| 基準 commit | `eafc7ae511d5`（dirty） | `18e190371b25`（dirty） |
| 檔位 | effort low | 同左 |
| 研究員 | 派 1 支，回 1 支 | 派 1 支，回 1 支 |
| 候選 | 2 條，去重後 2 條 | 2 條，去重後 2 條 |
| 投票 | 6 票全數投出 | 6 票全數投出 |
| 票型 | F1 3:0 中（＝第 118／119 項）；F2 0:3 否決（＝第 129 項後半段） | F1 3:0 中（＝第 118 項，一票評低）；F2 3:0 中（＝第 119 項） |
| 驗證章 | **verified**（`CLAUDE-SECURITY-REVISION-eafc7ae511d5-dirty.json`） | **verified**（`CLAUDE-SECURITY-REVISION-18e190371b25-dirty.json`） |
| 工具 run ID | `wf_14d2ee12-034` | `wf_54ef2399-800` |
| 耗時 | 約 13 分鐘（7 個 agent，零失敗） | 約 13 分鐘（7 個 agent，零失敗） |
| 工具原始報告 | 套件 repo `CLAUDE-SECURITY-20260924-071422/`（未入版控） | BE repo `CLAUDE-SECURITY-20260924-073342/`（未入版控） |

工具報告的 F 編號對到本報告：套件側 F1＝第 4.1 節；主專案側 F1、F2＝第 4.2 節。

---

## 9. 待首腦裁決

（**現況**：第 118／119 項已修（CM-2041／CM-2033 等，見 M11-11／M11-16）；第 129 項後半見 M11-12，FR-114 CM-2173 已修，1.21.0 出貨；以下為掃描當時的建議）
1. **淨新增 0，不用開新修正卡。**第 118／119 項的修法（CM-2041）已在 `fix/security-b1`，只差合回。
2. **第 129 項在 `fix/security-b1` 上還沒有修法**（我對照過該分支 `ssp_document_pool_service.py:157`、`:186`，照樣是全系統換算）。這一項的修正卡若還沒派，可以照抄 module_frame 側 `_get_doc_or_404` 的寫法：先確認文件屬於這份計畫，再建掛載。
3. **第 4.3 節的面板否決不推翻第 129 項**：否決理由是看不到主專案側的回傳與下載鏈，屬於單側視角的盲點。
4. **（可選）`ssp_export_app_service.py:74` 的「有接才守」寫法**，建議改成沒接就報錯，免得將來重構組裝檔時靜默失守。現在有接，不急。
5. **（可選）`oscal_containers.py:343` 與資料表定義裡的過期註解**，留給下次動到的人順手修。
