Разбор, на который меня зовут регулярно, и заканчивается он почти всегда одинаково. База клиентов всплывает в продаже. Выгрузку за последний год получали десятки контрагентов — интеграторы, колл-центр, маркетинговое агентство, аудиторы. У всех был законный доступ. Файл у всех был один и тот же.
Дальше начинается разговор в жанре «это не мы», и закончить его нечем: копии побайтово одинаковые, доказать причастность нельзя ни к кому.
Чинится это не после инцидента, а до него — и стоит недорого. Каждый получатель должен получать копию, отличающуюся от остальных так, чтобы отличие не мешало работе и переживало пересохранение файла.
Сколько информации нужно спрятать
Задача формулируется как передача идентификатора получателя внутри самих данных. Сорок подрядчиков — это шесть бит:
40 получателей -> нужно 6 бит 500 получателей -> нужно 9 бит 5000 получателей -> нужно 13 бит
Числа скромные, и это главная хорошая новость: прятать нужно не «метку», а полдюжины бит. Места под них в любой реальной выгрузке избыточно много.
Где брать биты, не искажая данные
Ключевое требование — данные должны остаться верными. Подрядчик работает с настоящими клиентами, и подменять им телефоны нельзя. Значит, использовать надо ту свободу, которая в формате уже есть и на смысл не влияет.
Порядок строк. Самый ёмкий источник. Перестановки n элементов дают log2(n!) бит:
10 строк -> 21.8 бит 20 строк -> 61.1 бит 50 строк -> 214.2 бит 1000 строк -> 8529.4 бит
Двадцать строк дают 20! ≈ 2,4·10¹⁸ перестановок — на этом уровне вопрос ёмкости просто не стоит. В выгрузке на сто тысяч записей её некуда девать.
Механика — детерминированная сортировка по функции от идентификатора получателя. Хранить сами копии не нужно, достаточно ключа и списка выданных идентификаторов:
import hmac, hashlib KEY = load_key() # из секрет-хранилища, не из репозитория def mark(rows, recipient_id): def rank(row): return hmac.new(KEY, f"{recipient_id}|{row['id']}".encode(), hashlib.sha256).digest() return sorted(rows, key=rank) def identify(leaked_rows, candidates): order = [r["id"] for r in leaked_rows] for cid in candidates: if [r["id"] for r in mark(leaked_rows, cid)] == order: return cid return None
порядок в выданной копии: [7, 6, 2, 4, 3, 5, 1, 8] порядок для другого получателя: [6, 4, 5, 7, 8, 3, 1, 2] утекла копия -> podryadchik-17
Восемь строк дают 8! = 40 320 вариантов порядка — с большим запасом на сорок подрядчиков, и опознание сводится к перебору сорока кандидатов.
HMAC взят не для красоты. Свой порядок строк получатель, разумеется, видит — метка лежит у него перед глазами. Ключ нужен для другого: без него нельзя ни построить копию, которая укажет на другого подрядчика, ни выдать свою за чужую.
Оговорка к этому коду: он требует точного совпадения всей перестановки. На практике из утёкшего файла что-то удалено или в него что-то дописано, и сравнивать надо не порядок целиком, а долю пар строк, стоящих в том же относительном порядке, что и в копии кандидата. Тогда запас в шестьдесят бит превращается в устойчивость к частичной порче, а не просто в лишние знаки.
Незначащие вариации формата. Телефон записывается как +7 (495) 000-00-00 или +74950000000, дата — как 2026-08-14 или 14.08.2026, регистр домена в адресе почты роли не играет. Каждое такое решение — как минимум один бит на поле, а если вариантов написания больше двух, то и больше. Способ менее ёмкий, чем перестановка, зато переживает сортировку строк.
Фантомные записи. В выгрузку добавляется несколько несуществующих клиентов с контактами, которые контролируете вы: отдельный номер, отдельный почтовый ящик, отдельный адрес. У каждого получателя набор фантомов свой.
Это самый грубый метод и одновременно самый полезный. Он переживает любую обработку данных: фильтрацию, сортировку, дедупликацию, выгрузку в другой формат, ручное копирование в чужую CRM. И он даёт не расчёт, а событие — звонок или письмо на контакт, который существует ровно в одной копии.
Что переживает обработку, а что нет
Метка бесполезна, если умирает при первом сохранении. Практический расклад такой.
Порядок строк ломается сортировкой. Достаточно одного клика по заголовку столбца в Excel — и метки нет. Причём ломается он и без злого умысла, просто в ходе работы.
Вариации формата ломаются нормализацией. Любой импорт в приличную CRM приведёт телефоны к одному виду.
Фантомные записи обработкой не ломаются вовсе — их можно только заметить и вычистить, а для этого нужна сверка с другим источником. Обычно их находят уже после того, как метка сработала.
Отсюда правило, которое стоит закладывать сразу: методы комбинируются. Порядок строк даёт много бит и работает, пока файл не трогали. Фантомы дают мало бит, но доживают до конца. Вместе они закрывают и аккуратную утечку файла целиком, и утечку переработанной выгрузки.
Что будет, если получатели сравнят свои копии
Слабое место всех подобных схем: двое получателей могут сравнить свои копии. Различия видны сразу, и дальше можно собрать третью копию, не совпадающую ни с одной из исходных, — или просто вычистить всё, что различается.
Задача эта изучена, и решения известны: коды, устойчивые к сговору, — схема Бонэ–Шоу и коды Тардоса. Идея в том, что метка кодируется избыточно и вероятностно, поэтому по «усреднённой» копии всё равно вычисляется хотя бы один из участников сговора, а вероятность обвинить непричастного ограничена сверху заданной величиной. Плата — длина метки. У кодов Тардоса она растёт как квадрат числа сговорившихся, умноженный на логарифм числа получателей, и это оптимальный порядок. У более ранней схемы Бонэ–Шоу расход заметно выше, она интересна скорее как первая конструкция с доказанной стойкостью.
Честная оценка: для типового случая с десятками подрядчиков это перебор. Смысл появляется там, где получателей тысячи, а данные дорогие. Знать про эти коды стоит хотя бы затем, чтобы не изобретать защиту от сговора самостоятельно — самодельные схемы ломаются сравнением двух копий.
Юридические и этические границы
Здесь легко сделать хуже, чем было.
Фантомные записи должны содержать только ваши собственные контакты. Вымышленный клиент со служебным номером компании персональными данными не является — а вот вписанный «для правдоподобия» настоящий чужой телефон является, и обрабатывать его вам никто не разрешал.
Получателей о маркировке лучше уведомлять — пунктом в договоре о том, что выгрузка индивидуально маркирована. Это снимает разговор о скрытом изменении данных, а заодно работает лучше любой метки: знание о том, что копия именная, останавливает ровно ту категорию утечек, против которой метка и задумана, — вынос данных своими руками.
Метка доказывает, из какой копии данные, а не кто именно их вынес. Копия могла утечь у подрядчика из-за взлома или неаккуратности. Вывод «нашли виновного» из результата не следует — следует «нашли, где искать».
Что сделать до следующей выгрузки
Завести реестр выгрузок: кому, когда, какой состав полей, какой идентификатор получателя. Без реестра любая метка бессмысленна, потому что сравнивать будет не с чем. По моему опыту, именно этого пункта не хватает чаще всего — маркировку внедрить успевают, а список того, что кому уходило, ведут в переписке.
Встроить маркировку в тот код, который формирует выгрузку, а не делать её руками. Ручная маркировка не переживает отпуск сотрудника.
Добавить фантомные контакты и завести правило, что звонки и письма на них попадают в мониторинг как события. Сработавший фантом — это готовое начало расследования с точной привязкой к получателю и датой.
И проверить, что ключ маркировки лежит отдельно от выгрузок и от их реестра. Ключ в том же репозитории, где скрипт выгрузки, превращает всю схему в украшение.