Опирается на правила:
AUTH-16,AUTH-17,AUTH-18из Auth Patterns → раздел 7. PII и секреты.
Важно знать
- PII не в логах (даже
debug) —nestjs-pinoredact-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-pinoredactработает на уровне 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 }),
Откуда берутся значения:
- Vault (HashiCorp) —
vault-secrets-operatorмонтирует секрет как env в pod. - SealedSecrets (Kubernetes) — шифрованный Secret в git, расшифровывается контроллером в кластере.
- Cloud Secret Manager — AWS SM / GCP SM через IAM-роль pod-а.
- Kubernetes Secret — базовый уровень, если остальное недоступно.
.gitignore со списком чувствительных файлов:
.env
.env.local
.env.production
*.pem
*.key
При обнаружении секрета в git history — ротировать немедленно, даже если коммит удалён из ветки.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| PII в pino-логах (email, phone) | AUTH-16 | masked или только id |
PII в err.message | AUTH-16 + AUTH-18 | error code, общее сообщение |
detail: err.message в Exception Filter | AUTH-18 | явный маппинг по err.code |
detail: String(err) в catch-all | AUTH-18 | фиксированная строка без деталей |
stackTrace: err.stack в response | AUTH-18 | только traceId для cross-ref |
| PII в Kafka-событиях широкого scope | AUTH-16 | только id + lazy fetch |
password: secret в .env в git | AUTH-17 | ${DB_PASSWORD} + Vault / SealedSecrets |
| Секрет в git history | AUTH-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.