法務部門的文件來源大致是這幾類:
這六類的處理路徑完全不同。而其中最危險的誤解是:把「原生 PDF」和「掃描 PDF」當成同一種東西。
它們的副檔名一樣、圖示一樣、打開來看起來一樣。但前者的文字可以直接抽取,後者裡面只有圖片——如果你的管線只做文字抽取,掃描件會回傳空字串。
然後會發生什麼事?空字串通過偵測(沒有個資),通過去識別化(沒東西可以去識別),然後原始檔案被送去做多模態推論。個資完整外洩,而且你的日誌上寫著「偵測完成,findings: 0」。
這是我看過最典型的靜默失效。
import fitz # PyMuPDF
def classify_pdf(path, text_threshold=50):
doc = fitz.open(path)
total_chars = 0
pages_with_text = 0
for page in doc:
t = page.get_text().strip()
total_chars += len(t)
if len(t) >= text_threshold:
pages_with_text += 1
ratio = pages_with_text / len(doc) if len(doc) else 0
if ratio >= 0.9:
return "native" # 原生 PDF,直接抽文字
if ratio <= 0.1:
return "scanned" # 掃描件,走 OCR
return "hybrid" # 混合,逐頁判斷
hybrid 這一類很常見:一份契約主文是原生 PDF,最後幾頁附了掃描的身分證影本。必須逐頁判斷、逐頁走不同路徑,不能整份用同一種方式處理。
| 類型 | 抽取方式 | 額外風險 |
|---|---|---|
| DOCX | python-docx / XML 解析 | 追蹤修訂、註解、頁首頁尾 |
| 原生 PDF | PyMuPDF 文字抽取 | 表單欄位、註記、附件 |
| 掃描 PDF | OCR(D15) | 偵測前出行風險 |
| 圖片 | OCR | 同上,加 EXIF |
| 解析 MIME + 遞迴處理附件 | 引用歷史、副本收件人 | |
| 試算表 | openpyxl / pandas | 隱藏工作表、公式、註解 |
每一列的「額外風險」欄都是實際出過事的地方。展開幾個:
DOCX 的追蹤修訂。 一份契約如果開著追蹤修訂,被刪掉的文字仍然在 XML 裡。你在畫面上看到的是修改後版本,但 document.xml 裡有 <w:del> 節點,裡面是原始文字——可能包含已經被刪掉的當事人姓名。
from docx import Document
import re
def extract_docx_all(path):
"""連被刪除的內容一起抽出來"""
doc = Document(path)
parts = []
# 主文
for p in doc.paragraphs:
parts.append(p.text)
# 表格
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
parts.append(cell.text)
# 頁首頁尾
for section in doc.sections:
for p in section.header.paragraphs:
parts.append(p.text)
for p in section.footer.paragraphs:
parts.append(p.text)
return "\n".join(parts)
注意這個函式抽了頁首頁尾和表格——很多實作只抽 doc.paragraphs,會漏掉表格內容。而契約裡的當事人資料經常就在表格裡。
被刪除的文字要用更底層的方式抓,得直接解析 word/document.xml 找 w:delText 節點。
Email 的引用歷史。 一封客訴回覆信,往下拉是十輪往返的引用內容,每一輪都有簽名檔、每個簽名檔都有電話和 Email。而且副本欄位裡可能有其他客戶的地址(如果承辦人誤用群發)。
試算表的隱藏工作表。 分析用的 Excel 常有一個「原始資料」工作表被隱藏起來。畫面上看到的是彙總數字,隱藏頁裡是完整的客戶清單。
import openpyxl
wb = openpyxl.load_workbook(path, data_only=False)
for ws in wb.worksheets:
if ws.sheet_state != "visible":
log.warning("發現隱藏工作表:%s", ws.title)
# 隱藏與否都要處理
一個原則:不確定能不能正確抽取的檔案,一律拒收,不要放行。
SUPPORTED = {".pdf", ".docx", ".doc", ".txt", ".eml", ".msg",
".xlsx", ".jpg", ".jpeg", ".png", ".tiff"}
def gate(path, mime):
ext = Path(path).suffix.lower()
if ext not in SUPPORTED:
raise UnsupportedFormat(ext)
if not mime_matches_extension(mime, ext):
raise SuspiciousFile("副檔名與內容不符")
if is_encrypted(path):
raise EncryptedFile("加密檔案無法檢查")
return True
加密的 PDF 這一列特別重要。一份有密碼的 PDF,你的管線抽不出文字,可能回傳空字串——又是一次靜默失效。必須明確拒收並告知使用者,而不是靜默通過。
同理,超大檔案、頁數異常、巢狀壓縮檔,全部擋掉。這對應 D3 威脅模型裡的 DoS 那一列:讓引擎處理失敗,然後利用 fail-open 的行為放行。
抽取結束後要做一個 sanity check:
def sanity_check(path, extracted_text, doc_type):
page_count = get_page_count(path)
chars = len(extracted_text.strip())
# 每頁至少應該有一定字數,否則可能抽取失敗
if doc_type in ("native", "scanned") and chars < page_count * 20:
raise ExtractionSuspect(
f"{page_count} 頁僅抽出 {chars} 字元,疑似抽取失敗"
)
return True
這個檢查會擋下絕大多數的靜默失效。寧可誤報要求人工確認,也不要讓一份沒被檢查的文件通過。
明天處理 OCR,那裡有一個大部分架構圖都畫錯的地方。
我是 Fngi,專注在 AI 資安、LLM 紅隊與 AI 治理框架落地。
IG:@aid3fend
有想討論的架構細節或不同意見,留言或私訊都歡迎。