iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0

為什麼還要自己做

前面幾天的架構都建立在雲端服務上。但 D7 提過一個根本問題:要做偵測,就要把原文送出去。

對某些機構這不能接受。特別是當文件包含特種個資、或者當合規要求「個資不得離開機構控制的實體環境」時。

所以我做了一套地端的工具 VeilCapybara,核心設計是同型改寫式遮罩。今天講它的設計思路,以及跟雲端方案的實際差別。

同型改寫是什麼

D9 提過,把所有東西替換成 [PERSON] 這種佔位符會造成三個問題。同型改寫的做法是:替換成同類型、同樣態、但不同的值。

王小明          →  林建成         (中文姓名 → 中文姓名)
A123456789     →  B847263915    (身分證格式 → 身分證格式,檢查碼有效)
台北市中山區民生東路三段 XX 號 → 新北市板橋區文化路二段 XX 號
0912-345-678   →  0987-654-321
慢性腎病        →  【健康資訊已遮蔽】(特種個資不改寫,直接遮罩)

跟 FPE 的差別在於:FPE 只能處理規整格式,同型改寫可以處理自由文字——代價是需要 Vault 存對應。

設計決策一:確定性 vs 隨機性

同一個「王小明」,在不同文件裡應該對到同一個假名嗎?

確定性(同一個名字永遠對到同一個假名)

  • 優點:跨文件一致,可做關聯分析(D17)
  • 缺點:可被頻率分析攻擊——如果攻擊者知道某份文件裡的「林建成」是誰,他就知道所有文件裡的「林建成」都是那個人

隨機性(每次都產生新假名)

  • 優點:無法跨文件關聯
  • 缺點:同一份文件的多次處理結果不同,Vault 快速膨脹

我的選擇是範圍內確定性(scoped determinism):在同一個 scope(案件/session)內確定性,跨 scope 隨機。

def scoped_pseudonym(real_value, scope_id, key, pool):
    material = f"{scope_id}\x00{real_value}".encode("utf-8")
    h = hmac.new(key, material, hashlib.sha256).digest()
    return pool[int.from_bytes(h[:8], "big") % len(pool)]

scope_id 混進 HMAC 的輸入,就自然得到這個性質:同案件內一致,跨案件不可關聯。這是 D18 Token 隔離的實作基礎。

分隔符用 \x00 而不是直接串接,是為了避免歧義——scope="A", value="BC"scope="AB", value="C" 不應該產生相同的 HMAC 輸入。

設計決策二:假名庫怎麼建

假名不能隨便產生。三個要求:

一、要像真的。 中文姓名有結構:姓氏來自有限集合,名字有常見用字。隨機組合漢字會產生「垚甯彧」這種一看就假的結果。

二、不能撞到真人。 這跟 D10 的 FPE 問題一樣。緩解方式是用「合理但罕見」的組合。

三、要有足夠的空間。 假名庫太小會造成碰撞。以中文姓名為例,100 個姓氏 × 500 個名字組合 = 5 萬個,對單一案件足夠,對全行資料量可能不夠。

我的作法是分層產生:

SURNAMES = ["陳", "林", "黃", "張", "李", ...]      # 常見姓氏
GIVEN_1  = ["建", "淑", "俊", "雅", "志", ...]      # 名字首字
GIVEN_2  = ["成", "芬", "宏", "婷", "豪", ...]      # 名字次字

def name_pool_index(idx: int) -> str:
    """把一個整數映射到假名,空間 = len(S) * len(G1) * len(G2)"""
    s = idx % len(SURNAMES)
    idx //= len(SURNAMES)
    g1 = idx % len(GIVEN_1)
    idx //= len(GIVEN_1)
    g2 = idx % len(GIVEN_2)
    return SURNAMES[s] + GIVEN_1[g1] + GIVEN_2[g2]

這樣不需要預先產生整個假名庫,只要一個索引函式。空間是三個列表長度的乘積。

要保留姓名的性別線索嗎? 這是一個真實的取捨。保留(男名換男名)會讓文件更自然、模型表現更好,但性別本身是準識別碼,保留它就是保留了一部分識別資訊。我的預設是不保留,需要時才開啟。

設計決策三:地址的處理

地址最麻煩,因為它同時包含需要保留的資訊(管轄地區)和必須移除的資訊(門牌)。

ADDR_PATTERN = re.compile(
    r"(?P<city>[^縣市]{1,4}[縣市])"
    r"(?P<district>[^鄉鎮市區]{1,4}[鄉鎮市區])"
    r"(?P<rest>.*)"
)

def deidentify_address(addr: str, level: str = "district") -> str:
    m = ADDR_PATTERN.match(addr)
    if not m:
        return "【地址已遮蔽】"
    if level == "city":
        return m.group("city")
    if level == "district":
        return m.group("city") + m.group("district")
    # level == "replace":整段換成另一個真實存在的地址
    return replace_with_pseudo_address(addr)

三個層級對應不同需求:法務分析通常需要到 district(判斷管轄法院),客訴分析可能只需要 city

replace 模式要小心——換成另一個真實地址,等於指向了一個真實存在的地方,可能誤指到無辜的住戶。用於需要格式完整性的測試資料時才開啟,且應該用保留地址區段。

跟雲端方案的實測對照

面向 雲端 SDP 地端自建
規整格式偵測 優(內建 + hotword) 需自己寫規則
中文姓名偵測 中(PERSON_NAME 對中文較弱) 優(可針對訓練)
特種個資偵測 弱(無對應 infoType) 優(可訓練,見 D6)
同型改寫 部分(FPE 限規整格式) 優(設計目標)
偵測前資料出行
延遲 網路往返(百毫秒級) 本機(十毫秒級)
維運負擔
可稽核性 中(黑箱部分) 高(規則全在自己手上)
擴展性 受硬體限制

最後兩列是真正的取捨點。地端方案的可稽核性優勢在金融場景很有價值——當稽核問「為什麼這筆沒被抓到」,你能打開規則檔案指給他看。但維運負擔是真實的:規則要維護、模型要重訓、硬體要管。

我的實務建議:分級路由

不是二選一,而是按敏感度分流:

文件進入
   ↓
分類器判斷敏感等級
   ↓
├─ 含特種個資/高敏感 → 地端管線 → 地端 LLM(或完全不上雲)
├─ 一般法務文件      → 地端偵測 + 雲端去識別化
└─ 低敏感            → 全雲端管線

這樣做的成本是要維護兩套管線,收益是你可以誠實地回答「最敏感的那批資料從來沒有離開過機房」

在金融業的合規審查裡,這句話的價值很高。


明天進非結構化文件與 OCR,那裡有一個大部分架構圖都畫錯的地方。


關於作者

我是 Fngi,專注在 AI 資安、LLM 紅隊與 AI 治理框架落地。
IG:@aid3fend

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


上一篇
Day 12|Token Vault:整個架構風險最集中的一點
下一篇
Day 14|多格式分流:一份 PDF 不是一種東西
系列文
《30 天為金融法務部門打造 LLM 個資防護閘:去識別化、可控還原與紅隊驗證》14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言