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 完整打開三次,記憶體上限要照三倍算;壓縮炸彈的檢查要在第一次開檔前做。