# B4 掃描報告 — SSP 匯出 Excel 範本（CM-2076）

> 範圍：`api/module_frame/routes/ssp_import_template_route.py`（169 行）＋
> `app/module_frame/service/ssp_import_template_app_service.py`（1,500 行），共 2 檔 1,669 行。
> 掃描工具：Claude Code 官方 `claude-security` plugin，effort low，focus 生產程式碼。
> 掃描基準 commit：`798a9b5f94a2`（工作區有其他 session 的未提交改動）。
> 驗證章：**verified**。只掃不修。

---

## 1. 一句話結論

**已知的那個洞確認成立，而且比原本記的更嚴重——它不只跨專案，是跨客戶**；旁邊那兩支同檔方法查過了，一支安全、一支有前提成立才會出事的低風險缺口；另外掃到一條與本功能無關但更急的問題：**一組還在用的資料庫密碼被寫進版控，而且跟著文件站公開在網路上。**

---

## 2. 這一棒在檢查什麼

系統裡有個功能叫「下載 SSP Excel 範本」。SSP 是「系統安全計畫」，一份文件裡寫著一家客戶的系統長什麼樣、誰負責哪一塊、有哪些設備、每條資安控制項他們實際怎麼做。顧問要離線作業時，可以把整份計畫匯出成一個九頁的 Excel 檔帶走。

這一棒要確認的是：**這個「帶走整份文件」的按鈕，有沒有檢查你是不是這份文件的主人。**

同一支程式檔裡有三個入口：一個從「合規範本」匯出、一個從「某份 SSP」匯出、一個從「框架版本」匯出空白表。盤點時只查過其中一個，另外兩個沒人看過——這棒就是把三個一次看完。

---

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - B4-1（下載別人的 SSP）＝M11 第 1 條，✅ 已修（CM-2188，commit `82618a297`，1.21.0 出貨）。
> - B4-2（資料庫密碼進版控並公開）：✅ 已修（CM-2049，commit `5d4c14221`；密碼換發排在正式環境上版前統一做）。
> - B4-3（Excel 公式）＝M11 第 20、21 條，✅ 已修（CM-2189，commit `895db0ef8`，1.21.0 出貨）。

## 3. 掃到什麼：總覽

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 嚴重度 |
|---|---|---|---|---|---|
| B4-1 | 下載 SSP 時**沒有檢查這份計畫是不是他自己的** | 任何登入者只要拿到別人的計畫編號，就能把**別家客戶**整份系統安全計畫下載走：人名、email、電話、地址、設備 IP 與 MAC、每條控制項怎麼做 | 有帳號且角色帶「資源庫可讀」權限（預設管理員就有）；知道對方的計畫編號（其他 API 會回傳） | `app/module_frame/service/ssp_import_template_app_service.py:280` | **高** |
| B4-2 | **一組還在用的資料庫密碼被寫進版控**，而且跟著公開文件站發佈到網路上 | 任何看得到 repo 或那個公開文件站的人，都能直接連上 POC（等同正式環境）的資料庫，而且用的是**會繞過客戶隔離機制**的管理員帳號，讀寫所有客戶的資料 | 連得到那台資料庫的網路（內網／VPN／已進到內網的任何機器） | `docs/system-design/scripts/generate_db_schema_docx.py:34`（同一組字串另散在約 250 個版控檔中） | **高** |
| B4-3 | 使用者填的文字**原封不動寫進 Excel 格子**，而檔案被設定成「打開就重算」 | 甲客戶在計畫裡填一段特製文字，乙顧問下載這份 Excel 打開時，那段文字會被 Excel 當成公式執行，把隔壁格的資料送去攻擊者的網站 | 攻擊者要能寫入該份計畫的欄位；要有第二個人下載並打開這個檔；部分手法還要對方點掉 Excel 的信任提示 | `app/module_frame/excel_template/generator.py:578` | **中** |

**另外兩支被點名要查的方法，查完的結論在第 5 節（一支沒問題、一支有條件性缺口）。**

---

## 4. 每條發現的詳述

### B4-1 下載別人的系統安全計畫，不會被擋

**白話說明**

網址是 `GET /ssp/<計畫編號>/excel-template`。程式收到這個編號之後，直接拿去資料庫把那份計畫撈出來，然後產生 Excel 回傳。**中間沒有任何一行程式去問「這份計畫是不是這個人的」。**

入口那層是有兩道檢查的：一是「有沒有登入」，二是「這個人的角色有沒有『資源庫可讀』這個權限」。但第二道問的是**「你能不能讀資源庫」**，不是**「這份計畫是不是你的」**——這是兩件不同的事。本專案的正規做法是把「是不是你的」這種檢查寫在下一層的商業邏輯程式裡，但這支**下一層也沒寫**。

**為什麼比原本登記的更嚴重**

原本總表第 128 項記的是「跨專案」。實際查過資料庫結構後，範圍更大：

- 存放 SSP 的那批資料表（`oscal.system_security_plans` 等）**沒有客戶編號欄位，也沒有開資料庫層的客戶隔離**（RLS，就是「每個客戶只能看自己資料」的資料庫機制）。我實際核對過 `scripts/init/02-schema.sql`：`oscal` 這個區域底下只有五張「匯入工作紀錄表」有客戶欄位與隔離設定，SSP 本體與它的子表都沒有。
- 也就是說，**程式這一關沒擋，資料庫那一關也擋不住**。這不是「跨專案看得到」，是**跨客戶看得到**。

**在哪裡**

| 位置 | 是什麼 |
|---|---|
| `api/module_frame/__init__.py:231` | 網址登記處 |
| `api/module_frame/routes/ssp_import_template_route.py:97-116` | 入口，只掛了「要登入」＋「資源庫可讀」 |
| `app/module_frame/service/ssp_import_template_app_service.py:280` | `generate_for_ssp` 直接用傳進來的編號撈資料，全檔沒有任何歸屬檢查 |
| `di_containers/module_frame/module_frame_containers.py:231` | 這支服務的組裝處——**連權限檢查器都沒有被裝進來**，所以想呼叫也沒得呼叫 |

**怎麼修**

同專案已經有做對的範例可以照抄：

- `app/oscal/service/export/ssp_export_app_service.py:74-75`
- `app/oscal/service/ssp_control_impl_import_service.py:97`

兩支都是在方法的第一行就先確認「呼叫的人是不是這份計畫所屬專案的參與者」。做法：把 `SspPermissionChecker` 加進 `SspImportTemplateAppService` 的建構參數、在 `di_containers/module_frame/module_frame_containers.py:231` 補上組裝，然後在 `generate_for_ssp` 的**第一行**呼叫參與者檢查，不通過就擋下來。

**另外建議（不屬本棒範圍，但這條的影響靠它才收得乾淨）**：給 `oscal` 底下的 SSP 相關資料表補上客戶編號與隔離設定。現在的狀況是「程式少寫一行檢查，整個客戶隔離就沒了」，沒有第二道防線。

**首腦核對註記**：三個獨立檢查員分別從「打不打得到」「出事多嚴重」「有沒有其他機制擋得住」三個角度投票，**三票全部認定成立**。

---

### B4-2 資料庫密碼被寫進版控，並公開在文件站上

**白話說明**

`docs/system-design/scripts/generate_db_schema_docx.py` 這支腳本裡，資料庫的主機、埠號、庫名、帳號、**密碼**全部是直接寫死的文字。這支檔案在版控裡。

更麻煩的是，同一組密碼字串還出現在約 250 個版控檔案中，其中有 67 個是 `docs/features/` 底下已經轉成 HTML 的文件——**而那批 HTML 是推上 main 就自動發佈到公開網站的**（`https://guidantai-feature-doc.jedicotech.com/`，不需要登入就能看）。

檢查員實際比對過：這組密碼**不是過期的舊值**，跟現在 `.env` 和 `docker/production/guidant.env` 裡在用的是同一組，沒有輪換紀錄。而且依專案自己的文件，這組密碼 `cm_app`（受客戶隔離限制）跟 `cmmgr`（系統管理員，**設計上就會繞過客戶隔離**）共用。

這件事已經違反本專案 CLAUDE.md 第一條「禁止將憑證寫入任何版控檔案」。

**出事會怎樣**

任何人只要連得到那台資料庫（內網、VPN，或任何已經進到內網的機器），就能用管理員帳號連上 POC 環境——而 POC 依專案定義**等同正式環境**，是給客戶試玩、對外 demo 用的。連上之後，所有客戶的專案、計畫、使用者、稽核證據都可讀可改，客戶之間的隔離完全失效。

**在哪裡**

- `docs/system-design/scripts/generate_db_schema_docx.py:34`（密碼寫死處；實際使用在同檔 `:1514`）
- 同一字串散在約 250 個版控檔，含 `docs/features/` 下 67 個已發佈的 HTML

檢查員也確認過**沒有任何機制擋著**：`.gitignore` 只蓋了 `.env` 跟 `.secrets/`，蓋不到這些路徑；沒有 git 過濾器、沒有 hook、CI 裡也沒有掃密碼的步驟。

**怎麼修**

1. **先輪換密碼**，不是先刪檔。DEV、基線庫、STG、POC 的 `cm_app` 與 `cmmgr` 都要換——這組值已經進了 git 歷史、也已經發佈在公開頁面上，**刪掉那一行不會讓它失效**。
2. 腳本改成從環境變數或 `.env` 讀（`os.environ["PGPASSWORD"]`）。
3. 在下次推 main 之前，把這組字串從版控文件與已產生的 HTML 裡清乾淨。
4. 加一道 commit 前的密碼掃描，讓「憑證不進版控」這條規則由機器執行，不要只靠人記得。

**首腦核對註記**：三票全數認定成立。**這條不在本棒原定範圍內**（是順著本功能的設定檔追出去的），但嚴重度與急迫性都高於本棒主題，故照實列入。

---

### B4-3 使用者填的字會被 Excel 當成公式跑起來

**白話說明**

產生 Excel 的時候，程式把使用者填過的文字——聯絡人姓名地址、元件說明、設備欄位、每條控制項的做法敘述、文件檔名——**原封不動**塞進儲存格。Excel 有個規則：**任何以 `=` 開頭的內容會被當成公式**。而這個檔案在產生時被設定成「打開時全部重算」（`fullCalcOnLoad = True`）。

所以甲客戶在自己的計畫裡填一段以 `=` 開頭的特製文字，乙顧問下載這份 Excel、打開，那段文字就會被 Excel 執行。

**出事會怎樣**

攻擊者可以讓公式把隔壁儲存格的內容（也就是這份計畫裡的其他資料）串進一個網址，送到他自己的伺服器；某些寫法在使用者點掉 Excel 的信任提示後，還能在對方電腦上執行外部指令。**注意這個執行發生在對方的電腦上，不在我們的系統裡**，我們的防護管不到。

**為什麼是中而不是高**

要三件事同時成立：攻擊者要先有權限寫入那些欄位、要有第二個人下載這份檔案、部分手法還要對方點掉 Excel 的信任提示。不像 B4-1 那樣一個網址就打得到。

**在哪裡**

- 寫進格子的地方：`app/module_frame/excel_template/generator.py:578`（`_write_filled_rows`）
- 上游只檢查長度、沒過濾內容：`api/oscal/serializers/ssp/ssp_control_implementation.py:42`、`api/module_frame/serializers/module_frame_party.py:9,16`

**怎麼修**

在**寫入的那幾個共用函式**處理，不要在每個呼叫端各修一次（否則下次新增欄位又會漏）。做法：值是字串且開頭是 `=`、`+`、`-`、`@`、Tab 或換行時，前面加一個單引號，或寫入後強制把儲存格型別設成文字。要改的三處：`_write_filled_rows`、`_build_vertical_single_row_sheet`，以及 lookup 工作表的產生函式——這三支都吃資料庫來的文字。

**首腦核對註記**：三票全數認定成立。**這條正好落在 B4 與 B6 的接縫上**——問題的源頭是 B4 把使用者填的內容當成可信任的資料傳下去，真正寫進檔案的動作在 B6 的 `generator.py`。B6 那一棒會從另一側再確認一次。

---

## 5. 被點名要查的另外兩支方法（首腦交辦）

盤點時只看過 `generate_for_ssp`，另外兩支沒人讀到底。兩支都實際開檔讀過了：

### `generate`（範本版，行 222-253）— **沒有同類問題**

網址 `GET /module-frame/<編號>/ssp-import-template`，拿範本編號去撈合規範本。

查法與結論：`compliance.module_frames` 這張表**有客戶編號欄位，而且有開資料庫層的客戶隔離**（`scripts/init/02-schema.sql:24303` 起，四種操作各有一條規則）。查詢時資料庫會自動只回傳這個人所屬客戶的資料，別家客戶的範本編號**撈出來就是空的**，程式會直接回「找不到」。

所以這支雖然一樣沒有在程式裡寫歸屬檢查，**但資料庫那一關擋得住**。掃描的三個檢查員也沒有對這支提出任何發現。

⚠️ 但要記一筆：這支是**靠資料庫隔離在守，不是靠程式**。同客戶底下不同專案之間的細分歸屬，這層擋不到；若未來 SSP 那批表補上隔離、或範本表的隔離設定被改動，這支的安全性會跟著變。

### `generate_by_framework_version`（框架版本版，行 599-651）— **摸得到客戶資料，但有限**

首腦問的是：這支到底是「只查全域框架目錄」（風險低），還是「也會碰到客戶資料」（風險高）。**答案是後者，但摸到的範圍有限。**

拆成兩半看：

**控制項清單那一半：純全域，沒問題。**
`framework_version_uid` → `oscal.framework_versions` → `catalog_id` → 該框架的全部控制項。`oscal.frameworks` 與 `oscal.framework_versions` 兩張表**都沒有客戶欄位、也沒有隔離設定**——因為它們本來就是全域共用的產品資料（CMMC、ISO 27001 這些框架，每家客戶看到的都一樣）。這部分是唯讀的公用目錄，沒有跨客戶問題。

**下拉選單那一半：會撈到呼叫者所屬客戶的實際資料。**
行 640 呼叫 `_fetch_lookups_without_mf()`，它會撈（行 676-689）：

| 撈什麼 | 撈到的內容 | 對應程式 |
|---|---|---|
| 使用者 | 該客戶全部使用者的暱稱與帳號（email） | `:809` |
| 部門 | 該客戶全部部門名稱 | `:816` |
| 設備 | 該客戶全部設備的名稱與 IP | `:849` |
| 資訊系統 | 該客戶全部資訊系統的簡稱與名稱 | `:861` |
| 範本程序書 | 空的（這支刻意留空） | `:678` |

另外行 641 的 `_fetch_lookups_helpers()` 還會多帶使用者的 email 與姓名、設備的 IP 與作業系統。

**這算不算漏洞？結論：不算跨客戶外洩，但是「權限粒度過寬」的缺口。**

我核對過這四張表（`public.users`、`public.org_units`、`public.devices`、`compliance.information_systems`）**都有開客戶隔離**，所以撈到的一定是呼叫者自己公司的資料，**拿不到別家客戶的**。

但問題在於：這支的入口只要求「資源庫可讀」權限。**一個只被授予「看合規範本」的低權限使用者，可以用這支下載一份空白範本，而這份空白範本的下拉選單裡會附上全公司的使用者 email、全部設備的名稱與 IP、全部資訊系統清單。**這在滲透測試裡是很好用的內部偵察材料——雖然不跨客戶，但它讓一個本來只該看到範本的人，拿到了全公司的資產與人員清冊。

三個檢查員沒有把這條提為獨立發現（掃描重點在跨客戶那條），**這是我實際讀程式後補的判斷，屬中低風險，是否要收成修正卡請首腦裁示。** 若要修，方向是把下拉資料的取得改成依呼叫者實際權限縮減，而不是一律給全客戶範圍。

---

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

分兩層講，這兩件事不一樣：

**「這三條存在嗎」——可信度高。**
每一條都由三個角度不同的獨立檢查員各投一票，**九票全數投出、沒有漏投，三條都是三票全數認定成立**，沒有任何一條是靠少數票勉強過關的。而且三條的關鍵事實我都另外自己開檔核對過：B4-1 的資料表隔離設定、B4-2 的密碼是否還在用、B4-3 的寫入位置，都對得上。

**「只有這三條嗎」——不保證。**
理由：

1. 這次用的是**最快的掃描檔位**（effort low），一輪研究員加一輪投票，沒有跑威脅建模與廣度掃描那些階段。
2. 第 5 節那條 `generate_by_framework_version` 的權限過寬問題，**是我讀程式讀出來的，掃描工具沒報**——這直接說明工具會漏。
3. 掃描過程沒有執行任何程式，全部是讀出來的判斷，**沒有實際打過任何一個網址驗證**。B4-1 建議實際打一次確認。

---

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

| 項目 | 數值 |
|---|---|
| 掃描範圍 | 2 檔 / 1,669 行 |
| 基準 commit | `798a9b5f94a2`（工作區 dirty，有平行 session 改動） |
| 檔位 | effort low，focus 生產程式碼 |
| 研究員 | 派 2 支，回 2 支 |
| 原始候選 | 5 條 → 去重後 3 條 |
| 投票 | 三個檢查員 × 3 條 = 9 票，全數投出 |
| 票型 | F1 3:0、F2 3:0、F3 3:0（無任何一條被降級） |
| 驗證章 | **verified** |
| 耗時 | 約 91 分鐘 |
| 工具產出原始報告 | `CLAUDE-SECURITY-20260922-090440/`（未入版控） |

---

## 8. 待首腦裁決（掃描當時；2026-10-01 同步：第 1、3 項對應的第 128、149 項已修，CM-2188，1.21.0 出貨；第 2 項密碼輪換屬環境異動，查不到處理紀錄故保留原文）

1. **B4-1** 要不要立刻開修正卡（影響跨客戶，建議是）。
2. **B4-2** 不屬本棒範圍但更急——密碼輪換是環境異動，依環境鐵律須由決策者發令，runner 不自行處理。
3. **第 5 節的 `generate_by_framework_version` 權限過寬**要不要收成獨立卡。
4. **B4-3** 待 B6 從另一側確認後再一起決定修在哪一層。
