Большинство команд обнаруживают утечки PII после жалобы клиента или аудита на соответствие требованиям. К тому времени данные уже прошли через ваш ETL-пайплайн, попали в логи приложений и были проиндексированы тремя разными инструментами наблюдаемости.

Обнаружение задним числом — это археология. То, что вам на самом деле нужно, — это система, которая отслеживает PII в момент его попадания в код, распространяет эти знания через каждое преобразование и блокирует выход через неправильные каналы. Это не решённая проблема, но решаемая.

Что на самом деле означает «отслеживание PII»

Отслеживание PII часто путают с маскированием данных или контролем доступа. Это родственные проблемы, но они находятся ниже по течению от реальной задачи. Вы не можете вырезать, зашифровать или ограничить доступ к данным, местонахождение которых вам неизвестно.

Отслеживание означает знание на каждом этапе вашего пайплайна, какие поля содержат чувствительные данные. Не догадываясь по названиям колонок вроде email или ssn. А точно зная, потому что данные несут метку, которая переживает преобразования, агрегации и сериализацию.

Для этого нужны три вещи: система типов или слой метаданных, который может помечать поля как чувствительные, механизм распространения, который переносит эти метки по мере движения данных между функциями и сервисами, и точки 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

Ключевой инсайт в том, что email и name теперь самоописывающиеся. Любая функция, которая получает значение Labeled, может проверить его чувствительность, не разбирая полезную нагрузку и не угадывая по имени ключа.

Распространение меток через преобразования

Помеченные данные полезны только если метка переживает вашу бизнес-логику. Когда вы применяете map, filter или aggregate, вам нужны правила для комбинирования чувствительности.

Простейший набор правил — это 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 boundaries.

Enforcement: где метки реально имеют значение

Отслеживание без enforcement — это дорогостоящий театр наблюдаемости. Вам нужны точки сужения, где помеченные данные проверяются перед записью в хранилище, отправкой по сети или отрисовкой в UI.

Распространённый паттерн — это реестр sink’ов, который сопоставляет назначения с максимально разрешённой чувствительностью.

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.

Для логирования конкретно вы можете применять это на уровне логгера. В Python logging module поддерживает фильтры, которые могут проверять записи логов перед их выводом.

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())

Это тупой инструмент. Лучший подход — структурированное логирование, где ваша полезная нагрузка лога — это тот же самый помеченный словарь, и форматировщик вырезает данные на основе тега чувствительности, а не регулярного выражения.

Статический анализ как подстраховка

Рантайм-маркировка мощна, но требует дисциплины. Кто-то забудет обернуть новое поле, или приведёт значение Labeled обратно к примитиву, чтобы избежать ошибки типов.

Статический анализ закрывает пробел. Инструменты вроде Semgrep или кастомные линтеры могут применять правила, что сырые строки из тел HTTP-запросов никогда не передаются напрямую в логгеры или базы данных без прохождения через конструктор маркировки.

Минимальное правило 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

Это не поймает каждую утечку, но поймает очевидные — а оттуда приходит большинство утечек.

Трейдоффы, о которых никто не хочет говорить

Этот подход добавляет трение. Каждое преобразование данных теперь включает дополнительное поле метаданных. Полезная нагрузка сериализации становится больше. Код-ревью превращаются в споры о том, является ли user_agent PII. (Кстати, обычно является. Евросоюз так считает.)

Производительность — реальная проблема. Если вы обрабатываете миллионы событий в секунду, выделение обёртки Labeled для каждого поля — это ощутимые накладные расходы. На горячих путях вам может понадобиться пакетная проверка меток или перенос enforcement на границу.

Также есть бремя поддержки. Метки чувствительности хороши ровно настолько, насколько хороши люди, которые их поддерживают. Когда меняются регламенты приватности, или когда ваш бизнес расширяется в новую юрисдикцию, вам нужно перемаркировать данные. Здесь нет волшебства. Это работа.

Что мы не выбрали: сканирование регулярными выражениями в покое

Некоторые команды решают это сканированием хранилищ данных задним числом с помощью регулярных выражений, ища паттерны, похожие на email или номера социального страхования. Это лучше, чем ничего, но фундаментально реактивно.

Регулярные выражения пропускают обфусцированные данные, хешированные идентификаторы и составные PII. Они также дают ложноположительные срабатывания, которые подрывают доверие к системе. Мы рассматривали этот подход на раннем этапе и отвергли его, потому что он лечит симптом (данные в неправильном месте), а не причину (данные покидают сервис немаркированными).

Начните с точек сужения

Вам не нужно маркировать каждое поле в каждом сервисе в первый день. Начните с границ. Маркируйте данные при их входе в систему от пользователей, сторонних API и потоков событий. Затем добавьте enforcement в ваши наиболее рискованные sink’и: аналитические пайплайны, инфраструктуру логирования и внешние интеграции.

Как только они будут на месте, расширяйтесь вглубь. Цель — не идеальное покрытие. Цель — сделать утечки PII дорогими для случайного создания и очевидными для обнаружения на код-ревью.

Если вы строите это на Python, класс Labeled выше — достаточно для старта. В TypeScript брендированный тип или похожая обёртка работают так же. В Go вам понадобится структура с неэкспортируемым полем, чтобы предотвратить случайное приведение типов.

Инструменты просты. Сложная часть — решить, что немаркированный PII — это баг, и относиться к нему как к багу.