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

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

Что такое PII

PII (Personally Identifiable Information) — персональные данные, по которым можно идентифицировать конкретного человека. Российский 152-ФЗ и европейский GDPR относят к ним:

  • email, номер телефона;
  • ФИО, дату рождения;
  • адрес, паспортные данные;
  • IP-адрес, идентификаторы устройств;
  • биометрические данные.

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

PII в логах

Самая распространённая утечка — через журналирование. Разработчик пишет «для отладки» и забывает убрать, а логи уходят в централизованное хранилище, к которому есть доступ у нескольких команд.

// Плохо — email и телефон попадают в лог
log.info("User registered: email={} phone={}", user.email(), user.phone());

// Хорошо — только внутренний идентификатор
log.info("User registered: userId={}", user.id());

// Если нужна диагностика — маскируйте данные
log.info("Email verification sent: userId={} emailMask={}",
    user.id(), maskEmail(user.email()));  // u***@example.com

Маскирование несложно реализовать один раз в утилитном классе и использовать везде:

public final class PiiMasking {
    public static String maskEmail(String email) {
        if (email == null || !email.contains("@")) return "***";
        var parts = email.split("@");
        return parts[0].charAt(0) + "***@" + parts[1];
    }

    public static String maskPhone(String phone) {
        if (phone == null || phone.length() < 4) return "***";
        return "***" + phone.substring(phone.length() - 4);
    }
}

Ещё одна скрытая ловушка — метод toString() у объектов. Если написать log.info("User: {}", user), Slf4j вызовет user.toString(), и если в нём есть поля с PII — они окажутся в логе.

public record Customer(Long id, String email, String phone, String fullName) {
    @Override
    public String toString() {
        return "Customer[id=" + id + "]";  // только id, без персональных данных
    }
}

PII в тексте исключений

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

// Плохо — email оказывается в тексте исключения
throw new InvalidEmailException("Email " + email + " is invalid format");

Что здесь происходит:

  1. Исключение попадает в лог — PII в логе.
  2. Обработчик ошибок (RestControllerAdvice) берёт ex.getMessage() и кладёт в поле detail ответа.
  3. Пользователь (или злоумышленник) видит подтверждение, что такой email существует или не существует.

Правильный подход — использовать коды ошибок без данных в тексте:

public class InvalidEmailException extends DomainException {
    public InvalidEmailException() {
        super("INVALID_EMAIL_FORMAT", "Provided email is in invalid format");
    }
}

В тексте исключения — общее описание без конкретного значения. Если нужна диагностика — отдельная строка в лог с маскированным значением.

Безопасный обработчик ошибок

RestControllerAdvice — центральное место, где собираются все исключения приложения. Здесь легко случайно пробросить внутренние детали в ответ клиенту.

// Плохо — берём текст исключения напрямую
problem.setDetail(ex.getMessage());           // может содержать PII
problem.setDetail(ex.getCause().getMessage()); // и тем более причина
problem.setProperty("stackTrace", ex.getStackTrace()); // полная структура кода

Безопасная реализация — явное сопоставление кода ошибки с заранее написанным текстом:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(OrderDomainException.class)
    public ProblemDetail handleOrderDomain(OrderDomainException ex) {
        var problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        problem.setType(URI.create("urn:order:domain"));
        problem.setTitle("Order operation failed");
        problem.setDetail(switch (ex.errorCode()) {
            case "ORDER_NOT_FOUND" -> "Order with given id not found";
            case "ORDER_NOT_CANCELLABLE" -> "Order in current status cannot be cancelled";
            default -> "Order operation failed";
        });
        problem.setProperty("errorCode", ex.errorCode());
        return problem;
    }

    @ExceptionHandler(Exception.class)
    public ProblemDetail handleGeneric(Exception ex) {
        var problem = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
        problem.setTitle("Internal server error");
        problem.setDetail("An unexpected error occurred. Reference: " + MDC.get("requestId"));
        return problem;
    }
}

Клиент получает понятное сообщение без внутренних деталей. Для поиска по логам достаточно requestId.

PII в сообщениях между сервисами

Kafka-топики — это широковещательный канал: событие видят все потребители, подписанные на топик. Если в событие включить email или телефон клиента, они окажутся сразу во всех сервисах, которые это событие обрабатывают.

// Плохо — все потребители топика видят персональные данные
public record OrderConfirmedEvent(
    Long orderId,
    String customerEmail,   // утечка
    String customerPhone    // утечка
) {}

// Хорошо — только идентификатор, данные запрашиваются при необходимости
public record OrderConfirmedEvent(
    Long orderId,
    Long customerId,
    Money totalAmount
) {}

Если сервису уведомлений нужен email — он запрашивает его напрямую у customer-service: GET /customers/{id}/email. Это даёт точечный доступ и полный журнал, кто и когда запрашивал данные.

Секреты не в git

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

# Плохо — секрет попадёт в историю git
spring:
  datasource:
    password: super-secret-password-prod

# Хорошо — ссылка на переменную окружения
spring:
  datasource:
    password: ${DB_PASSWORD}

Проблема с git в том, что даже удалённый коммит остаётся в истории, в форках, в кэше CI. Если секрет попал в репозиторий — его нужно считать скомпрометированным и немедленно менять, а не просто удалять из файла.

Где хранить секреты по-настоящему:

  • HashiCorp Vault — специализированное хранилище секретов с управлением доступом, ротацией и аудитом. Spring Cloud Vault интегрируется напрямую.
  • Kubernetes SealedSecrets — секрет шифруется в репозитории, расшифровывается оператором уже внутри кластера.
  • Cloud Secret Manager (AWS Secrets Manager, GCP Secret Manager) — управляемый сервис облака, pod получает секрет через IAM-роль.
  • Переменные окружения — базовый вариант, работает везде.

Добавьте в .gitignore файлы, которые никогда не должны попасть в репозиторий:

application-prod.yml
application-secrets.yml
*.pem
*.key
.env

Дополнительная защита — хуки проверки коммитов. Инструменты git-secrets и trufflehog сканируют изменения перед фиксацией и блокируют коммит, если находят строки похожие на пароли или токены.

Коротко

  • PII — email, телефон, ФИО, адрес и похожие данные. Нельзя выводить в логи ни на каком уровне, даже DEBUG.
  • Для диагностики — маскируйте: u***@example.com, ***1234. Используйте единый утилитный класс.
  • toString() объектов с PII — переопределяйте так, чтобы выводился только идентификатор.
  • Текст исключений — без значений данных. Только коды ошибок и общие описания.
  • RestControllerAdvice — явное сопоставление кодов, никакого ex.getMessage() в detail.
  • В Kafka-события — только идентификаторы. Нужны данные — запрашивайте через API конкретного сервиса.
  • Секреты никогда в git. Используйте переменные окружения или специализированные хранилища.
  • Секрет, попавший в историю репозитория — нужно считать скомпрометированным и менять.

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

  • Логирование и наблюдаемость — полные правила журналирования.
  • Обработка ошибок и RFC 9457 — как правильно формировать ответы об ошибках.
  • Kafka: проектирование событий — что включать в события.
  • Аудит административных команд — как фиксировать действия без утечки данных.