Перейти к основному содержимому

Логирование и ошибки

Правила

MUST

  • Логи не раскрывают внутреннюю архитектуру: пути файлов, имена сервисов, структуру проекта
  • Source maps отключены для production или отправляются только в систему мониторинга (Sentry)
  • В системах сбора ошибок настроена маскировка чувствительных данных через beforeSend / beforeBreadcrumb

FORBIDDEN

  • Логирование токенов, паролей, PII (email, телефон, данные карт)
  • Публикация .map файлов на публичных серверах в production
  • Debug-логирование в production-сборках
  • Логирование полного stack trace в production

SHOULD

  • Использовать структурированное логирование
  • Логировать только необходимый минимум информации

Безопасное логирование

Запрещённые практики

// ❌ FORBIDDEN: Логирование чувствительных данных
function login(credentials: { email: string; password: string }) {
console.log('Login attempt:', credentials); // Логируется пароль!
}

function fetchUserData(token: string) {
console.log('API token:', token); // Логируется токен!
}

// ❌ FORBIDDEN: Раскрытие внутренней архитектуры
function handleError(error: Error) {
console.error('Full error:', error.stack); // Раскрывает структуру проекта!
}

// ❌ FORBIDDEN: Debug-логи в production
function processPayment(data: PaymentData) {
console.log('Processing payment:', data); // Останется в production!
}

Правильный подход

// ✅ Безопасное логирование
function login(credentials: { email: string; password: string }) {
console.log('Login attempt for:', credentials.email); // Только email
}

function handleError(error: ApiError) {
// Production: только код ошибки
console.error('API Error:', error.code);

// Development: расширенная информация
if (process.env.NODE_ENV === 'development') {
console.error('API Error details:', {
code: error.code,
message: error.message,
});
}
}

Автоматическая санитизация логов

Все данные, попадающие в логи, проходят через слой санитизации — это предотвращает случайную утечку секретов, токенов и чувствительных полей, даже если разработчик забыл об этом при логировании.

Санитизация работает в двух направлениях: по имени ключа (например, password, token) и по значению — через регулярные выражения, покрывающие основные форматы секретов.

secrets.ts — паттерны и хелперы

export interface SecretPattern {
id: string;
regex: RegExp;
}

// Список известных форматов секретов для проверки по значению
export const SECRET_PATTERNS: SecretPattern[] = [
{ id: 'jwt', regex: /eyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*/g },
{ id: 'bearer', regex: /Bearer\s+[A-Za-z0-9_-]{20,}/gi },
{ id: 'github-pat', regex: /ghp_[A-Za-z0-9]{36}/g },
{ id: 'github-oauth', regex: /gho_[A-Za-z0-9]{36}/g },
{ id: 'openai', regex: /sk-[A-Za-z0-9]{48}/g },
{ id: 'stripe-secret', regex: /sk_(live|test)_[A-Za-z0-9]{24,}/g },
{ id: 'stripe-pk', regex: /pk_(live|test)_[A-Za-z0-9]{24,}/g },
{ id: 'google-oauth', regex: /ya29\.[A-Za-z0-9_-]+/g },
{ id: 'firebase', regex: /AIza[A-Za-z0-9_-]{35}/g },
{ id: 'slack', regex: /xox[baprs]-[A-Za-z0-9-]{10,}/g },
{
id: 'discord',
regex: /[MN][A-Za-z0-9]{23,}\.[A-Za-z0-9-_]{6}\.[A-Za-z0-9-_]{27}/g,
},
{ id: 'sendgrid', regex: /SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/g },
{ id: 'twilio', regex: /SK[a-f0-9]{32}/g },
{ id: 'aws', regex: /(AKIA|ASIA)[A-Z0-9]{16}/g },
{ id: 'private-key', regex: /-----BEGIN[A-Z ]*PRIVATE KEY-----/g },
];

// Проверка по имени ключа — если само название поля намекает на секрет
export const SENSITIVE_KEY_PATTERN =
/^(password|passwd|pwd|secret|token|apikey|api_key|auth|authorization|credential|bearer|private_key|access_token|refresh_token)$/i;

// Проверяет, содержит ли строка секрет по одному из паттернов
// lastIndex сбрасывается вручную, так как все паттерны используют флаг /g
export const isSecretValue = (value: unknown): boolean => {
if (typeof value !== 'string') return false;
return SECRET_PATTERNS.some((pattern) => {
pattern.regex.lastIndex = 0;
return pattern.regex.test(value);
});
};

// Проверяет, является ли имя ключа чувствительным
export const isSensitiveKey = (key: string): boolean => {
return SENSITIVE_KEY_PATTERN.test(key);
};

logger.ts — Logger с встроенной санитизацией

Logger рекурсивно обходит объект перед выводом и заменяет все чувствительные значения на [SECRET]. В production из ошибок выводится только тип и код — без стека и деталей.

import { isSecretValue, isSensitiveKey } from './secrets';

class Logger {
static log(message: string, data?: unknown) {
if (process.env.NODE_ENV !== 'development') return;
console.log(message, this.sanitize(data));
}

static error(message: string, error?: unknown) {
if (process.env.NODE_ENV === 'development') {
console.error(message, this.sanitize(error));
} else {
// Production: только безопасные метаданные ошибки
console.error(message, {
type: (error as Error)?.name || 'Error',
code: (error as any)?.code,
});
}
}

private static sanitize(data: unknown): unknown {
if (typeof data !== 'object' || data === null) {
return isSecretValue(data) ? '[SECRET]' : data;
}

if (Array.isArray(data)) {
return data.map((item) => this.sanitize(item));
}

return Object.fromEntries(
Object.entries(data as Record<string, unknown>).map(([key, value]) => {
if (isSensitiveKey(key)) return [key, '[SECRET]']; // → по имени ключа
if (isSecretValue(value)) return [key, '[SECRET]']; // → по значению
return [key, this.sanitize(value)]; // → рекурсия вглубь
})
);
}
}

Пример работы

Logger.log('user session', {
userId: 'u_123',
token: 'ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456789',
meta: {
ip: '192.168.1.1',
authorization: 'Bearer eyJhbGciOiJIUzI1NiJ9...',
},
});

// Вывод:
// {
// userId: 'u_123',
// token: '[SECRET]', ← чувствительный ключ
// meta: {
// ip: '192.168.1.1',
// authorization: '[SECRET]', ← чувствительный ключ
// }
// }

Source Maps

MUST

// next.config.js

// ❌ FORBIDDEN: Публичные source maps в production
productionBrowserSourceMaps: false, // MUST быть false!

webpack: (config, { isServer, dev }) => {
if (!dev && !isServer) {
// ✅ Генерируем для Sentry, но не публикуем в бандл
config.devtool = 'hidden-source-map';
}
return config;
}

Загрузка source maps только в Sentry

// next.config.js
const { withSentryConfig } = require('@sentry/nextjs');

module.exports = withSentryConfig(
{
productionBrowserSourceMaps: false, // Не публикуем
},
{
hideSourceMaps: true, // Source maps только в Sentry
widenClientFileUpload: true,
}
);
# ✅ Проверка: source maps не должны быть публично доступны
ls .next/static/**/*.map
# Если файлы найдены — это ошибка!

# В .js файлах не должно быть:
# //# sourceMappingURL=

Интеграция с Sentry

MUST: beforeSend и beforeBreadcrumb для маскировки данных

В системах сбора ошибок (Sentry, Datadog) обязательна настройка маскировки чувствительных данных перед отправкой.

// sentry.client.config.ts
import * as Sentry from '@sentry/nextjs';

Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NODE_ENV,
tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,

// ✅ MUST: Санитизация перед отправкой события
beforeSend(event) {
if (event.request) {
// Удаляем cookies
delete event.request.cookies;

// Удаляем auth headers
if (event.request.headers) {
delete event.request.headers['Authorization'];
delete event.request.headers['Cookie'];
}

// Маскируем токены в query-параметрах
if (event.request.query_string) {
const params = new URLSearchParams(event.request.query_string);
if (params.has('token')) {
params.set('token', '[SECRET]');
event.request.query_string = params.toString();
}
}
}

// Удаляем PII из user context
if (event.user) {
delete event.user.email;
delete event.user.ip_address;
}

return event;
},

// ✅ MUST: Санитизация breadcrumbs
beforeBreadcrumb(breadcrumb) {
// Удаляем чувствительные данные из breadcrumbs
if (breadcrumb.data) {
const sensitiveKeys = ['password', 'token', 'secret'];
for (const key of sensitiveKeys) {
if (breadcrumb.data[key]) {
breadcrumb.data[key] = '[SECRET]';
}
}
}
return breadcrumb;
},

ignoreErrors: [
'ResizeObserver loop limit exceeded',
'Non-Error promise rejection captured',
],
});

Использование Sentry

// ✅ Правильный захват ошибок
try {
await processPayment(data);
} catch (error) {
Sentry.captureException(error, {
tags: { section: 'payment' },
contexts: {
operation: {
name: 'processPayment',
userId: user.id, // Только ID, не PII
},
},
});
}