← назад к разделу

Разработчику нужно воспроизвести баг, который виден только на реальных данных. Первое желание — скинуть дамп производственной базы. Проблема: это нарушение закона о персональных данных (GDPR в Европе, 152-ФЗ в России). Здесь разбираем, как дать разработчику рабочие данные, не нарушая закон.

customer в базе-анонимизаторе — копия прода, боевую базу не трогаем email phone first_name ivan@mail.ru p.orlov@corp.ru masha97@ya.ru user2e3f9c14@example.test user8b40d7a1@example.test userc51f6e93@example.test +7 916 341-22-08 +7 903 112-45-67 +7 921 550-31-09 +70004812390 +70009137744 +70002650118 Иван Пётр Мария Имя41 Имя42 Имя43 :anon_key секрет хранится вне базы hmac(email, :anon_key) pg_dump -Fc prod-anon.dump разра- ботчик

Скрипт идёт по колонкам копии и подменяет значения: адрес превращается в псевдоним от 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, а не часть.

Оставить базу-анонимизатор после дампа — полуобработанные данные остаются поверхностью для утечки. Удалите сразу.

Оставить свободный текст (комментарии) без обработки — там могут быть телефоны и имена, написанные пользователями.

Анонимизировать прямо в производственной базе — любая ошибка в скрипте необратимо изменит данные.

Дополнительно: при первом чтении можно пропустить

Глубже: Как замаскировать конкретные полярасширенное

Email

Псевдонимизация тем же 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 — реальная угроза: маскировка имени недостаточна, если остались точные дата рождения и город.
  • Псевдонимизация обязана быть детерминированной: хеш от нормализованного значения плюс один секретный ключ, применённый во всех таблицах, иначе связи по почте и телефону разъезжаются.
  • Разработчику нужен срез (пятьсот покупателей со всеми зависимыми строками), а не терабайтный дамп; промежуточная незащищённая копия живёт минуты в защищённом контуре или не создаётся вовсе.
  • Персональные данные лежат ещё в журналах, поисковом индексе, объектном хранилище, очередях и резервных копиях; результат проверяют запросами на остатки доменов и масок телефонов.
  • Часто дешевле не выносить данные: синтетика для обычной разработки и временный доступ к проду под наблюдением для дефекта, который виден только на реальных данных.

Что почитать дальше