---
title: B1 檢查結果：合規範本主體的增刪改查
---

# B1 檢查結果：合規範本主體的增刪改查

> 檢查日期 2026-09-22｜耗時 1 小時 34 分｜對應卡片 CM-2078｜範圍 19 檔／1,787 行

---

## 🔴 一句話結論

**找到兩個問題，最嚴重的是：在一家公司底下的子公司管理員，可以改掉母公司分享出來的合規範本——把適用的控制項清單整個換掉，順帶刪掉範本裡對應的實作說明。** 系統只檢查了「你有沒有改範本的功能權限」，沒有檢查「這份範本是不是你家的」。

---

## 這一棒在檢查什麼

合規範本是整個稽核系統的骨架。公司要做 ISO 27001 稽核時，先建一份「範本」——裡面寫著這次要查哪些控制項、每個控制項要交什麼證據。之後開的每個稽核專案，都是從這份範本複製出來的。

這一棒檢查的是**範本本身的建立、查詢、修改、刪除、複製**這條主線，19 個檔案：從網址入口（使用者的瀏覽器打進來的地方）一路到資料庫存取，再加上兩支「接線設定」（決定哪支程式由誰建立起來的設定檔）。

範本可以設定分享範圍：**只給自己公司用**、**分享給旗下子公司用**（SHARED）、或**原廠出貨就內建、所有人都看得到**（SYSTEM）。後面兩種是這次問題的關鍵。

---

## 找到什麼

| # | 嚴重度 | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 修正卡 |
|---|---|---|---|---|---|---|
| 1 | 🟡 中 | 修改範本時，系統**只檢查「你有沒有改範本的權限」，沒有檢查「這份範本是不是你家的」**。同一支程式裡，改「分享範圍」那一段有檢查歸屬，改「適用控制項清單」那一段完全沒有 | 子公司的管理員可以**把母公司分享出來的範本改掉**——換掉適用控制項清單，而且被移除的控制項，它在範本裡的實作說明會被**一併刪除**。母公司的合規基準被改壞，之後從這份範本開出來的每個稽核專案都是壞的。同一條路也能改掉範本的中文名稱與說明（改了之後所有人看到的都是被竄改的版本） | ① 系統開了多租戶模式（出貨預設是開的）<br>② 攻擊者是自己公司的管理員（預設角色就有這個權限）<br>③ 有一份母公司分享下來的範本，或原廠內建的範本<br>④ 送出的請求**只帶控制項清單**這一個欄位 | `app/module_frame/service/module_frame_service.py` 第 117 行 | ✅ 已修（M11-5，CM-2178，1.21.0） |
| 2 | 🟡 中 | 有**兩個查詢入口只檢查「有沒有登入」，沒有檢查「有沒有看範本的權限」**。同一個模組其他的查詢入口都有檢查，就這兩支漏了 | 沒有被授權看合規範本的員工（畫面上連選單都不會出現），**還是可以直接打 API 把全公司的範本清單撈出來，再一份一份讀完整內容**，包含完整的控制項樹、供應商、版本。只能看、不能改 | ① 攻擊者有這套系統任一個帳號、能登入<br>② **不需要**有看範本的權限——這正是問題所在 | `api/module_frame/routes/module_frame_route.py` 第 47 行、第 27 行 | ✅ 已修（M11-14，CM-2186，1.21.0） |

---

## 詳細說明

### 問題 1：改範本時沒檢查「這份範本是不是你家的」

**白話**：你的門禁卡能開公司大門，警衛就讓你進任何一間辦公室——沒人再確認「這間是不是你的辦公室」。

系統檢查了兩件事的其中一件：

- ✅ **「你有沒有改範本這個功能的權限」**——有檢查（`@require_capability("module-frame.update")`）
- ❌ **「這份範本是不是你家的」**——**沒檢查**

而且問題就出在同一支程式裡：

```python
# app/module_frame/service/module_frame_service.py 第 104-119 行
def update_module_frame(self, uid, user, payload, locale=None):
    mf = self.module_frame_domain_service.verify_module_frame_exists_by_uid(uid)   # 只是「查得到嗎」
    ...
    new_scope = payload.get("scope")
    if new_scope is not None and new_scope != mf.scope:
        assert_scope_writable(mf.tenant_id, new_scope)     # ← 改「分享範圍」有檢查歸屬 ✅
        mf.scope = new_scope
    ...
    if payload.get("include_controls") is not None and ...:
        self._resource_library_app_service.update_applicable_controls(   # ← 改「控制項清單」沒有 ❌
            uid, payload["include_controls"], user)
```

**這就是前面十一棒一直在抓的同一個病**：同一個方法裡，這條分支守了、旁邊那條沒守。

**為什麼資料庫沒擋下來（第二道防線是破的）**：這套系統有一個「每個客戶只能看自己資料」的資料庫隔離機制（RLS）。但這裡有三個洞：

1. **查詢那一步是刻意放行的。** 政策原文（`scripts/sql/2026-09-14-fr094-cm1790-shared-scope-subtree.sql` 第 99-106 行）明寫：原廠內建的（SYSTEM）任何人都查得到，母公司分享下來的（SHARED）子公司也查得到。這是**設計如此**，分享功能本來就要這樣。問題是程式拿這個「查得到」當成了「可以改」。
2. **真正被改的兩張表根本沒開這個隔離機制。** 控制項清單存在 `oscal.profile_imports`，實作說明存在 `oscal.ssp_implemented_requirements`——我實際翻過 `scripts/sql/` 底下所有檔案，**這兩張表沒有任何隔離政策**。所以資料庫這一層完全不知道要擋。
3. **連範本自己那張表的寫入檢查都繞得過去。** 如果請求只帶控制項清單、不帶名稱版本那些欄位，程式對範本主表根本不會下 UPDATE 指令，寫入政策連被觸發的機會都沒有。

**怎麼打**：母公司把 ISO 27001 範本分享給旗下子公司（scope 設成 SHARED）。子公司的管理員在自己畫面上看得到這份範本，抄下它的編號，然後直接對 `PUT /api/1.0/module-frame/<編號>` 送出 `{"include_controls": ["ac-1"]}`。存在性檢查過關，控制項清單被換成只剩一項，母公司範本裡其他控制項的實作說明被連帶刪除。

**為什麼是「中」不是「高」**：要打到需要疊三個前提——要開多租戶、攻擊者得是自己公司的管理員（不是隨便一個員工）、而且要真的存在一份分享出來或原廠內建的範本。但這三個前提在正式出貨的環境**都是常態**，不是罕見設定，所以也不能算低。

**怎麼修**：在 `app/module_frame/service/module_frame_service.py` 的 `update_module_frame()` 裡，**任何寫入動作之前**，先比對這份範本的擁有者是不是呼叫者自己的公司——就用同一支程式裡 `assert_scope_writable` 已經在用的那個判斷（`get_user_context().tenant_id == mf.tenant_id`），不符就 `raise ForbiddenError`。

**不要只靠資料庫隔離機制補**，因為 `oscal.profile_imports`、`oscal.ssp_implemented_requirements` 和翻譯表 `compliance.module_frames_trans` 三張表都沒有開。如果要讓資料庫這層也有防線（建議要，這樣就是雙保險），那是另一件工——替這三張表補上隔離政策，或者讓 oscal 那側的寫入也走有帶公司條件的資料存取層。

### 問題 2：兩個查詢入口只檢查有沒有登入

**白話**：選單上把某個功能藏起來了，但功能本身的網址還是通的——知道網址就能直接打。

這個模組有十幾個入口，絕大多數都有檢查權限。我把主線入口全部並排比對，落差很清楚：

| 入口 | 做什麼 | 有沒有檢查登入 | 有沒有檢查權限 |
|---|---|---|---|
| `GET /module-frames/menu`（第 27 行） | 列出所有範本（下拉選單用） | ✅ | ❌ **沒有** |
| `GET /module-frame/<編號>`（第 47 行） | 讀一份範本的完整內容 | ✅ | ❌ **沒有** |
| `POST /module-frame`（第 58 行） | 新增範本 | ✅ | ✅ create |
| `PUT /module-frame/<編號>`（第 74 行） | 修改範本 | ✅ | ✅ update |
| `DELETE /module-frame/<編號>`（第 86 行） | 刪除範本 | ✅ | ✅ delete |
| `POST /module-frame/clone`（第 97 行） | 複製範本 | ✅ | ✅ create |

而且同模組的其他檔案（控制項預設值、SSP 匯出、範本匯入）**都有** `@require_capability("module-frame.read")`——就這兩支漏了。

**怎麼打**：一個沒被授權看範本的稽核員（他的畫面上連「合規資源庫」選單都不會出現），直接打 `GET /api/1.0/module-frames/menu` 拿到全公司範本編號清單，再一個一個打 `GET /api/1.0/module-frame/<編號>`，把每份範本的完整控制項樹讀完。

**為什麼是「中」不是「高」**：只能讀、不能改，而且跨公司的部分仍然被資料庫隔離機制擋著（只有原廠內建與母公司分享的範本例外）。洩漏的是公司內部的合規設定，不是個資或密碼。但它確實讓權限設定形同虛設，所以也不能算低。

**怎麼修**：在 `api/module_frame/routes/module_frame_route.py` 兩個地方，於 `@jwt_required()` 與 `@inject` 之間補上 `@require_capability("module-frame.read")`：

```python
# 第 45-47 行，ModuleFrameRoute.get
@jwt_required()
@require_capability("module-frame.read")     # ← 補這行
@inject
def get(self, uid, module_frame_service=...):

# 第 25-27 行，ModuleFramesMenuRoute.get
@jwt_required()
@require_capability("module-frame.read")     # ← 補這行
@inject
def get(self, module_frame_service=...):
```

⚠️ 補之前要**確認一件事**：選單這支（`/module-frames/menu`）是「建立專案」畫面的下拉選單在用的。如果有某個角色**該能建專案、但不該逛資源庫**，加了這道檢查會讓他建不了專案。這個要由決策者確認角色設計，不是 runner 能判斷的。

---

## 另外查了兩件卡片點名、但報告沒有列為問題的

### AI 儀表板那條旁路——接線正確，沒問題

卡片要我確認 `di_containers/dashboard_apis/module_frame.py` 接出去的東西，跟主線是不是同一套守門。我開檔核對過：

它把 `get_module_frame_list_for_dashboard` 這支方法註冊給 AI 儀表板呼叫。這支是**專門為儀表板寫的變體**，跟主線的 `get_module_frame_list` 是兩支不同的方法——檔案裡有註解明講「方法名與 api_key 刻意不同」，而且這支會**主動把 profile 資料清空**再回傳（`mf.oscal_profile = None`）。也就是說，儀表板拿到的資料**比主線更少**，不是繞過守門拿到更多。

至於「只回自己公司的」這件事：這支走 `get_all_with_oscal`，由資料庫的隔離機制負責過濾，跟主線列表走同一條路。**這條沒有落差，不列為問題。**

### 路由總表沒有被註解掉的入口

卡片要我看 `api/module_frame/__init__.py`（252 行）有沒有「路由關了但程式還在、別的路由通得到」的狀況。我整份看過**沒有被註解掉的 add_resource**，也沒有孤兒入口。

一個附帶發現（不是資安問題，記錄備查）：`add_module_frame`（新增範本）這支 app service 方法**目前是空殼**——程式裡只有一行註解「requires jedi_oscal profile/framework services」然後 `return None`。所以 `POST /module-frame` 這個入口雖然通、權限也守著，但實際上不會建出任何東西。這是 FR-038 v2 遷移的殘留，不是漏洞，但下一棒如果碰到新增路徑要知道這件事。

---

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

分兩層講：

**「這兩條是真的嗎」——可信度高。** 三個獨立的檢查員（分別從「打得到嗎」「打到了會怎樣」「有沒有其他機制擋住」三個角度）各投一票，兩條發現都是 **3 票對 0 票全數確認**，六票全投出、沒有漏投。而且我自己另外開檔核對過：確認 RLS 政策原文確實放行 SYSTEM 與母公司的 SHARED、確認 oscal 兩張表真的沒有任何隔離政策、確認同模組其他入口真的都有補權限檢查。

**「只有這兩條嗎」——不保證。** 這一棒跑的是**快速檔位**（工具的 low effort），一個研究員讀完 19 個檔，然後交給三人面板驗。這是targeted 的一輪讀，**不是窮盡式的多研究員交叉掃**。同時，19 個檔以外的東西**完全沒看**——範本子項、YAML 匯入、三組清單型資料、Excel 匯出匯入，那些在 B1-item／B1c／B2／B4-B6 各自的棒次。

另外要講清楚：**這次掃描沒有實際執行過任何程式**——沒跑測試、沒發過攻擊請求、沒有做過概念驗證。所有結論都是讀程式碼推出來的。上面那兩條要真的確認可打，需要在 DEV 環境實際發一次請求驗證，**那不在這一棒範圍內**。

---

## 執行概況

| 項目 | 數字 |
|---|---|
| 掃描範圍 | 19 檔／1,787 行 |
| 掃描起點 commit | `60cf754f`（branch `feature/review`，工作區有未提交改動） |
| 工具檔位 | low（一個研究員 + 三人面板） |
| 研究員 | 派 1 個，回 1 個 |
| 候選發現 | 2 條 |
| 面板投票 | 3 人 × 2 條 = 6 票，全數投出 |
| 通過 | 2 條（各 3:0 全數確認） |
| 被打掉 | 0 條 |
| 驗證章 | **verified** |
| 耗時 | 1 小時 34 分 |
| 工具原始報告 | `CLAUDE-SECURITY-20260922-104426/`（英文，未入版控） |
