Представьте: разработчик добавил строчку в лог для диагностики — User registered: email=ivan@example.com phone=+79001234567. Приложение работает, баг нашли. Но теперь каждый, кто имеет доступ к логам — коллеги, аналитические системы, агрегаторы — видит личные данные пользователей. Это называется утечкой PII.
PII (Personally Identifiable Information) — персональные данные, по которым можно идентифицировать человека: email, телефон, ФИО, адрес, IP-адрес, паспортные данные, биометрия. Их утечка — это не просто технический инцидент, а нарушение закона о персональных данных.
В NestJS три основных места, где PII регулярно «утекает»: логи, тексты ошибок и сообщения Kafka. Разберём каждое.
Логи и персональные данные
Логи пишутся на все уровни: debug, info, warn, error. Правило одно — PII не должно попадать ни на один из них.
Частая ошибка: сделать лог «для диагностики» с реальными данными.
// Плохо — PII в логе
this.logger.log(`User registered: email=${cmd.email} phone=${cmd.phone}`);
// Хорошо — только внутренний id
this.logger.log({ customerId: result.customerId }, 'customer registered');
Если диагностика реально нужна — используйте маскирование: показывайте часть данных, но не всё.
// shared/pii-masking.ts
export function maskEmail(email: string): string {
if (!email?.includes('@')) return '***';
const [local, domain] = email.split('@');
return `${local[0]}***@${domain}`;
}
export function maskPhone(phone: string): string {
if (!phone || phone.length < 4) return '***';
return `***${phone.slice(-4)}`;
}
// Диагностика с маскированием — приемлемо
this.logger.log(
{ customerId: result.customerId, emailMask: maskEmail(cmd.email) },
'email verification sent',
);
Системная защита через nestjs-pino
Даже если разработчики осторожны, HTTP-запросы автоматически логируются целиком — включая тело. nestjs-pino позволяет задать список полей, которые будут вырезаны до записи в лог. Это срабатывает на уровне сериализатора, ещё до вывода в stdout.
// app.module.ts
LoggerModule.forRoot({
pinoHttp: {
redact: {
paths: [
'req.headers.authorization',
'req.body.email',
'req.body.phone',
'req.body.password',
'req.body.customer.email',
'req.body.customer.phone',
],
censor: '[REDACTED]',
},
},
}),
Если объект домена (агрегат, сущность) передаётся в лог напрямую, нужно переопределить toJSON — именно этот метод pino вызывает при сериализации.
// core/customer/customer.ts
export class Customer {
constructor(
readonly id: string,
readonly email: string,
readonly phone: string,
readonly fullName: string,
) {}
toJSON() {
return { id: this.id }; // только id — без PII
}
}
Тексты ошибок и персональные данные
Текст исключения попадает сразу в два места: в логи (когда исключение перехватывается) и в ответ клиенту (если Exception Filter прокидывает err.message). Оба пути опасны.
// Плохо — email попадёт в лог и в response
throw new BadRequestException(`Email ${email} already registered`);
// Хорошо — только код без значения
throw new CustomerAlreadyExistsError();
Domain-исключение несёт код ошибки, но не данные:
// core/customer/errors/customer-already-exists.error.ts
export class CustomerAlreadyExistsError extends DomainError {
readonly code = 'CUSTOMER_ALREADY_EXISTS';
constructor() {
super('Customer with given identity already exists');
}
}
Если в обработчике нужно залогировать контекст — маскируйте и передавайте отдельно, не в текст исключения:
async execute(cmd: RegisterCustomer): Promise<RegisterCustomerResult> {
const exists = await this.customers.existsByEmail(cmd.email);
if (exists) {
this.logger.warn(
{ requestId: cmd.requestId, emailMask: maskEmail(cmd.email) },
'duplicate registration attempt',
);
throw new CustomerAlreadyExistsError();
}
// ...
}
Exception Filter: маппинг кода в сообщение
Exception Filter — место, где формируется ответ клиенту. Распространённая ошибка — подставить err.message или String(err) в поле detail. Если в тексте исключения когда-то окажутся данные, они уйдут в ответ.
Правильный подход: явный словарь кодов → сообщений. Никакой прямой передачи текста из исключения.
// adapters/in/http/filters/domain-exception.filter.ts
@Catch(DomainError)
export class DomainExceptionFilter implements ExceptionFilter {
catch(err: DomainError, host: ArgumentsHost): void {
const res = host.switchToHttp().getResponse<Response>();
const status = domainStatusMap[err.code] ?? 400;
res.status(status).json({
type: `urn:order:${err.code.toLowerCase().replace(/_/g, '-')}`,
title: domainTitleMap[err.code] ?? 'Operation failed',
detail: domainDetailMap[err.code] ?? 'Operation failed',
status,
errorCode: err.code,
});
}
}
const domainDetailMap: Record<string, string> = {
ORDER_NOT_FOUND: 'Order with given id not found',
ORDER_NOT_CANCELLABLE: 'Order in current status cannot be cancelled',
CUSTOMER_ALREADY_EXISTS: 'Customer with given identity already exists',
};
Глобальный обработчик непойманных исключений — аналогично:
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
constructor(private readonly logger: Logger) {}
catch(err: unknown, host: ArgumentsHost): void {
const res = host.switchToHttp().getResponse<Response>();
this.logger.error({ err }, 'unhandled exception');
res.status(500).json({
type: 'urn:system:internal-error',
title: 'Internal server error',
detail: 'An unexpected error occurred',
status: 500,
});
}
}
Что никогда не делать в Exception Filter:
// Плохо — любая из этих строк может содержать PII
res.json({ detail: err.message });
res.json({ detail: String(err) });
res.json({ stackTrace: err.stack });
PII в сообщениях Kafka
Kafka — это широковещательный канал: все потребители (consumers) получают полный payload события. Если в событие попало customerEmail, его видят все сервисы, которые подписаны на топик — независимо от того, нужны ли им эти данные.
// Плохо — PII видят все потребители
export class OrderConfirmedEvent {
orderId: string;
customerEmail: string; // утечка
customerPhone: string; // утечка
totalAmount: number;
}
// Хорошо — только идентификатор
export class OrderConfirmedEvent {
orderId: string;
customerId: string;
totalAmount: number;
}
Сервис уведомлений, которому нужен email для отправки письма, запрашивает его у customer-service напрямую. Это даёт точечный контроль доступа и журнал обращений на стороне источника данных.
// adapters/out/http/customer.client.ts
async getCustomerContact(customerId: string): Promise<CustomerContact> {
const { data } = await this.http.get<CustomerContact>(
`/internal/customers/${customerId}/contact`,
{ headers: { Authorization: `Bearer ${await this.tokenProvider.getToken()}` } },
);
return data;
}
Секреты и переменные среды
Секрет в коде или в конфигурационном файле, попавший в репозиторий — это утечка. Даже если коммит удалить из ветки, репозиторий могли уже склонировать.
# Плохо — пароль в git
database:
password: super-secret-prod
# Хорошо — ссылка на переменную среды
database:
password: ${DB_PASSWORD}
В NestJS секреты читаются через @nestjs/config. Добавьте валидацию схемы при старте — так приложение упадёт сразу, если переменная не задана, а не в момент первого обращения к базе.
// config/app-config.ts
import { plainToInstance } from 'class-transformer';
import { IsString, IsUrl, IsInt, validateSync } from 'class-validator';
export class AppConfig {
@IsString() DB_PASSWORD: string;
@IsUrl() JWKS_URI: string;
@IsString() CLIENT_SECRET: string;
@IsInt() PORT: number;
}
export function validateConfig(config: Record<string, unknown>) {
const validated = plainToInstance(AppConfig, config, { enableImplicitConversion: true });
const errors = validateSync(validated, { skipMissingProperties: false });
if (errors.length > 0) throw new Error(errors.toString());
return validated;
}
// app.module.ts
ConfigModule.forRoot({ validate: validateConfig, isGlobal: true }),
Откуда берутся значения в продакшене:
- Vault (HashiCorp) — оператор монтирует секрет как переменную среды в под.
- SealedSecrets (Kubernetes) — зашифрованный Secret в git, контроллер расшифровывает в кластере.
- Cloud Secret Manager — AWS Secrets Manager или GCP Secret Manager через IAM-роль пода.
- Kubernetes Secret — базовый уровень, если остальное недоступно.
Добавьте в .gitignore файлы, которые не должны попадать в репозиторий:
.env
.env.local
.env.production
*.pem
*.key
Если секрет всё же попал в историю git — смените его немедленно, даже если коммит удалили из ветки.
Коротко
- PII — email, телефон, ФИО, адрес, IP — нельзя писать в логи ни на каком уровне, включая debug.
nestjs-pinoсredact-paths вырезает чувствительные поля из HTTP-логов до записи в вывод.- Для диагностики используйте маскирование (
maskEmail,maskPhone), а не полные данные. toJSONагрегата должен возвращать только{ id }— именно егоpinoсериализует в лог.- Тексты исключений не должны содержать данные пользователя — только код ошибки и общее описание.
- Exception Filter строит ответ через явный словарь кодов, а не через
err.messageилиString(err). - В Kafka-событии — только идентификатор (
customerId), PII потребитель запрашивает сам при необходимости. - Секреты хранятся в переменных среды, Vault или Secret Manager — никогда в коде или конфигурационных файлах в git.
- Если секрет попал в историю git — ротировать немедленно.
Что почитать дальше
- Владение ресурсом (ABAC) — проверка прав доступа к конкретному объекту в Handler.
- JWT validation — проверка токена через
passport-jwtиjwks-rsa. - RBAC и роли —
RolesGuardи декоратор@Roles. - Service-to-service — взаимодействие сервисов без анонимного трафика.