Разработчику нужно воспроизвести баг, который виден только на реальных данных. Первое желание — скинуть дамп производственной базы. Проблема: это нарушение закона о персональных данных (GDPR в Европе, 152-ФЗ в России). Здесь разбираем, как дать разработчику рабочие данные, не нарушая закон.
Скрипт идёт по колонкам копии и подменяет значения: адрес превращается в псевдоним от hmac с ключом, телефон — в номер, собранный из id, имя — в подстановку от того же id. Ключ лежит вне базы, поэтому в дампе, который уезжает разработчику, восстановить исходные адреса нечем. Копию после дампа удаляют.
Почему нельзя просто передать дамп как есть
Каждая копия производственных данных — новая поверхность для утечки. Если дамп попадёт не туда (ноутбук, незашифрованный диск, мессенджер), вы получите юридическую ответственность, а пользователи — нарушение их приватности. Они не давали согласия на то, чтобы их данные хранились на рабочем ноутбуке стажёра.
Хорошая новость: почти все задачи разработки решаются синтетическими (сгенерированными) данными. Реальный дамп нужен редко — только когда воспроизводится конкретная проблема с конкретным набором строк.
Что такое PII
PII (Personally Identifiable Information) — данные, по которым можно идентифицировать человека, и делятся они по тому, как именно. Прямые идентификаторы указывают на человека сами по себе: имя, фамилия и отчество, email и телефон, адрес, паспорт, СНИЛС и ИНН, дата рождения, IP-адрес, номер банковской карты или счёта; их маскируют всегда. Косвенные по отдельности безобидны, а вместе выдают человека: точная геолокация, User-Agent браузера и fingerprint-поля, комбинация «профессия + город + год рождения», которая в маленьком городе уже уникальна. Их легко пропустить, и ради них ниже есть раздел про re-identification. И есть данные, которые не PII, но утекать тоже не должны: B2B-цены и скидки конкретных клиентов, внутренние комментарии модераторов, логи действий сотрудников.
Шесть стратегий анонимизации
Удаление — поставить NULL. Используйте, когда поле вообще не нужно для отладки.
UPDATE customer SET address = NULL, apartment = NULL;
Замена константой — все строки получают одно и то же значение. Подходит, когда нужна структура, но не содержимое.
UPDATE customer SET email = 'masked@example.com';
Псевдонимизация через хеш — каждый уникальный email превращается в уникальный псевдоним, и связи между строками сохраняются: тот же адрес всегда даёт тот же псевдоним.
Здесь есть важная тонкость. Просто взять md5(email) недостаточно: адресов на свете конечное число, и злоумышленник может перебрать список известных ему адресов, посчитать хеши и сопоставить. Спасает секрет, который хранится отдельно от базы: без него перебор ничего не даёт. Для этого берут hmac из расширения pgcrypto. Заодно не стоит обрезать хеш до восьми символов: восемь шестнадцатеричных знаков — это 32 бита, и по парадоксу дней рождения шанс встретить совпадение доходит до половины уже на 77 тысячах адресов. Два разных email получат один псевдоним, и UPDATE упадёт на уникальном индексе.
UPDATE customer
SET email = 'user' || encode(hmac(email, :'anon_key', 'sha256'), 'hex') || '@example.test'
WHERE email IS NOT NULL;
И сразу важная оговорка, потому что тут легко успокоиться раньше времени. Псевдоним от hmac — это не обезличивание, а псевдонимизация, и разница не в словах, а в том, как на это смотрит закон. Пока ключ где-то существует — у вас, в хранилище секретов, в прошлогодней резервной копии — превратить псевдоним обратно в адрес принципиально можно. И GDPR, и 152-ФЗ считают такой набор персональными данными со всеми требованиями к хранению и передаче. Дамп после hmac — это не «юридически чистые» данные, а данные, которые дешевле обойдутся при утечке. Обезличенным набор становится только тогда, когда восстановить человека нельзя ничем: после удаления поля, замены константой или обобщения до групп.
Обе стороны — сохранение связей и опасность обрезки — видно без базы: hmac есть в стандартной библиотеке Java.
живой пример
import java.nio.charset.StandardCharsets;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.Set;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class PseudonymDemo {
static Mac mac;
static String pseudonym(String email) {
return HexFormat.of().formatHex(mac.doFinal(email.getBytes(StandardCharsets.UTF_8)));
}
public static void main(String[] args) throws Exception {
String secret = "anon-key-stored-outside-the-database";
mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
for (String email : new String[]{"ivan@mail.ru", "petr@mail.ru", "ivan@mail.ru"}) {
System.out.println(email + " -> user" + pseudonym(email).substring(0, 12) + "@example.test");
}
Set<String> short8 = new HashSet<>();
int collisions = 0;
for (int i = 0; i < 200_000; i++) {
if (!short8.add(pseudonym("user" + i + "@mail.ru").substring(0, 8))) {
collisions++;
}
}
System.out.println("обрезали до 8 символов, 200 000 адресов: совпадений " + collisions);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Первая и третья строки вывода совпадают — один и тот же адрес дал один и тот же псевдоним, значит внешние ключи и JOIN по email в дампе уцелеют. Последняя строка печатает совпадений 6: это ровно те шесть мест, где короткий псевдоним обвалил бы UPDATE на уникальном индексе.
Замена случайными данными (faker) — вместо реального имени подставляется случайное, но реалистичное. Набор выглядит естественно.
Перестановка (shuffle) — значения переставляются между строками. Распределение сохраняется, привязка к конкретному человеку теряется.
Обобщение (generalization) — точное значение заменяется менее точным: дата рождения → год, город → регион. Используется для аналитических данных, где важно распределение.
Одинаковое преобразование во всех местах
Главное правило псевдонимизации формулируется в одну строку: одно и то же значение должно превращаться в одно и то же подставное значение везде, где оно встречается. Почта покупателя лежит не только в таблице покупателей — она есть в подписках, в журнале обращений, в очереди писем, в исходящих сообщениях, иногда в поле payload у события. Если в одной таблице anna@example.com стала user_17@example.test, а в другой user_93@example.test, связи разъехались: отчёты не сходятся, воспроизвести сценарий по данным нельзя, а сама база выглядит испорченной, а не анонимной.
Достигается это тем, что преобразование делает чистая функция от исходного значения и одного секретного ключа, а не случайное число:
CREATE FUNCTION mask_email(src text, salt text) RETURNS text
LANGUAGE sql IMMUTABLE AS $$
SELECT left(encode(digest(lower(src) || salt, 'sha256'), 'hex'), 12) || '@example.test'
$$;
Тогда одинаковый вход даёт одинаковый выход в любой таблице и при любом следующем прогоне, а без ключа обратное преобразование невозможно. Три условия, о которые спотыкаются. Ключ один на весь прогон и хранится отдельно от дампа — иначе пседонимизацию можно развернуть. Нормализация до хеширования (lower, trim) обязательна, иначе Anna@… и anna@… станут разными людьми. И сам список мест находят не по памяти, а запросом к каталогу — по именам колонок и по типам:
живой пример
SELECT table_schema, table_name, column_name
FROM information_schema.columns
WHERE column_name ~* 'email|phone|passport|inn|snils|address|birth'
ORDER BY table_schema, table_name;
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Запрос даёт список мест, а не ответ: часть попаданий окажется ложной, а часть данных лежит в колонках с невинными именами (comment, payload, raw_response). Но начинать разбор надо с него, а не с таблицы покупателей.
Объём: дамп прода разработчику не нужен
Про это часто забывают, хотя это первая причина, по которой «просто дамп» не работает. База на несколько терабайт разработчику бесполезна: она не поднимется на ноутбуке, не скопируется за разумное время и каждое её обновление превращается в отдельную задачу.
Нужен не весь прод, а срез: пятьсот покупателей со всеми их заказами, платежами и сообщениями, или неделя данных, или один клиент, на котором воспроизводится дефект. И сделать такой срез с сохранением ссылочной целостности сложнее, чем анонимизировать: нельзя просто взять первые N строк из каждой таблицы — заказы окажутся без покупателей, позиции без заказов.
Рабочий порядок такой. Выбираются «корневые» строки (список покупателей), кладутся во временную таблицу. От них рекурсивно собираются зависимые строки по внешним ключам — заказы этих покупателей, позиции этих заказов, платежи этих заказов. Справочники (статусы, валюты, категории) берутся целиком: они маленькие и без них ничего не работает. И только потом к получившемуся применяется маскирование.
CREATE TEMP TABLE sample_customer AS
SELECT id FROM customer ORDER BY created_at DESC LIMIT 500;
CREATE TEMP TABLE sample_orders AS
SELECT o.* FROM orders o JOIN sample_customer c ON c.id = o.customer_id;
Дальше выгружают эти таблицы, а не исходные. Инструменты для этого есть (pg_sample, отдельные генераторы срезов), но самописный скрипт на десяток таблиц обычно понятнее, потому что порядок обхода связей в каждой схеме свой.
Где живёт промежуточная копия
Сценарий «снять дамп прода, восстановить, замаскировать, выгрузить» содержит опасный шаг: между восстановлением и маскированием существует полная незащищённая копия персональных данных. Это не деталь, а отдельный объект защиты.
Практические требования к нему. Стоит он в том же защищённом контуре, что и прод, а не на машине разработчика и не в тестовом окружении. Доступ — только у того, кто выполняет процедуру, и только на время процедуры. Живёт он минуты-часы, а не «до следующего раза»: после выгрузки маскированного дампа промежуточная база удаляется вместе с файлами, а не оставляется «на всякий случай». И весь процесс лучше выполняет автоматика (задание в защищённом контуре), а не человек руками: тогда не бывает забытой копии на чьём-то диске.
Полностью убрать этот шаг тоже можно — маскировать при выгрузке, а не после восстановления: pg_dump с представлениями, которые уже отдают замаскированные значения, или расширение postgresql_anonymizer в режиме маскирующего дампа. Тогда незащищённая копия не появляется вовсе, и это лучший вариант, если он доступен.
Персональные данные лежат не только в таблицах
Схема — самое очевидное место, но далеко не единственное. Прежде чем объявлять задачу решённой, стоит пройти по списку.
Журналы приложения: телефон в сообщении об ошибке, тело запроса в отладочном выводе, адрес в трассировке. Поисковый индекс: отдельная копия данных, которую анонимизация базы не трогает. Объектное хранилище: сканы документов, чеки, аватары — файлы, в которых персональные данные лежат в чистом виде, а имя файла часто содержит идентификатор. Кеш и очереди: сообщения с полными данными, которые живут часами. Аналитическое хранилище, куда данные уехали через захват изменений. И резервные копии всего перечисленного.
Отсюда практический вывод: анонимизация дампа — это одна процедура из набора, а не весь ответ. Список мест, где живут персональные данные, ведут отдельно, в одном документе, и обновляют при каждой новой интеграции.
Как проверить, что ничего не забыли
Совет «скрипт проходит ревью» работает только вместе с проверкой результата, и она несложная. По готовому маскированному дампу (или по восстановленной из него базе) гоняют поиск остатков:
SELECT count(*) FROM customer WHERE email ~* '@(gmail|yandex|mail)\.';
SELECT count(*) FROM customer WHERE phone ~ '^\+7(9[0-9]{2})';
SELECT count(*) FROM audit_log WHERE payload::text ~* '@(gmail|yandex|mail)\.';
Ноль по каждому запросу — необходимое условие, но не достаточное: проверка ищет то, о чём вы подумали. Поэтому её дополняют двумя вещами. Первое — сверка количества строк и распределений до и после: если маскирование сломало данные (все телефоны стали одинаковыми, все даты рождения совпали), это видно по count(DISTINCT …). Второе — проверка по списку колонок из каталога: каждая колонка из списка персональных данных должна быть либо в скрипте маскирования, либо явно помечена как «не требует маскирования» с причиной. Такой список-реестр и есть то, что проверяют на ревью.
Когда дамп вообще не нужен
Самый надёжный способ не утечь — не выносить данные. Два пути, которые часто оказываются дешевле полноценного конвейера анонимизации.
Синтетика. Данные генерируются с нуля, по правилам предметной области: тысяча покупателей, у каждого случайное число заказов, суммы в разумном диапазоне, часть заказов отменена, часть возвращена. Персональных данных нет вовсе, значит, и защищать нечего; такую базу можно держать в открытом репозитории и поднимать на ноутбуке одной командой. Ограничение честное: синтетика не воспроизводит «странности» реальных данных, а именно на них и ломается код. Поэтому её обычно дополняют тем, что воспроизводит конкретный дефект.
Временный доступ под наблюдением. Дефект, который виден только на реальных данных, иногда быстрее разобрать так: разработчик получает доступ к проду на час, только на чтение, через отдельную роль с записью всех выполненных запросов, и работает под наблюдением того, кто отвечает за данные. Это не «плохая практика» — это осознанный выбор между «вынести копию данных наружу» и «дать посмотреть внутри». В регулируемых средах второе обычно предпочтительнее, потому что оставляет след и не создаёт новой копии.
Частые ошибки
Передать дамп без анонимизации «только надёжному человеку» — закон не делает исключений для надёжности. Каждая копия данных требует правового основания: согласия субъекта или договора поручения на обработку.
Замаскировать только имя, забыть про email или телефон — анонимизация работает только если охватить весь PII, а не часть.
Оставить базу-анонимизатор после дампа — полуобработанные данные остаются поверхностью для утечки. Удалите сразу.
Оставить свободный текст (комментарии) без обработки — там могут быть телефоны и имена, написанные пользователями.
Анонимизировать прямо в производственной базе — любая ошибка в скрипте необратимо изменит данные.
Глубже: Как замаскировать конкретные полярасширенное
Псевдонимизация тем же hmac, что выше: одинаковый email всегда даёт одинаковый псевдоним, поэтому уникальные ограничения и внешние ключи не ломаются.
Телефон
Номер в формате российского мобильного, собранный из id:
UPDATE customer
SET phone = '+7000' || lpad(id::text, 7, '0')
WHERE phone IS NOT NULL;
Заманчиво поставить сюда random() — номера выглядели бы естественнее. Но это ровно та ловушка, о которой шла речь выше, только с другими числами: семь цифр — это десять миллионов вариантов, и на трёхстах тысячах клиентов совпадений наберётся около четырёх с половиной тысяч. Если на phone висит уникальный индекс — а он обычно висит, — UPDATE упадёт. id уникален по определению, и номер выходит уникальным без всякой теории вероятностей.
Заодно id уберегает от второй мелочи, на которую не сразу посмотришь. lpad не только дополняет строку, но и обрезает её справа, если она длиннее указанной длины: lpad('10000000', 7, '0') даёт 1000000 — на знак короче, чем задумано, и без единой ошибки. А (random() * 10000000)::int округлением как раз способен выдать ровно 10000000.
Имя и фамилия
Самый простой вариант — добавить суффикс из ID (уникальность гарантирована):
UPDATE customer SET
first_name = 'Имя' || id,
last_name = 'Фамилия' || id;
Если нужны реалистичные имена, используйте postgresql_anonymizer (подробнее ниже) или библиотеки генерации тестовых данных на стороне приложения: JavaFaker (Java), faker-js (Node/TypeScript), Faker (Python), gofakeit (Go).
Дата рождения
Убрать конкретный день, оставить год — возраст сохраняется приближённо:
UPDATE customer SET born_on = date_trunc('year', born_on)::date;
Данные карты
Обнулить всё, кроме технических полей:
UPDATE payment SET
card_last4 = '0000',
card_holder_name = 'TEST';
Текстовые поля (комментарии, описания)
Свободный текст — сложный случай. Пользователь мог написать в комментарии свой телефон или адрес. Надёжнее всего — обнулить или заменить заглушкой:
UPDATE order_comment SET body = '[REDACTED]' WHERE body IS NOT NULL;
Если содержимое важно для отладки, придётся искать персональные данные в свободном тексте внешним инструментом распознавания именованных сущностей: сама база такого не умеет. Частично спасает функция anon.partial из расширения postgresql_anonymizer, о нём ниже, — anon.partial() — она оставляет от значения только начало и конец, скрывая середину.
Три стратегии из списка выше стоит разобрать подробнее, потому что они выглядят проще, чем есть.
Генератор правдоподобных значений. Подставляет вместо настоящего имени случайное из словаря: «Анна Волкова» становится «Мария Петрова». Плюс — данные остаются похожими на настоящие, и интерфейс не выглядит сломанным. Минус — это не псевдонимизация: одинаковый вход даёт разный выход, и связи по значению (одна и та же почта в двух таблицах) рвутся. Поэтому генератор берут для полей, по которым ничего не связывают: имя, фамилия, название компании.
Перестановка значений. Значения остаются настоящими, но перемешиваются между строками: распределение сохраняется идеально, а связь «строка — человек» теряется. Пишется это неочевидно: наивный UPDATE … SET phone = (SELECT phone FROM customer ORDER BY random() LIMIT 1) даёт повторы и оставляет часть значений на месте. Правильная форма — пронумеровать строки и соединить две нумерации:
WITH src AS (
SELECT id, row_number() OVER (ORDER BY id) AS rn FROM customer
),
shuffled AS (
SELECT phone, row_number() OVER (ORDER BY random()) AS rn FROM customer
)
UPDATE customer c
SET phone = shuffled.phone
FROM src JOIN shuffled ON shuffled.rn = src.rn
WHERE c.id = src.id;
И главная опасность перестановки: она не защищает от повторного опознания. Если в таблице один клиент из Магадана с оборотом в миллион, перемешанный телефон его не спрячет — его опознают по остальным полям. Для таких случаев нужна не перестановка, а обобщение.
Обобщение. Точное значение заменяется диапазоном или категорией: дата рождения — годом, зарплата — интервалом, адрес — городом, IP-адрес — сетью. Данные остаются пригодными для отчётов («сколько клиентов 30–40 лет»), а конкретного человека по ним не найти. Практическое правило берут из подхода к обезличиванию: в каждой группе после обобщения должно оказаться не меньше нескольких человек; группа из одного — это не обобщение, а тот же исходный человек другими словами.
Глубже: Полный сценарий: от производственной базы до дампарасширенное
Никогда не анонимизируйте данные прямо в производственной базе. Алгоритм:
# 1. Скопировать данные в отдельную базу-анонимизатор
pg_dump prod | psql anonymizer
-- 2. Анонимизировать всё в одной транзакции
BEGIN;
UPDATE customer SET
email = 'user' || encode(hmac(email, :'anon_key', 'sha256'), 'hex') || '@example.test',
phone = '+7000' || lpad(id::text, 7, '0'),
first_name = 'Имя' || id,
last_name = 'Фамилия' || id;
UPDATE customer_address SET
street = NULL,
building = NULL,
apartment = NULL;
UPDATE customer_document SET
number = '0000' || lpad(id::text, 6, '0'),
issued_by = 'TEST';
UPDATE payment SET
card_last4 = '0000',
card_holder_name = 'TEST';
DELETE FROM audit_log WHERE created_at < now() - interval '7 days';
COMMIT;
# 3. Сделать дамп анонимизированной базы
pg_dump anonymizer -Fc > prod-anon-$(date +%F).dump
# 4. Передать разработчику
# 5. Удалить базу-анонимизатор — не оставляйте полуобработанные данные
dropdb anonymizer
Важно: скрипт анонимизации хранится в репозитории и проходит ревью. Не пишите его каждый раз заново по памяти — это источник ошибок.
Глубже: postgresql_anonymizerрасширенное
postgresql_anonymizer — расширение PostgreSQL, которое добавляет встроенные функции для генерации реалистичных данных и декларативные правила маскирования.
CREATE EXTENSION anon CASCADE;
SELECT anon.init();
-- Объявить правила маскирования
SECURITY LABEL FOR anon ON COLUMN customer.email
IS 'MASKED WITH FUNCTION anon.fake_email()';
SECURITY LABEL FOR anon ON COLUMN customer.last_name
IS 'MASKED WITH FUNCTION anon.fake_last_name()';
-- Замаскировать данные прямо в копии базы
SELECT anon.anonymize_database();
Расширение умеет и динамическое маскирование: помеченные роли видят только замаскированные данные даже в живой базе. Это полезно, когда разработчику нужен прямой доступ, без возни с дампами.
Само собой оно, правда, не включается — здесь та же ловушка, что у pg_stat_statements с его shared_preload_libraries. Режим включают на базе, а роли помечают отдельно:
ALTER DATABASE app SET anon.transparent_dynamic_masking TO true;
SECURITY LABEL FOR anon ON ROLE developer IS 'MASKED';
Без первой строки правила маскирования будут висеть в базе и ничего не делать. Без второй — роль увидит настоящие данные, потому что маскируются только помеченные роли, а не все подряд.
Альтернативы: Greenmask, ARX, собственные скрипты.
Глубже: Re-identification: когда анонимизации имени недостаточнорасширенное
Классическая ошибка: заменить имя и email, но оставить точную дату рождения, профессию и город. В небольшом городе комбинация «врач, 1985 год, Кострома» может быть уникальной — человека можно найти без имени.
Этот риск называется re-identification — повторная идентификация через комбинацию косвенных признаков.
Как защититься:
- обобщайте поля-идентификаторы (дата рождения → год, город → регион);
- удаляйте или объединяйте редкие категории;
- для аналитических данных применяйте k-anonymity: каждая строка должна быть неотличима от не менее чем k других строк по всем идентифицирующим полям.
На практике для большинства задач разработки достаточно обобщения полей — k-anonymity нужна при передаче данных для аналитики.
Коротко
- Передача производственного дампа без анонимизации нарушает закон; для большинства задач разработки хватает синтетических данных.
- PII: email, телефон, ФИО, адрес, документы, дата рождения, IP, карта; косвенные: геолокация, уникальные комбинации.
- Шесть стратегий: удаление, замена константой, псевдонимизация (
hmacс секретом), faker, перестановка, обобщение. - Email псевдонимизируют через
hmacс отдельно хранимым секретом: уникальность сохраняется, а сопоставить с оригиналом без секрета нельзя. Гологоmd5для этого мало. - Полный сценарий: копия прода → скрипт анонимизации из репозитория → дамп → удалить базу-анонимизатор.
- Re-identification — реальная угроза: маскировка имени недостаточна, если остались точные дата рождения и город.
- Псевдонимизация обязана быть детерминированной: хеш от нормализованного значения плюс один секретный ключ, применённый во всех таблицах, иначе связи по почте и телефону разъезжаются.
- Разработчику нужен срез (пятьсот покупателей со всеми зависимыми строками), а не терабайтный дамп; промежуточная незащищённая копия живёт минуты в защищённом контуре или не создаётся вовсе.
- Персональные данные лежат ещё в журналах, поисковом индексе, объектном хранилище, очередях и резервных копиях; результат проверяют запросами на остатки доменов и масок телефонов.
- Часто дешевле не выносить данные: синтетика для обычной разработки и временный доступ к проду под наблюдением для дефекта, который виден только на реальных данных.
Что почитать дальше
- Резервное копирование PostgreSQL — как делать дамп и восстанавливать базу.
- Расширения PostgreSQL — откуда берутся
pgcryptoс егоhmacи самpostgresql_anonymizer. - Multi-tenancy в PostgreSQL — как в одной базе разделяют данные разных клиентов.