# B2 掃描報告：範本底下三組清單型資料（控制項預設清單／稽核目標預設清單／參考文件池）

- 卡號：CM-2075（FR-113 module_frame 掃描母案 CM-2073 第 B2 棒）
- 掃描範圍：12 支檔／1,642 行
- 掃描版本：`798a9b5f94a2816767a5d3a1fe78bf61123a9ac5`（工作區有未提交改動，屬平行 session 的檔，與本棒無關）
- 掃描時間：2026-09-22 09:04 UTC
- 工具：Claude Code 官方 `claude-security` plugin 0.11.0，effort `low`
- 驗證章：**verified**（工具程式碼自行計票蓋章，非模型宣稱）

---

## 1. 一句話結論

**掃完了，找到 2 個問題，都是同一個病：有三個「看資料」的網址入口只檢查「你有沒有登入」，沒有檢查「你有沒有資格看這個」——任何一個登入帳號都能把合規範本裡的內容讀走，其中一條還能順著讀到的編號把範本的程序書檔案整份下載下來。**

兩條都屬中等嚴重度（MEDIUM），不是最高級，原因寫在第 4 節每條的「為什麼是中等不是高」。

---

## 2. 這一棒在檢查什麼（白話）

系統裡有一種東西叫「合規範本」——把一套法規（例如 ISO 27001）先做成範本，之後每個客戶專案要做稽核時，直接從範本套用，不用每次從零填。

範本底下掛了三組清單：

1. **控制項預設清單**：這套範本要求做哪些控制項。
2. **稽核目標預設清單**：每條控制項底下要驗哪些細項目標，以及預設要怎麼寫、做到什麼程度。
3. **參考文件池**：這套範本可以掛哪些程序書（實際的 Word／PDF 檔）。

這一棒把這三組各自的「網址入口 → 資料檢查 → 商業邏輯 → 資料傳遞物件」四層共 12 支檔看完，逐支對外方法核對一件事：**每個入口有沒有檢查「這個人有沒有權限做這件事」。**

要先講清楚一個本專案的正規寫法，免得誤會：本專案允許「入口只驗登入、真正的權限判斷寫在下一層商業邏輯裡」。所以看到入口只有「要登入」不等於有洞，得往下追一層才知道。**這次找到的兩條，是往下追過了、下一層也確實沒有檢查**，不是誤判。

---

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - 發現 1（範本程序書清單無權限檢查）＝M11 第 16 條，✅ 已修（CM-2186 `aafaa070d`；下載歸屬檢查 CM-2033 `a334f5385`，1.21.0 出貨）。
> - 發現 2（稽核目標預設內容讀取無權限檢查）＝M11 第 17 條，✅ 已修（CM-2186，commit `aafaa070d`，1.21.0 出貨）。
> - 「檔案下載網址用一般權杖是否檢查擁有者資源權限」那張評估卡：已由 CM-2033 處理下載歸屬（M11 第 16 條），其餘查不到後續。

## 3. 掃到什麼：總覽表

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 |
|---|---|---|---|---|---|
| 1 | 列出範本程序書清單的網址，沒有檢查這個人有沒有權限看範本 | 任何登入帳號都能列出範本掛了哪些程序書，而且清單裡附了檔案編號；拿那個編號去打下載網址就能把程序書整份抓走 | 只要有一組能登入的帳號（任何角色都行），再知道或猜到一個範本編號 | `api/module_frame/routes/module_frame_reference_document_route.py:70` | 在該方法補上 `@require_capability("module-frame.read")`，位置放在 `@jwt_required()` 跟 `@inject` 之間 |
| 2 | 讀取稽核目標預設內容的兩個網址，同樣沒有檢查權限 | 任何登入帳號都能讀走範本裡每條稽核目標的完成狀態、實作說明、備註，以及掛了哪些文件 | 同上 | `api/module_frame/routes/module_frame_control_objective_default_route.py:59` 與 `:119` | 兩支都補上 `@require_capability("module-frame.read")`，同樣位置 |

---

## 4. 每條發現的詳述

### 發現 1：範本程序書清單可被任意登入帳號讀走，並可順勢下載檔案

**白話說明**

系統裡每個網址入口前面會掛「檢查條」，像門口的警衛。這個入口只掛了一條「你要先登入」，沒掛「你要有看範本的權限」。同一支檔案裡，新增、修改、刪除那三個入口**都有掛**權限檢查——只有「列出清單」這個漏了。

更麻煩的是這個清單回傳的內容裡包含每份程序書的**檔案編號**（`file_uid`）。系統的檔案下載網址 `/file/download/<編號>` 在用一般登入權杖存取時，只認編號、不再檢查「這份檔案是不是你有資格看的」（見 `core/plugins/file_upload.py:110`）。所以讀到清單 ≈ 讀到檔案本身。

**在哪裡**

- `api/module_frame/routes/module_frame_reference_document_route.py:70`，方法 `ModuleFrameReferenceDocumentListResource.get`
- 對照組（有正確掛上檢查的同類型入口）：`api/module_frame/routes/module_frame_control_default_route.py:51`

**實際走一遍會怎樣**

1. 一個只有專案參與者身分的帳號（沒有任何範本相關權限，所以他的畫面上根本看不到「資源庫範本」這個選單）用自己正常的登入權杖，直接打 `GET /api/1.0/module-frame/<範本編號>/reference-documents`。
2. 因為門口只檢查「有登入」，這個呼叫成功，回傳整份程序書清單，每筆都帶檔案編號與檔名。
3. 他再拿其中任一個檔案編號打 `GET /api/1.0/file/download/<檔案編號>`，用同一組權杖，把程序書整份下載下來。

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

- 一組能登入這套系統的帳號，任何角色都可以。
- 知道或猜到一個範本編號——範圍是自己租戶的範本、上層組織分享下來的範本、或系統層級的公版範本。

**為什麼是中等嚴重度不是高**

攻擊者必須先有一組合法帳號（不是外部陌生人打得到的），而且影響限於「讀」——不能改、不能刪。但因為任何角色都算數、而且可以一路讀到檔案本體，所以也不算低。

**怎麼修**

在 `api/module_frame/routes/module_frame_reference_document_route.py` 的 `get` 方法上，於 `@jwt_required()` 與 `@inject` 之間補一行：

```python
@require_capability("module-frame.read")
```

`require_capability` 已經在該檔第 36 行 import 進來了（同檔的 POST／PUT／DELETE 都在用），不需要額外改 import。

另外建議分開決策兩件事，**不屬本棒範圍、不自行處理**：

- 這個清單回傳裡到底需不需要附上 `file_uid`。
- 檔案下載網址在用一般登入權杖時，要不要也檢查「呼叫者有沒有資格看這份檔案的擁有者資源」。這條影響的不只範本，範圍比本棒大，應另開卡評估。

**首腦核對註記**

已開檔核對：該 `get` 方法確實只有 `@marshal_with` / `@jwt_required()` / `@inject` 三條，第 70 行直接把網址參數丟進 `list_pool()`；同檔第 84／117／146 行的三個寫入入口都有 `require_capability`。對照的 `module_frame_control_default_route.py` 四個入口（51／77／101／128）全部有掛。事實吻合。

---

### 發現 2：稽核目標預設內容的兩個讀取入口，同樣沒有權限檢查

**白話說明**

跟發現 1 完全一樣的病，換個地方。「讀取稽核目標預設清單」和「讀取單一筆稽核目標預設」這兩個入口都只檢查登入。回傳的是這套範本裡每條稽核目標的完成狀態、實作說明文字、備註，以及掛了哪些參考文件。

這條的對照特別明顯：**同一支檔案裡的修改（PUT，第 73 行）跟刪除（DELETE，第 133 行）都有掛權限檢查，只有兩個讀取的漏掉。** 而且隔壁做同一件事的「控制項預設清單」那支檔，四個入口全部掛齊。所以這不是設計上刻意開放，是漏掉。

**在哪裡**

- `api/module_frame/routes/module_frame_control_objective_default_route.py:59`，方法 `ModuleFrameObjectiveDefaultListResource.get`（列出全部）
- `api/module_frame/routes/module_frame_control_objective_default_route.py:119`，單筆讀取的 `get`（裝飾器在第 108～110 行）

**實際走一遍會怎樣**

一個低權限帳號（沒有 `module-frame.read` 這個權限）打 `GET /api/1.0/module-frame/<範本編號>/objective-defaults`，回傳整份稽核目標預設內容。同一個帳號去打隔壁的 `/control-defaults`，會被擋下回 403——同樣性質的資料，一個擋一個不擋。

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

- 一組能登入的帳號，任何角色。
- 知道或猜到一個範本編號（同發現 1）。

**為什麼是中等嚴重度不是高**

同發現 1：要先有帳號，且只能讀不能寫。比發現 1 略輕一點的是，這條沒有「順勢下載檔案」的延伸路徑，洩漏範圍就是範本文字內容本身。

**怎麼修**

兩支 `get` 都補上同一行，位置同樣在 `@jwt_required()` 與 `@inject` 之間：

```python
@require_capability("module-frame.read")
```

**首腦核對註記**

已開檔核對：第 49～51 行（列表 get）與第 108～110 行（單筆 get）都只有 `@marshal_with` / `@jwt_required()` / `@inject`；同檔 PUT 與 DELETE 有掛。事實吻合。

---

## 5. 逐支公開方法核對的結果（卡片指定的重點，含「沒有問題」的部分）

卡片要求的不是「找沒守門的端點」，是「逐支公開方法核對守門齊不齊」。以下是核完的全貌，包含查了之後確認沒問題的。

### 5.1 ★ 卡片點名的優先檔：參考文件池商業邏輯（`module_frame_reference_document_service.py`，257 行，8 支對外方法）

盤點時的紅旗是「整支檔 grep 權限關鍵字零命中」。**核對後的結論：這支檔零命中是正常的，不是洞。** 原因是本專案的分工——「你有沒有權限做這件事」寫在網址入口那層（用 `require_capability`），這支商業邏輯檔負責的是另一個問題：「你指定的這筆資料，是不是真的屬於你指定的那個範本」。

八支方法逐支核對「有沒有檢查歸屬」的結果：

| 方法 | 有沒有檢查歸屬 | 說明 |
|---|---|---|
| `list_pool` | 有（間接） | 先經 `_template_ssp_id()` 解析範本，只撈該範本底下的池 |
| `upload_to_pool` | 有 | 同上，寫入綁在解析出來的範本 |
| `update_meta` | **有，且檢查得精準** | 第 133 行明文比對 `existing.context_id != ssp_id`，不符就 404 |
| `delete_from_pool` | **有，且檢查得精準** | 第 151 行同樣比對 `context_id != ssp_id`，不符就 404 |
| `attach_to_control_default` | 有 | 經 `_get_doc_or_404(ssp_id, doc_uid)`，該 helper 第 200 行比對 `context_id != ssp_id` |
| `detach_from_control_default` | 有 | 同上 |
| `attach_to_objective_default` | 有 | 同上 |
| `detach_from_objective_default` | 有 | 同上 |

**卡片擔心的「檢查 A、動手改 B」在這支檔沒有發生。** 每一支都是先解析出網址上那個範本對應的 SSP 編號，再用那個編號去驗證使用者另外送上來的文件編號確實屬於同一個範本，不符就 404。這正是卡片要找的形狀——只是這裡做對了。

**刪除路徑（卡片特別點名，對應跨 arc 總表第 133 項）的結論**：`delete_from_pool` 的歸屬檢查是完整的，沒有「刪到別人家東西」的洞。

### 5.2 另外兩支商業邏輯檔的落差檢查（卡片第①項要求）

| 檔案 | 權限關鍵字命中 | 對外方法數 | 判讀 |
|---|---|---|---|
| `module_frame_control_default_service.py` | 1 | 4 | 正常，權限守在入口層，這層做歸屬檢查 |
| `module_frame_control_objective_default_service.py` | 1 | 4 | 同上；四支方法都先走 `_resolve_template_ssp_id()` 再動作 |
| `module_frame_reference_document_service.py` | 0 | 8 | 正常，理由見 5.1 |

**結論：落差檢查在這批三支檔上都是假警報**，真正的缺口不在商業邏輯層，在入口層——就是第 4 節那兩條。

### 5.3 一個順手記下的觀察（不是資安問題，屬資料完整性，不開修正卡）

`delete_from_pool` 刪除程序書時，**沒有檢查這份文件是不是還有控制項或稽核目標正在引用它**。底層 `delete_by_uid`（`jedi-compliance-audit` 套件的 `ssp_reference_document_repo_impl.py:66`）是直接 `session.delete(model)`。

這跟跨 arc 總表第 133 項（刪框架不檢查還有沒有客戶在用）是同一種形狀，但**性質不同**：這裡刪的是自己範本底下的文件、刪的人已經通過權限檢查，所以不是越權，是「可能刪出孤兒關聯」的資料完整性問題。列在這裡供決策者判斷要不要另開一般性 bug 卡，**本報告不把它算進資安發現數**。

---

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

分兩層講，這兩層可信度差很多。

**第一層：「這兩條真的存在嗎」——可信度高。**

兩條發現都經過三個獨立檢查員投票：一個查「打不打得到」、一個查「打到了會怎樣」、一個查「有沒有別的機制其實擋住了」。**兩條各 3 票全數認定成立，六票沒有一票反對。** 而且本棒收工前我自己開檔核對過行號與裝飾器內容，事實吻合（核對註記寫在每條末尾）。

要注意：**全程沒有執行任何程式**，沒有真的發一個請求去打那個網址、沒有跑測試、沒有做攻擊驗證。結論全部來自讀程式碼。要百分之百確認，需要人工實際用低權限帳號打一次那三個網址。

**第二層：「只有這兩條嗎」——可信度中等，不能當成「這塊掃乾淨了」。**

- 本棒 effort 設定為 `low`，意思是「一個研究員把 12 支檔讀完，再交給三人面板驗證」，**沒有跑資產盤點、沒有建威脅模型、沒有做廣度橫掃**。這是快速定點掃描，不是窮盡式檢查。
- 只讀了範圍內這 12 支檔。它們呼叫到的下層（資料庫存取、ORM 模型、資料庫的租戶隔離規則）只當背景資料翻閱，沒有逐支審。
- 範圍外的東西一概沒看：範本主體本身的增刪改查在 B1、Excel 匯出匯入在 B5。

所以這份報告能說的是「這 12 支檔裡有這兩個洞」，**不能說「這塊已經安全了」**。

---

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

| 項目 | 數值 |
|---|---|
| 掃描範圍 | 12 檔／1,642 行 |
| effort 設定 | `low` |
| 派出研究員 | 1 名（派 1 回 1，無失聯） |
| 提出的候選問題 | 2 條 |
| 去除重複後 | 2 條 |
| 三人面板投票 | 3 名檢查員 × 2 條發現 = 6 票，全數投出，無漏投 |
| 投票結果 | 發現 1：3 票全認定成立；發現 2：3 票全認定成立 |
| 被面板否決的 | 0 條 |
| 嚴重度被面板下修的 | 0 條 |
| 驗證輪數 | 1 輪（無候選被延到下一輪、無遺失） |
| 驗證章 | **verified** |
| 總耗時 | 約 50 分鐘 |
| 子代理數 | 7 |

工具原始產出（機器可讀格式、SARIF、版本戳記）在 `CLAUDE-SECURITY-20260922-090431/`，該目錄自帶 `.gitignore` 不入版控。

---

## 8. 建議的下一步（等決策者裁示，本棒不執行）

1. **開修正卡**：三支讀取入口補 `@require_capability("module-frame.read")`。三處都在同一個模組、改法相同，建議併成一張卡。
2. **另開評估卡（範圍比本棒大）**：檔案下載網址用一般登入權杖存取時，要不要檢查呼叫者對擁有者資源的權限。這條影響全系統的檔案下載，不只範本。
3. **決定要不要處理 5.3 的孤兒關聯**：屬資料完整性，不是資安。
