範圍:主專案 BE repo
domain/oscal/parser/5 檔/1,658 行(原 FR-113 O7a 4 支+補入framework_patterns.py)。 掃描工具:Claude Code 官方claude-securityplugin,effort low,scoped 掃描。 基準 commit:82c6627d23a2(feature/review,工作區有平行 session 的未提交改動,stamp 標 dirty;範圍內 5 檔乾淨)。 驗證章:verified,3 條全部 3:0 通過。只掃不修。
一份幾 KB 的特製 Word 檔,就能讓解析卡到 120 秒逾時被砍。這樣的寫法 runner 一共找到四種:三種是工具報的、三人面板都 3:0 通過;另一種是 runner 自己做惡意檔實測出來的。另外還有一條錯誤訊息吐伺服器暫存檔路徑。全部都是實測數字,不是推論。
跟 V4b 最大的不同是門檻。V4b 要平台管理員;這裡同一家客戶裡任何登入帳號,只要知道一個資源庫的編號,就能上傳解析(走「蓋掉既有範本」那條路,那條只檢查資源庫存不存在,=總表第 125 項的守門缺口)。
[ 會平方級變慢。32 萬個 [ 跑 30 秒(實測)。四種卡死同屬一類(CWE-1333/CWE-407,正規表達式或演算法複雜度造成的服務阻斷),我建議合成一條中登總表。錯誤訊息吐路徑另登一條低。卡片擔心的 XML 外部實體攻擊不成立;控制項編號 regex 也不成立。
使用者在「匯入 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)。
本棒要回答五件事:
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度+為什麼 | 來源 |
|---|---|---|---|---|---|---|
| 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 自行開檔+實測,未經三人面板投票 |
三條都是研究員「讀程式碼估算步數」提出的。研究員自己寫了「沒有實跑」。所以 runner 逐條拿研究員給的攻擊字串實測,結果全部成立,而且數字支持三次方和平方的判斷。
現況:已修(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,正好讓比對失敗,所以真實流程打得到。面板三票(可達性、影響、防禦)都確認這條路上沒有任何長度限制。
現況:已修(M11-26,同上)
模糊比對前,_strip_noise(:29-34)先用 \[[^\]]*\]|【[^】]*】|([^)]*) 去掉 [FCI DATA] 這類註記。遇到一長串 [ 卻沒有 ] 時,每個起點都會掃到行尾才放棄,工作量是長度的平方。
誰會進來:
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 秒。
現況:已修(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 攔不到。
實測環境:BE repo .venv,python-docx 1.2.0、lxml 6.1.3、RapidFuzz 3.14.6。惡意 Word 檔由 runner 手寫腳本產生(放在 session 暫存目錄,不入版控),直接呼叫範圍內的函式,沒有經過網路。
現況:已修(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),沒有先看解壓大小。
實測:
Text node too long),記憶體多用 217MB 後報錯。這就是總表第 127 項,runner 只補實測數字,不另計。
另外提醒修第 127 項的人:一份上傳會被 python-docx 完整打開三次(服務層 :759 接受修訂、docx_parser_core.py:192 解析、服務層 :623+:625 轉接器再開兩次),記憶體要乘上去算。
python-docx 建 XML 解析器時設了 resolve_entities=False(docx/oxml/parser.py:19),外部實體不會被解析。(「十億笑聲」這類實體展開 runner 沒另外實測,但同一份設定下實體不被解析,展開也就無從發生。)本棒範圍內沒有其他 XML 解析點。
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 時一併檢查即可。現況: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:153docx_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),範圍內沒有任何遞迴走訪儲存格裡的表格,不會爆。
見 4.2 末段。耗時主要在 W1-2 那個 regex,不在 rapidfuzz。
現況:已修(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)。
實測:
FlowControlErrorCode.GRC_DOCX_PARSE_FAILED: Package not found at '/var/folders/g6/…/T/tmpxjsrz3vf.docx',帶伺服器暫存目錄完整路徑。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。
docx_intermediate.py(72 行)是純資料結構,沒有判斷邏輯。_extract_metadata(docx_parser_core.py:485-539)與 docx_section_extractors 都只做字串比對與搬家,不寫資料庫、不組 SQL。parsed_implementation_description)與人員欄位原樣保留,不中和 =、+、-、@ 開頭。之後匯出成 Excel 時就是第 148 項的形狀,修在匯出端已涵蓋,不另計。name/title/email 這類文字欄位,沒有 uuid 欄位。所以「Word 裡寫的 uuid 被照抄」這條,如果成立,只能發生在 W2 轉接器自己組 OSCAL 字典的那一步。「這幾條存在嗎」:高。
「只有這幾條嗎」:中偏高。
ssp_docx_import_app_service.py,FR-113 O4 範圍)讀了上傳到解析的整段呼叫鏈。cmmc_ssp_adapter.py)裡同形狀的 regex 屬下一棒。| 項目 | 值 |
|---|---|
| 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 唯讀實查 | 本棒不涉資料庫(解析器不讀寫表),未查 |
_iter_body_blocks 改寫法。