---
title: O2 檢查結果：資源庫、公版範本、匯出
---

# O2 檢查結果：資源庫、公版範本、匯出

> 檢查日期 2026-09-21｜掃描耗時 3 小時 0 分｜對應卡片 CM-2002

---

## 🔴 一句話結論

**派工時預判的那支「整支檔沒有任何權限檢查」的 530 行程式，確實有洞，而且是這批到目前為止最嚴重的一條。**

一家公司的管理員，可以**改掉、甚至清空另一家公司（或原廠公版）合規範本的控制項清單，並把該範本底下所有「我們公司怎麼做到這條」的文字記錄整批刪除**。系統裡明明已經有一支專門防這件事的警衛程式，而且它的說明還寫明了「不能只靠資料庫擋」——但這條路徑沒有叫它。

另外還有一條高風險：**一組正在用的資料庫帳號密碼，被完整寫死在一支受版控的腳本裡**（主機、埠號、資料庫名、帳號、密碼五樣齊全）。

---

## 這一棒在檢查什麼

三塊功能，共 11 支檔、1,772 行：

- **資源庫**：客戶自己建的合規範本庫（選一套框架 → 系統複製一份控制項目錄給你 → 你勾選哪些控制項適用）
- **公版範本**：原廠預先做好、所有客戶都看得到的範本
- **匯出**：把系統安全計畫輸出成 Word／PDF／ODT，其中包含整批唯一一支會去呼叫外部程式（LibreOffice）的檔

選這一棒先掃，是因為派工前已經 grep 確認 `resource_library_app_service.py` 這支 530 行的檔**整檔找不到任何一個權限檢查關鍵字**，而且裡面有 9 段手寫 SQL。結果證實這個預判是對的。

---

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - 問題 1（改範本適用控制項不查歸屬）＝M11 第 5 條，✅ 已修（CM-2178，commit `abf1cf8a4`；CM-2040 `60015232f`，1.21.0 出貨）。
> - 問題 2（資料庫帳密寫死）：✅ 已修（CM-2049，commit `5d4c14221`）。
> - 問題 3（建資源庫無權限檢查）＝M11 第 13 條，✅ 已修（CM-2179，commit `94275d72f`，1.21.0 出貨）。
> - 問題 4（Word 匯出未跳脫）＝M11 第 18 條，✅ 已修（CM-2055，commit `f85a82edb`，1.21.0 出貨）。
> - 問題 5、6（Google 密鑰、雲端權杖加密金鑰）：✅ 已修（CM-2051，commit `ccbb27148`；原作廢卡 CM-1631）。
> - 問題 7、8（AI 金鑰、簽章金鑰）：✅ 已修（CM-2048，commit `e8e134a4e`；原作廢卡 CM-1607）。

## 找到什麼：8 個問題

| # | 嚴重度 | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 |
|---|---|---|---|---|---|
| 1 | 🔴 **高** | **改範本的「適用控制項」時，系統沒有檢查這份範本是不是你家的** | 別家公司的合規範本、或原廠給所有人用的公版範本，**控制項清單被改掉，底下所有「我們怎麼做到這條」的記錄被整批刪除**（連帶說明文字一起）。被害方不會收到任何通知 | ① 是自己公司的管理員（這是一般客戶就有的角色）<br>② 系統裡存在一份「看得到但不屬於你」的範本——原廠公版（每家都看得到）或母公司分享下來的 | `app/oscal/service/resource_library_app_service.py:412` |
| 2 | 🔴 **高** | **一組正在用的資料庫帳密被寫死在受版控的腳本裡**（主機、埠號、資料庫名、帳號、密碼五樣齊全） | 讀得到程式碼、又連得到內網的人可以直接連進 POC 資料庫。**本專案規定 POC 等同正式環境**（對外 demo、客戶會試玩）。同一組密碼字串散在 **249 個受版控檔案**裡，其中不少是配更高權限的 `cmmgr` 帳號 | ① 讀得到程式碼倉庫<br>② 連得到 `192.168.50.189`（內網或 VPN）| `docs/system-design/scripts/generate_db_schema_docx.py:34` |
| 3 | 🟡 中 | **建立資源庫的入口完全沒有權限檢查**，只驗有沒有登入 | 連唯讀帳號都能建資源庫。而且**每建一次就複製一整套框架目錄、每個查核項目各產一張流程範本**——重複呼叫就是用很小的代價讓資料庫暴增 | 任何登入帳號 | `api/oscal/routes/resource_library_route.py:50` |
| 4 | 🟡 中 | **匯出 Word 時，使用者填的文字沒有做「跳脫處理」就塞進文件** | 有權編輯計畫內容的人，可以讓匯出的 Word 檔**夾帶偽造的段落，或藏一段會叫閱讀者的 Word 去抓外部內容的指令**。匯出檔就是要交出去的稽核證據，被動手腳等於證據本身不可信 | ① 能編輯該計畫的文字欄位（專案負責人，或有範本編輯權的人）<br>② 另一個人去匯出並打開那份檔案 | `app/oscal/service/export/ssp_docx_generator.py:60` |
| 5 | 🟡 中<br>⚠️ 範圍外 | **Google 雲端硬碟的「應用程式密鑰」**寫在 12 個受版控檔案裡 | 攻擊者可以冒充我們的系統向 Google 要權限，讀取客戶存在雲端硬碟的檔案。**這把不是每套安裝各自產生的**，要到 Google 後台重設 | 讀得到程式碼倉庫 ＋ 誘使使用者走一次授權流程 | `docs/conversation-history/2026-04-21-.../part-05-of-14.md:8157` |
| 6 | 🟡 中<br>⚠️ 範圍外 | **保護客戶雲端硬碟權杖的加密金鑰**寫在 10 個受版控檔案裡 | 配合問題 2 拿到資料庫之後，可以把**所有客戶存著的雲端硬碟權杖全部解密**，不需要再通過任何驗證 | 讀得到程式碼倉庫 ＋ **要先透過問題 2 之類的路徑拿到資料庫** | 同上檔案 `:8161` |
| 7 | 🟡 中<br>⚠️ 範圍外 | **OpenAI／Anthropic／Google 三把 AI 金鑰**寫在 9 個受版控檔案裡 | 只要有一把漏掉沒作廢，就能拿公司帳單去用；OpenAI 那把還能讀服務端保存的請求紀錄（裡面常含客戶資料） | 讀得到程式碼倉庫 ＋ 該金鑰還沒被作廢 | `docs/conversation-history/2026-04-28-to-04-30-.../part-verbatim-01-of-03.md:3651` |
| 8 | 🟡 中<br>⚠️ 範圍外 | **登入憑證的簽章金鑰**寫在 30 個受版控檔案裡 | 理論上可以偽造任何人的登入身分（包含最高管理員），不需要密碼。**但安裝程式出來的每套系統都會各自產生一把新的**，外流的是手動設定的開發／測試機那把 | 讀得到程式碼倉庫 ＋ 目標機器還在用這把（主要是舊的手動設定機）| `docs/conversation-history/2026-04-21-.../part-03-of-14.md:14232` |

**一到四號是這棒範圍內的。五到八號是掃描工具的「祕密金鑰搜查」順手在整個 repo 撿到的**——它們是真的（每一條的檔案數都實際 `git grep` 數過），但那些目錄不是這次的掃描目標，**不能因為只找到這四條就認為 `docs/` 乾淨**。這四條與 FR-081 的 I2 報告、FR-113 的 O1 報告高度重疊，是同一批舊帳。

---

## 詳細說明

### 問題 1：改別人家的範本（唯一的新高風險）

**白話**：合規範本就是「這套框架裡，哪些控制項我們公司要做」的清單，底下還掛著每一條的實作說明。範本有三種身分：

- **自己家的**（TENANT）——你建的，你自己用
- **母公司分享下來的**（SHARED）——集團母公司做好給子公司參考的，子公司**看得到但不該改**
- **原廠公版**（SYSTEM）——我們出廠附的，**所有客戶都看得到，誰都不該改**

編輯範本的功能長這樣：

```
PUT /module-frame/<哪一份範本>     body: { "include_controls": [...] }
```

送出後系統會做三件事：更新這份範本的控制項清單、**把被拿掉的控制項底下的實作記錄整批刪除**、替新加的控制項建流程範本。

**問題在於：這段程式從頭到尾沒有問過一句「這份範本是不是你家的」。**

#### 為什麼資料庫沒擋下來

系統其實有兩層防護，這次**兩層同時失效**，而且失效的原因彼此獨立：

**第一層（資料庫層）**：`compliance.module_frames` 這張表有設「不准改別人家的、不准改公版」的規則。但這次的請求**只送了控制項清單、沒有改到這張表上的任何欄位**，所以程式根本沒有對這張表發出任何更新指令——**沒發指令，規則就沒有機會觸發**。而真正被改被刪的那三張表（`oscal.profile_imports`、`oscal.ssp_implemented_requirements`、`oscal.catalog_control_parts`）**根本沒有設這層防護**。

這點是實際數過的，不是推論：

```bash
# 整個 oscal schema 只有 5 張表有這層防護，這三張都不在裡面
$ grep -c "ALTER TABLE oscal.profile_imports ENABLE ROW LEVEL SECURITY" scripts/init/02-schema.sql
0
$ grep -c "ALTER TABLE oscal.ssp_implemented_requirements ENABLE ROW LEVEL SECURITY" scripts/init/02-schema.sql
0
$ grep -c "ALTER TABLE oscal.catalog_control_parts ENABLE ROW LEVEL SECURITY" scripts/init/02-schema.sql
0
```

**第二層（程式層）**：系統裡**已經有一支專門擋這件事的警衛程式**——`common/authz/sharing.py` 的 `assert_scope_writable`。它的說明文字甚至直接寫明了為什麼不能只靠資料庫：

> 「RLS 的 update policy 判準是『資料租戶在我的 allowed path 內』，而母租戶的 allowed path 涵蓋整個子樹⋯⋯所以這一刀要顯式切在『作用租戶＝資源租戶』上。」

**問題是它只有在「使用者要改範本的分享身分」時才會被叫到**：

```python
# app/module_frame/service/module_frame_service.py:110-118
new_scope = payload.get("scope")
if new_scope is not None and new_scope != mf.scope:
    assert_scope_writable(mf.tenant_id, new_scope)   # ← 警衛只站在這個 if 裡面
    mf.scope = new_scope
self.module_frame_domain_service.update_module_frame(mf, locale)
# 適用控制項增減 → profile + IR 同步
if payload.get("include_controls") is not None and ...:
    self._resource_library_app_service.update_applicable_controls(   # ← 這條路沒有警衛
        uid, payload["include_controls"], user)
```

**只送控制項清單、不送分享身分，就完全繞過警衛。**

#### 攻擊怎麼進行

1. 攻擊者是自己公司的管理員——這是一般客戶就有的角色（`scripts/init/04-seed-core.sql` 預設就配了範本編輯權）。
2. 他打開資源庫列表，看到原廠公版範本（**每家公司都看得到，這是設計如此**）或母公司分享下來的範本，抄下編號。
3. 他送出 `PUT /module-frame/<別人的範本編號>`，內容只放 `{"include_controls": []}`。
4. 網址入口的權限檢查問的是「**你有沒有編輯範本的權限**」——他在自己公司有，通過。
5. 資料庫那層因為沒有欄位變動，連觸發的機會都沒有。
6. 那份範本的控制項清單被清空，底下**所有實作記錄被刪除**（說明文字因為資料庫的連鎖刪除設定一起消失）。

**打原廠公版的話，影響的是所有客戶。**

#### 怎麼修

在 `update_applicable_controls` 動手寫任何東西之前，先把範本那筆重新撈出來，明確擋兩件事：

- 身分是原廠公版（SYSTEM）→ 直接拒絕
- 範本的所屬公司跟呼叫者的公司不同 → 直接拒絕

做法照抄 `common/authz/sharing.py` 既有的判斷方式即可。**不要想著靠資料庫補**——那三張表沒有防護，而且它們本來就設計成「跟著範本走」，補防護是第二道保險、不是這條的解法。

同一條路徑上的 `_init_ao_workflows`（處理新增控制項那半邊）需要同一道檢查。

---

### 問題 2：資料庫帳密寫死在腳本裡

**白話**：一支產生資料庫結構文件的腳本，把連資料庫需要的**五樣東西全部打在程式碼裡**：

```python
# docs/system-design/scripts/generate_db_schema_docx.py:33-34
DB_CONN = dict(host="192.168.50.189", port=25432, dbname="guidant_ai_poc",
               user="cm_app", password=<密碼字面值>)
```

**主機、埠號、資料庫名、帳號、密碼——複製貼上就能連。** 指向的是 POC 機器，依專案規定「等同正式環境、對外 demo、客戶會試玩」。

更麻煩的是同一組密碼字串的散布範圍——實際數過：

```bash
$ git grep -l '<見 .env>' | wc -l
249
```

**249 個受版控檔案**，其中不少配的是 `cmmgr` 這個會**繞過「每家公司只看自己資料」隔離機制**的帳號。

**這條與 FR-081 的 I2 報告第 1 條、FR-113 的 O1 報告第 4 條是同一件事**，不是新發現，但這次是從另一條路徑再次撞到——代表它還沒被處理。

**怎麼修**：腳本改成跟主程式一樣從環境變數讀（`config/config_loader.py` 已經有現成的 `DB_HOST`／`DB_USER`／`DB_PASSWORD`），把 188／189 上 `cm_app` 與 `cmmgr` 的密碼換掉，然後清掉那 249 個檔裡的字串。**注意：單純在後面的 commit 把檔案刪掉沒用**——舊的 commit 裡還讀得到。

---

### 問題 3：建資源庫不檢查權限

**白話**：建立資源庫的入口只驗「有沒有登入」，沒有問「你有沒有建的權限」。而隔壁那支做同樣事情的入口（`ModuleFrameRoute.post`）**是有要求權限的**。

這個檔案裡有一段註解解釋為什麼放開：建立時一定會綁呼叫者自己的公司、不會碰到原廠公版，所以「一般登入者即可」。

**這個理由對了一半**——就「會不會動到別人家的資料」而言確實成立。但它沒回答兩件事：

1. **公司內部哪些角色該能建？** 唯讀帳號、一般稽核員是不是也該能建範本庫？
2. **每次呼叫的代價有多大？** 建一次資源庫 = 複製一整套框架控制項目錄 + 每個查核項目各產一張 BPMN 流程範本。這不是一筆小寫入。而系統**沒有裝任何流量限制**，所以重複呼叫就是用很小的代價把資料庫灌大。

**怎麼修**：補上跟隔壁入口一樣的權限要求。Excel 匯入、Word 匯入那兩條也會建資源庫，三個入口要一致，不然補了這個還是有其他路可以進來。

---

### 問題 4：匯出的 Word 檔可以被動手腳

**白話**：Word 檔（.docx）內部其實是一種標記語言寫的文字檔。系統匯出時，會把使用者填的「系統名稱」「系統描述」「網路架構」「資料流」等欄位填進 Word 範本。

**填進去的時候沒有做「跳脫處理」**——也就是沒有把使用者輸入裡的特殊符號轉成安全形式。所以如果有人在系統描述裡填入一段**看起來像 Word 內部指令的文字**，它就會被 Word 當成真的指令執行，而不是顯示成文字。

技術上的位置：

```python
# app/oscal/service/export/ssp_docx_generator.py:60
tpl.render(context)          # ← 這裡少了 autoescape=True
```

用的那個套件（docxtpl）**預設是關閉跳脫處理的**，要自己明確打開。

**可以造成什麼**：

- **偽造段落**——在匯出的稽核文件裡插入稽核員根本沒寫過的實作說明或標題
- **藏外部指令**——Word 有一種「欄位指令」可以叫閱讀者的 Word 去抓外部檔案。閱讀者一開啟文件就會觸發

**為什麼這條重要**：匯出的 SSP 就是要交出去給稽核方的正式文件。內容能被動手腳，等於**證據本身不可信**。

**怎麼修**：`tpl.render(context, autoescape=True)`，一行。或者把每個從資料來的字串先包成套件提供的安全型別。**在這個出口修比在每個輸入端擋好**——因為 Word、PDF、ODT 三條路都走這同一個出口。

---

### 問題 5～8：舊憑證還躺在對話紀錄裡

這四條是掃描工具的祕密金鑰搜查在整個 repo 撿到的，**不是這棒的掃描範圍**，但既然撿到就照實報。每一條的檔案數都實際數過：

| 是什麼 | 幾個受版控檔案 | 現在的狀態 |
|---|---|---|
| Google 雲端硬碟應用程式密鑰 | 12 | **沒在 CM-1607／CM-1608 的清單裡，未換過** |
| 客戶雲端權杖的加密金鑰（兩把，兩個環境）| 10 | **沒在清單裡，未換過** |
| OpenAI／Anthropic／Google AI 金鑰 | 9 | repo 內部紀錄說 2026-09-08 已作廢，但**檔案裡的字串沒清**（CM-1607 尚未開工）|
| 登入憑證簽章金鑰 | 30 | 安裝程式出的每套系統各自產生新的，**外流的是手動設定的開發／測試機那把** |

**這四條跟 FR-081 的 I2 報告第 4～6 條、FR-113 的 O1 報告第 3 條指的是同一批東西。** 從三個不同方向重複撞到同一件事，本身就是個訊號：**這批舊帳還在原地**。

**特別注意前兩條**：它們**不在現有的 CM-1607／CM-1608 修正清單裡**。前兩棒的報告雖然提過雲端硬碟相關的金鑰，但既有工單列的是 AI 金鑰與資料庫密碼。如果照現有工單做完，這兩把還是會留著。

**問題 6 和問題 2 可以串起來**：拿問題 2 的資料庫密碼把客戶的雲端硬碟權杖撈出來，再用問題 6 的加密金鑰解開，就能直接讀客戶的雲端硬碟內容。兩條單看都是「中」，串起來的實際後果比任何一條單獨都嚴重。

---

## 這棒沒有回答的事

派工卡片點名要看的幾件事裡，**有兩件掃描沒有給出結論**，要據實說明：

**一、`ssp_libreoffice_converter.py` 的暫存檔與多人同時匯出**。卡片特別點名這支是整批唯一會呼叫外部程式的檔，問了「檔名會不會被使用者控制」「多人同時匯出會不會互相踩到」。掃描讀了這支檔但**沒有提出任何發現**——可能是真的沒問題，也可能是這一輪的快速檔沒追到那個深度。**不能當成「已確認安全」**，要確認得另外開檔細讀。

**二、9 段手寫 SQL 的 SQL injection 檢查**。研究員從 `update_applicable_controls` 這一段找到了問題 1，但**沒有逐段回報其餘 8 段的參數綁定狀況**。從問題 1 的描述看，那幾段用的是綁定參數（`:u`、`:ids`、`:sid`）而不是字串拼接，這是安全的寫法；但這是從報告內容推得的，**不是研究員逐段核過的結論**。

**三、`module_frame_template_ssp_route.py`（38 行，零守門）通到哪裡**。掃描沒有針對這支提出發現。

如果這三件會影響後續決策，**需要另外開一棒針對性地讀**。

---

## 執行概況

| 項目 | 內容 |
|---|---|
| 掃描範圍 | 11 支檔、1,772 行（資源庫 ＋ 公版範本 ＋ 匯出三條線）|
| 程式碼版本 | `77a8906d`（branch `feature/review`，工作區有未提交異動）|
| 工具設定 | `claude-security` plugin v0.10.2.3／effort `low`／focus `attack-surface` |
| 派出／回報 | **26 個 agent 派出，26 個回報，0 失敗、0 空回**（研究員 2 派 2 回，零重試）|
| 候選發現 | 8 條原始候選，去重後仍 8 條，**零漏投**（`unreviewed_candidate_sites: 0`）|
| 面板驗證 | **有跑完**。8 條各由 3 位驗證員從不同角度投票，共 **24 票**；問題 1～7 全部 **3:0** 通過，問題 8（登入簽章金鑰）**2:1** 通過 |
| 驗證章 | `verified` |
| 耗時 | 3 小時 0 分 |
| 報告原檔 | `CLAUDE-SECURITY-20260921-060255/`（工具產出，含 JSONL／SARIF／版本戳記）|

⚠️ **沒有任何程式碼被執行過。** 沒有跑測試、沒有實際發動攻擊、沒有驗證過概念驗證程式。所有結論都來自讀程式碼。

**本報告所有 8 條發現，runner 都自己開檔核對過**，不是只採信面板：問題 1 核了那段程式、警衛程式的說明、`module_frame_service.py` 的呼叫條件、三張表沒有防護（`grep -c` 各回 0）、以及入口的權限要求；問題 2 核了第 34 行字面值與 249 個檔的數字；問題 3 核了兩支入口的權限裝飾器對比；問題 4 核了第 60 行；問題 5～8 各以 `git grep -l` 數過檔案數。

---

## 建議的後續

1. **問題 1 應優先處理**——這是本批目前唯一的新高風險，修法明確（既有警衛程式抄過來），影響面是「客戶資料可被別的客戶破壞」。
2. **問題 5、6 要補進既有的憑證清理工單**（CM-1607／CM-1608）——它們現在**不在清單上**，照現有工單做完還是會留著。問題 6 與問題 2 串起來可以直接讀到客戶的雲端硬碟內容。
3. **問題 3、4 可以併一張卡**——都是單點修改（補一個權限裝飾器、加一個參數），風險中等但修起來很便宜。
4. **考慮補一棒**：上面「這棒沒有回答的事」那三項，特別是 LibreOffice 那支（整批唯一呼叫外部程式的地方）。
5. **問題 2、7、8 交給首腦判斷**——與前兩棒重疊，應該併進既有項次而不是另計。
