Опирается на правила: AUTH-16, AUTH-17, AUTH-18 из Auth Patterns → раздел 7. PII и секреты.

Важно знать

  • PII не в логах (даже debug) — nestjs-pino redact-paths закрывают утечку на уровне конфига.
  • PII не в err.message — Exception Filter не выводит String(err) / err.message в detail.
  • PII не в ProblemDetails.detail — только заранее заданное сообщение по error-коду.
  • PII не в Kafka-событиях широкого scope — передавать только id, payload подгружает потребитель.
  • Секреты не в git — только через process.env / Vault / SealedSecrets, читаются через @nestjs/config с валидацией.
  • toString() агрегата не возвращает PII — если класс попадает в лог, pino сериализует его через toJSON.
  • Ротировать секрет при обнаружении в git history — даже если коммит удалён, он мог быть склонирован.
  • nestjs-pino redact работает на уровне pino-serializer до записи в поток — PII не оседает ни в файле, ни в stdout/stderr.

PII (Personally Identifiable Information) — email, телефон, ФИО, паспорт, адрес, IP, биометрия. Утечка через лог, через response, через Kafka — это compliance-инцидент. В NestJS три механизма защиты: pino redact-paths, явный маппинг в Exception Filter и дисциплина Kafka-событий.

PII не в логах

AUTH-16: запрет на все уровни логирования.

// adapters/in/http/customers/customer.controller.ts

// КАТАСТРОФА
this.logger.log(`User registered: email=${cmd.email} phone=${cmd.phone}`);

// ХОРОШО — только internal id
this.logger.log({ customerId: result.customerId }, 'customer registered');

// ЕСЛИ нужна диагностика — masked
this.logger.log(
  { customerId: result.customerId, emailMask: maskEmail(cmd.email) },
  'email verification sent',
);

Системная защита — nestjs-pino redact-paths в конфиге модуля:

// 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]',
    },
  },
}),

Маскирование там, где диагностика оправдана (masked — не PII в чистом виде):

// 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)}`;
}

Агрегат, который может попасть в лог, не раскрывает PII через сериализацию:

// 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 };    // pino вызывает toJSON при сериализации
  }
}

PII не в err.message

AUTH-16 + AUTH-18: текст исключения попадает в логи и в 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');
  }
}

Если в хендлере нужно залогировать контекст — маскировать и передавать отдельно, не в текст исключения:

// core/customer/handlers/register-customer.handler.ts
async execute(cmd: RegisterCustomer, principal: Principal): Promise<RegisterCustomerResult> {
  const exists = await this.customers.existsByEmail(cmd.email);
  if (exists) {
    this.logger.warn({ customerId: cmd.requestId, emailMask: maskEmail(cmd.email) },
      'duplicate registration attempt');
    throw new CustomerAlreadyExistsError();
  }
  ...
}

Exception Filter без String(err) в detail

AUTH-18: Exception Filter делает явный маппинг кода в сообщение, не прокидывает err.message.

// 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-all аналогично не светит err.message:

@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');   // err в лог (без PII в message)

    res.status(500).json({
      type: 'urn:system:internal-error',
      title: 'Internal server error',
      detail: 'An unexpected error occurred',
      status: 500,
    });
  }
}

Что не делаем:

// ПЛОХО
res.json({ detail: err.message });              // может содержать PII
res.json({ detail: String(err) });              // тем более
res.json({ stackTrace: err.stack });            // утечка внутренней структуры

PII не в Kafka-событиях

AUTH-16: Kafka — broadcast-канал, все consumers получают весь payload.

// ПЛОХО — все consumers видят PII
export class OrderConfirmedEvent {
  orderId: string;
  customerEmail: string;    // leak
  customerPhone: string;    // leak
  totalAmount: number;
}

// ХОРОШО — только id, PII подгружает потребитель
export class OrderConfirmedEvent {
  orderId: string;
  customerId: string;
  totalAmount: number;
}

Notification-service, которому нужен email, делает запрос к customer-service — это даёт точечный access и audit log на стороне 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

AUTH-17: переменные среды вместо значений в конфиге.

# КАТАСТРОФА — в git
database:
  password: super-secret-prod

# ХОРОШО — env-ref
database:
  password: ${DB_PASSWORD}

Конфиг читается через @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 }),

Откуда берутся значения:

  1. Vault (HashiCorp) — vault-secrets-operator монтирует секрет как env в pod.
  2. SealedSecrets (Kubernetes) — шифрованный Secret в git, расшифровывается контроллером в кластере.
  3. Cloud Secret Manager — AWS SM / GCP SM через IAM-роль pod-а.
  4. Kubernetes Secret — базовый уровень, если остальное недоступно.

.gitignore со списком чувствительных файлов:

.env
.env.local
.env.production
*.pem
*.key

При обнаружении секрета в git history — ротировать немедленно, даже если коммит удалён из ветки.

Что запрещено

АнтипаттернПравилоЧто взамен
PII в pino-логах (email, phone)AUTH-16masked или только id
PII в err.messageAUTH-16 + AUTH-18error code, общее сообщение
detail: err.message в Exception FilterAUTH-18явный маппинг по err.code
detail: String(err) в catch-allAUTH-18фиксированная строка без деталей
stackTrace: err.stack в responseAUTH-18только traceId для cross-ref
PII в Kafka-событиях широкого scopeAUTH-16только id + lazy fetch
password: secret в .env в gitAUTH-17${DB_PASSWORD} + Vault / SealedSecrets
Секрет в git historyAUTH-17ротировать немедленно
toJSON агрегата с PII-полямиAUTH-16только { id } в toJSON

Куда дальше

  • Владение ресурсом (ABAC) — проверка order.customerId === principal.sub в Handler.
  • Аудит admin-команд — NestInterceptor записывает actor_id без PII в payload.
  • Хранение токенов на клиенте — HttpOnly cookie, rotation refresh-токенов.
  • Идемпотентность команд — Idempotency-Key для money-команд.
  • JWT validation — passport-jwt + jwks-rsa, AUTH-4..6.
  • RBAC и роли — RolesGuard + @Roles(...), AUTH-7..9.
  • Service-to-service — mTLS / Client Credentials, outbound без анонимного трафика.
  • Где делается проверка — AUTH-1..3: gateway / BFF / domain-handler.