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 末段)。