# FR-113.O6 掃描報告——程序書文件池與匯入比對引擎

> 卡片：CM-2006 ｜ 只掃不修，修正卡由首腦統一開
> 報告日期：2026-09-21

## 執行概況

| 項目 | 內容 |
|---|---|
| 掃描範圍 | 6 支檔、1,917 行（程序書文件池 ＋ 匯入比對引擎） |
| 掃到的 commit | `8d0bf41c115f55a27e2b949fc9870fb361b56d45` |
| 工具設定 | effort `low`、focus `attack-surface` |
| 派出幾個 agent／回報幾個 | 派 2 個，回報 1 個（主研究員完成並交卷；另一支「找寫死的密碼」補充掃描逾時未回） |
| **三人面板有沒有跑完** | **沒有——`unverified`** |
| 面板沒跑完的原因 | 補充掃描卡住超過 40 分鐘未回，決策者額度將盡，手動停止工作流並從磁碟撈回已完成的研究結果 |
| 高風險項目的補強驗證 | 兩條發現 runner 皆已**自行開檔核對**（含主專案與 jedi-compliance-audit 套件兩側），核對結果寫在各條之內 |

**這份報告的可信度要怎麼看**：正式流程會由三位獨立審查員投票篩掉誤報，這次沒跑到。但下面兩條的關鍵事實（哪一行程式碼檢查了什麼、資料庫查詢有沒有限定範圍）都由 runner 親自打開檔案確認過，是**看到的事實**不是推論。核對細節附在每條的「開檔核對」段。

**範圍內未發現問題的部分**（一併列出，讓「沒寫到」不等於「沒看」）：比對引擎的合併決定（`decision_merge.py`）經核對是安全的，理由見文末。

---

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - 發現一（刪程序書檢查與刪除對象不同）＝M11 第 11 條，✅ 已修（CM-2041，commit `15d4efd3c`，1.21.0 出貨）。
> - 發現二（把別人的程序書掛到自己控制項）＝M11 第 12 條，✅ 已修（CM-2173，套件 `5170e507`／BE `c4b6bdb16`，1.21.0 出貨）。

## 發現一（高風險）：刪程序書時，檢查的和刪掉的不是同一份

**這是什麼問題（白話）**

刪除一份程序書的網址長這樣：`DELETE /ssp/<計畫編號>/document-pool/<文件編號>`。系統會檢查「你是不是這個**計畫**的負責人」——檢查的是網址前半段那個計畫編號。但真正被刪掉的，是網址後半段那個**文件編號**，而這個文件編號從頭到尾**沒有人核對過它到底屬不屬於前面那個計畫**。

換句話說：系統確認了「你是 A 計畫的負責人」，然後照你說的把 B 計畫的文件刪了。

**出事會怎樣**

- 任何一個「至少管得動一個計畫」的人，可以刪掉**任何其他計畫**的程序書，包含別的客戶（別的租戶）的。
- 刪除會**連鎖**：資料庫設定是「文件刪掉，它掛在哪些控制項、哪些查核項目上的關聯一起刪」。所以一次點擊毀掉的不只一筆資料，是那份文件在整個稽核體系裡的所有連結。
- **已定版的稽核快照也擋不住**。系統本來有一道保護：稽核輪次一旦進入執行階段，那一版的內容就凍結不准改（改了會回 412 錯誤）。但這道保護是針對「網址上那個計畫」檢查的——攻擊者拿自己還在規劃中的計畫當通行證，刪的卻是凍結版的文件，保護完全沒被觸發。
- 沒有還原機制。

**要先有什麼才打得到**

1. 有正常帳號登入。
2. 至少是**某一個**計畫的負責人，而且那個計畫還在可編輯狀態（用來當通行證，不需要是目標計畫）。
3. 知道目標文件的編號。**這個很好拿**——連唯讀的旁觀者都能呼叫「列出這個計畫的程序書」查到全部編號，那支功能只要求「是這個計畫的成員」。

**實際攻擊長怎樣**

小美在 A 專案只是唯讀的旁觀者，但她自己的 B 專案是她在管。她先列出 A 專案的程序書清單（合法，旁觀者就能看），抄一個文件編號下來。接著送出 `DELETE /ssp/<B的計畫編號>/document-pool/<A的文件編號>`。系統檢查「小美是 B 的負責人嗎」——是，放行。然後把 A 專案的文件刪掉，連同它在 A 專案所有控制項上的關聯。

**在哪裡**

- `app/oscal/service/ssp_document_pool_service.py:119`（檢查）與 `:129`（刪除）
- 路由：`api/oscal/routes/ssp/ssp_document_pool_route.py:79`
- 真正執行刪除的地方在套件裡：`jedi-compliance-audit/.../ssp_reference_document_repo_impl.py:66`

**開檔核對（runner 親自確認）**

打開 `ssp_document_pool_service.py` 看到的就是這樣：第 119 行 `ctx = self._perm.require_manager(ssp_uid)`——檢查的對象是 `ssp_uid`（網址上的計畫）。第 129 行 `result = self._ref_doc_ds.delete_by_uid(doc_uid)`——刪的對象是 `doc_uid`，中間沒有任何一行把這兩者核對起來。再打開套件端的 `delete_by_uid`，查詢條件是 `.filter(SspReferenceDocument.uid == uid)`，**只有文件編號一個條件**，沒有任何限定所屬計畫的條件。兩邊都證實了。

**建議怎麼修**

在檢查通過之後、刪除之前，把文件撈出來核對它的歸屬：確認 `ref_doc.context_type == 'ssp'` 而且 `ref_doc.context_id == ctx.ssp.id`，對不上就回「找不到」。或者更穩的做法——在套件加一支限定範圍的刪除方法，把「屬於哪個計畫」直接寫進資料庫查詢條件裡，讓它不可能被繞過。

**順帶一提**：`SspControlImplementationService.delete_reference_document` 是同一個寫法（檢查計畫、刪文件編號），修的時候要一起看。

---

## 發現二（中風險）：可以把別人的文件掛到自己的控制項上，順便看到檔案

**這是什麼問題（白話）**

把程序書掛到某個控制項上時，前端送一份文件編號清單上來。後端拿這些編號去資料庫換成內部 ID——**但這個換算是全系統範圍的查詢，沒有限定「只能是你這個計畫池子裡的文件」**。所以清單裡塞任何一個系統內存在的文件編號，都會被接受。

**出事會怎樣**

掛上去之後，「列出這個控制項掛了哪些文件」這支功能會把那份文件的**檔案代號、檔名、大小、說明**一併回傳。而檔案代號正是下載網址吃的參數，下載那支只驗「有沒有登入」。等於：知道一個文件編號 → 掛到自己的控制項 → 讀到檔名與檔案代號 → 用自己的帳號把檔案下載下來。

**要先有什麼才打得到**

1. 有正常帳號登入，且是某個可編輯計畫的負責人。
2. 知道目標文件編號（同樣可從「列出程序書」取得，唯讀旁觀者即可）。
3. **跨客戶（跨租戶）的檔案內容下載另有一道防線擋著**——檔案表上有租戶隔離政策（`scripts/sql/packages/jedi_file_upload/004-upload-files-tenant-owner-rls.sql`）。**但同一個客戶底下的跨專案沒有擋**。所以實際影響是：同一家公司內，A 專案的機密程序書可以被 B 專案的人拿到。

**在哪裡**

- `app/oscal/service/ssp_document_pool_service.py:159`（控制項掛載）與 `:188`（查核項目掛載，同一個寫法）
- 路由：`api/oscal/routes/ssp/ssp_document_pool_route.py:112`
- 換算的地方在套件裡：`jedi-compliance-audit/.../ssp_document_pool_query.py:199`

**開檔核對（runner 親自確認）**

打開套件端的 `resolve_doc_uids_to_ids`，第 205-206 行的查詢是 `session.query(...).filter(SspReferenceDocument.uid.in_(doc_uids))`——**只有「編號在你給的清單裡」這一個條件**，沒有任何限定所屬計畫的條件。回到主專案側，`add_control_mappings`（159 行）與 `add_ao_mappings`（188 行）呼叫它時也沒有傳入任何計畫範圍。兩處寫法逐字相同。

**建議怎麼修**

把計畫範圍傳進這支換算方法，查詢條件加上「屬於這個計畫的池子」；不屬於的編號直接丟掉或報錯。兩支掛載方法（控制項、查核項目）要一起改。

---

## 核對過但沒有問題的部分

**比對引擎的「使用者勾選哪些要套用」是安全的。** 這是這一棒特別被交代要追的線——擔心使用者送回來的合併決定裡可以夾帶不該改的欄位。

開檔核對結果（`decision_merge.py:66-89`）：解析使用者送上來的決定時，程式**只挑三樣東西出來**——控制項編號（`control_id`）、要採用哪一邊（`action`）、以及查核項目層級的同樣兩樣（`objective_key` / `action`）。送上來的資料結構裡就算塞了其他欄位，也不會被讀到、不會進入後續流程。這等於天然的白名單，形狀是對的。

## 同一支檔內已登記、不重複報的項目

O1 那棒已登記「刪除方法檢查計畫、刪的是文件編號」——即本報告的**發現一**。本報告仍完整寫出，是因為 runner 這次獨立開檔核對了主專案與套件兩側的程式碼、補齊了觸發前提（唯讀旁觀者即可取得文件編號）與凍結快照繞過路徑；開修正卡時請與 O1 那條併為一張，不要重複開。
