大多数团队在客户投诉或合规审计后才发现 PII 泄露。到那时候,数据已经穿越了 ETL 流水线,落入了应用日志,并被三种不同的可观测性工具索引了。

事后发现属于考古学。你真正需要的是一套系统,在数据进入代码的瞬间就追踪 PII,将这一知识传播到每一次转换中,并阻止它从错误的渠道流出。这不算已解决的问题,但它是可解决的。

“追踪 PII” 到底意味着什么

PII 追踪经常与数据脱敏或访问控制混为一谈。这些是相关但下游的问题。你无法对找不到的数据进行脱敏、加密或限制访问。

追踪意味着在流水线的每一个节点上,你都知道哪些字段包含敏感数据。不是根据 emailssn 这样的列名去猜,而是真正知道——因为数据携带着一个能经受住转换、聚合和序列化的标签。

这需要三样东西:一个能在语言层面将字段标记为敏感的类型系统或元数据层,一个能在数据在函数和服务之间移动时携带这些标签的传播机制,以及用于验证被标记数据是否只被写入经批准的目的地的 enforcement 点。

架构:标记、传播、强制执行

我见过最干净的实现是在语言层面使用带标签的数据类型。在 Python 中,你可以把值包装在一个携带敏感度标签的类里。

from dataclasses import dataclass
from enum import Enum, auto
from typing import Generic, TypeVar

T = TypeVar("T")

class Sensitivity(Enum):
    PLAIN = auto()
    PII = auto()
    PCI = auto()

@dataclass(frozen=True)
class Labeled(Generic[T]):
    value: T
    sensitivity: Sensitivity = Sensitivity.PLAIN

    def map(self, fn):
        return Labeled(fn(self.value), self.sensitivity)

当用户注册账户时,你在边界处对原始输入打标签。

from labeled import Labeled, Sensitivity

def create_user(payload: dict) -> dict:
    email = Labeled(payload["email"], Sensitivity.PII)
    name = Labeled(payload["name"], Sensitivity.PII)
    age = Labeled(int(payload["age"]), Sensitivity.PLAIN)

    user = {
        "id": generate_uuid(),
        "email": email,
        "name": name,
        "age": age,
    }

    return user

关键洞察在于 emailname 现在具备了自描述性。任何函数接收 Labeled 值时,都可以在不解析 payload 或不根据键名猜测的情况下检查其敏感度。

在转换中传播标签

打了标签的数据只有在标签能经受住业务逻辑时才真正有用。当你 map、filter 或聚合时,你需要一套规则来决定敏感度如何合并。

最简单的规则集是一个 join semilattice。如果你合并两个值,结果获得更严格的标签。

class Sensitivity(Enum):
    PLAIN = 1
    PII = 2
    PCI = 3

    def join(self, other: "Sensitivity") -> "Sensitivity":
        return self if self.value >= other.value else other

当你为下游服务序列化一条记录时,把标签包含在元数据中。消费者可以据此决定写入原始数据库、脱敏后的分析数仓,还是审计日志。

def serialize(record: dict) -> dict:
    return {
        key: {
            "value": serialize_value(val.value),
            "sensitivity": val.sensitivity.name.lower(),
        }
        for key, val in record.items()
        if isinstance(val, Labeled)
    }

这是最容易绊倒人的地方。你不能只在数据摄入时打标签就指望万事大吉。标签必须是你序列化层的一等公民。如果你的内部 RPC 格式丢弃了元数据,那么数据一旦跨越 service boundary,你的流水线就会失明。

强制执行:标签真正发挥作用的地方

只追踪不强制执行,就是昂贵的可观测性表演。你需要在数据被写入存储、通过网络发送或在界面中渲染之前设置检查点,对打了标签的数据进行检查。

一种常见模式是 sink registry,它将目的地映射到允许的最大敏感度。

SINK_POLICIES = {
    "raw_postgres": {Sensitivity.PLAIN, Sensitivity.PII, Sensitivity.PCI},
    "analytics_clickhouse": {Sensitivity.PLAIN},
    "application_logs": {Sensitivity.PLAIN},
    "third_party_api": set(),
}

def write(sink_name: str, record: dict):
    allowed = SINK_POLICIES[sink_name]
    for key, val in record.items():
        if isinstance(val, Labeled) and val.sensitivity not in allowed:
            raise ValueError(
                f"Field {key} with sensitivity {val.sensitivity.name} "
                f"is not allowed in sink {sink_name}"
            )
    # proceed with write

如果你的分析流水线试图将包含 Sensitivity.PII 的记录摄入 ClickHouse,写入会在运行时以明确的错误失败。这不算优雅,但足够清晰。清晰总比 GDPR 违规好。

特别是对于日志,你可以在 logger 层面强制执行。Python 的 logging 模块支持 filters,可以在日志记录被发出前检查它们。

import logging

class PiiFilter(logging.Filter):
    def filter(self, record):
        msg = record.getMessage()
        # In practice, use a more sophisticated check or structured logging
        if any(label in msg for label in ["email", "ssn", "phone"]):
            record.msg = "[REDACTED: PII detected]"
            record.args = ()
        return True

logger = logging.getLogger(__name__)
logger.addFilter(PiiFilter())

这是一种粗暴的手段。更好的方法是结构化日志,让你的日志 payload 就是同一个带标签的字典,formatter 根据敏感度标签而不是正则表达式来进行脱敏。

静态分析作为后备防线

运行时打标签很强大,但它需要纪律。总有人会忘记包装一个新字段,或者会把 Labeled 值强制转回原始类型来避免类型错误。

静态分析填补了这个缺口。像 Semgrep 或自定义 linter 这样的工具可以强制执行:来自 HTTP 请求体的原始字符串绝不能未经 labeling 构造函数就直接传给 logger 或数据库 sink。

一个最小的 Semgrep 规则看起来像这样:

rules:
  - id: unlabeled-pii-log
    patterns:
      - pattern: logger.info($X)
      - metavariable-pattern:
          metavariable: $X
          pattern-either:
            - pattern: request.json[$FIELD]
            - pattern: request.form[$FIELD]
    message: "Logging raw request data without sensitivity labeling"
    languages: [python]
    severity: ERROR

这不会捕获所有泄露,但会捕获最明显的那些——而大多数泄露恰恰来自这些最明显的疏漏。

没人愿意谈的权衡

这种方法会增加摩擦。每一次数据转换现在都涉及一个额外的元数据字段。序列化 payload 会变得更大。代码审查会变成关于 user_agent 算不算 PII 的争论。(它通常是,顺便说一句。欧盟是这么认为的。)

性能是一个真实的顾虑。如果你每秒处理数百万事件,为每个字段分配 Labeled 包装器是可测量的开销。在热路径中,你可能需要批量检查标签,或者把 enforcement 移到边缘。

还有维护负担。敏感度标签的好坏取决于维护它们的人。当隐私法规改变,或者你的业务扩展到新的司法管辖区时,你需要重新打标签。这里没有魔法。这是工作。

我们没有选择的方案:静态正则扫描

有些团队通过在事后用正则表达式扫描数据存储来解决这个问题,寻找看起来像邮箱或社保号的模式。这比什么都没有好,但它本质上是反应式的。

正则表达式会漏掉混淆数据、哈希标识符和复合 PII。它还会产生侵蚀系统信任度的误报。我们早期考虑过这种方法并拒绝了它,因为它治疗的是症状(数据出现在错误的地方),而不是病因(数据未经标签就流出服务)。

从检查点开始

你不需要在第一天就给每个服务中的每个字段打标签。从边界开始。在数据从用户、第三方 API 和事件流进入系统时为其打标签。然后在最高风险的 sink 处添加 enforcement:分析流水线、日志基础设施和外部集成。

一旦这些就位,再向内扩展。目标不是完美覆盖。目标是让 PII 泄露在无意中变得昂贵,并在代码审查中变得容易被发现。

如果你用 Python 构建这个系统,上面的 Labeled 类足以让你起步。在 TypeScript 中,branded type 或类似的包装器也能起到同样的作用。在 Go 中,你会想要一个带有未导出字段的 struct,以防止意外类型转换。

工具很简单。困难的部分是决定未追踪的 PII 是一个 bug,并像对待 bug 一样处理它。