iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0

「支援 PDF」這句話沒有意義

法務部門的文件來源大致是這幾類:

  • Word 檔(自己起草的契約、書狀)
  • 原生 PDF(對方寄來的、系統產出的)
  • 掃描 PDF(用印後掃描回來的、法院寄來的)
  • 照片(客戶用手機拍的證件、單據)
  • Email(.eml/.msg,含附件)
  • 試算表(帳務明細、客戶清單)

這六類的處理路徑完全不同。而其中最危險的誤解是:把「原生 PDF」和「掃描 PDF」當成同一種東西。

它們的副檔名一樣、圖示一樣、打開來看起來一樣。但前者的文字可以直接抽取,後者裡面只有圖片——如果你的管線只做文字抽取,掃描件會回傳空字串。

然後會發生什麼事?空字串通過偵測(沒有個資),通過去識別化(沒東西可以去識別),然後原始檔案被送去做多模態推論。個資完整外洩,而且你的日誌上寫著「偵測完成,findings: 0」。

這是我看過最典型的靜默失效。

分流的第一步:判斷 PDF 有沒有文字層

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
Email 解析 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.xmlw: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

有想討論的架構細節或不同意見,留言或私訊都歡迎。


上一篇
Day 13|地端自建:同型改寫式遮罩的設計
系列文
《30 天為金融法務部門打造 LLM 個資防護閘:去識別化、可控還原與紅隊驗證》14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言