---
title: FR-118 V4b 掃描報告——CMMC PDF／Excel 解析器
---

# V4b 掃描報告 — CMMC PDF／Excel 解析器（CM-2128）

> 範圍：套件 `jedi-oscal-v2`，8 檔／1,110 行（含 2 支空的 `__init__.py`）。
> 掃描工具：Claude Code 官方 `claude-security` plugin，effort low，scoped 掃描。
> 基準 commit：jedi monorepo `eafc7ae511d5`（`feature/review`；本套件目錄乾淨，monorepo 其他目錄有平行 session 的未提交改動，所以 stamp 標 dirty）。
> 驗證章：**verified，工具零候選**（沒有東西送進三人面板）。只掃不修。

---

## 1. 一句話結論

**工具零發現，但 runner 手工做了幾份惡意 PDF 實測，追出兩條「一份特製 PDF 就能把伺服器拖垮」的問題（V4b-1、V4b-2），兩條都要先是平台管理員才打得到，定低。**

- **V4b-1 頁數沒上限、每頁讀完不釋放**：一份 3MB、兩萬頁的 PDF，解析吃掉 3.4GB 記憶體。而且要等整份讀完才會放掉。
- **V4b-2 一行字太長時，判斷「這是不是目錄行」的比對會卡死**：一份 0.04MB、5 頁的 PDF，每頁一行四萬字，光這個比對就跑了 52 秒。
- 卡片擔心的其他幾件：
  - 錯誤訊息吐路徑：套件這端不成立。
  - YAML 地雷：不成立。
  - 框架代號查不到會掉到預設解析器：不成立。
  - Excel XML 炸彈：防護靠主機環境，這點成立；但這條 Excel 路徑目前在主專案沒有任何網址入口，列清理建議。
  - 公式開頭沒中和：成立。不過它是既有第 148 項的上游，修在匯出端就好，不另計。

---

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

平台管理員在「框架管理」上傳 CMMC 官方 PDF，系統逐頁讀文字，認出「領域 → 控制項 → 評估目標 → 評估方法」，存成一份可以審閱的解析單。管理員確認後，才正式建成控制項目錄。

整條路是這樣走的：

| 步驟 | 在哪 | 做什麼 |
|---|---|---|
| ① 上傳 | 主專案 `api/oscal/routes/framework/framework_parse_job_route.py:52-99` | 只收 `.pdf` 副檔名；量了檔案大小但**只記錄不擋**；全站請求上限 50MB（`config/config.py:105`） |
| ② 守門＋解析 | 主專案 `app/oscal/service/framework_parse_job_service.py:143`（`require_platform_admin()`）→ `:215-218` | **在同一個請求裡同步解析**，解析期間整個請求、一個工作程序、一條資料庫連線都被佔住 |
| ③ 解析器 | 套件 `infra/adapter/cmmc/cmmc2_lv2_parser_adapter.py:162-448` `pdf_parser` | `pdfplumber` 開檔，**整份讀兩輪**：第一輪收集領域代號，第二輪才真正解析 |
| ④ 失敗處理 | 主專案 `framework_parse_job_service.py:220-229` | 攔下所有例外，把 `f"{業務訊息}: {原始例外}"` 存進解析單（＝既有第 132 項） |

套件另有一條「Excel 匯入」`catalog_service.import_catalog_from_excel` → `excel_parser`（`:450-472`）。它在主專案唯一的呼叫者是 `framework_app_service.py:194` `import_framework_version`，**而這支方法全庫零呼叫、沒有網址入口**（grep `api`／`app`／`jedi-compliance-audit` 實查）。所以 Excel 那條目前打不到。

這一棒要找的是「惡意檔案」，跟前面「只驗身分不驗歸屬」那批完全不同，有五件事：

1. 大小與頁數有沒有上限。
2. 壓縮炸彈擋不擋得住。
3. regex（正則表達式，比對文字樣式的規則）會不會卡死。
4. 錯誤訊息會不會吐出伺服器路徑。
5. XML 防護是不是靠主機環境剛好有裝。

---

## 3. 掃到什麼：總覽

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度＋為什麼 | 來源 |
|---|---|---|---|---|---|---|
| V4b-1 | PDF 頁數沒上限，每頁讀完的版面物件留在記憶體直到整份讀完 | 3MB、兩萬頁的 PDF 吃掉 3.4GB（實測）。上限 50MB 的檔可以塞更多頁，逾時前記憶體就可能把整個容器撐爆，**產品整個下線** | 平台管理員帳號 | 套件 `cmmc2_lv2_parser_adapter.py:232-235`（開檔後、`_resolve_pages` 之前先擋總頁數），加 `:244`／`:269`（每頁讀完呼叫 `page.close()`）；主專案 `framework_parse_job_service.py:215` 前可再擋一次 | **低**：只有平台管理員（產品方的最高權限角色，客戶的管理員都打不到）能上傳；這個角色本來就能直接刪改所有框架，拖垮伺服器對他幾乎沒有額外價值。值得修是為了「帳號被盜」與「誤傳一份異常 PDF」這兩種情況 | runner 自行開檔＋實測，**未經三人面板投票** |
| V4b-2 | 判斷「目錄行」的 regex 遇到超長的一行時，耗時跟長度平方成正比 | 0.04MB、5 頁（每頁一行四萬字）的 PDF 跑 52 秒（實測）。十來頁就超過 120 秒逾時，工作程序被砍，每一次上傳都佔住一個工作程序兩分鐘 | 平台管理員帳號 | 套件 `cmmc2_lv2_parser_adapter.py:81`（`_is_toc_noise` 開頭先擋行長），或改寫 `:73` 的 `_TOC_PATTERN`；同形狀的 `:407` 也一併改 | **低**：理由同 V4b-1；而且會被 120 秒逾時截斷，不會無限跑 | runner 自行開檔＋實測，**未經三人面板投票** |
| V4b-3 | 套件自己沒宣告 `defusedxml`，Excel 的 XML 炸彈防護靠主機剛好有裝 | 換一個沒裝 `defusedxml` 的環境，防護靜默消失、不會報錯 | Excel 路徑目前**沒有網址入口**，打不到 | 套件 `pyproject.toml:10-21` 的 `dependencies` 補一行 | **不列資安發現**，列清理建議（打不到） | runner 開檔核對 |
| V4b-4 | 從 PDF 讀進來的控制項名稱、說明，不中和 `=`、`+`、`-`、`@` 開頭 | 這些文字之後會寫進 SSP Excel 範本的「控制項名稱」欄，是第 148／150 項公式注入的另一個上游 | 平台管理員上傳特製 PDF | 修在**匯出端**（第 148 項修法已涵蓋），進來端不必另修 | **不另計**，併第 148 項 | runner 開檔核對 |

---

## 4. 工具報的

**零條。** 研究員讀完 8 檔，密鑰專項也跑了，兩者都沒提出候選，所以三人面板沒有東西可以投。

這不代表範圍內乾淨。這一棒的問題要動手做惡意檔案、實際量時間與記憶體才看得出來，光讀程式碼判斷不出「會不會慢」與「慢多少」。下面第 5 節的結論全部來自 runner 開檔與實測。

---

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

實測環境：BE repo 的 `.venv`，裝的是 jedi-oscal-v2 2.4.0（與掃描基準同版）、pdfplumber 0.11.10、pdfminer.six 20260107。惡意 PDF 由 runner 手寫腳本產生（放在 session 暫存目錄，不入版控），直接呼叫 `CMMC2Level2Adapter().pdf_parser(...)`，沒有經過網路。

### 5.1 頁數與記憶體：成立（V4b-1）

**現況**：已修（M11-36，FR-114 CM-2184，套件 commit `ae5ffff5`，1.21.0 出貨）

**`_resolve_pages`（`base_parser_adapter.py:37-57`）只把頁碼範圍夾在總頁數之內，沒有總頁數上限。** 主專案呼叫時根本沒傳頁碼範圍（`framework_parse_job_service.py:217` 只傳 stream），所以一律整份讀。

**每頁讀完不釋放。** pdfplumber 會把每頁排好的版面結果快取在該頁物件上（`pdfplumber/page.py:256-270` 的 `_layout`），要等頁物件 `close()` 或整份 PDF 關掉才清。這支解析器對每頁只呼叫 `extract_words`，從不關頁；而且整份讀兩輪（`:242` 第一輪、`:267` 第二輪）。

實測（同一段兩行文字的頁面重複 N 次；檔很小，因為所有頁共用同一段內容）：

| 頁數 | 檔案大小 | 耗時 | 記憶體高點 |
|---|---|---|---|
| 1,000 | 0.16MB | 1.0 秒 | 267MB |
| 5,000 | 0.80MB | 5.6 秒 | 942MB |
| 20,000 | 3.26MB | 23 秒 | **3,429MB** |

約每頁 170KB、每秒 870 頁，幾乎是直線成長。照這個速度推，120 秒逾時前可以讀到約十萬頁，記憶體約 17GB。**這是外推，沒有實跑**。落地版是單一容器、沒設記憶體上限（總表第 130 項已查過），先撐爆的會是整台主機的記憶體。

**修法有實測撐腰**：每頁讀完就 `page.close()`，同樣 5,000 頁，記憶體從 534MB 降到 128MB，耗時從 2.8 秒升到 4.2 秒（第二輪要重排版面）。再加一道總頁數上限（CMMC L2 官方文件三百多頁，設個幾千頁綽綽有餘），兩件一起修。

**壓縮炸彈（PDF 內壓縮的內容串流）：影響有限。** 做了一份 0.2MB、解壓 200MB 的頁面，1 秒讀完，記憶體多 211MB。pdfminer 會把整段串流解壓進記憶體，所以理論上 50MB 的檔能解出數 GB；但這跟 V4b-1 是同一條「沒有總量上限」的線，修 V4b-1 時一併考慮即可，不另列。

### 5.2 regex 會不會卡死：成立（V4b-2）

**現況**：已修（M11-37，FR-114 CM-2184，同上）

逐條量過這支檔的 11 個 regex（用三種一萬八千字的長行各跑一次），只有兩個在真實可能出現的長行上會變慢：

| regex | 位置 | 一行 2 萬字 | 一行 4 萬字 | 一行 6 萬字 |
|---|---|---|---|---|
| `_TOC_PATTERN` `.{5,}\s\d{1,3}$`（判斷「標題＋頁碼」的目錄行） | `:73`，被 `:81` `_is_toc_noise` 呼叫 | 2.7 秒 | — | 23 秒 |
| `.*?\[SELECT FROM:`（切出評估方法清單；只有該行含 `[SELECT FROM:` 才會跑） | `:407` | 18,000 字 0.65 秒 | | |

原因是 `search` 會從每一個起點各試一次，而 `.{5,}` 每次都掃到行尾，總工作量等於「長度的平方」。它沒有「巢狀重複」那種指數級爆炸，但平方級已經夠用：5 頁、每頁一行四萬字，**0.04MB 的 PDF 跑 52 秒**。

**為什麼一行會這麼長**：組行邏輯（`:133-160`）把同一高度的字全部接成一行，沒有長度上限。PDF 的頁面寬度由檔案自己宣告，攻擊者宣告一頁一千萬點寬，一行就能塞任意長。`_is_toc_noise` 在兩輪都會被每一行呼叫（`:249`、`:275`），所以同一行會被算兩次。

**其他 regex 沒事**（領域標題與 `[FCI DATA]` 尾綴兩個，只有在「整行都是空白」時才會慢到 0.3 秒；但組行時字與字之間只放一個空格，這種行在真實流程裡不會出現）：
- 領域標題 `^(.+)\s+\(([A-Z]{2})\)$` 一行 2 萬字 0.001 秒。
- 控制項編號 `_PRACTICE_PATTERN` 開頭就錨定，0 秒。
- AO 收尾 `\s*(;|\.|,)?\s*(and|or)?\s*$` 0.001 秒。

**修法**：`_is_toc_noise` 開頭先判斷 `len(line) > 500` 就直接當內文、不跑 regex（正常 PDF 一行不會超過兩百字）；或把 `_TOC_PATTERN` 改成只看行尾的 `\s\d{1,3}$`，再另外檢查長度 ≥7。`:407` 改成 `re.sub(..., count=1)` 或用 `str.find` 切。

### 5.3 錯誤訊息會不會帶暫存檔路徑、記憶體位址：套件這端不成立（下次不用重查）

- **沒有暫存檔**：主專案把上傳內容讀成位元組，再包成 `BytesIO` 交給解析器（`framework_parse_job_service.py:180`、`:217`），全程沒有落地檔名，pdfminer 的例外也就沒有路徑可吐。
- **亂數變造 3,000 次實測**：拿一份正常的 3 頁 PDF 隨機改 1～8 個位元組，收集到 983 種不同的例外訊息，**沒有一種含伺服器路徑（`/Users`、`/tmp`、`site-packages`、`.py`），也沒有記憶體位址（`0x`）**。
- 最常見的是 `No /Root object! - Is this really a PDF?`、`'NoneType' object is not iterable`、`Invalid dictionary construct: [/'Type', /'Page', ... <PDFObjRef:6> ...]`。最後這種會把**上傳檔本身**的內部結構印出來，那是攻擊者自己給的內容，不算外洩。

**所以第 132 項的「吐伺服器路徑」在這條路上實際吐不出路徑**，吐的是套件名稱（`PdfminerException`）與上傳檔的結構。第 132 項的修法（只存固定錯誤碼）照樣該做，本棒不改它的結論。

順帶一提給修第 132 項的人：`:215-219` 這個 try 區塊同時包住了寫資料庫（`write_parsed_result`）。如果寫資料庫出錯，SQLAlchemy 的例外訊息**會帶 SQL 語句與參數**，一樣會被串進解析單回給前端。修第 132 項時一起涵蓋。

### 5.4 Excel 走 openpyxl，XML 炸彈防護靠什麼：靠主機環境，成立但打不到（V4b-3）

- pandas 讀 xlsx 時用 `read_only=True`（串流讀）＋`keep_links=False`（`pandas/io/excel/_openpyxl.py:570`），比主專案第 130 項那支 `read_only=False` 好。
- openpyxl 解析工作簿裡的 XML 時，**只有在 `defusedxml` 裝著、且環境變數 `OPENPYXL_DEFUSEDXML` 沒被設成 False 時**，才會改用防護版解析器（`openpyxl/xml/__init__.py:29-42`、`openpyxl/xml/functions.py:37-42`）。
- 主專案 `pyproject.toml:93` 有宣告 `defusedxml`，所以現在有防護。但**套件自己的 `pyproject.toml` 沒宣告**。換一個只裝這支套件的環境，防護就靜默消失。
- 本機 Python 內建的 expat 是 2.6.0，這個版本本身已經擋住經典的「十億笑聲」實體展開，所以就算沒有 `defusedxml`，最嚴重的那種 XML 炸彈也未必會成功。
- **最關鍵的是：這條 Excel 路徑目前沒有網址入口**（見第 2 節），現在打不到。

建議：套件補宣告 `defusedxml`。改套件要走發版流程，由決策者裁定。

### 5.5 `ImportType.YAML`／`JSON` 與 `pyyaml`：不是地雷（下次不用重查）

- `parser_adapter_type.py:23-27` 的 `ImportType` 只是四個字串常數，**全套件與主專案零引用**（grep 實查）。主專案匯入時自己用 `import_type.upper() == "EXCEL"` 判斷（`framework_app_service.py:213`），也沒用這個常數。
- 套件 `pyproject.toml:15` 宣告了 `pyyaml`，但全套件零 import（grep `import yaml|from yaml` 0 筆）。
- 不存在「預留入口、某天被接上 `yaml.load`」的現成接點。建議拿掉 `pyyaml` 依賴與 `YAML`／`JSON` 兩個常數，併進 FR-092 死碼清理。

### 5.6 框架代號查不到會不會落到預設解析器：不成立（下次不用重查）

`oscal_parser_factory.py:31-34` 用 `TYPE.get(provider)` 查；查不到就丟 `BadRequestError(OSCAL_V2_PARSER_PROVIDER_NOT_SUPPORTED)`，沒有預設值。主專案這一端，代號來自表單的 `parser_type`（任意字串），查不到時被 `:220` 的 try 攔下，解析單記成失敗。不會拿錯的解析器去讀。

### 5.7 Excel／PDF 讀進來的文字有沒有中和公式開頭：沒有，併第 148 項（V4b-4）

**現況**：修在匯出端：已修（M11-20／M11-21，FR-114 CM-2189，commit `895db0ef8`，1.21.0 出貨）

- PDF 路徑：控制項名稱經過 `.title()`（`:331`）、說明與評估目標原樣保留，**沒有任何一處處理 `=`、`+`、`-`、`@` 開頭**。
- Excel 路徑（`:450-472`）也是儲存格原值照收。
- 這些文字之後會流到 SSP Excel 範本：主專案 `ssp_import_template_app_service.py:1262` 把目錄的 `control_title` 寫進「控制項名稱」欄。這正是第 148／150 項「匯出時公式被執行」的形狀，只是上游換成了框架目錄。
- 門檻是平台管理員，而且上傳後還有第二步「審閱確認」，管理員能看到並改掉。

**第 148 項的修法本來就是在匯出端統一中和**，所以會一起蓋到，不必在進來端另外修。建議修第 148 項的人把「控制項名稱／評估目標名稱」這兩欄加進驗收清單。

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

- `catalog_service.add_catalog`（`:53-116`）只被 `import_catalog_from_pdf`／`import_catalog_from_excel` 呼叫，**這兩支在主專案只有那支零呼叫的 `import_framework_version` 會用**。實際上線的解析流程在確認時走主專案自己的 `_persist_catalog`，不經過這裡。套件這三支可以併進 FR-092 死碼清理評估。
- `list_catalogs()`（`:161-163`）不帶條件就回全部。控制項目錄本來就是全平台共用資源，符合預期。
- 解析器用 `tqdm` 印進度條到標準錯誤輸出（`:242`、`:267`），正式環境 log 會多出進度條雜訊，不是資安問題。
- 資料庫隔離（DEV 唯讀實查，2026-09-24 15:55 前後 +08）：`oscal.framework_parse_jobs` 有開隔離；`oscal.catalogs`／`catalog_groups`／`catalog_controls`／`catalog_control_parts` 沒開。目錄是全平台共用，這是預期。

---

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

**「這幾條存在嗎」：高。** V4b-1、V4b-2 都是實測數字，不是讀程式碼推論。惡意 PDF 由腳本產生，直接呼叫套件解析器，量時間與記憶體高點。沒有實測的只有兩項，都已標明：「十萬頁 17GB」是線性外推，「約十頁超過逾時」是從 5 頁 52 秒推算。

**「只有這幾條嗎」：中偏高。**
- 8 支檔 runner 逐支通讀完（兩支是空檔）。
- 主專案兩個呼叫端（`framework_parse_job_route.py`、`framework_parse_job_service.py`）跨 repo 讀到呼叫那一行。
- 11 個 regex 逐一量過。
- 沒做的：沒有用真實的 CMMC 官方 PDF 跑基準（要拿平台上的檔案，本棒沒去撈）；pdfminer 本身解析 PDF 物件結構的深層問題（例如極深的巢狀物件、循環參照）屬第三方函式庫，沒有深挖，只做了 3,000 次亂數變造，沒有卡死或崩潰。

---

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

| 項目 | 值 |
|---|---|
| run ID | `wf_adad8af8-46d` |
| 報告目錄 | 套件 repo `jedi-oscal-v2/CLAUDE-SECURITY-20260924-074524/`（不入版控） |
| stamp | `CLAUDE-SECURITY-REVISION-eafc7ae511d5-dirty.json` |
| verification.status | **verified**（`reason_kind` 無；候選 0、面板票 0） |
| 形狀 | low：1 名研究員讀 8 檔＋1 次密鑰專項（focus=attack-surface） |
| 候選 → 通過 | 0 → 0 |
| agent 數／失敗 | 2／0（`journal.jsonl` 無 `failed`） |
| 耗時 | 約 2 分鐘（stamp `duration_s` 131） |
| 人工實測 | 惡意 PDF 7 種（1k／5k／20k 頁、200MB 壓縮炸彈、2～6 萬字單行、5 頁×4 萬字）＋3,000 次亂數變造＋每頁關閉修法對照 |
| DEV 唯讀實查 | 5 張表的 `relrowsecurity`（2026-09-24 15:55 前後 +08），未寫入 |

---

## 8. 待首腦裁決

1. **V4b-1、V4b-2 登不登、登什麼等級**：我定低，兩條建議開同一張修正卡（同一支檔、同一次發版）。
   - ⚠️ 跟第 132 項的尺要對一下：第 132 項也是「必須先是平台管理員」，當時定中。
   - 如果首腦認為「平台管理員門檻＝中」是這批的統一尺，這兩條（後果是整個產品下線，比第 132 項的資訊外洩重）也應該升中。
   - 我定低的理由是：平台管理員本來就能刪改所有框架，拖垮伺服器對他沒有額外價值。
2. **套件補宣告 `defusedxml`、拿掉 `pyyaml` 與 `ImportType.YAML`／`JSON`**：改套件要發版，建議跟 V4b-1／V4b-2 同一次發。
3. **`import_framework_version`＋套件 `import_catalog_from_pdf`／`import_catalog_from_excel`／`add_catalog` 零入口**：併 FR-092 死碼清理，或確認保留。
4. **第 132 項修法範圍補一句**：try 區塊也包住寫資料庫，SQL 錯誤訊息同樣會外洩（5.3 末段）。
