---
title: B1c 檢查結果：用檔案整批匯入合規範本
---

# B1c 檢查結果：用檔案整批匯入合規範本

> 檢查日期 2026-09-23｜耗時 1 小時 27 分｜對應卡片 CM-2080｜範圍 8 檔／925 行｜掃的版本 `f80b9f682`

---

## 🔴 一句話結論

**掃描工具找到 3 個問題（三個檢查員全數確認），其中新的只有 2 個，都屬於「讓伺服器忙到當掉」這一類；我自己開檔另外查出 2 個，最嚴重的是：「整批匯入」可以直接覆蓋一份已經存在的範本，但系統只檢查了「你能不能建新範本」。**

另外兩件卡片特別要求單獨回答的事：

- **上傳 YAML 檔能不能在伺服器上執行程式？不能。** 系統用的是安全的讀法（`yaml.safe_load`），檔案內容只會被當成資料，不會被當成程式執行。但它**沒有限制檔案的巢狀層數**，一個幾 KB 的檔案就能讓伺服器忙到當掉（見 B1c-2）。
- **YAML 整批匯入建子項，走的是有守門的那支方法，還是沒守的？兩邊都不是——它完全繞過子項服務，而且這條路目前根本建不出任何東西。** 真正會呼叫子項服務的是另一個入口「Excel 整批匯入」，而它走的正是 B1-item 講的那支**沒守的**方法（見 B1c-4）。

---

## 這一棒在檢查什麼

合規範本是稽核流程的骨架（例如「ISO 27001 要查哪些條款、每條要做哪些事」）。除了在畫面上一條一條建，**合規資源庫頁面**（`ModuleFrame.vue`）還有「匯入」按鈕，讓使用者上傳一個檔案，一次建出整份範本連同底下所有條款。

匯入有兩條路：

| 入口 | 畫面上的操作 | 檔案格式 |
|---|---|---|
| `POST /module-frame/import/yaml` | 上傳 YAML 檔（一種純文字的設定檔格式） | YAML |
| `POST /module-frame/import/verify` → `POST /module-frame/import` | 上傳 Excel，先預覽檢核、確認後存檔 | 前端把 Excel 轉成 JSON 送過來 |

三個入口**都只掛了一道檢查：`module-frame.create`**（「你能不能建新範本」），出廠預設只有「管理員」角色有。

這一棒要回答：匯入進來的東西，**有沒有跟在畫面上一條一條建時經過一樣的檢查**。

---

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - B1c-1＝M11 第 5 條，✅ 已修（CM-2178，1.21.0 出貨）。
> - B1c-2（YAML 巢狀引用）＝M11 第 34 條，✅ 已修（CM-2190，commit `374392065`，1.21.0 出貨）。
> - B1c-3（Excel 檢核比對拖慢）＝M11 第 35 條，✅ 已修（CM-2181，commit `48638cbdb`，1.21.0 出貨）。
> - B1c-4（建立權限即可覆蓋既有範本）＝M11 第 10 條，✅ 已修（CM-2178，commit `abf1cf8a4`，1.21.0 出貨）。
> - B1c-5（YAML 匯入建不出東西，資訊性）：無修正卡，查不到後續處理。

## 找到什麼

| # | 嚴重度 | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡（該補檢查的位置） | 來源 |
|---|---|---|---|---|---|---|
| **B1c-1** | 🟡 中 | 修改範本時，只有改「分享範圍」才檢查範本是不是你家的，改「適用控制項清單」完全不檢查 | 子公司管理員可以把母公司分享出來的範本的控制項清單清空，順帶刪掉範本裡的實作說明 | 自己公司的管理員＋一份母公司分享或原廠內建的範本 | `app/module_frame/service/module_frame_service.py:104`（`update_module_frame` 開頭） | 工具，三票確認。**與 B1 第 1 條同一個問題，不另開卡** |
| **B1c-2** | 🟢 低 | 上傳的 YAML 檔沒有限制「一層包一層」的層數，而 YAML 允許「這裡引用上面那段」 | 一個幾 KB 的檔案可以讓伺服器建出天文數字個物件，那條處理程序卡死或記憶體用盡，其他公司的使用者同時受影響 | 有 `module-frame.create` 權限的帳號（預設只有管理員） | `infra/module_frame/adapter/yaml_to_module_frame_parser_adapter.py:14`（`parse` 開頭） | 工具，三票確認 |
| **B1c-3** | 🟢 低 | Excel 匯入檢核時，用來檢查「有沒有夾帶網頁標籤」的比對寫法，遇到特定內容會越跑越慢，而且欄位沒有長度上限 | 送一筆一百萬個 `<` 的條款名稱，就能讓一條處理程序忙兩分鐘，幾個請求同時送就把伺服器全部卡住 | 同上 | `app/module_frame/service/module_frame_import_service.py:52`（`verify_import_module_frame_data` 開頭） | 工具，三票確認 |
| **B1c-4** | 🟡 中（當時待裁；已裁不能覆蓋、已修 CM-2178） | Excel 整批匯入可以**覆蓋既有範本**，但入口只檢查「你能不能建新範本」，也沒檢查那份範本是不是你家的 | 只有「建立」權限的人可以改掉既有範本的名稱、條款內容與子項 | 有 `module-frame.create` 權限的帳號＋知道既有範本的編號 | `api/module_frame/routes/module_frame_import_route.py:45-47`（`ModuleFrameImportDataRoute.post` 的裝飾器）；`app/module_frame/service/module_frame_import_service.py:124`（`save_import_module_frame_data` 開頭） | ⚠️ **我自己開檔查出，未經工具投票** |
| **B1c-5** | ⚪ 資訊 | YAML 匯入這條路第一步就會出錯，實際上建不出任何範本 | 目前不是安全問題；但**哪天有人把那支空殼方法補回來，這條路會變成完全沒守的批次寫入** | — | `app/module_frame/service/module_frame_service.py:98`（`add_module_frame`） | ⚠️ **我自己開檔查出，未經工具投票** |

---

## 每條發現的詳述

### B1c-1：改控制項清單不檢查範本歸屬（與 B1 第 1 條相同）

**在哪個畫面、誰、做什麼**：合規資源庫頁面，**子公司的管理員**打開一份「母公司分享給旗下子公司」或「原廠內建」的範本，改它的適用控制項清單並存檔（或直接送 API）。

**會發生什麼**：`update_module_frame` 這支方法裡有兩段寫入。改「分享範圍」那段有呼叫 `assert_scope_writable`（檢查「這份範本是不是你家的」），改「適用控制項清單」那段**沒有**。後者會直接改 OSCAL 的 profile 表並刪除範本 SSP 裡被移除控制項的實作說明——**這兩張表沒有開「每個客戶只能看自己資料」的資料庫隔離機制（RLS）**，所以資料庫那道最後防線也擋不住。

**這條不是新的**：B1 那一棒（`scan-B1.md` 第 1 條）已經找到同一個問題、同一個行號。本棒工具再次獨立找到並三票確認，**等於多一次佐證，不另開修正卡**。

**怎麼修**：照 B1 報告的修法——在 `update_module_frame()` **開頭、任何寫入之前**，比對 `get_user_context().tenant_id == mf.tenant_id` 且範本不是原廠內建（SYSTEM），不符就擋下。

---

### B1c-2：YAML 檔可以用「引用」讓伺服器爆掉

**這是什麼問題**：YAML 格式允許「在這裡貼上前面某一段的內容」（稱為錨點與引用）。解析器 `_parse_workflow` 是一層一層往下展開子流程的，而且**沒有限制層數也沒有限制總數**。攻擊者可以寫：第 1 層引用第 0 層 10 次、第 2 層引用第 1 層 10 次……疊 30 層，檔案只有幾 KB，但展開後是 10 的 30 次方個物件。

**在哪個畫面、誰、做什麼**：合規資源庫頁面的「匯入 YAML」，**有建立範本權限的管理員**上傳這個特製檔案。

**出事會怎樣**：處理這個請求的伺服器程序會吃滿一顆 CPU、記憶體一路漲，直到 120 秒逾時被砍或整個容器記憶體耗盡。預設只有 4 條處理程序，連送幾個請求，**所有公司的使用者都會同時連不上系統**。

**為什麼是「低」不是「中」**：要先有管理員帳號，而且後果是服務暫停、不是資料外洩或被竄改。

**跟「會不會執行程式」的關係**：這是兩件事。`yaml.safe_load`（`:16`）已經防住「上傳檔案在伺服器上執行程式」這個最嚴重的風險，這點我開檔確認過；B1c-2 是另一種攻擊——不執行程式，只是讓伺服器算不完。

**在哪裡**：

```
infra/module_frame/adapter/yaml_to_module_frame_parser_adapter.py:14   ← parse() 開頭，該在這裡擋
infra/module_frame/adapter/yaml_to_module_frame_parser_adapter.py:16   ← safe_load（安全，不用改）
infra/module_frame/adapter/yaml_to_module_frame_parser_adapter.py:35   ← 無上限的遞迴展開
```

**怎麼修**：在 `parse()` 開頭改用自訂的 SafeLoader，**遇到引用（alias）就直接拒絕**（範本檔沒有正當理由需要引用）；另外在 `_parse_workflow` 加層數上限（例如 10 層）與總節點數上限（例如 5,000 個），超過就丟 `BadRequestError`。順帶：格式錯的檔案目前 `data.get` 會直接炸成 500，應一併改回「格式錯誤」訊息。

---

### B1c-3：Excel 匯入檢核的比對寫法會被拖慢

**這是什麼問題**：Excel 匯入時，系統會檢查每一條的「條款名稱」與「要求事項」有沒有夾帶網頁標籤，用的比對規則是 `<[^>]+>`（`:23`）。這個寫法遇到「很多個 `<` 但沒有 `>`」的字串時，每個 `<` 都會從頭掃到尾，工作量是字串長度的平方。而欄位**沒有長度上限**。

**在哪個畫面、誰、做什麼**：合規資源庫頁面的「匯入 Excel」→ 預覽檢核這一步，**有建立範本權限的管理員**送出一筆條款名稱是一百萬個 `<` 的資料。

**出事會怎樣**：一個請求就讓一條處理程序忙到 120 秒逾時，四個請求就把預設的四條全部卡住。後果跟 B1c-2 一樣是全系統暫停。

**在哪裡**：

```
app/module_frame/service/module_frame_import_service.py:52      ← verify_import_module_frame_data() 開頭，該在這裡先擋長度
app/module_frame/service/module_frame_import_service.py:23      ← 比對規則本身
```

**怎麼修**：在 `verify_import_module_frame_data()` 開頭，條款名稱與要求事項超過合理長度（例如 2,000 字）就直接判為不合格；比對規則改成有上限的寫法 `<[^<>]{1,200}>`。

---

### B1c-4：「建立」權限就能覆蓋既有範本（⚠️ 我自己查出，未經投票）

**這是什麼問題**：Excel 匯入存檔的入口（`/module-frame/import`）只掛了 `module-frame.create`。但進到 `save_import_module_frame_data()` 後，**只要送上來的資料帶了既有範本的編號**（`module_frame.uid`），程式就走「覆蓋」這條路（`:125-129`）：

1. 呼叫 `update_module_frame` 改範本主檔（名稱、版本等）——這支就是 B1c-1 那支，**不改分享範圍就不檢查歸屬**
2. 對既有的每一條，呼叫子項服務的 `update_module_frame_item`（`:169`）——這支就是 B1-item 第 3 條講的「**檢查甲、動手改乙**」、本身完全沒有權限檢查的那支

同一件事在畫面上一條一條改，要有 `module-frame.update`；走匯入就只要 `module-frame.create`。**這就是卡片預測的「上傳入口只問你能不能建，不問你能不能改既有的」**。

**出事會怎樣**：只被授權「建新範本」、沒被授權「改範本」的人，可以用匯入改掉既有範本的名稱與每一條的內容。

**可以打到多遠——同公司確定打得到，跨公司我判斷會被資料庫擋下**：範本主表與流程主表有開資料庫隔離，「只准改自己公司的」。這條路每一步都會寫主表（至少會寫「最後修改人」），跨公司時，資料庫的隔離規則會讓主表那一筆「改了等於沒改」（不報錯、只是改到 0 筆），而程式的資料存取層（SQLAlchemy）發現「預期改 1 筆卻改了 0 筆」會丟錯，整批撤銷。**這是我讀程式碼推的，沒有實際打過。**

**跟 B1c-1 的關係**：B1c-1 修好之後，主檔那一步會擋下跨公司，但**同公司內「只有建立權限就能改」這個落差仍在**，所以要分開修。

**修法先要決策者裁一件事**：「只有建立權限的人能不能透過匯入覆蓋既有範本？」

- 如果**不行**：在 `ModuleFrameImportDataRoute.post` 的裝飾器（`module_frame_import_route.py:45-47`），把 `require_capability("module-frame.create")` 改成依有沒有帶 `uid` 分流——或更簡單，在 `save_import_module_frame_data()` 開頭（`:124`）遇到 `is_update` 為真時，再呼叫一次 `viewer_has_capability("module-frame.update")`，沒有就擋。
- 無論哪個答案，都要**確認這份範本是呼叫者公司的**（B1c-1 修好就涵蓋）。

---

### B1c-5：YAML 匯入目前建不出東西（⚠️ 我自己查出，資訊性）

YAML 匯入第一步呼叫 `module_frame_service.add_module_frame()`（`import_service.py:274`），但那支方法目前是空殼，固定回 `None`（`module_frame_service.py:98-101`，註解寫「v2 切換後停用，2B 重建」）。下一行 `module_frame_dto.template_uid` 就會出錯，整個請求 500。

**所以 YAML 這條路今天不會建出任何範本或子項**——這也是為什麼它「沒有走子項服務」這件事目前不構成漏洞。

**要留意的是未來**：YAML 匯入後面的步驟是直接呼叫流程套件建範本、改範本（`:321`、`:340`、`:371`、`:396`），**完全繞過子項服務，也沒有任何歸屬檢查**。哪天有人把 `add_module_frame` 補回來，這條路就會變成一條沒守的批次寫入。**建議在重建 `add_module_frame` 的那張卡上註明：YAML 匯入要改成走子項服務，不要直接呼叫流程套件。** B1c-2 的 YAML 解析問題則是**今天就存在的**——解析發生在建範本之前，不受這支空殼影響。

另外 Excel 匯入的「新建範本」分支（`save_import_module_frame_data` 的 `else`，`:131`）也呼叫同一支空殼，**所以 Excel 匯入今天也只有「覆蓋既有範本」這條路能成功**——剛好就是 B1c-4 那條。

---

## 首腦交辦的兩件事

### 一、B1-item 那條「待評」：刪／改子項「檢查甲、動手改乙」

**答案：入口的權限點沒有把範圍限住，建議定為「中」。**

- 刪除子項入口（`module_frame_item_route.py:78-80`）掛的是 `module-frame.delete`，修改子項入口（`:63-65`）掛的是 `module-frame.update`。這兩個都是**「你這個角色能不能做這個動作」**的檢查，不看你要動哪一份範本。拿到權限之後，請求內容裡的 `moduleFrameUid`（刪除）、`parentTemplateUid`（修改）可以隨便填，程式照填的去改。
- 下一層（`module_frame_item_service.py:159`、`:227`）也沒有任何權限或歸屬檢查。
- **最後擋住跨公司的是資料庫**：範本流程主表（`compliance.workflow_templates`）有隔離規則，只准改自己公司的；流程圖內容雖然存在**沒有隔離的翻譯表**，但每次存檔都會同時更新主表的「最後修改人」。跨公司時資料庫會讓主表那一筆改到 0 筆（不報錯），程式的資料存取層發現「預期改 1 筆卻改了 0 筆」就丟錯，整個交易撤銷，連翻譯表的改動一起還原。**所以我判斷跨公司打不到——這是讀程式碼推的，沒有實際打過。**
- **同公司內則確定打得到**：有刪除權限的人，可以指定刪「另一份範本」裡的某一條，或把 A 範本的子項掛到 B 範本的流程圖上改。
- 另外注意：**刪除那支最後一行 `delete_workflow_template(uid)`（`:247`）刪的是網址上那個子項，跟前面改的那份範本毫無關聯**——可以造成「刪掉的子項」與「流程圖上被移除的方塊」屬於不同範本，資料就此對不起來。

**為什麼是「中」不是「高」**：要先有管理員等級的刪除或修改權限，而且被擋在公司邊界內。但它會讓範本資料悄悄對不上，事後很難查出原因。

**怎麼修**：在 `update_module_frame_item()`（`:159`）與 `delete_module_frame_item()`（`:227`）開頭，先用網址上的 `uid` 撈出子項，反查它所屬的範本，**比對跟請求內容裡填的是不是同一份**，不是就擋；再確認那份範本是呼叫者公司的。

### 二、YAML 解析會不會讓上傳者執行程式

**不會。** `yaml_to_module_frame_parser_adapter.py:16` 用 `yaml.safe_load`，只會產出字串、數字、清單這類純資料，不會建出任意物件，也就不會執行程式。整個掃描範圍內沒有其他地方用 `yaml.load`、`yaml.unsafe_load` 或 `pickle`。**這條可以結案**，剩下的風險是 B1c-2（讓伺服器算不完），另案處理。

---

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

**「掃到的這些存在嗎」**

- **B1c-1～B1c-3：可信。** 三個檢查員各自獨立投票（分別從「打不打得到」「後果多大」「有沒有別的防線擋住」三個角度看），9 票全數確認成立。我也開檔逐行核對過行號。
- **B1c-4、B1c-5：事實可信，影響判斷未經驗證。** 程式碼確實如此，我開檔核對過；但「跨公司會被資料庫擋下」是我讀程式碼推的，沒有實際打過，也沒經過檢查員投票。
- **B1-item 待評那條的「中」**：同上，是我的判斷，首腦可推翻。

**「只有這些嗎」**

- 這一棒用的是**最省的掃法**（effort low：一個研究員讀完整個範圍，沒有先畫威脅地圖，也沒有第二輪廣掃），研究員讀完後三個檢查員驗證。**不是逐行窮舉**。
- 驗證章：**verified**（工具驗證流程完整跑完）。
- 掃描時工作區有別的 session 的未 commit 改動（驗證章標了 `dirty`），**但這 8 支檔在掃描時沒有未 commit 的改動**，結果對應的就是 `f80b9f682` 這個版本。

---

## 執行概況

| 項目 | 數字 |
|---|---|
| 掃描範圍 | 8 檔／925 行（入口 95、匯入邏輯 415、範本主體接縫 260、解析器與轉接器 91、資料格式定義 64） |
| 掃的 commit | `f80b9f6824fd88987e47af0b7e8744cf46228caa`（branch `feature/review`） |
| 工具 run ID | `wf_93ae6f70-4e7` |
| effort | low（加開「只看攻擊者碰得到的正式程式碼」模式） |
| 研究員 | 派出 2、回報 2 |
| 候選發現 | 3 條 |
| 檢查員投票 | 3 條 × 3 人＝應投 9 票，**9 票全數投出、全數確認**，沒有漏投、沒有降級 |
| 驗證章 | `verified`（`CLAUDE-SECURITY-REVISION-f80b9f6824fd-dirty.json`） |
| 耗時 | 5,247 秒（約 1 小時 27 分） |
| 工具原始報告 | `CLAUDE-SECURITY-20260923-095422/`（不入版控） |
