Среди всех ошибок безопасности утечка персональных данных стоит особняком: её последствия видны не сразу, но масштаб может быть огромным — штрафы регуляторов, потеря доверия пользователей, обязательное уведомление всех пострадавших. Разберём, где данные утекают чаще всего и как этого не допустить.
Что такое 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");
Что здесь происходит:
- Исключение попадает в лог — PII в логе.
- Обработчик ошибок (
RestControllerAdvice) берётex.getMessage()и кладёт в полеdetailответа. - Пользователь (или злоумышленник) видит подтверждение, что такой 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: проектирование событий — что включать в события.
- Аудит административных команд — как фиксировать действия без утечки данных.