---
title: FR-118 W1 掃描報告——主專案 Word 內容抽取器
---

# W1 掃描報告 — 主專案 Word 內容抽取器（CM-2129）

> 範圍：主專案 BE repo `domain/oscal/parser/` 5 檔／1,658 行（原 FR-113 O7a 4 支＋補入 `framework_patterns.py`）。
> 掃描工具：Claude Code 官方 `claude-security` plugin，effort low，scoped 掃描。
> 基準 commit：`82c6627d23a2`（`feature/review`，工作區有平行 session 的未提交改動，stamp 標 dirty；範圍內 5 檔乾淨）。
> 驗證章：**verified，3 條全部 3:0 通過**。只掃不修。

---

## 1. 一句話結論

**一份幾 KB 的特製 Word 檔，就能讓解析卡到 120 秒逾時被砍。這樣的寫法 runner 一共找到四種**：三種是工具報的、三人面板都 3:0 通過；另一種是 runner 自己做惡意檔實測出來的。另外還有一條錯誤訊息吐伺服器暫存檔路徑。全部都是實測數字，不是推論。

跟 V4b 最大的不同是門檻。V4b 要平台管理員；**這裡同一家客戶裡任何登入帳號，只要知道一個資源庫的編號，就能上傳解析**（走「蓋掉既有範本」那條路，那條只檢查資源庫存不存在，＝總表第 125 項的守門缺口）。

- **W1-1（工具 F1）**：判斷「這欄是不是範本佔位字」的比對，遇到一長串空白會三次方爆炸。3,200 個空白跑 66 秒（實測）。
- **W1-2（工具 F2）**：模糊比對前「去掉括號註記」那步，遇到一長串 `[` 會平方級變慢。32 萬個 `[` 跑 30 秒（實測）。
- **W1-3（工具 F3）**：逐段走訪文件時每一步都重建整份段落清單，段落數平方級變慢。2KB、3,000 個空段落，轉接器跑 96 秒（實測）。
- **W1-4（runner 補）**：表格一格宣告「橫跨 N 欄」，程式照數字逐欄產生物件。1.5KB 的檔宣告兩百萬欄跑 14 秒，一億欄跑六分鐘沒停（實測）。
- **W1-5（runner 補）**：Word 檔打不開時，錯誤訊息帶出伺服器暫存檔的完整路徑，原樣回給前端。

四種卡死同屬一類（CWE-1333／CWE-407，正規表達式或演算法複雜度造成的服務阻斷），我建議合成**一條中**登總表。錯誤訊息吐路徑另登一條低。卡片擔心的 XML 外部實體攻擊不成立；控制項編號 regex 也不成立。

---

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

使用者在「匯入 SSP」上傳一份 Word 檔（.docx），系統從裡面讀出三類東西：

- 每條控制項的實作敘述；
- 系統名稱、人員、外部服務；
- 元件、設備清單。

讀完先存成一張「解析單」給使用者審閱，確認後才寫進 SSP。

| 步驟 | 在哪 | 做什麼 |
|---|---|---|
| ① 上傳 | `api/oscal/routes/ssp/ssp_docx_import_route.py:28-54` | 只掛 `@jwt_required()`（有沒有登入） |
| ② 守門 | `app/oscal/service/ssp_docx_import_app_service.py:128-165` | 限定副檔名 `.docx`、**壓縮後**大小 ≤20MB（`:50`、`:131`）。依去向分三條：寫進專案要專案負責人；新建範本要 `module-frame.create`；**蓋掉既有範本只驗資源庫存在**（`:165`，＝第 125 項） |
| ③ 落地 | 同檔 `:177-182` | 把上傳內容寫成伺服器暫存檔 |
| ④ 接受修訂 | 同檔 `:205` `_normalize_docx_revisions` | 用 python-docx 開一次，接受所有追蹤修訂，另存一份 |
| ⑤ 抽控制項 | **本棒** `docx_parser_core.py:175` `parse` | 開檔 → 逐段 → 用控制項編號比對或模糊比對 |
| ⑥ 抽其他欄位 | 同檔 `:623-641` → W2 的 CMMC 轉接器 → **本棒** `docx_section_extractors.py` 八支抽取函式 | 再開兩次檔，找「Introduction」「Leveraged」等章節底下的表格 |

整個流程在**同一個請求裡同步跑完**，佔住一個工作程序（落地版預設 4 個，`main.py:265`）和一條資料庫交易，最多撐到 120 秒被砍（`main.py:267`）。

本棒要回答五件事：

1. 開檔前後有沒有大小上限。
2. 壓縮炸彈擋不擋得住。
3. 比對控制項編號的 regex（正則表達式，比對文字樣式的規則）會不會卡死。
4. 模糊比對的耗時。
5. 合併儲存格、巢狀表格這類特殊結構會不會讓迴圈爆掉。

---

## 3. 掃到什麼：總覽

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度＋為什麼 | 來源 |
|---|---|---|---|---|---|---|
| W1-1 | 「範本佔位字」判斷的 regex 會三次方爆炸 | 3,200 個空白＋換行，單一欄位就跑 66 秒；再長一點就過逾時 | 同客戶登入帳號＋一個看得到的資源庫編號（或專案負責人、或 `module-frame.create`）＋框架選 CMMC | `docx_section_extractors.py:538-541`（改寫四條樣式）＋`:545-549`（`_is_sc_placeholder` 開頭先截長度） | **中**（面板 3:0，三票都中）：只要一般帳號、檔案幾 KB、可以重複打，四個請求就能讓整個 API 對所有客戶停擺；不外洩資料、會被 120 秒截斷，所以不到高 | 工具 F1 |
| W1-2 | 「去掉括號註記」regex 平方級 | 32 萬個 `[` 跑 30 秒；解壓後放大到百萬字就過逾時 | 同上（還要候選控制項清單不為空，一般匯入都有） | `control_id_matcher.py:50-76`（`fuzzy_match_control` 開頭先截文字長度），或改寫 `:20` 的 `_BRACKETED_RE` | **中**（面板 3:0）：理由同 W1-1 | 工具 F2 |
| W1-3 | 逐段走訪每步重建整份段落清單 | 2KB、3,000 個空段落：抽控制項 0.1 秒，轉接器兩步合計 96 秒 | 同 W1-1 | `docx_section_extractors.py:74-86`（`_iter_body_blocks` 改成直接從元素建物件，同 `docx_parser_core.py:115-122` 的寫法）；外加段落數上限 | **中**（面板 3:0）：理由同上 | 工具 F3 |
| W1-4 | 表格一格宣告「橫跨 N 欄」，程式照數字逐欄產生物件 | 1.5KB 的檔宣告兩百萬欄跑 14 秒、五百萬欄 36 秒、一億欄六分鐘沒結束 | 同 W1-1 | `docx_parser_core.py:152-157`（讀表格前檢查欄數）、`docx_section_extractors.py` 各處 `row.cells`；或在服務層開檔後先掃一次 `w:gridSpan` 的最大值 | **併入 W1-1～3 同一條中**（同一種後果、同一個修法方向） | runner 自行開檔＋實測，**未經三人面板投票** |
| W1-5 | Word 打不開時，錯誤訊息帶伺服器暫存檔完整路徑，原樣回給前端 | 前端看到 `Package not found at '/var/folders/…/tmpxxxx.docx'`，洩漏伺服器目錄結構 | 同 W1-1 | 本棒範圍內 `docx_parser_core.py:171-172`、`:193-194` 把原始例外包進 `ParseException`；真正吐出去的地方是服務層 `ssp_docx_import_app_service.py:220`、`:226`（兩處都是 `str(e)`） | **低**：跟第 132 項同形；這裡門檻更低（一般帳號），但洩漏的只是暫存目錄路徑，比第 132 項想像的少 | runner 自行開檔＋實測，**未經三人面板投票** |

---

## 4. 工具報的三條（經三人面板投票）

三條都是研究員「讀程式碼估算步數」提出的。研究員自己寫了「沒有實跑」。所以 runner **逐條拿研究員給的攻擊字串實測**，結果全部成立，而且數字支持三次方和平方的判斷。

### 4.1 W1-1「範本佔位字」判斷三次方爆炸（F1，3:0）

**現況**：已修（M11-26，FR-114 CM-2182，commit `9edcf4de4`，1.21.0 出貨）

`_extract_introduction_fields`（`:603`）讀「Introduction」章節裡「標籤：值」形式的段落，拿值去問 `_is_sc_placeholder`（`:684-685`、`:700`）：「這是不是範本沒填的佔位字」。判斷用四條 regex（`:538-541`），第一條長這樣：

```
^[「『\[\(]?\s*Insert\s+.*?[\)\]」』]?\s*$
```

`\s+`、`.*?`、`\s*` 三段都能吃同一串空白。只要最後比對失敗（例如中間夾一個換行，`.` 吃不過去），引擎就把「三段各分幾個空白」的所有分法都試一遍，工作量是空白數的三次方。

實測（直接呼叫 `_is_sc_placeholder`，輸入「`Insert` ＋ N 個空白 ＋ `x` ＋換行＋ `y`」）：

| 空白數 | 耗時 |
|---|---|
| 800 | 0.4 秒 |
| 1,600 | 3.1 秒 |
| 3,200 | **66 秒** |

每加倍約慢 8～20 倍，符合三次方。約 4,500 個空白就會超過 120 秒；這串字壓縮後只有幾十個位元組。

Word 裡的手動換行（`<w:br/>`）會被 python-docx 轉成 `\n`，正好讓比對失敗，所以真實流程打得到。**面板三票（可達性、影響、防禦）都確認這條路上沒有任何長度限制。**

### 4.2 W1-2「去掉括號註記」平方級（F2，3:0）

**現況**：已修（M11-26，同上）

模糊比對前，`_strip_noise`（`:29-34`）先用 `\[[^\]]*\]|【[^】]*】|（[^）]*）` 去掉 `[FCI DATA]` 這類註記。遇到一長串 `[` 卻沒有 `]` 時，每個起點都會掃到行尾才放棄，工作量是長度的平方。

誰會進來：

- 每個比對不到編號的 H3 標題（`docx_parser_core.py:259`）；
- **每一段沒被認領的內文**（`:340`）。

實測（`fuzzy_match_control` 對 110 個候選控制項）：

| `[` 的個數 | 耗時 |
|---|---|
| 16 萬 | 7.6 秒 |
| 32 萬 | **29.8 秒** |

約 64 萬個 `[` 就會過逾時。這串字壓縮後只有幾 KB，遠低於 20MB 上限。

模糊比對本身不是問題：`rapidfuzz` 拿 100 萬字的一般文字跑 0.6 秒。卡片擔心的「段落數 × 控制項數 × 文字長度」實測也還好：3,000 段一般文字 × 110 個候選控制項，只跑 1.1 秒。

### 4.3 W1-3 逐段走訪平方級（F3，3:0）

**現況**：已修（M11-26，同上）

`_iter_body_blocks`（`:74-86`）每遇到一段，就讀一次 `doc.paragraphs[p_idx]`。python-docx 1.2.0 的 `doc.paragraphs` **每讀一次就把整份段落清單重建一遍**，所以走完一份文件的工作量是段落數的平方。

同一支檔的另一個寫法 `docx_parser_core.py:115-122` `_iter_block_items` 是直接從元素建物件，**沒有這個問題**：同一份檔抽控制項只要 0.1 秒。

而 W2 的 CMMC 轉接器會叫 `docx_section_extractors` 的八支抽取函式各走一遍。其中 `leveraged`、`revision` 這些章節找不到時還會換關鍵字再走，一份上傳總共要走十幾遍。

實測（`adapt` 與 `adapt_to_bundle`，即服務層 `:626`、`:634` 兩次呼叫）：

| 空段落數 | 檔案大小 | `adapt` | `adapt_to_bundle` | 合計 |
|---|---|---|---|---|
| 1,000 | 2KB | 7.1 秒 | 4.9 秒 | 12 秒 |
| 3,000 | 2KB | 56 秒 | 40 秒 | **96 秒** |

約 3,500 段就會過逾時。真實的 SSP 範本（`ASIA-CMMC-SSP-DRAFT-202604.docx`）只有 273 段，正常客戶碰不到，所以只要設一個幾千段的上限，就不會影響正常使用。

⚠️ **服務層把轉接器包在 try 裡、失敗只記 log 不中斷**（`ssp_docx_import_app_service.py:640-642`）。但**逾時不是例外**：gunicorn 直接把整個工作程序砍掉，try 攔不到。

---

## 5. 卡片點名的疑點，逐條回答（runner 自行開檔核對＋實測，未經三人面板投票）

實測環境：BE repo `.venv`，python-docx 1.2.0、lxml 6.1.3、RapidFuzz 3.14.6。惡意 Word 檔由 runner 手寫腳本產生（放在 session 暫存目錄，不入版控），直接呼叫範圍內的函式，沒有經過網路。

### 5.1 開檔前有沒有看大小、解壓後有沒有上限：只看壓縮後（＝第 127 項，補實測數字）

**現況**：已修（M11-24／M11-23，FR-114 CM-2181，1.21.0 出貨）

服務層只擋**壓縮後** 20MB（`ssp_docx_import_app_service.py:50`、`:131`）。範圍內兩個開檔點 `docx_parser_core.py:170`、`:192` 都直接 `Document(file_path)`，沒有先看解壓大小。

實測：

- **單一大段文字擋得住**：一份 0.2MB 的 Word，裡面一段 200MB 的文字，被 lxml 內建的「單一文字節點上限 10MB」擋下（`Text node too long`），記憶體多用 217MB 後報錯。
- **拆成很多段就擋不住**：300 段各 1MB，檔案只有 0.32MB，開檔吃到 **1.9GB 記憶體**。壓縮比約 1000 比 1，20MB 上限換算解壓後可以是十幾 GB。

**這就是總表第 127 項**，runner 只補實測數字，不另計。

另外提醒修第 127 項的人：**一份上傳會被 python-docx 完整打開三次**（服務層 `:759` 接受修訂、`docx_parser_core.py:192` 解析、服務層 `:623`＋`:625` 轉接器再開兩次），記憶體要乘上去算。

### 5.2 XML 外部實體攻擊：不成立（下次不用重查）

python-docx 建 XML 解析器時設了 `resolve_entities=False`（`docx/oxml/parser.py:19`），外部實體不會被解析。（「十億笑聲」這類實體展開 runner 沒另外實測，但同一份設定下實體不被解析，展開也就無從發生。）本棒範圍內沒有其他 XML 解析點。

### 5.3 控制項編號 regex 會不會卡死：不成立（下次不用重查）

`framework_patterns.py` 的四組樣式都是固定長度，或只有單層重複。卡片點名的 `iso27001` `\bA\.\d+(\.\d+)*\b` 拿 20 萬字的 `A.1.1.1…` 測是 0 秒，改成最後比對失敗的版本也是 0 秒：`\d` 和 `\.` 不會重疊，所以沒有回溯空間。

範圍內其他 regex 也逐條看過，只有 W1-1、W1-2 兩處會隨長度爆炸：

- `_LEADING_SECTION_RE`、`_CJK_RE`、`HEADING_RE`、`_AO_LETTER_RE`、`_CLAUSE_SEP_RE` 都是線性。`_PLACEHOLDER_RE`（`^\s*\[.*\]\s*$`）有回溯空間但只從行首試一次，runner 未實測；它只作用在表格儲存格文字上，修 W1-1 時一併檢查即可。

### 5.4 合併儲存格、巢狀表格：合併儲存格成立（W1-4），巢狀表格不成立

**現況**：W1-4 已修（M11-26，FR-114 CM-2182，commit `9edcf4de4`，1.21.0 出貨）

**合併儲存格**：Word 用 `<w:gridSpan w:val="N"/>` 表示「這一格橫跨 N 欄」。python-docx 讀 `row.cells` 時照這個數字產生 N 個物件（`docx/table.py:430`），而 N 只驗了在 32 位元整數範圍內（`docx/oxml/simpletypes.py:145`，上限約 21 億）。

範圍內讀儲存格的地方很多：

- `docx_parser_core.py:153`
- `docx_section_extractors.py` 的 `_cell_text` 迴圈（`:63`、`:71`、`:197`、`:284`、`:365`、`:407`、`:460`、`:514`、`:807`、`:872`）

實測（1 個表格、1 列、1 格）：

| 宣告欄數 | 檔案大小 | 耗時 |
|---|---|---|
| 10 萬 | 1.5KB | 0.7 秒 |
| 100 萬 | 1.5KB | 7.2 秒 |
| 200 萬（走完整 `parse()`） | 1.5KB | 14 秒 |
| 500 萬 | 1.5KB | 36 秒 |
| 1 億 | 1.5KB | **六分鐘未結束，runner 手動中止**（記憶體到 800MB） |

線性但係數大，約 1,700 萬欄就過逾時。一格就夠，檔案一個字都不用多。

**巢狀表格**：python-docx 的 `doc.tables` 只回最外層（`docx/document.py:225`），範圍內沒有任何遞迴走訪儲存格裡的表格，不會爆。

### 5.5 模糊比對耗時：不成立（下次不用重查）

見 4.2 末段。耗時主要在 W1-2 那個 regex，不在 rapidfuzz。

### 5.6 解析失敗的錯誤訊息長什麼樣：會帶伺服器暫存檔路徑（W1-5）

**現況**：已修（M11-32，FR-114 CM-2183，commit `c33f7e828`，1.21.0 出貨）

範圍內兩個開檔點（`docx_parser_core.py:171-172`、`:193-194`）把原始例外包成 `ParseException(error_code, e)`。它的訊息是 `f"{error_code}: {original}"`（`:23`），原始例外原封不動接在後面。服務層再把 `str(e)` 存進解析單（`:220`），並直接回給前端（`:226`）。

實測：

- 上傳「不是 zip」的檔，訊息是 `FlowControlErrorCode.GRC_DOCX_PARSE_FAILED: Package not found at '/var/folders/g6/…/T/tmpxjsrz3vf.docx'`，**帶伺服器暫存目錄完整路徑**。
- 亂數變造一份正常 Word 1,500 次：1,450 次打不開，其中 **32 次訊息帶路徑**。其餘是 `Bad CRC-32 for file '[Content_Types].xml'`、`Error -3 while decompressing data` 這類，吐的是上傳檔本身的結構。

**跟 V4b 的差別**：PDF 那條路全程在記憶體裡，沒有路徑可吐；Word 這條路先寫暫存檔，所以有。

**總表查無登記**：第 132 項只講 PDF；FR-113 O4 報告第 187 行只寫「暫存檔清理未完整復核」，沒提錯誤訊息。所以這是新項目。

**修法**：同第 132 項——存進資料庫與回給前端的都只放固定錯誤碼。本棒範圍內 `ParseException` 的訊息也可以不再帶 `original`，原始例外改用 `raise … from e` 保留給 log。

### 5.7 整份通讀的其他觀察

- `docx_intermediate.py`（72 行）是純資料結構，沒有判斷邏輯。
- `_extract_metadata`（`docx_parser_core.py:485-539`）與 `docx_section_extractors` 都只做字串比對與搬家，**不寫資料庫、不組 SQL**。
- 控制項實作敘述（`parsed_implementation_description`）與人員欄位原樣保留，**不中和 `=`、`+`、`-`、`@` 開頭**。之後匯出成 Excel 時就是第 148 項的形狀，修在匯出端已涵蓋，不另計。
- **uuid 的接縫（W2 要追）**：本棒範圍內五支檔**沒有任何一處讀 Word 裡的 uuid**。抽出來的人員、元件、外部服務都是 `name`／`title`／`email` 這類文字欄位，沒有 `uuid` 欄位。所以「Word 裡寫的 uuid 被照抄」這條，如果成立，只能發生在 W2 轉接器自己組 OSCAL 字典的那一步。

---

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

**「這幾條存在嗎」：高。**

- 工具三條面板 9 票全投、全部 3:0。研究員原本只寫步數估算，runner 逐條拿它的攻擊字串實測，數字全部支持。
- runner 補的 W1-4、W1-5 也是實測，未經面板投票。

**「只有這幾條嗎」：中偏高。**

- 5 支檔 runner 逐支通讀完。
- 服務層（`ssp_docx_import_app_service.py`，FR-113 O4 範圍）讀了上傳到解析的整段呼叫鏈。
- 範圍內所有 regex 逐條看過，能跑的都實測了。
- 沒做的：
  - 沒有用客戶真實檔案跑完整流程的基準；
  - lxml 與 zip 解壓本身的深層問題（例如極深的 XML 巢狀）沒有深挖；
  - W2 轉接器（`cmmc_ssp_adapter.py`）裡同形狀的 regex 屬下一棒。

---

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

| 項目 | 值 |
|---|---|
| run ID | `wf_daaa6afe-b3a` |
| 報告目錄 | `CLAUDE-SECURITY-20260924-080234/`（不入版控） |
| stamp | `CLAUDE-SECURITY-REVISION-82c6627d23a2-dirty.json` |
| verification.status | **verified**（`reason_kind` 無） |
| 形狀 | low：1 名研究員讀 5 檔＋1 次密鑰專項（focus=attack-surface） |
| 候選 → 通過 | 3 → 3（F1／F2／F3 皆 3:0，嚴重度三票皆中，未調降） |
| 面板票數 | 9 票，0 票反對 |
| agent 數／失敗 | 11／0（`journal.jsonl` 無 `failed`） |
| 耗時 | 約 46 分鐘（研究員約 30 分鐘） |
| 人工實測 | 三條工具發現原字串重現＋惡意 Word 6 種（壓縮炸彈兩型、段落數、gridSpan 四檔、長 regex 輸入）＋1,500 次亂數變造 |
| DEV 唯讀實查 | 本棒不涉資料庫（解析器不讀寫表），未查 |

---

## 8. 待首腦裁決

1. **W1-1～W1-4 怎麼登**：我建議合成**一條中**，名目寫「特製 Word 檔讓解析卡死到逾時」，四個位置同一張修正卡。
   - 修法：抽取前設段落數、欄數、單段長度上限；改寫三條 regex；`_iter_body_blocks` 改寫法。
   - 門檻是一般帳號＋一個資源庫編號（跟第 125 項同一個缺口），所以比 V4b 的兩條（平台管理員門檻）高一級。
   - **第 125 項修好之後，門檻會升到「範本管理者」**，但仍屬中。
2. **W1-5 錯誤訊息吐暫存檔路徑**：建議登新的一條低，修正時跟第 132 項併同一張卡（同一種修法：只回固定錯誤碼）。
3. **V4b 與 W1 的尺度要一起看**：V4b-1／V4b-2 是同一種「一份檔拖垮伺服器」，差在門檻是平台管理員。如果這裡定中，V4b 維持低是合理的；如果首腦認為後果（整個產品下線）比門檻更重要，兩邊一起升。
4. **給第 127 項修正卡補一句**：一份上傳會被 python-docx 完整打開三次，記憶體上限要照三倍算；壓縮炸彈的檢查要在第一次開檔前做。
