---
title: O9a 檢查結果：Excel／Word 兩線共用的資料轉換接縫
---

# O9a 檢查結果：Excel／Word 兩線共用的資料轉換接縫

> 檢查日期 2026-09-22｜掃描耗時 1 小時 37 分｜對應卡片 CM-2069

---

## 🔴 一句話結論

**掃完了，找到 3 個問題，最嚴重的是兩條：任何一個登入帳號（哪怕是只能看的旁觀者）都能把整個公司的合規範本清空並換成自己的內容——Excel 一條、Word 一條，兩條各自獨立成立。**

那份範本是所有新專案的起點，被換掉之後**每一個之後開的稽核專案都會長在被污染的內容上**，而且清空是連鎖刪除（控制措施、對應條文、系統元件、負責單位、外部授權、設備資產全消失），沒有復原按鈕。

**派工時點名要追的第一問——「兩條線呼叫共用函式的方式一不一樣」——答案是「有，而且是重點」**：詳見下方〈兩線並排比對〉。兩線在**權限這一層**的不一致最嚴重（Word 那條的確認步驟還額外接受使用者自己指定要覆寫誰），在**資料防護這一層**則是兩邊都沒驗、一起漏。

派工時點名要追的第三問（「有沒有把檔案裡的編號直接當系統編號用」）**有一條**，就是下方問題 3——不是覆寫既有資料的那種形狀，而是「偽造某個人被對應到這筆資料上」。

O3b 交代要追的公式注入殘段（`= + - @` 開頭的儲存格）：**本棒範圍內沒有中和，但也沒有惡化**——這幾支接縫檔只做搬運，不動內容，該補的位置仍在 O3b 指出的解析端與匯出端，本棒不另計。

---

## 這一棒在檢查什麼

使用者上傳一份系統安全計畫（Excel 或 Word），系統要把裡面的文字轉成內部資料格式存起來。**這一棒掃的是「轉換」這個動作**，也就是 Excel 與 Word 兩條匯入線唯一共用的那支程式（`_common.py`，620 行、20 支函式），外加兩支呼叫它的檔（各約 150 行）。共 3 檔、916 行。

之所以要單獨掃一棒，是因為共用檔有一種**只有並排看才看得出來**的病：同一支函式，Excel 那邊呼叫前先驗過、Word 那邊直接送進來（或反過來），分開在兩棒裡看永遠看不到。

---

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - 問題 1、2（Excel／Word 匯入無權限檢查，Word 確認可換目標）＝M11 第 2、3 條，✅ 已修（CM-2178，commit `abf1cf8a4`，1.21.0 出貨）。
> - 問題 3（負責人對應使用者編號由使用者自填）：M11／M12 頁與 SUMMARY 都查不到對應條目，保留原文、現況待核。

## 找到什麼：3 個問題

| # | 嚴重度 | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 |
|---|---|---|---|---|---|
| 1 | 🔴 高 | **Excel 匯入沒有檢查這個人有沒有權限改範本**，只檢查了有沒有登入 | 任何登入帳號可以把公司的合規範本**整份清空再換成自己的內容**。同一份範本會被複製到每個新開的專案，等於污染所有未來的稽核。若目標是系統公版範本（設計上人人可讀），破壞範圍跨出自己公司 | ① 任何一個登入帳號（不需任何角色或專案身分）<br>② 範本的識別碼——由「列出資源庫」這支同樣只驗登入的端點提供給每個登入者<br>③ 一份格式合法的 Excel（內容可以是垃圾） | `app/oscal/service/ssp_excel_import_app_service.py:478`（`_confirm_update_module_frame`）<br>入口 `api/oscal/routes/ssp/ssp_excel_import_route.py:34`（上傳）與 `:101`（確認） |
| 2 | 🔴 高 | **Word 匯入同樣沒有檢查權限**，而且確認的那一步**還接受使用者自己指定「要覆寫哪一份範本」** | 同上，但更直接：攻擊者可以先用一份自己有權限碰的範本建立工作，等到按確認時再把目標換成別人的範本 | ① 任何一個登入帳號<br>② 一份能被解析的 Word 檔（用來建立待確認的工作）<br>③ 目標範本的識別碼（同上，人人拿得到） | `app/oscal/service/ssp_docx_import_app_service.py:384`（`confirm_import`）<br>接受外部指定目標在 `:335`<br>入口 `api/oscal/routes/ssp/ssp_docx_import_route.py:94` |
| 3 | 🟡 中 | **「這位負責人對應到系統裡哪個使用者」這個編號，使用者可以自己填**，系統不檢查型態就存下去 | 兩種後果：①填一個不是數字的值，之後**任何人打開這份範本的匯入預覽都會直接壞掉**（回 500），而且會一直壞到有人去資料庫改掉；②填別人的編號，就在資料上**偽造出「某某人是這筆資料的負責人」**，開新專案時系統會照這個假連結自動建議把那個人拉進專案 | ① 能按「確認匯入」（依問題 1，就是任何登入帳號）<br>② 那份檔案裡至少要有一列負責單位或參與人員<br>③ 壞掉那半邊還需要有人之後去打開該範本的匯入預覽 | 存進去：`app/oscal/service/import_adapter/_common.py:123`（`match_props`）<br>使用者填得到：`app/oscal/service/ssp_excel_import_app_service.py:695`<br>讀爆掉：`app/oscal/service/import_diff/snapshot_views.py:90` |

---

## 🔴 兩線並排比對（本棒的核心題目）

派工卡指定的第一問：同一支共用函式，Excel 與 Word 兩邊呼叫前驗的東西一不一樣。逐支比對結果如下。

### 一、權限這一層：兩邊都沒守，但破法不同（問題 1、2）

| | Excel 線 | Word 線 |
|---|---|---|
| 上傳步驟有沒有檢查權限 | ❌ 只驗登入 | ❌ 只驗登入 |
| 確認步驟有沒有檢查權限 | ❌ 只驗登入 | ❌ 只驗登入 |
| 檔案裡**唯一一處**權限檢查 | 無 | 有一處（`ssp_docx_import_app_service.py:161` 檢查「能不能建範本」），**但只守在「另開新範本」那條岔路，覆寫既有範本這條走不到它** |
| 確認時能不能換目標 | 不能（用工作建立時記下的目標） | ⚠️ **能**（`:335` 讀使用者送來的 `source_uid`） |

**這正是前九棒反覆出現的同一個病：有人守了一半。** Word 那條檔案裡明明有一支能力檢查，卻只擋在「建立新的」那條岔路上；「清空既有的再覆寫」這條——破壞力更大的那條——旁邊什麼都沒有。Excel 那條更乾脆，整支檔案一處檢查都沒有，而且 `ssp_excel_import_app_service.py:128` 還留著一行註解寫「權限由 RBAC middleware 處理」——**這句話是假的**，實際掛在這條路上的只有登入、記錄、追蹤編號、唯讀授權四樣，沒有一樣看能力。

對照組：同一份資源庫的其他寫入端點（`api/module_frame/routes/` 底下）每一支都掛著 `@require_capability("module-frame.update")`。也就是說**同一筆資料，正門有鎖，這兩條匯入側門沒有**。

### 二、資料防護這一層：兩邊一起漏（問題 3）

共用函式 `match_props`（`_common.py:89`）負責把「這位負責人對應到系統裡的誰」寫進資料。它**假設送進來的編號是系統自己比對出來的**，所以拿到什麼就 `str()` 存什麼。

- **Excel 線**：呼叫前**沒有驗**（`excel_to_oscal_ssp.py:43、55`）。而且 Excel 這條的確認步驟允許使用者用「內容覆寫」功能塞任意欄位到任意一列（`ssp_excel_import_app_service.py:695` 的 `rows[idx][k] = v`，只檢查列號、不檢查欄位名），**所以這個欄位實際上是使用者可以直接寫的**。
- **Word 線**：呼叫前**也沒有驗**（`docx_to_oscal_ssp.py:48`）。

兩邊一致地沒驗——這是「共用函式假設呼叫者已驗、兩個呼叫者都假設共用函式會驗」的典型形狀。諷刺的是**同一個目錄下就有現成的正確寫法**：`party_match_enrich.py:22` 的 `_to_int()` 正是「轉得成整數才用、轉不成就丟掉」，只是沒被這條路用上。

### 三、其餘共用函式：兩邊呼法一致，沒找到不對稱缺口

以下逐支確認過兩線的呼叫方式，**沒有發現「一邊驗過、另一邊沒驗」**：

| 共用函式 | 比對結果 |
|---|---|
| `clean` / `gen_uuid` | 兩線用法相同，純字串整理與產生識別碼，不吃使用者語意 |
| `dedupe_parties` / `dedupe_rows` | 兩線都是最後統一呼叫一次；去重的判斷欄位（型態＋名稱，去空白、不分大小寫）對兩線一致。**判太鬆會合併不同人的疑慮不成立**——鍵包含型態與完整名稱，只有完全同名同型態才合併，且合併行為本身是刻意的（修過的重複匯入問題） |
| `build_system_characteristics` | 兩線參數相同。差別只在 Word 線通常不注入「查使用者編號」的函式，缺了就跳過寫入，不是缺口 |
| `build_components` / `build_leveraged_authorizations` / `build_inventory_items` | 兩線呼叫順序與參數完全一致（先建外部授權拿對照表 → 建元件 → 建資產）|
| `component_uuid_by_title` / `_resolve_implemented_components` | 兩線相同。**這支雖然是「拿檔案裡的標題去對應系統內的編號」，但對應範圍只限於同一次匯入自己剛產生的元件**（`comp_uuid_by_title` 來自本次 `build_components` 的產出，不是查資料庫），查不到就略過，**不構成「使用者指定要改到誰」** |
| `assemble` / `maybe_by_component` / `soa_props` / `impl_status_desc_props` | 純組裝，兩線一致 |
| `_login_name_from_user_label` / `_resolve_owner_uid` | 只有 Excel 線實際會用到（Word 側不注入查詢函式）。查不到回 None 不寫入，查詢出錯有攔截不中斷匯入；**沒有跨公司撈到別人使用者的路**——查詢函式由上層注入，本身在資料庫隔離機制之下 |

**一個小的不對稱（不構成安全問題，但值得記一筆）**：Word 線的 `_build_parties` 逐列先檢查「這列是不是字典」（`docx_to_oscal_ssp.py:36`），Excel 線沒有（`excel_to_oscal_ssp.py:33、48`）。Excel 的資料來自解析器產出的固定結構，正常情況不會不是字典，所以現在打不出問題；但同一支共用函式的兩個呼叫者對「輸入長什麼樣」抱持不同假設，正是日後長出缺口的地方。修問題 3 時順手補齊即可。

---

## 詳細說明

### 問題 1：任何人都能清空並替換公司的合規範本（高，Excel 線）

**白話**：系統裡有一份「合規範本」，是公司所有稽核專案的起點——開新專案時會把它整份複製一份過去。這份範本可以用 Excel 匯入的方式更新，而**更新它的時候，系統只確認了「你有登入」，沒有確認「你有沒有權限改它」**。

打法很直接：

1. 攻擊者用任何帳號登入（連旁觀者身分都可以），呼叫「列出資源庫」拿到範本的識別碼——這支端點同樣只驗登入。
2. 上傳一份格式合法但內容是垃圾的 Excel，指定目標是那份範本。
3. 按確認。系統執行 `clear_ssp_body()`——**連鎖刪除**整份範本的內容（控制措施、每條對應條文、系統元件、負責單位、外部授權、設備資產、系統特性全部消失），接著把垃圾寫進去。

```python
# app/oscal/service/ssp_excel_import_app_service.py:478
self._oscal_io.clear_ssp_body(template_ssp_id)
```

**為什麼是高不是中**：前提只有「有一個帳號」，沒有任何附加條件；後果是不可逆的資料破壞，而且會沿著「範本 → 新專案」擴散到所有未來的稽核。

**跨公司的那一半要說清楚**：資料庫對「資源庫」這張表有開客戶隔離，但隔離規則明文允許每個客戶讀得到標記為「系統公版」的那些；而存放範本實際內容的那些表**沒有開隔離**。所以如果系統公版範本存在，這個攻擊打到的就是全體客戶共用的內容。**我沒有連線上資料庫確認目前有沒有這種公版資料**，這一半是照設計推的，需要決策者查一次 `compliance.module_frames` 有無 `scope='SYSTEM'` 的資料才能定案。

**怎麼修**：在 Excel 匯入的上傳與確認兩支端點，對目標是資源庫的情況掛上與其他寫入端點相同的能力檢查——`common.authz` 的 `require_capability("module-frame.update")`。另外要比照 `assert_scope_writable` 的做法，確認這份資源庫是呼叫者能寫的（系統公版／共享的，只有平台管理員能動）。順手把 `:128` 那行假的「權限由 RBAC middleware 處理」註解刪掉——留著會讓下一個人以為已經守過了。

### 問題 2：Word 匯入同樣沒守，而且確認時還能換目標（高，Word 線）

**白話**：與問題 1 同一件事，發生在 Word 匯入這條線上。破壞後果完全一樣。但這條多一個毛病：**按「確認」的時候，系統會讀使用者送來的「要寫到哪一份範本」，而不是用當初上傳時記下來的那份。**

打法比 Excel 那條還省事：

1. 攻擊者上傳一份隨便的 Word 檔，**不指定目標**（走「之後再選」那種形狀），拿到一個待確認的工作。
2. 按確認時，在請求內容裡填上受害範本的識別碼。
3. 系統解析出那份受害範本，第一次嘗試寫入時因為「已經有內容了」而失敗，接著在錯誤處理裡執行 `clear_ssp_body()` 清空它，然後把攻擊者的內容寫進去。

```python
# app/oscal/service/ssp_docx_import_app_service.py:384
self._oscal_io.clear_ssp_body(template_ssp_id)
```

這條線的檔案裡**其實有一支能力檢查**（`:161`，檢查「能不能建範本」），但它守在「另開一份新範本」那條岔路上，覆寫既有範本這條根本走不到它。**這就是前九棒一再出現的「守了一半」。**

**怎麼修**：兩件事一起做。① 上傳與確認兩支端點掛 `require_capability("module-frame.update")`，既有的「能不能建範本」檢查保留。② **確認時不要相信請求裡送來的目標**，改成拿當初建立工作時記下的那份——一個「確認」動作不應該能改去動一份當初沒被授權的資料。

### 問題 3：負責人對應到誰，使用者自己說了算（中）

**白話**：範本裡的每位負責單位／參與人員，系統會試著對應到系統內的某個使用者帳號，並把那個人的編號記在資料上。這個編號**本來應該是系統自己比對出來的**，但實際上使用者可以在按確認時自己塞進去，而系統存下去之前不檢查它是不是一個合法的編號。

程式是這樣：

```python
# app/oscal/service/import_adapter/_common.py:120-123
muid = parsed_party.get("matched_user_id")
if muid is not None:
    pairs.append(("matched-user-id", str(muid)))
```

`str()` 的意思是「拿到什麼都轉成文字存起來」——包括 `"not-an-int"` 這種東西。使用者塞得進去的原因在確認那一步：

```python
# app/oscal/service/ssp_excel_import_app_service.py:694-695
for k, v in field_overrides.items():
    rows[idx][k] = v      # 欄位名不設限，任何欄位都能覆寫
```

兩種後果：

- **把系統打壞**：讀回來的地方寫的是 `int(raw_uid)`（`snapshot_views.py:90`），沒有防呆。存了一個不是數字的值之後，**任何人打開這份範本的匯入預覽都會直接 500**，而且會一直壞到有人進資料庫把那個值改掉。
- **偽造負責人**：填一個別人的編號，資料上就出現「某某人是這筆資料的負責人」這個假連結。前端讀回來會顯示那個人的暱稱，開新專案的精靈還會照這個假連結**自動建議把那個人加進專案**。

**為什麼是中不是高**：打壞那半邊要等有人去開預覽才發作；偽造那半邊是誤導而不是直接取得權限（被建議加入專案的人仍然要有人按下確認）。但它的前提依賴問題 1——問題 1 修好之後，能打這條的人就縮小到本來就有權限改範本的人，嚴重度會再降一級。

**怎麼修**（三件，建議一起）：
1. 在 `_common.py:123` 存進去之前先驗型態——照抄同目錄 `party_match_enrich.py:22` 的 `_to_int()`：轉得成正整數才存，轉不成就不寫這個欄位。
2. 把 `snapshot_views.py:90` 的 `int(raw_uid)` 改成轉不成就當 None，讓已經被寫壞的資料不再讓整頁掛掉。
3. **「內容覆寫」不應該開放任意欄位**——把 `matched_user_id`、`matched_org_unit_id`、`match_method` 這類系統內部用的欄位從可覆寫清單中排除，不要讓 `rows[idx][k] = v` 什麼都收。

---

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

分兩層講，因為這兩件事的可信度差很多。

**第一層：「這三條是真的嗎」——可信度高。**
三條都經過三個獨立檢查員各自投票，**9 票全數成立、0 票反對**（每條 3 票，三個檢查員分別從「打不打得到」「出事多嚴重」「有沒有其他防護擋住」三個角度看）。我另外自己開檔核對過每一條的關鍵行：兩支確認端點確實只掛 `@jwt_required()`、`clear_ssp_body()` 確實在那兩行、`str(muid)` 與 `int(raw_uid)` 確實沒有防呆、`rows[idx][k] = v` 確實不限欄位名。**這三條不是推測。**

唯一有保留的是問題 1 的**跨公司那一半**——我沒有連資料庫確認目前有沒有「系統公版」範本存在。同公司內的破壞不受這個保留影響，照樣成立。

**第二層：「只有這三條嗎」——中等，不能當成保證。**
這一棒是低強度設定，只派一位研究員把 916 行看過一遍，再交給三人面板驗。低強度的設計目的是快速篩出明顯的東西，不是窮盡。此外掃描工具本身有已知的三個盲點（見 `security-scan-lead` skill 第三節），需要在驗收時自行補。

不過本棒**題目很集中**（就是兩線並排比對），而且我在面板之外自己逐支對照了 20 支共用函式的兩線呼叫方式（結果見上方〈兩線並排比對〉第三段表格），**那一題的覆蓋是完整的**。

---

## 執行概況

| 項目 | 數值 |
|---|---|
| 掃描範圍 | 3 檔／916 行（`_common.py` 620、`excel_to_oscal_ssp.py` 144、`docx_to_oscal_ssp.py` 152）|
| 強度設定 | low（一位研究員 ＋ 三人面板）|
| 耗時 | 5,815 秒（約 1 小時 37 分）|
| 派出的檢查代理 | 10 個，全部正常回來，0 個失敗、0 個被砍 |
| 候選發現 | 3 條（去重後仍 3 條）|
| 面板票數 | **9 票全數投出**（3 條 × 3 個檢查員各一票），全部投「成立」，沒有漏投、沒有棄權 |
| 嚴重度有沒有被面板調降 | 無 |
| 掃描版本 | `00c60d1deae7309cc65012a288fec19c4f0bd6b8` |
| 驗證章 | `verified` |

**掃描範圍的三個檔全部讀完**，沒有略過的區塊（`coverage` 內 `skippedComponents`、`droppedComponents`、`lostCandidates` 皆為空）。

---

## 給首腦的收口建議

- **問題 1 與問題 2 是同一個病的兩條腿，建議併成一張修正卡**——修法相同（掛 `require_capability("module-frame.update")` ＋ 確認時不信任外部指定的目標），分開修容易只補一邊。
- **問題 3 建議排在問題 1／2 之後**，因為它的可打性依賴問題 1；但第 2 小點（讀回時的防呆）可以獨立先做，成本一行。
- **待決策者查一件事**：`compliance.module_frames` 有沒有 `scope='SYSTEM'` 的資料。有的話問題 1／2 的破壞範圍跨出單一客戶，優先序要再往上提。
- 本棒與 O3b 指出的公式注入殘段**無交集**，不要合併登記。
