Безопасность приложений Приватность и соответствие: персональные данные, минимизация, аудит
0%

Приватность и соответствие: персональные данные, минимизация, аудит

Приватность и соответствие: персональные данные, минимизация, аудит

Четырнадцать предыдущих статей трека отвечали на вопрос «как не дать посторонним получить доступ к данным». Эта — на другой: какие данные вообще имеют право лежать в вашей системе, сколько времени и кто может их увидеть изнутри. Разница принципиальная. Безупречно защищённая база, в которой хранятся паспортные данные всех, кто когда-либо заходил на сайт, — это не успех, а отложенный инцидент: рано или поздно её кто-то выгрузит, и масштаб ущерба будет определяться не качеством ваших TLS-настроек, а тем, что вы решили собирать три года назад.

Приватность — это свойство проектирования, а не слой поверх готовой системы. Нельзя «добавить приватность» в конце спринта так же, как нельзя добавить в конце спринта транзакционность. Она складывается из решений, принятых на уровне схемы данных, контрактов API, конфигурации логгера и политики бэкапов — то есть ровно там, где работает разработчик.

Рамка статьи. Здесь нет юридических консультаций и толкований нормативных актов. Мы говорим только об инженерных практиках и о том, какие технические требования из норм вытекают. Правовую сторону работы с персональными данными в российском контуре портал разбирает в отдельном материале — 152-ФЗ для разработчиков; там же ссылки на первоисточники. За квалификацией конкретного случая — к юристу по персональным данным, а не к статье в интернете. Любая практическая проверка описанных здесь контролей (сканирование дампа на ПДн, попытка получить чужие данные через запрос субъекта, тест на реидентификацию) выполняется только на ваших системах или при письменном разрешении владельца с зафиксированным периметром, окном и контактом для эскалации.

Опорные классификаторы: OWASP Top 10A01:2021 Broken Access Control, A02:2021 Cryptographic Failures, A04:2021 Insecure Design, A09:2021 Security Logging and Monitoring Failures; отдельный проект OWASP Top 10 Privacy Risks. CWE — 200 (раскрытие информации постороннему), 359 (раскрытие персональных данных), 532 (ПДн в лог-файле), 312 (хранение в открытом виде), 201 (лишние данные в ответе), 524 (чувствительное в кэше), 213 (раскрытие из-за несогласованных политик). NIST — SP 800-122 (защита конфиденциальности PII), Privacy Framework 1.0, SP 800-188 (обезличивание наборов данных), SP 800-88r1 (уничтожение носителей). RFC — 6973 Privacy Considerations for Internet Protocols и 7258 / BCP 188.

Что считать персональными данными: почему интуиция подводит

Разработчик обычно держит в голове короткий список: ФИО, телефон, e-mail, паспорт, карта. Этого списка категорически недостаточно, потому что персональность — свойство не поля, а связи поля с человеком в конкретном контексте.

Три уровня:

  • Прямые идентификаторы — сами по себе указывают на человека: ФИО, номер документа, телефон, e-mail, номер счёта, биометрический шаблон.
  • Квазиидентификаторы — по отдельности безобидны, в комбинации уникальны: дата рождения, почтовый индекс, пол, должность, модель устройства, часовой пояс, набор установленных шрифтов. Классический результат Латани Суини: комбинации «индекс + дата рождения + пол» достаточно, чтобы уникально идентифицировать примерно 87% населения США. Ни одно из трёх полей в вашей таблице не выглядит как «персональные данные».
  • Данные о поведении, привязанные к устойчивому ключу — история заказов, геотреки, логи поиска, cookie-идентификатор. Ключ может быть «анонимным» числом, но если он стабилен и по нему накапливается история, он превращается в идентификатор человека.

Два исторических кейса, которые стоит помнить, потому что оба выполнялись именно по «обезличенным» данным. AOL, 2006: опубликован массив поисковых запросов, где логины заменены на номера; журналисты сопоставили запросы одного номера и вышли на конкретного человека — люди ищут собственное имя, свой адрес и фамилии соседей. Netflix Prize, 2008: Нараянан и Шматиков показали в работе Robust De-anonymization of Large Sparse Datasets, что «обезличенные» оценки фильмов сопоставляются с публичными отзывами на IMDb — достаточно нескольких совпадений по редким фильмам и датам.

Вывод для проектирования: разреженные данные почти всегда идентифицирующие. Чем длиннее вектор поведения на одного пользователя, тем меньше нужно внешних сведений, чтобы связать его с человеком. Удаление колонки name анонимизацией не является.

Это LINDDUN — методология моделирования угроз приватности, зеркальная STRIDE из статьи Моделирование угроз. STRIDE спрашивает «что злоумышленник может сделать с системой», LINDDUN — «что система делает с человеком, даже работая штатно». Разбирать обе стоит на одной и той же диаграмме потоков данных: угрозы приватности живут ровно на тех же стрелках, что и угрозы безопасности, но выявляются другими вопросами.

Инвентаризация: карта данных как код

Любая работа с приватностью начинается с ответа на вопрос «какие персональные данные у нас есть и где». В большинстве команд честный ответ — «никто не знает целиком». Документ в вики устаревает за спринт, поэтому карта данных должна жить рядом с кодом и проверяться автоматикой.

Минимальная модель, которую стоит завести явно.

Ключевая мысль схемы: срок хранения крепится не к таблице, а к паре «элемент данных — цель обработки». Один и тот же телефон может жить два года ради поддержки заказа и три дня ради антифрод-проверки; если срок один на таблицу, вы автоматически храните всё по максимуму.

Практическая реализация — манифест в репозитории, который читают и линтер, и миграции, и генератор отчётности:

# data-map.yaml — лежит рядом с кодом сервиса, ревьюится в тех же PR
version: 1
service: checkout
elements:
  - path: users.email_enc
    category: contact
    sensitivity: 2          # 0 публично, 1 внутреннее, 2 ПДн, 3 спец. категория
    quasi_identifier: false
    purposes: [account, transactional_email]
    retention: { trigger: account_closed, days: 90, action: crypto_shred }
    stores: [postgres.primary, s3.backup]
    recipients: [mail_gateway]

  - path: orders.delivery_address
    category: location
    sensitivity: 2
    quasi_identifier: true  # адрес + время доставки почти уникальны
    purposes: [fulfilment]
    retention: { trigger: order_completed, days: 1095, action: anonymize }
    stores: [postgres.primary, dwh.mart_orders]
    recipients: [delivery_partner]

Что даёт манифест в CI:

  1. Ни одно новое поле не проходит без записи. Тест сравнивает список колонок из миграций со списком path в манифесте и падает на расхождении. Это заставляет автора фичи один раз подумать «зачем мне это поле».
  2. Автогенерация отчёта об обработке. Реестр операций, который иначе собирают вручную раз в год, превращается в артефакт сборки.
  3. Проверка непротиворечивости. Поле с sensitivity >= 2 не может лежать в сторе с kind: log; поле с получателем обязано иметь цель; поле без retention не мержится.

Родственная дисциплина в других треках — каталог данных и происхождение (lineage) в Data Quality и Governance. Разница в акценте: там карта нужна для доверия к цифрам, здесь — для того, чтобы удаление и отчётность были выполнимы механически.

Минимизация: четыре разных приёма, которые путают

«Минимизация данных» звучит как один принцип, но на практике это четыре независимых решения на разных этапах. Их регулярно смешивают и потому выполняют только первое.

Приём 1: не собирать

Самый недооценённый. Форма регистрации с полем «дата рождения», которое нужно только для поздравления, — это трёхлетнее обязательство хранить квазиидентификатор ради письма раз в год. Инженерный вопрос к продукту звучит так: «какой сценарий сломается, если поля не будет, и сколько он стоит». Он же — самый эффективный: данных, которых нет, не бывает в утечке, их не надо шифровать, удалять и включать в отчётность.

Приём 2: не хранить сырое, если достаточно производного

Типичный уязвимый код — «сохраним всё, разберёмся потом»:

# УЯЗВИМО: сырой ответ внешнего сервиса верификации кладётся целиком
async def verify_identity(user_id: int, passport: str) -> bool:
    resp = await kyc_client.check(passport=passport)   # ответ содержит ФИО, адрес, скан
    await db.execute(
        "INSERT INTO kyc_results (user_id, raw_response) VALUES ($1, $2)",
        user_id, json.dumps(resp),                     # весь JSON, включая всё лишнее
    )
    return resp["status"] == "ok"

Почему это работает против вас. raw_response — свободная форма: никто не знает, что там лежит, поэтому колонку невозможно ни классифицировать, ни удалить выборочно. Через год состав ответа меняется на стороне поставщика, и в базу начинают приезжать новые поля, которых нет ни в одной карте данных. Это CWE-201 и одновременно A04:2021 Insecure Design — дефект не в строке кода, а в решении.

Как чинить. Извлечь ровно то, что нужно бизнес-логике, и зафиксировать структуру типом:

@dataclass(frozen=True)
class KycOutcome:
    """Всё, что нам нужно от проверки. Паспорт и ФИО тут не хранятся."""
    passed: bool
    checked_at: datetime
    provider_ref: str      # идентификатор проверки у поставщика — для разбора спора
    risk_band: str         # "low" | "medium" | "high", не сырой скор

async def verify_identity(user_id: int, passport: str) -> KycOutcome:
    resp = await kyc_client.check(passport=passport)
    outcome = KycOutcome(resp["status"] == "ok", datetime.now(timezone.utc),
                         resp["request_id"], _band(resp["score"]))
    await db.execute(
        "INSERT INTO kyc_results (user_id, passed, checked_at, provider_ref, risk_band) "
        "VALUES ($1, $2, $3, $4, $5)",
        user_id, outcome.passed, outcome.checked_at, outcome.provider_ref, outcome.risk_band,
    )
    return outcome     # сам номер паспорта не пережил границу функции

Как проверить, что починено. Тест на схему: SELECT * FROM information_schema.columns WHERE table_name = 'kyc_results' не содержит колонок типа json/text без записи в манифесте. Плюс интеграционный тест, который подсовывает поставщику ответ с лишним полем и убеждается, что оно не долетело до базы.

Приём 3: не копировать без причины

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

Маршрут одного персонального поля по системе: от формы до бэкапов, поискового индекса, трассировок и внешних получателей, с точками контроля на каждом этапе

Синие рамки — копии, о которых помнят. Янтарные пунктирные — те, до которых DELETE FROM users не дотягивается никогда: поисковый индекс, access-логи, трассировки APM, витрина DWH, снапшоты, аналитические теги в браузере. Практическое следствие: каждая стрелка на этой схеме должна быть явной проекцией, а не передачей объекта целиком.

// УЯЗВИМО: доменная сущность уезжает в шину как есть
await bus.publish("user.registered", user);   // внутри: phone, email, passport_series…
// подписчики — аналитика, CRM, антифрод — получают всё и сохраняют у себя навсегда

// ПРАВИЛЬНО: контракт события — отдельный тип, поля перечислены руками
type UserRegistered = {
  readonly userId: string;      // непрозрачный идентификатор, не e-mail
  readonly registeredAt: string;// RFC 3339
  readonly channel: "web" | "ios" | "android";
  readonly countryCode: string; // страна, не адрес
};

const event: UserRegistered = {
  userId: user.id,
  registeredAt: user.createdAt.toISOString(),
  channel: ctx.channel,
  countryCode: user.country,
};
await bus.publish("user.registered", event);

Правило, которое ловит большую часть регрессий: сериализация по белому списку. Тот же механизм, что защищает от массового присвоения на входе (см. Безопасность API), нужен и на выходе. JSON.stringify(entity), model_dump() без include=, json.Marshal структуры БД в HTTP-ответ — три способа однажды случайно отдать наружу поле, добавленное коллегой в другой миграции.

Приём 4: не показывать целиком

Данные, которые обязаны храниться, не обязаны отображаться. Маскирование в интерфейсе поддержки (+7 9•• ••• •• 43), раскрытие полного значения по отдельному действию с записью в журнал, ограничение выгрузок по количеству строк — всё это снижает не риск взлома, а риск внутреннего злоупотребления, который статистически случается чаще. Полный номер должен требовать явного «показать» и оставлять след, кто и зачем посмотрел.

Псевдонимизация, обезличивание и почему хеш не спасает

Три разных состояния, которые в разговоре называют одним словом «обезличили».

Состояние Что сделано Можно ли вернуться к личности Остаётся ли ПДн
Псевдонимизация прямые идентификаторы заменены на ключ, соответствие хранится отдельно да, у владельца таблицы соответствия да
Обезличивание (де-идентификация) удалены прямые идентификаторы, квазиидентификаторы огрублены до порога только внешними данными, с вероятностью зависит от качества
Агрегация наружу отдаются только сводные величины с порогом на размер группы нет, при корректном пороге нет

Главная инженерная ошибка: считать, что хеш обезличивает. sha256(email) — не обезличивание, а псевдоним с публично известной функцией. Пространство e-mail-адресов и телефонов мало: полный перебор всех российских мобильных номеров — это меньше 10^9 вариантов, то есть секунды на обычной видеокарте. Тот же дефект, что у нехешированных паролей из статьи Аутентификация, только здесь его чаще не замечают, потому что «это же не пароль».

import hmac, hashlib

# УЯЗВИМО: обратимо перебором, одинаково для всех систем — идеальный ключ связывания
pseudo = hashlib.sha256(email.lower().encode()).hexdigest()

# ПРАВИЛЬНО: ключ живёт в KMS, у каждой цели — свой ключ,
# поэтому идентификаторы из аналитики и из поддержки не связываются между собой
def pseudonymize(value: str, purpose: str, key: bytes) -> str:
    normalized = value.strip().lower().encode()
    return hmac.new(key, purpose.encode() + b"|" + normalized, hashlib.sha256).hexdigest()

Такой отпечаток называют слепым индексом (blind index): по нему можно искать точное совпадение, не храня само значение в открытом виде. Приём подробно разобран у Paragon Initiative; ограничения существенные — работает только точное сравнение, а частотный анализ по редким значениям остаётся возможным, поэтому для полей с малым числом вариантов (пол, город) слепой индекс бесполезен. Управление ключом — по правилам из статей Прикладная криптография и Секреты и ключи.

Порог группы: k-анонимность и проверка выгрузки

Прежде чем отдать «обезличенный» набор аналитикам или партнёру, стоит проверить, нет ли в нём строк, уникальных по комбинации квазиидентификаторов. Набор удовлетворяет k-анонимности, если каждая комбинация квазиидентификаторов встречается не менее чем у k записей (Sweeney, 2002).

Псевдокод:

вход: таблица T, список квазиидентификаторов Q, порог k
groups := пустая хеш-таблица
для каждой строки r из T:
    key := кортеж значений r по колонкам Q
    groups[key] += 1
worst := минимум по значениям groups
если worst < k: вернуть НЕ ПРОШЛО и примеры ключей с малым размером
иначе вернуть ПРОШЛО
from collections import Counter
from typing import Iterable, Mapping, Sequence

def k_anonymity_report(
    rows: Iterable[Mapping[str, object]],
    quasi_ids: Sequence[str],
    k: int,
) -> tuple[bool, int, list[tuple]]:
    """Возвращает (прошло ли, фактический k, примеры слишком мелких групп)."""
    groups: Counter[tuple] = Counter()
    for row in rows:
        groups[tuple(row[c] for c in quasi_ids)] += 1
    if not groups:
        return True, 0, []
    actual_k = min(groups.values())
    offenders = [key for key, cnt in groups.items() if cnt < k][:20]
    return actual_k >= k, actual_k, offenders

# в CI перед публикацией витрины
ok, actual, bad = k_anonymity_report(rows, ["birth_year", "region", "gender"], k=10)
assert ok, f"выгрузка не k-анонимна: k={actual}, примеры групп: {bad}"

Сложность: O(n · |Q|) по времени (один проход, вычисление ключа на строку) и O(g) по памяти, где g — число различных комбинаций квазиидентификаторов; в худшем случае g = n, поэтому для больших витрин считайте агрегатом в самой СУБД (GROUP BY … HAVING count(*) < k), а не в приложении.

k-анонимность — необходимый, но не достаточный порог. Две известные дыры: если в группе из k человек у всех одинаковое значение чувствительного атрибута, вы всё равно узнали факт о каждом (лечится l-разнообразием), а если распределение внутри группы сильно отличается от общего — узнали почти всё (t-близость). Строгую гарантию даёт дифференциальная приватность: к результату запроса добавляется шум, калиброванный так, чтобы присутствие или отсутствие одной записи почти не влияло на ответ; сила гарантии задаётся параметром эпсилон, и суммарный «бюджет приватности» тратится с каждым новым запросом. Введение — The Algorithmic Foundations of Differential Privacy (Dwork, Roth) и NIST SP 800-226. Практический вывод для инженера: DP имеет смысл там, где вы публикуете статистику наружу или обучаете модель на чувствительных данных (см. MLOps), и почти не имеет — для внутренней операционной базы.

Логи, трассировки и телеметрия: самый частый канал утечки

Если провести один аудит в незнакомой системе, начинать надо с логов. Там ПДн оказываются не по злому умыслу, а тремя одинаковыми путями.

Путь 1: идентификатор в URL. GET /api/users?email=ivan@example.com попадает в access-лог nginx, в лог балансировщика, в метрику по маршрутам, в заголовок Referer при переходе на внешний сайт и в историю браузера. Лечится переносом в тело запроса или в непрозрачный идентификатор в пути. Это CWE-598 в паре с CWE-532.

Путь 2: логирование объекта целиком. logger.info("user %s", user) — и __str__ вываливает всю модель. То же с логированием тела запроса при ошибке и с автоматическими интеграциями APM, которые по умолчанию пишут аргументы функций.

Путь 3: сообщения об ошибках. Текст исключения от драйвера БД содержит параметры запроса; валидатор докладывает «значение +79001234567 не прошло проверку».

Правильная архитектура — редакция на границе, а не в каждом вызове. Полагаться на дисциплину разработчиков бесполезно: достаточно одного logger.debug(payload) в срочном хотфиксе.

import logging, re

# Редактор работает как фильтр на корневом логгере: любой вызов проходит через него
_PATTERNS = [
    (re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b"), "<email>"),
    (re.compile(r"\+?\d[\d\s()-]{9,15}\d"), "<phone>"),
    (re.compile(r"\b\d{4}[\s-]?\d{6}\b"), "<passport>"),
    (re.compile(r"\b(?:\d[ -]?){13,19}\b"), "<pan>"),
]
_SENSITIVE_KEYS = {"password", "token", "authorization", "phone", "email", "address"}

class PiiRedactor(logging.Filter):
    def filter(self, record: logging.LogRecord) -> bool:
        record.msg = self._scrub(str(record.msg))
        if record.args:
            record.args = self._scrub(record.args)
        return True

    def _scrub(self, value):
        if isinstance(value, str):
            for pattern, repl in _PATTERNS:
                value = pattern.sub(repl, value)
            return value
        if isinstance(value, dict):   # ключ решает судьбу значения целиком
            return {k: ("<redacted>" if k.lower() in _SENSITIVE_KEYS else self._scrub(v))
                    for k, v in value.items()}
        if isinstance(value, (list, tuple)):
            return type(value)(self._scrub(v) for v in value)
        return value

logging.getLogger().addFilter(PiiRedactor())

Аналог для Go на структурированном логгере — перехват атрибутов до записи:

// ReplaceAttr вызывается для каждого атрибута: имя поля решает судьбу значения
var sensitive = map[string]bool{"email": true, "phone": true, "token": true, "address": true}

logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
        if sensitive[strings.ToLower(a.Key)] {
            return slog.String(a.Key, "<redacted>")
        }
        return a
    },
}))

Три оговорки, без которых редакция создаёт ложное чувство защиты:

  1. Регулярные выражения ловят не всё. Адрес, ФИО и произвольный текст жалобы они не распознают. Регулярки — страховка, а основной механизм — типизированные поля с явной пометкой чувствительности.
  2. Фильтр должен стоять до отправки в коллектор, а не в самом коллекторе: иначе сырые данные уже прошли по сети и осели в буфере агента.
  3. Трассировки и метрики — тоже логи. В OpenTelemetry атрибуты спанов часто содержат параметры запроса; настраивайте процессор, отбрасывающий или маскирующий их, — про устройство конвейера см. Наблюдаемость.

Как проверить. Регрессионный тест, который прогоняет типовой сценарий и грепает получившийся лог на образцы из тестовых данных:

def test_no_pii_in_logs(caplog, client):
    client.post("/signup", json={"email": "canary@example.com", "phone": "+79001234567"})
    blob = "\n".join(r.getMessage() for r in caplog.records)
    for canary in ("canary@example.com", "+79001234567", "79001234567"):
        assert canary not in blob, f"утечка в лог: {canary}"

Приём с «канареечными» значениями работает и шире: заведите набор уникальных строк-маркеров, прогоняйте e2e-сценарии и ищите маркеры в логах, метриках, письмах, экспортах и телеметрии фронтенда. Это дешёвый способ поймать копию, о которой никто не знал.

Сроки хранения и удаление, которое дотягивается до бэкапа

Отсутствие срока хранения — самая распространённая ошибка соответствия, потому что она не проявляется как баг. Система работает, данные копятся.

Инженерная формулировка: у каждого элемента данных есть триггер отсчёта (не «дата создания», а событие: закрытие аккаунта, завершение заказа, истечение гарантии), срок и действие по истечении. Хранить срок нужно в самой записи — тогда чистка становится обычным запросом, а не проектом.

-- Retention как атрибут строки: сборщик мусора не должен знать бизнес-правила
ALTER TABLE orders
    ADD COLUMN retain_until timestamptz,           -- когда истекает основание хранить
    ADD COLUMN retention_reason text NOT NULL      -- почему именно этот срок
        DEFAULT 'fulfilment';

CREATE INDEX orders_retain_until_idx ON orders (retain_until)
    WHERE retain_until IS NOT NULL;

-- Заполняется в момент перехода в терминальное состояние, а не при вставке
UPDATE orders
   SET retain_until = completed_at + interval '3 years'
 WHERE status = 'completed' AND retain_until IS NULL;

-- Ежедневная задача: обезличивание вместо удаления там, где нужна аналитика
UPDATE orders
   SET delivery_address = NULL,
       recipient_name   = NULL,
       recipient_phone  = NULL,
       anonymized_at    = now()
 WHERE retain_until < now() AND anonymized_at IS NULL;

Обратите внимание: строка заказа остаётся — исчезают персональные атрибуты. Финансовая история и агрегаты для отчётности не ломаются, а связь с человеком разорвана. Это почти всегда лучше физического удаления, потому что не порождает каскад проблем со ссылочной целостностью.

Жизненный цикл записи

Состояние PendingErasure — не бюрократия, а необходимость: удаление в распределённой системе занимает часы и требует подтверждений от подписчиков. Всё это время данные должны быть недоступны обычным сценариям, иначе пользователь, «удаливший аккаунт», продолжит получать рассылку.

Бэкапы и неизменяемые журналы

Здесь спотыкаются даже аккуратные команды. Бэкап по определению неизменяем — его нельзя переписать, не сломав. Событийный журнал (см. CQRS и Event Sourcing) неизменяем по замыслу. Блокчейн-подобные структуры неизменяемы принципиально. Как удалять?

Основных стратегий три:

  1. Не класть туда ПДн вообще. В событие пишется userId, а персональные атрибуты живут в отдельной таблице, куда обращаются по ссылке. Удаление таблицы делает старые события безличными. Самое чистое решение, требует дисциплины с первого дня.
  2. Крипто-шреддинг. Значения шифруются ключом, уникальным для субъекта; удаление ключа делает нечитаемыми все копии сразу.
  3. Отложенное истечение. Бэкапы живут ограниченный срок (30–90 дней), и удаление считается завершённым после ротации последнего снапшота, содержащего запись; факт фиксируется в журнале. Способ законный и рабочий, но требует, чтобы восстановление из бэкапа автоматически повторно применяло очередь удалений — иначе восстановленная база воскресит удалённых пользователей.

Крипто-шреддинг: данные каждого субъекта зашифрованы своим ключом, стирание одной строки связки делает нечитаемыми все копии, включая бэкапы и витрину

Ограничения крипто-шреддинга, которые надо проговорить до внедрения: приём работает только если ключ никогда не покидал контур и ни одна копия не была сохранена расшифрованной. Выгрузка в CSV для аналитика, кэш с расшифрованным профилем, лог с телом ответа — каждая из этих вещей отменяет весь эффект. Кроме того, шифрование поштучным ключом ломает поиск и джойны по этому полю: планируйте слепые индексы заранее. Хранилище объектов, куда уезжают бэкапы, тоже должно уметь версионирование и настоящее удаление версий — детали в Объектных хранилищах.

Запросы субъектов: доступ, исправление, удаление, переносимость

Права человека на свои данные превращаются в четыре конкретных API-сценария. Их полезно проектировать как один механизм с общей инфраструктурой: проверка личности, оркестрация, дедлайн, журнал.

Четыре вещи, которые делают этот механизм рабочим, а не декоративным.

Проверка личности — это точка атаки, а не формальность. Исследователь Джеймс Павур в работе GDPArrr: Using Privacy Laws to Steal Identities (Black Hat USA 2019) показал: заметная доля компаний по запросу «дайте мои данные» выдавала сведения о человеке тому, кто знал лишь его имя и адрес почты. Запрос субъекта — легальный канал, по которому можно вытащить максимум данных о человеке за один раз, и защищать его надо соответственно. Правильный вариант — подтверждение внутри аутентифицированной сессии с повторным вводом фактора (см. Аутентификация); присланная по почте копия документа — худший вариант, потому что вы создаёте новую копию чувствительных данных ради проверки.

Идемпотентность и повторяемость. Оркестратор рассылает команды с идентификатором запроса; сервис, получивший команду дважды, обязан ответить done без побочных эффектов. Иначе повторная доставка в очереди превращается в частично выполненное удаление.

Отказ — тоже результат. Часть данных обязана храниться по другим основаниям (бухгалтерия, налоговый учёт). Инженерное требование: система должна уметь ответить «не удалено, основание такое-то, срок до такого-то», а не молча пропустить.

Полнота обеспечивается реестром, а не памятью. Оркестратор рассылает команды по списку из data-map.yaml. Сервис, появившийся в прошлом квартале и забывший зарегистрироваться, — главная причина неполного удаления. Тест в CI: каждый сервис, объявляющий элементы данных с sensitivity >= 2, обязан иметь зарегистрированный обработчик erase и export, иначе сборка падает.

# Контракт обработчика, одинаковый для всех сервисов
from typing import Protocol, Literal

class SubjectDataHandler(Protocol):
    service: str

    async def export(self, subject_id: str) -> dict:
        """Машиночитаемая выгрузка (переносимость). Только данные субъекта."""

    async def erase(self, subject_id: str, request_id: str) -> tuple[
        Literal["done", "refused", "deferred"], str
    ]:
        """Возвращает статус и человекочитаемую причину.
        Обязана быть идемпотентной по (subject_id, request_id)."""

    async def rectify(self, subject_id: str, patch: dict) -> None:
        """Исправление неточных данных."""

Готовый пример реализации privacy API на прикладном уровне есть в портальном материале про 152-ФЗ — там же разобраны формы согласия и структура политики обработки.

Согласие как данные, а не как чекбокс

users.consent_given BOOLEAN — антипаттерн, который делает невозможным доказать что-либо через год. Согласие нужно моделировать как событие с версией, потому что доказывать придётся не факт, а обстоятельства: на какую редакцию текста человек согласился, когда, каким действием и на какие цели.

CREATE TABLE consents (
    id             bigserial PRIMARY KEY,
    subject_id     uuid        NOT NULL,
    purpose        text        NOT NULL,      -- 'marketing_email', 'analytics', 'personalization'
    granted        boolean     NOT NULL,      -- отзыв — такая же строка с granted = false
    policy_version text        NOT NULL,      -- хеш или версия текста, который видел человек
    ui_context     text        NOT NULL,      -- 'signup_form_v3' — где именно нажали
    occurred_at    timestamptz NOT NULL DEFAULT now(),
    source_ip      inet,                      -- сам по себе ПДн: свой retention
    user_agent     text
);

-- Строки не обновляются и не удаляются: только добавляются. Текущее состояние — проекция.
CREATE VIEW current_consents AS
SELECT DISTINCT ON (subject_id, purpose) subject_id, purpose, granted, occurred_at, policy_version
  FROM consents
 ORDER BY subject_id, purpose, occurred_at DESC;

Инженерные следствия, которые обычно упускают:

  • Отзыв должен работать так же быстро, как выдача. Если галочку ставят за секунду, а снимают через письмо в поддержку, механизм сломан. Проверка согласия обязана быть в том же месте кода, что и отправка: if not consents.allows(user, "marketing_email"): return.
  • Изменение текста политики — новая версия, а не правка старой. Иначе через год невозможно сказать, на что именно согласился человек.
  • Согласие не универсальное основание. Значительная часть обработки идёт по другим основаниям (исполнение договора, требование закона), и требовать согласия там, где оно не нужно, — такая же ошибка проектирования, как не требовать там, где нужно. Квалификация основания — вопрос к юристу, задача инженера — чтобы система умела различать основания и не смешивала их в одном флаге.
  • Клиентский слой. Аналитические и рекламные теги подключаются после проверки согласия, а не «загрузим и подождём». Технически это удобно закрывать политикой CSP (см. XSS, CSRF и клиентские атаки): жёсткий connect-src и script-src физически не дают стороннему скрипту отправить данные до того, как вы разрешили. Реализация форм и состояния согласия на клиенте — в треке Frontend.

Тестовые среды: где утекает чаще всего

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

Три рабочих подхода, по возрастанию стоимости:

  1. Синтетические данные. Генератор с фиксированным seed выдаёт правдоподобные, но выдуманные значения. Лучший вариант, ломается на воспроизведении редких продовых аномалий.
  2. Маскирование при выгрузке. Дамп проходит через трансформацию до того, как покидает защищённый контур. Критично: маскирование выполняется на стороне прода в конвейере выгрузки, а не «скачаем и почистим» — иначе сырой файл уже существовал.
  3. Детерминированное псевдонимирование. Одно и то же исходное значение всегда даёт один и тот же фейк, поэтому джойны между таблицами продолжают работать. Обратная сторона: детерминизм сохраняет частотное распределение, и по редким значениям возможна реидентификация — для чувствительных полей комбинируйте с огрублением.
-- Конвейер маскирования: запускается внутри прод-контура, наружу уходит уже результат
CREATE OR REPLACE VIEW export_users_masked AS
SELECT
    id,
    -- детерминированный фейк: джойны сохраняются, значение не восстанавливается
    encode(hmac(email, current_setting('app.mask_key'), 'sha256'), 'hex') || '@example.invalid'
        AS email,
    left(first_name, 1) || repeat('*', 5)                       AS first_name,
    date_trunc('year', birth_date)::date                        AS birth_date,  -- огрубление
    left(postal_code, 3) || '00'                                AS postal_code,
    country
FROM users;

Про то, как встроить такую выгрузку в конвейер и не дать никому обойти её вручную, — Безопасность в пайплайне.

Журнал аудита: кто, что, когда и зачем смотрел

Контроль доступа отвечает на вопрос «можно ли». Аудит отвечает на вопрос «а что на самом деле происходило» — и без него невозможны ни расследование инцидента, ни разбор жалобы, ни выявление внутреннего злоупотребления. Это прямая проекция A09:2021 на приватность.

Что должно быть в записи:

Поле Зачем Типичная ошибка
actor кто действовал (сотрудник, сервис, автоматика) пишут только user_id без роли и без признака «действие от имени»
on_behalf_of если сотрудник действует по обращению клиента отсутствует — невозможно отличить работу от любопытства
subject_id чьи данные затронуты пишут только идентификатор записи
action read / export / update / erase / unmask не логируют чтение — а именно оно нужно для разбора
fields какие именно поля «доступ к профилю» без деталей
reason номер обращения, задачи, инцидента нет обязательного поля — нет и вопроса «зачем»
occurred_at момент по UTC, RFC 3339 локальное время без зоны
request_id связь с трассировкой нет связи между аудитом и логами приложения
result успех, отказ, частичный отказ пишут только успехи

Пять свойств, отличающих настоящий журнал аудита от logger.info:

  1. Только добавление. Отдельная база или таблица без прав UPDATE/DELETE у приложения. Приложение, которое может править свой аудит, аудита не имеет.
  2. Отдельный контур прав. Читают журнал не те, кто в нём фигурирует. Администратор прода не должен уметь стереть запись о своём доступе.
  3. Целостность. Цепочка хешей (hash_n = H(hash_{n-1} || record_n)) или регулярная подпись контрольной точки делает незаметное изъятие записи невозможным — механика та же, что у журналов прозрачности из статьи Цепочка поставок и RFC 6962.
  4. Свой срок хранения. Журнал сам содержит ПДн (кто на кого смотрел) и подчиняется тем же правилам, но обычно с другим сроком — и удаление данных субъекта не должно стирать след о том, что удаление состоялось.
  5. Он должен читаться. Журнал, в который никто не смотрит, — это только затраты на диск. Минимальный полезный набор автоматических правил: массовая выгрузка сверх обычного объёма, доступ вне рабочего времени, доступ сотрудника к данным человека, с которым у него нет открытого обращения, всплеск операций unmask.
# Аудит как побочный эффект слоя доступа, а не как ручной вызов в контроллере
from contextlib import asynccontextmanager

@asynccontextmanager
async def pii_access(actor: Actor, subject_id: str, fields: list[str], reason: str):
    """Любое чтение ПДн проходит через этот контекст. Нет reason — нет доступа."""
    if not reason:
        raise PermissionError("доступ к персональным данным требует указания причины")
    started = time.monotonic()
    result = "ok"
    try:
        yield
    except Exception:
        result = "error"
        raise
    finally:
        await audit.append(
            actor=actor.id, actor_role=actor.role, on_behalf_of=actor.ticket,
            subject_id=subject_id, action="read", fields=fields, reason=reason,
            request_id=ctx.request_id, result=result,
            duration_ms=int((time.monotonic() - started) * 1000),
        )

Ключевое проектное решение — сделать аудит невозможным обойти. Если запись в журнал делается отдельной строкой рядом с запросом, её однажды забудут. Если она встроена в единственный способ получить данные (репозиторий, декоратор, контекстный менеджер, прокси перед БД), забыть нельзя. Тот же принцип «безопасное по умолчанию», что и в Авторизации.

Инциденты с персональными данными

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

Способность ответить «что именно утекло». Без карты данных и аудита ответ будет «вся база», а он определяет и масштаб уведомлений, и репутационный ущерб. Это самая веская причина вести data-map.yaml.

Готовый канал и шаблон уведомления. Регуляторные сроки уведомления жёсткие и исчисляются часами — конкретика в первоисточниках и у юриста; инженерное следствие: собирать сведения об объёме и составе утечки надо уметь за часы, а не за недели. Порядок реагирования — NIST SP 800-61r2.

Сохранение доказательств. Первый инстинкт — «почистить и перезапустить» — уничтожает данные расследования. Снапшот состояния и выгрузка логов делаются до восстановления.

Отдельно: не обещайте того, чего не можете подтвердить. Фраза «данные были зашифрованы» ничего не значит, если ключ лежал на том же сервере. Шифрование на диске защищает от кражи диска, не от кражи через приложение — см. Прикладная криптография.

Как встроить в процесс: приватность как gate, а не как ревью

Всё описанное держится не на внимательности, а на автоматике. Практический набор проверок, которые окупаются.

Проверка Где Что ловит
Колонка без записи в data-map.yaml CI, после миграций «тихое» появление новых ПДн
Поле sensitivity >= 2 без retention CI, линтер манифеста бессрочное хранение
Канареечные значения в логах и телеметрии e2e-тесты CWE-532
Сервис с ПДн без обработчиков erase/export CI неполное удаление по запросу
Секреты и дампы в репозитории pre-commit + сканер истории утечка через git
SELECT * в слое API статический анализ избыточный ответ, CWE-201
Проверка k-анонимности выгрузки конвейер публикации витрины реидентификация
Просроченные записи (retain_until < now()) мониторинг сломавшаяся чистка
Внешний домен в connect-src без записи в реестре получателей сборка фронтенда незадекларированная передача

Организационная часть — короткая. Privacy-раздел в шаблоне дизайн-документа (какие ПДн появляются, зачем, на сколько, кому уходят, как удаляются) стоит десять минут на фиче и снимает большинство проблем. Оценка воздействия (DPIA/PIA, ISO/IEC 29134) — тот же дизайн-документ, но для рискованных обработок: биометрия, профилирование, дети, специальные категории; там же порог, когда надо звать юриста и не решать самим. Роль security-чемпиона из статьи Безопасная разработка естественно расширяется до privacy-чемпиона — это тот же человек, который задаёт на груминге вопрос «а зачем нам это поле».

Типичные ошибки

  • «Мы же хешируем e-mail, это анонимно». Нет: пространство значений мало, функция публична. Нужен HMAC с ключом в KMS и разными ключами под разные цели.
  • Retention привязан к таблице, а не к цели. В итоге всё хранится по максимальному сроку.
  • Удаление реализовано как DELETE в основной базе. Индексы, кэш, витрины, очереди, бэкапы и партнёры остаются с копией.
  • ПДн в идентификаторе запроса. E-mail в URL, телефон в имени файла экспорта, ФИО в теме письма — всё это уезжает в логи и внешние системы.
  • Согласие как булев флаг. Невозможно показать, на какую редакцию текста и когда человек согласился.
  • Проверка личности при запросе субъекта слабее, чем при входе в аккаунт. Легальный канал массовой выдачи данных постороннему.
  • Аудит пишется тем же логгером, что и отладка. Ротация, произвольный формат, возможность правки, отсутствие обязательного reason.
  • Прод-дамп в стейджинге «на время отладки». Живёт годами, доступ шире, мониторинга нет. Рядом — обезличивание без порога группы: комбинация «год рождения + район + профессия» идентифицирует не хуже паспорта.
  • Приватность обсуждают на релизе. К этому моменту схема данных и контракты API уже зафиксированы, а изменить их дороже в разы.

Мини-итог

Приватность инженерно сводится к пяти вопросам, на которые система должна уметь ответить машинно, а не рассказом:

  1. Что у нас есть? — карта данных как код, проверяемая в CI, а не документ в вики.
  2. Зачем оно нам? — цель обработки на каждый элемент; поле без цели не мержится.
  3. Сколько живёт? — триггер отсчёта, срок и действие по истечении, записанные в самой строке.
  4. Кто это видел? — журнал аудита, который невозможно обойти и невозможно незаметно поправить.
  5. Что будет по запросу человека? — работающие export, rectify, erase, дотягивающиеся до всех копий, включая те, что за пределами основной базы.

И одно правило, из которого следует всё остальное: самое надёжное персональное данное — то, которое вы не собрали. Каждое поле в форме — обязательство на годы вперёд: защищать, шифровать, удалять, отчитываться, объяснять при инциденте. Минимизация — это не ограничение продукта, а снижение стоимости владения системой.

Источники

Что дальше

Это последняя статья трека «Безопасность приложений». Пятнадцать шагов назад мы начинали с вопроса «чем уязвимость отличается от обычного бага» в Карте трека — и пришли к тому, что защищённая система определяется не набором инструментов, а последовательностью решений: что мы собираем, кому доверяем, что проверяем на границе и что умеем доказать постфактум.

Куда идти дальше, в зависимости от того, где вам сейчас нужнее глубина:

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов