Обнаружение персональных данных в файлах и базах: скрипт поиска и проверка тестовых сред

0
26
Обложка статьи: Обнаружение персональных данных в файлах и базах: скрипт поиска и проверка тестовых сред

В реестре у вас написано, что клиентские данные хранятся в базе CRM. А на деле они лежат ещё в «Выгрузка_для_отчёта_финал2.xlsx» на сетевой шаре, в дампе тестовой базы у разработчика и в файле, который кто-то отправил подрядчику три года назад. Эти копии не попадают ни в какие реестры, их не защищает никто, и именно оттуда данные чаще всего утекают. Единственный способ найти такие места — искать. Коммерческие решения класса DCAP делают это централизованно, но небольшая компания может начать с простого скрипта. Ниже как искать данные в файлах и базах, как проверять тестовые среды и разработку, как не получить тысячи ложных срабатываний и что делать с находками.

Куда записывать результаты: реестр защищаемой информации. Тестовые данные и разработка: разработка с ПДн. Если найденное нужно обезличить: обезличивание. Предотвращение утечек по каналам: DLP.

Как это устроено

Поиск персональных данных в хранилищахСканер проходит каталоги и базы, находит похожие на ПДн значения с проверкой контрольных сумм, находки разбираются и данные защищаются или удаляются.Областишары,выгрузки,тестовые базыСканершаблоны иконтрольныесуммыНаходкифайл, тип,числозначенийРешениенужно лихранить здесьДействиезащитить,перенести,удалитьНаходка без решения владельца считается рискованной: сканирование должнозаканчиваться действиями, а не отчётом.
Поиск повторяют по расписанию: данные появляются в новых местах постоянно.

Поиск не работает как единичное мероприятие: данные расползаются снова. Поэтому его запускают по расписанию (раз в месяц для файловых серверов, при каждом merge request для репозиториев) и каждый раз доводят до решения.

Что искать и как не получить тысячи ложных срабатываний

Регулярные выражения для номеров дают массу ложных срабатываний: любая десятизначная цифра выглядит как ИНН. Поэтому значения проверяют контрольными суммами: у СНИЛС, ИНН и банковских карт они есть, и случайное число проходит проверку лишь в одном случае из ста или десяти.

Тип данных Как находим Надёжность
СНИЛС 11 цифр, проверка контрольного числа Высокая (1 случайное из 100)
ИНН 10 или 12 цифр после слова «ИНН», проверка контрольных цифр Высокая с контекстом; без контекста слишком шумно
Банковская карта 13–19 цифр, алгоритм Луна, префиксы платёжных систем Высокая
Телефон +7 или 8 и десять цифр Средняя: подходит как признак, не как доказательство
Паспорт РФ Слово «паспорт» рядом с серией и номером Средняя
Электронная почта Шаблон адреса Высокая, но сама по себе ничего не значит: нужна связка с ФИО

Шаг 1. Скрипт поиска

Скрипт обходит каталог, читает текстовые файлы, таблицы .xlsx и документы .docx (они сами по себе архивы с XML), ищет значения, проверяет контрольные суммы и выводит только количество находок по типам, а не сами данные. Выводить найденные номера в отчёт нельзя: отчёт сам превращается в хранилище ПДн.

#!/usr/bin/env python3
"""Поиск персональных данных в файлах (без внешних библиотек).
Использование: pdn_scan.py КАТАЛОГ [КАТАЛОГ...] [--max-findings N]
Можно передать '-' для чтения со стандартного ввода (например, вывода pg_dump).
Выводит только количество находок по типам. Код выхода 1, если находок больше порога."""
import argparse
import os
import re
import sys
import zipfile
from collections import Counter

TEXT_EXT = {'.txt', '.csv', '.tsv', '.sql', '.json', '.xml', '.md', '.log', '.yml', '.yaml', '.ini', '.conf', '.html'}
ZIP_EXT = {'.xlsx', '.docx'}
MAX_SIZE = 100 * 1024 * 1024

RE_SNILS = re.compile(r'(?<!\d)(\d{3})[- ]?(\d{3})[- ]?(\d{3})[ -]?(\d{2})(?!\d)')
RE_INN = re.compile(r'(?i)инн\D{0,12}(\d{12}|\d{10})(?!\d)')
RE_CARD = re.compile(r'(?<!\d)(?:\d[ -]?){13,19}(?!\d)')
RE_PHONE = re.compile(r'(?<!\d)(?:\+7|8)[\s(-]*\d{3}[\s)-]*\d{3}[\s-]*\d{2}[\s-]*\d{2}(?!\d)')
RE_PASSPORT = re.compile(r'(?i)паспорт[^\n]{0,30}?(\d{2}\s?\d{2})\s?(\d{6})')
RE_EMAIL = re.compile(r'[\w.+-]+@[\w-]+\.[\w.-]+')


def snils_ok(s):
    n, ctrl = s[:9], int(s[9:])
    if int(n) <= 1001998:
        return False
    c = sum(int(d) * (9 - i) for i, d in enumerate(n)) % 101
    return (0 if c == 100 else c) == ctrl


def inn_ok(s):
    def cs(d, k):
        return sum(int(a) * b for a, b in zip(d, k)) % 11 % 10
    if len(s) == 10:
        return cs(s[:9], [2, 4, 10, 3, 5, 9, 4, 6, 8]) == int(s[9])
    return (cs(s[:10], [7, 2, 4, 10, 3, 5, 9, 4, 6, 8]) == int(s[10])
            and cs(s[:11], [3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8]) == int(s[11]))


def luhn_ok(s):
    total = 0
    for i, ch in enumerate(reversed(s)):
        d = int(ch)
        if i % 2:
            d = d * 2 - 9 if d * 2 > 9 else d * 2
        total += d
    return total % 10 == 0


def scan_text(text):
    found = Counter()
    for m in RE_SNILS.finditer(text):
        if snils_ok(''.join(m.groups())):
            found['СНИЛС'] += 1
    for m in RE_INN.finditer(text):
        if inn_ok(m.group(1)):
            found['ИНН'] += 1
    for m in RE_CARD.finditer(text):
        digits = re.sub(r'\D', '', m.group(0))
        if 13 <= len(digits) <= 19 and digits[0] in '2456' and luhn_ok(digits):
            found['Банковская карта'] += 1
    found['Телефон'] += len(RE_PHONE.findall(text))
    found['Паспорт'] += len(RE_PASSPORT.findall(text))
    found['Email'] += len(RE_EMAIL.findall(text))
    return found


def read_file(path):
    ext = os.path.splitext(path)[1].lower()
    if os.path.getsize(path) > MAX_SIZE:
        return ''
    if ext in TEXT_EXT:
        return open(path, encoding='utf-8', errors='ignore').read()
    if ext in ZIP_EXT:
        try:
            with zipfile.ZipFile(path) as z:
                parts = [z.read(n).decode('utf-8', 'ignore') for n in z.namelist() if n.endswith('.xml')]
            return re.sub(r'<[^>]+>', ' ', ' '.join(parts))
        except zipfile.BadZipFile:
            return ''
    return ''


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('paths', nargs='+')
    ap.add_argument('--max-findings', type=int, default=0)
    args = ap.parse_args()
    total = Counter()
    if args.paths == ['-']:
        total = scan_text(sys.stdin.read())
        print('stdin:', dict(total))
    else:
        for root_dir in args.paths:
            for root, _, files in os.walk(root_dir):
                for name in files:
                    p = os.path.join(root, name)
                    try:
                        c = scan_text(read_file(p))
                    except OSError:
                        continue
                    strong = sum(v for k, v in c.items() if k in ('СНИЛС', 'ИНН', 'Банковская карта', 'Паспорт'))
                    if strong or c['Телефон'] >= 5 or c['Email'] >= 5:
                        print(f'{p}: ' + ', '.join(f'{k} {v}' for k, v in c.items() if v))
                    total.update(c)
    strong_total = sum(v for k, v in total.items() if k in ('СНИЛС', 'ИНН', 'Банковская карта', 'Паспорт'))
    print(f'\nИтого значимых находок: {strong_total}', file=sys.stderr)
    sys.exit(1 if strong_total > args.max_findings else 0)


if __name__ == '__main__':
    main()

Ожидаемо: список файлов с количеством находок по типам и итоговая строка. Телефоны и почта сами по себе в код выхода не входят: они дают шум, а настоящий сигнал — документы с СНИЛС, ИНН, картами и паспортами. Работает на любой системе с Python 3, не требует установки пакетов. Не запускайте скрипт на копии, которую потом отправляете наружу, и сохраняйте отчёт там же, где лежат данные: он содержит пути к файлам с ПДн. Проверьте скрипт на своём тестовом файле с заведомо вымышленными значениями, прежде чем запускать на серверах. Файлы PDF и сканы скрипт не читает: для них нужен отдельный этап с извлечением текста (pdftotext) или распознаванием.

Шаг 2. Где запускать

Область Как Как часто
Файловые серверы и сетевые шары Запуск на самом сервере или на смонтированной копии в режиме только чтения: pdn_scan.py /srv/shares/ Раз в месяц
Рабочие станции критичных отделов Через Ansible или групповую политику с отчётом на сервер: GPO и Ansible Раз в квартал
Выгрузки и дампы баз pg_dump --data-only тестовая_база | pdn_scan.py - При каждой выгрузке
Репозитории и тестовые наборы В конвейере GitLab CI (ниже) Каждый merge request
Почтовые архивы, облачные хранилища Экспорт и проверка или встроенные инструменты сервиса Раз в полгода

Шаг 3. Базы данных

В базе данных сначала ищут по именам столбцов: часто этого достаточно, чтобы составить карту. Запрос для PostgreSQL:

SELECT table_schema, table_name, column_name, data_type
FROM information_schema.columns
WHERE column_name ~* '(inn|snils|passport|phone|email|birth|fio|surname|address|инн|снилс|паспорт|телефон|фамили)'
  AND table_schema NOT IN ('pg_catalog', 'information_schema')
ORDER BY 1, 2, 3;

Ожидаемо: перечень столбцов, где вероятны ПДн. Затем выборочно проверяйте значения. Для тестовых и учебных баз проверка простая: значения должны быть заведомо вымышленными.

-- в тестовой базе не должно быть настоящих телефонов и почты клиентов
SELECT count(*) FROM customers WHERE phone !~ '^\+7000' ;
SELECT count(*) FROM customers WHERE email !~ '@example\.(com|org)$';

Ожидаемо: ноль в обоих запросах для корректно обезличенной базы. Подставьте свои шаблоны замены. Общие принципы подготовки тестовых данных: разработка с ПДн. Не забывайте журналы приложений: ПДн часто лежат в логах отладки, а не в базе.

Шаг 4. Автоматическая проверка в конвейере

Промышленные данные в средах разработки и тестирования — одна из самых частых причин утечек. Правило «не использовать» нужно подкрепить автоматикой: каждый merge request проверяется на наличие ПДн в тестовых наборах и дампах.

pdn-scan:
  stage: test
  image: python:3.12-slim
  script:
    - python3 tools/pdn_scan.py tests/ fixtures/ db/ --max-findings 0
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

Ожидаемо: задание зелёное при чистых тестовых данных и падает, если в каталогах найден СНИЛС, ИНН, номер карты или паспорт. Это частичное покрытие технологического стека: проверка видит файлы в репозитории, но не видит данные, которые разработчик загрузил в тестовую базу вручную. Для баз добавьте периодическое задание, выгружающее образцы и пропускающее их через тот же скрипт. Блокирующим сделайте только строгие типы с контрольными суммами, чтобы не заваливать разработчиков шумом: SAST и SCA как процесс.

Что делать с находками

Находка Решение
Копия данных, которая не нужна Удалить с подтверждением владельца и записью в журнале: уничтожение ПДн
Данные нужны, лежат не там Перенести в защищённую систему, ограничить права, внести в реестр
Данные нужны на месте Закрыть доступ группой, включить аудит: мониторинг доступа, зашифровать диск
Реальные данные в тестовой среде Заменить обезличенными, расследовать, как они там оказались, проверить доступ
Данные у подрядчика без основания Запрос на удаление и подтверждение, проверка договора: поручение обработки

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

Когда нужен готовый продукт

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

Какие артефакты хранить

Что хранить Что показывает
Описание используемого решения (скрипт, версия, настройки, расписание) Автоматизированный поиск применяется
Журналы запусков и отчёты о находках с решениями по каждой Обнаружение даёт действия
Конфигурация проверки в конвейере и отчёты о результатах сканирования сред Реальные данные не попадают в разработку и тестирование
Регламент классификации данных, на который ссылается процесс Находки сопоставляются с категориями

Диагностика

Симптом Причина и что делать
Сотни находок телефонов и почты Шум. Смотрите только файлы со значимыми типами (СНИЛС, ИНН, карты, паспорта), остальное в реестре потоков
Скрипт не находит заведомо известные данные Формат файла не читается (PDF, сканы, архивы). Добавьте извлечение текста или распакуйте архивы отдельно
Сканирование нагружает файловый сервер Запускайте ночью, на копии или с ограничением по каталогам
Задание в конвейере падает на вымышленных данных Тестовые значения случайно проходят контрольную сумму. Измените значения или добавьте исключение по пути с комментарием
Находки есть, решений нет Нет владельца. Привяжите находку к владельцу набора данных из реестра и поставьте срок

Где это в модели зрелости

Материал относится к направлению «Безопасность данных» (хаб: безопасность данных, карта: карта зрелости). На втором уровне применяются автоматизированные решения для обнаружения и предотвращения использования реальных данных в средах разработки и тестирования, с частичным покрытием технологического стека; на третьем для обнаружения и классификации данных «в покое» используются автоматизированные решения. Скрипт и проверка в конвейере закрывают оба пункта на уровне небольшой компании; для крупных объёмов нужен отдельный продукт.

Как проверить

  1. Скрипт на тестовом каталоге с вымышленными, но валидными СНИЛС, ИНН и номером карты находит все три значения и не выводит сами числа.
  2. Файловый сервер просканирован за последний месяц, все находки получили решения и сроки.
  3. Задание в конвейере падает на тестовом наборе с валидным СНИЛС и проходит на чистом.
  4. В тестовых базах запросы на реальные адреса и телефоны возвращают ноль.
  5. Результаты сохранены без самих персональных данных.

Источники

  • CIS Control 3, защита данных: cisecurity.org/controls.
  • NIST SP 800-122, защита конфиденциальности персональной информации: csrc.nist.gov. Не закон РФ.
  • Microsoft Presidio, открытый инструмент поиска персональных данных: microsoft.github.io/presidio.

Комментарии закрыты.