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

Представьте: разработчик добавил строчку в лог для диагностики — 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 — ротировать немедленно.

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