Приватность и соответствие: персональные данные, минимизация, аудит
Четырнадцать предыдущих статей трека отвечали на вопрос «как не дать посторонним получить доступ к данным». Эта — на другой: какие данные вообще имеют право лежать в вашей системе, сколько времени и кто может их увидеть изнутри. Разница принципиальная. Безупречно защищённая база, в которой хранятся паспортные данные всех, кто когда-либо заходил на сайт, — это не успех, а отложенный инцидент: рано или поздно её кто-то выгрузит, и масштаб ущерба будет определяться не качеством ваших TLS-настроек, а тем, что вы решили собирать три года назад.
Приватность — это свойство проектирования, а не слой поверх готовой системы. Нельзя «добавить приватность» в конце спринта так же, как нельзя добавить в конце спринта транзакционность. Она складывается из решений, принятых на уровне схемы данных, контрактов API, конфигурации логгера и политики бэкапов — то есть ровно там, где работает разработчик.
Рамка статьи. Здесь нет юридических консультаций и толкований нормативных актов. Мы говорим только об инженерных практиках и о том, какие технические требования из норм вытекают. Правовую сторону работы с персональными данными в российском контуре портал разбирает в отдельном материале — 152-ФЗ для разработчиков; там же ссылки на первоисточники. За квалификацией конкретного случая — к юристу по персональным данным, а не к статье в интернете. Любая практическая проверка описанных здесь контролей (сканирование дампа на ПДн, попытка получить чужие данные через запрос субъекта, тест на реидентификацию) выполняется только на ваших системах или при письменном разрешении владельца с зафиксированным периметром, окном и контактом для эскалации.
Опорные классификаторы: OWASP Top 10 — A01: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:
- Ни одно новое поле не проходит без записи. Тест сравнивает список колонок из миграций со списком
pathв манифесте и падает на расхождении. Это заставляет автора фичи один раз подумать «зачем мне это поле». - Автогенерация отчёта об обработке. Реестр операций, который иначе собирают вручную раз в год, превращается в артефакт сборки.
- Проверка непротиворечивости. Поле с
sensitivity >= 2не может лежать в сторе сkind: log; поле с получателем обязано иметь цель; поле безretentionне мержится.
Родственная дисциплина в других треках — каталог данных и происхождение (lineage) в Data Quality и Governance. Разница в акценте: там карта нужна для доверия к цифрам, здесь — для того, чтобы удаление и отчётность были выполнимы механически.
Минимизация: четыре разных приёма, которые путают
«Минимизация данных» звучит как один принцип, но на практике это четыре независимых решения на разных этапах. Их регулярно смешивают и потому выполняют только первое.
«нужно поле X»"] --> B{"Сценарий работает
без X?"} B -- да --> STOP["Не собирать.
Самая дешёвая защита"] B -- нет --> C{"Нужно точное
значение?"} C -- нет --> D["Собрать огрублённо:
год вместо даты рождения,
город вместо координат,
диапазон вместо суммы"] C -- да --> E{"Нужно хранить
или достаточно
проверить и забыть?"} E -- проверить --> F["Верифицировать в моменте,
сохранить только факт:
is_adult = true"] E -- хранить --> G{"Нужно читать
значение или только
сравнивать?"} G -- сравнивать --> H["Хранить HMAC-отпечаток
с ключом из KMS,
значение не хранить"] G -- читать --> I["Шифровать полем,
ключ субъекта,
назначить retention"] D --> J{"Кому уходит
дальше?"} F --> J H --> J I --> J J --> K["Явная проекция под каждого
получателя: логи, аналитика,
партнёр, поддержка"] K --> L["Запись в data-map.yaml
+ тест в CI"]
Приём 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
},
}))
Три оговорки, без которых редакция создаёт ложное чувство защиты:
- Регулярные выражения ловят не всё. Адрес, ФИО и произвольный текст жалобы они не распознают. Регулярки — страховка, а основной механизм — типизированные поля с явной пометкой чувствительности.
- Фильтр должен стоять до отправки в коллектор, а не в самом коллекторе: иначе сырые данные уже прошли по сети и осели в буфере агента.
- Трассировки и метрики — тоже логи. В 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) неизменяем по замыслу. Блокчейн-подобные структуры неизменяемы принципиально. Как удалять?
Основных стратегий три:
- Не класть туда ПДн вообще. В событие пишется
userId, а персональные атрибуты живут в отдельной таблице, куда обращаются по ссылке. Удаление таблицы делает старые события безличными. Самое чистое решение, требует дисциплины с первого дня. - Крипто-шреддинг. Значения шифруются ключом, уникальным для субъекта; удаление ключа делает нечитаемыми все копии сразу.
- Отложенное истечение. Бэкапы живут ограниченный срок (30–90 дней), и удаление считается завершённым после ротации последнего снапшота, содержащего запись; факт фиксируется в журнале. Способ законный и рабочий, но требует, чтобы восстановление из бэкапа автоматически повторно применяло очередь удалений — иначе восстановленная база воскресит удалённых пользователей.
Ограничения крипто-шреддинга, которые надо проговорить до внедрения: приём работает только если ключ никогда не покидал контур и ни одна копия не была сохранена расшифрованной. Выгрузка в CSV для аналитика, кэш с расшифрованным профилем, лог с телом ответа — каждая из этих вещей отменяет весь эффект. Кроме того, шифрование поштучным ключом ломает поиск и джойны по этому полю: планируйте слепые индексы заранее. Хранилище объектов, куда уезжают бэкапы, тоже должно уметь версионирование и настоящее удаление версий — детали в Объектных хранилищах.
Запросы субъектов: доступ, исправление, удаление, переносимость
Права человека на свои данные превращаются в четыре конкретных API-сценария. Их полезно проектировать как один механизм с общей инфраструктурой: проверка личности, оркестрация, дедлайн, журнал.
а не «пришлите скан паспорта на почту» V-->>P: подтверждено, subject_id=42 P->>O: create_request(type=erasure, subject=42) O->>A: зафиксировать приём, выдать номер и срок par рассылка по владельцам данных O->>S1: erase(subject=42, request=R-771) O->>S2: erase(subject=42, request=R-771) O->>S3: erase(subject=42, request=R-771) end S2-->>O: отказ: 3 года хранения по основанию учёта Note over O,S2: отказ — легитимный ответ,
но он обязан быть зафиксирован
с причиной и сроком S1-->>O: done (профиль обезличен) S3-->>O: done (события удалены) O->>K: стереть DEK субъекта 42 K-->>O: ok, версия ключа отозвана O->>A: свести результаты, включая частичный отказ O-->>P: статус completed_with_exceptions P-->>U: что удалено, что осталось, почему и до какого срока
Четыре вещи, которые делают этот механизм рабочим, а не декоративным.
Проверка личности — это точка атаки, а не формальность. Исследователь Джеймс Павур в работе 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-бакете, про который забыли.
Три рабочих подхода, по возрастанию стоимости:
- Синтетические данные. Генератор с фиксированным seed выдаёт правдоподобные, но выдуманные значения. Лучший вариант, ломается на воспроизведении редких продовых аномалий.
- Маскирование при выгрузке. Дамп проходит через трансформацию до того, как покидает защищённый контур. Критично: маскирование выполняется на стороне прода в конвейере выгрузки, а не «скачаем и почистим» — иначе сырой файл уже существовал.
- Детерминированное псевдонимирование. Одно и то же исходное значение всегда даёт один и тот же фейк, поэтому джойны между таблицами продолжают работать. Обратная сторона: детерминизм сохраняет частотное распределение, и по редким значениям возможна реидентификация — для чувствительных полей комбинируйте с огрублением.
-- Конвейер маскирования: запускается внутри прод-контура, наружу уходит уже результат
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:
- Только добавление. Отдельная база или таблица без прав
UPDATE/DELETEу приложения. Приложение, которое может править свой аудит, аудита не имеет. - Отдельный контур прав. Читают журнал не те, кто в нём фигурирует. Администратор прода не должен уметь стереть запись о своём доступе.
- Целостность. Цепочка хешей (
hash_n = H(hash_{n-1} || record_n)) или регулярная подпись контрольной точки делает незаметное изъятие записи невозможным — механика та же, что у журналов прозрачности из статьи Цепочка поставок и RFC 6962. - Свой срок хранения. Журнал сам содержит ПДн (кто на кого смотрел) и подчиняется тем же правилам, но обычно с другим сроком — и удаление данных субъекта не должно стирать след о том, что удаление состоялось.
- Он должен читаться. Журнал, в который никто не смотрит, — это только затраты на диск. Минимальный полезный набор автоматических правил: массовая выгрузка сверх обычного объёма, доступ вне рабочего времени, доступ сотрудника к данным человека, с которым у него нет открытого обращения, всплеск операций
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 уже зафиксированы, а изменить их дороже в разы.
Мини-итог
Приватность инженерно сводится к пяти вопросам, на которые система должна уметь ответить машинно, а не рассказом:
- Что у нас есть? — карта данных как код, проверяемая в CI, а не документ в вики.
- Зачем оно нам? — цель обработки на каждый элемент; поле без цели не мержится.
- Сколько живёт? — триггер отсчёта, срок и действие по истечении, записанные в самой строке.
- Кто это видел? — журнал аудита, который невозможно обойти и невозможно незаметно поправить.
- Что будет по запросу человека? — работающие
export,rectify,erase, дотягивающиеся до всех копий, включая те, что за пределами основной базы.
И одно правило, из которого следует всё остальное: самое надёжное персональное данное — то, которое вы не собрали. Каждое поле в форме — обязательство на годы вперёд: защищать, шифровать, удалять, отчитываться, объяснять при инциденте. Минимизация — это не ограничение продукта, а снижение стоимости владения системой.
Источники
- OWASP Top 10 Privacy Risks и OWASP Application Security Verification Standard — раздел V8 о защите данных.
- OWASP Logging Cheat Sheet и User Privacy Protection Cheat Sheet.
- LINDDUN — методология моделирования угроз приватности, включая карточный вариант LINDDUN GO.
- NIST SP 800-122 — защита конфиденциальности персональной информации; NIST Privacy Framework.
- NIST SP 800-188 и NISTIR 8053 — обезличивание наборов данных; NIST SP 800-226 — оценка гарантий дифференциальной приватности.
- NIST SP 800-88r1 — уничтожение данных на носителях; NIST SP 800-61r2 — реагирование на инциденты.
- RFC 6973 — модель угроз приватности для интернет-протоколов; RFC 7258 / BCP 188 — массовая слежка как атака.
- ISO/IEC 27701 — система управления приватностью поверх 27001; ISO/IEC 29100; ISO/IEC 29134 — оценка воздействия; ISO/IEC 20889 — терминология обезличивания.
- Sweeney L. k-Anonymity: A Model for Protecting Privacy и Simple Demographics Often Identify People Uniquely.
- Narayanan A., Shmatikov V. Robust De-anonymization of Large Sparse Datasets.
- Dwork C., Roth A. The Algorithmic Foundations of Differential Privacy.
- Pavur J., Knerr C. GDPArrr: Using Privacy Laws to Steal Identities — почему запрос субъекта надо защищать как вход в аккаунт.
- Hoepman J.-H. Privacy Design Strategies — восемь стратегий: minimise, separate, abstract, hide, inform, control, enforce, demonstrate.
- 152-ФЗ для разработчиков — материал портала об инженерной стороне работы с персональными данными в российском контуре и ссылки на нормативные первоисточники.
Что дальше
Это последняя статья трека «Безопасность приложений». Пятнадцать шагов назад мы начинали с вопроса «чем уязвимость отличается от обычного бага» в Карте трека — и пришли к тому, что защищённая система определяется не набором инструментов, а последовательностью решений: что мы собираем, кому доверяем, что проверяем на границе и что умеем доказать постфактум.
Куда идти дальше, в зависимости от того, где вам сейчас нужнее глубина:
- Проверять то, что построили — Тестирование: тестирование безопасности: как превращать разобранные здесь классы дефектов в автоматические проверки.
- Довести до продакшна — DevOps: безопасность в пайплайне и SDLC: релиз и эксплуатация.
- Понять, что происходит в бою — Распределённые системы: наблюдаемость.
- Спроектировать данные правильно с самого начала — Базы данных и Инженерия данных: качество и governance.
- Заглянуть в соседний класс угроз — AI-инженерия: безопасность и prompt injection: та же старая проблема «данные стали инструкцией», но в новом окружении.
- Уложить всё в личный план обучения — Дорожная карта портала: какие треки логично проходить рядом и в каком порядке.