MAATRIX / Блог / Пароли и токены в логах: как они туда попадают и как вычистить

Пароли и токены в логах: как они туда попадают и как вычистить

MAATRIX

Вы месяцами укрепляли доступ к базе данных — шифрование, отдельные учётки, аудит запросов, — а те же самые пароли и токены тем временем спокойно лежат открытым текстом в /var/log/app/app.log, куда у половины команды есть доступ по SSH. Это не гипотетический сценарий, а типовая находка почти на любом проекте старше года: логи считаются техническим мусором, а по факту в них годами копится столько же чувствительных данных, сколько в основной базе, только без единой защиты вокруг.

Почему логи опаснее, чем кажутся

Разница между базой данных и логами не в том, что содержится, а в том, как это защищено. К продовой БД обычно ведёт узкий список: два-три инженера, VPN, отдельные учётки. К каталогу с логами приложения зачастую имеет доступ вся команда разработки — «чтобы отлаживать баги» — плюс саппорт, плюс иногда подрядчики, которым дали SSH временно и забыли отозвать.

Добавьте три фактора, которые делают утечку в лог хуже утечки из БД:

  • Логи живут дольше, чем сами секреты должны жить. Ротация настроена на хранение 30–180 дней «для расследований» — токен, попавший в лог в июне, всё ещё читаем в октябре, хотя сессия, к которой он относился, давно истекла.
  • Логи копируются без контроля. Кусок продового лога вставляют в тикет Jira или скидывают в чат для разбора бага — и вместе с трассировкой стека туда улетает Authorization: Bearer … из соседней строки. Никто это не аудирует, в отличие от прямого доступа к БД.
  • Логи попадают в бэкапы целиком. Если бэкап сервера включает /var/log, а хранится он у стороннего провайдера без отдельного шифрования, — секрет из лога полугодовой давности может утечь через компрометацию бэкапа, а не самой системы.

Дальше разберём три конкретных пути, которыми пароли и токены реально попадают в логи, и что с этим делать — сначала на уровне «не пускать», потом на уровне «уже накопилось, надо вычистить».

Путь первый: полное тело HTTP-запроса и ответа

Самый частый источник утечки — миддлварь или интерсептор, который логирует request/response целиком для отладки и остаётся в коде после того, как отладка закончилась. Пример на Express, который встречается почти в каждом втором приложении на ранней стадии:

app.use((req, res, next) => {
  console.log(`${req.method} ${req.url}`, JSON.stringify(req.body));
  console.log('headers:', JSON.stringify(req.headers));
  next();
});

Если это форма логина или смены пароля, req.body содержит пароль в открытом виде. Заголовки — отдельная беда: там лежит Authorization: Bearer <токен>, Cookie: session=…, иногда X-Api-Key от интеграции с третьей стороной. Один такой console.log, случайно оставленный в проде на несколько месяцев, — это тысячи рабочих токенов в файле, который читает вся команда.

Ответы логируют реже, но эффект тот же: если API при ошибке возвращает объект с токеном обновления (refresh_token) или временным паролем (сброс пароля через email), а вы логируете res.body целиком — этот секрет оседает в логе так же, как если бы вы сохранили его туда вручную.

Отдельно стоит логирование URL-параметров: если токен передаётся как ?access_token=… (старые интеграции и вебхуки), он автоматически попадает в access-лог веб-сервера — nginx, Apache, балансировщик — даже если приложение вообще ничего специально не логирует. Проверять, что реально уходит наружу, а не полагаться на предположения, — тот же принцип, что разбирали в статье про пароли в .env, отдающиеся по HTTP.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Путь второй: дампы ошибок с переменными окружения и объектом запроса

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

try:
    process_payment(request)
except Exception as e:
    logger.error(f"Payment failed: {e}, env={dict(os.environ)}, request={request.__dict__}")

Здесь dict(os.environ) — это буквально весь набор переменных окружения процесса: DATABASE_URL с паролем внутри строки подключения, JWT_SECRET, STRIPE_SECRET_KEY, SMTP_PASSWORD и всё остальное, что обычно передают через .env или секреты оркестратора. Одна строка кода для отладки одной ошибки платежа — и в лог целиком улетает конфигурация доступа ко всей инфраструктуре.

request.__dict__ в Python или JSON.stringify(req) на объекте Express-запроса цепляют заголовки авторизации, тело формы, куки — то же самое, что в первом пути, только маскируется под «просто вывели объект для дебага», а не под явное логирование конкретных полей. Такие места сложнее найти при код-ревью: разработчик не пишет req.headers, он пишет request, и не всегда очевидно, что стоит за этим объектом — визуально это выглядит как разумная попытка получить больше контекста для расследования, а не как утечка.

Путь третий: debug-логирование, забытое включённым в проде

Третий путь — не ошибка в коде, а ошибка в конфигурации окружения. На этапе разработки debug-уровень логирования полезен и даже необходим: видно каждый SQL-запрос с параметрами, каждый входящий запрос целиком, каждое значение переменной на промежуточном шаге. Проблема в том, что этот уровень логирования не предназначался для боевого окружения — а флаг, который его включает, забыли переключить при деплое.

Классика — ORM в debug-режиме:

DEBUG: UPDATE users SET password_hash='$2b$12$xJ3k9F...', last_login=NOW() WHERE id=42
DEBUG: INSERT INTO api_tokens (user_id, token, expires_at) VALUES (42, 'sk_live_a1b2c3...', '2026-09-15')

Даже хэш пароля — не то, что должно лежать в файле с широким доступом, а если по ошибке где-то в цепочке валидации логируется сырой пароль до хэширования (нередкий баг при отладке форм регистрации) — это прямая утечка в открытом виде.

Или логгер фреймворка, у которого уровень задаётся переменной окружения:

# .env на проде — по недосмотру скопирован из dev
LOG_LEVEL=debug
# settings.py
LOG_LEVEL = os.environ.get('LOG_LEVEL', 'info')
logging.basicConfig(level=LOG_LEVEL.upper())

Один параметр в конфиге деплоя, унаследованный из шаблона окружения разработки, — и приложение начинает писать в лог то, что задумывалось только для локальной отладки: полные SQL-запросы, содержимое сессий, тела запросов к внутренним сервисам. Найти момент, когда это включилось, помогает та же дисциплина чтения логов, что и при разборе сбоя — см. как читать логи и находить причину сбоя.

Как предотвратить: фильтрация на уровне логирующей инфраструктуры

Правильный уровень борьбы с этим — не «запретить логировать что-либо», а сделать так, чтобы известные чувствительные поля физически не могли попасть в лог в исходном виде, независимо от того, кто и зачем добавил console.log в очередной коммит.

Список полей на автоматическое редактирование. Современные логгеры поддерживают декларативный redact-список, который применяется на уровне самой библиотеки логирования, а не на уровне каждого отдельного вызова. Node.js, pino:

const pino = require('pino');

const logger = pino({
  redact: {
    paths: [
      'req.headers.authorization',
      'req.headers.cookie',
      'req.headers["x-api-key"]',
      'req.body.password',
      'req.body.token',
      'req.body.card_number',
      '*.password',
      '*.refresh_token',
    ],
    censor: '[REDACTED]',
  },
});

logger.info({ req }, 'incoming request');

Ключевое отличие такого подхода от точечных правок кода: redact-правила применяются ко всем вызовам logger.info(...) во всём приложении сразу, включая те, что напишут разработчики через полгода и не будут знать про этот антипаттерн. Аналог на Python без structlog — фильтр, рекурсивно маскирующий известные ключи в структурированном контексте перед записью:

SENSITIVE_KEYS = {"password", "token", "authorization", "api_key", "refresh_token", "card_number"}

def redact(obj):
    if isinstance(obj, dict):
        return {k: "[REDACTED]" if k.lower() in SENSITIVE_KEYS else redact(v) for k, v in obj.items()}
    if isinstance(obj, list):
        return [redact(v) for v in obj]
    return obj

Закрыть переменные окружения от случайного дампа. Раз путь номер два — это dict(os.environ) в обработчике ошибок, полезно явно запретить логировать окружение целиком через линтер или код-ревью-чеклист, логируя вместо этого только конкретные нечувствительные переменные (ENVIRONMENT=production, APP_VERSION=1.4.2).

Явно выключить debug-уровень при деплое, а не полагаться на дефолт. Безопаснее завалить старт приложения, если конфиг окружения не задан явно для прода, либо жёстко зашить WARNING/INFO как максимум для продовой сборки на уровне CI, а не полагаться на то, что переменная окружения всегда правильно проставлена при деплое.

Пункт в чеклисте код-ревью. Добавьте в шаблон пул-реквеста: «Если в диффе есть новый logger.*/console.log с объектом (не строкой) — проверено, что в нём нет password/token/authorization/env». Это дешевле, чем находить утечку постфактум, и работает там, где поле называется нестандартно (secret_key, auth_hash, passcode) и не попадает под общий redact-паттерн.

ИсточникЧто обычно утекаетГде фильтровать
Тело запроса/ответаpassword, token, card_numberredact-список логгера
ЗаголовкиAuthorization, Cookie, X-Api-Keyredact-список логгера
Дамп окружения в error-хендлеревсе секреты процессазапрет на os.environ в логах, код-ревью
SQL в debug-режиме ORMpassword_hash, токены в INSERT/UPDATEвыключить debug-уровень ORM в проде
URL access-лога веб-сервератокены в query-параметрахне передавать секреты в URL, менять API-контракт

Как искать и вычищать уже накопленные логи с утечкой

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

Шаг первый — найти, что уже утекло. Простой grep по продовым и архивным логам ловит большинство случаев:

grep -riE "password|passwd|token|secret|api[_-]?key|authorization: bearer|refresh_token" /var/log/app/*.log* | head -100

Для архивных логов, которые уже сжаты ротацией:

zgrep -riE "password|token|authorization: bearer" /var/log/app/*.log.*.gz | wc -l

Специализированные сканеры ловят структурные паттерны (JWT, ключи облачных провайдеров), которые пропустит grep, если поле называется нестандартно:

trufflehog filesystem /var/log/app/ --json | jq -r '.DetectorName' | sort | uniq -c

Если логи собираются централизованно (Graylog, Grafana Loki), тот же поиск удобнее делать через индекс, а не построчным перебором файлов на каждом сервере — плюс поиск по индексу сразу покажет период, за который проблема существовала, что важно для следующего шага.

Шаг второй — ротировать или удалить затронутые файлы. Если находка в старых архивных логах, самый простой вариант — принудительно удалить или зашифровать конкретные файлы, не дожидаясь штатной ротации:

# найти и безопасно удалить архивные логи старше найденной даты утечки
find /var/log/app -name "*.log.*.gz" -newermt "2026-06-01" ! -newermt "2026-08-20" -delete

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

Шаг третий, и он не опциональный — сменить скомпрометированные секреты. Самая частая ошибка при реакции на такую находку: удалить строку из лога или сам файл и считать вопрос закрытым. Удаление лога не отменяет того, что секрет уже был доступен всем, у кого был доступ к файлу или бэкапу за это время, — а достоверно доказать, что им никто не воспользовался, обычно невозможно.

Практический чеклист по типам секретов:

  • Пароли пользователей. Принудительный сброс пароля (форс-логаут + email со ссылкой на смену) для всех аккаунтов, чьи пароли в открытом виде или ненадёжном хэше засветились в найденном диапазоне логов.
  • Токены сессий / JWT. Отозвать через blacklist или, если используется stateless JWT без возможности отзыва, — сменить подписывающий секрет целиком, что разлогинит вообще всех, но это правильный компромисс при подтверждённой утечке.
  • API-ключи третьих сторон (Stripe, SendGrid, облачные провайдеры). Перевыпустить ключ в панели провайдера и обновить его во всех местах, где он используется, — не забыв про CI/CD-переменные и секреты в оркестраторе, иначе старый ключ останется активным где-то ещё.
  • Секреты подключения к БД. Смена пароля пользователя БД с одновременным обновлением строки подключения во всех сервисах — операция с простоем, поэтому делать её нужно по плану, а не в панике, но обязательно.

Про сам процесс ротации ключей без простоя — отдельный разбор в статье про инцидент с секретом, утёкшим в лог сборки. Логика та же, что и для утечки токена в публичный репозиторий — разбирали в статье что отзывать и в каком порядке: менять всё одновременно рискованно, а менять по одному — оставляет окно, где старый секрет ещё жив.

Как убедиться, что утечка не повторится

После разовой чистки стоит закрепить результат, иначе через полгода найдётся точно такая же находка в новом коде.

  • Автоматический сканер в CI на секреты в логах тестового окружения. Прогонять trufflehog или detect-secrets по выгрузке логов staging перед каждым релизом — staging часто использует боевые интеграционные ключи и воспроизводит утечку до того, как код доедет до прода.
  • Регулярная выборочная проверка. Раз в квартал вручную просматривать свежий срез логов на предмет новых полей, которые появились с новыми эндпоинтами и не попали под redact-список — интеграцию часто добавляют «по образцу», унаследовав чувствительные поля вместе со структурой.
  • Права доступа к логам как к секретам. Ограничить чтение продовых логов отдельной группой, а не общим доступом всей команды разработки:
chown -R app:logreaders /var/log/app
chmod 750 /var/log/app

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Если мы уже поставили redact-список в логгере, нужно ли ещё чистить старые логи?

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

Правда ли, что смена пароля/токена дороже, чем просто удалить проблемную строку из лога?

По трудозатратам — да, ротация занимает больше времени. Но удаление строки из файла никак не отменяет того, что секрет уже был читаем какое-то время всеми, у кого был доступ, — вы просто теряете возможность это доказать или опровергнуть. Ротация закрывает риск фактически, удаление лога — только косметически.

Как быть с логами, которые уже попали в архивный бэкап у стороннего провайдера?

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

Достаточно ли не логировать req.body целиком, чтобы закрыть проблему?

Это снижает риск, но не закрывает его полностью — заголовки авторизации, параметры URL и дампы объекта запроса в обработчиках ошибок остаются отдельными путями утечки. Нужен системный redact-список на уровне логгера, а не одно точечное правило.

Как проверить, что debug-уровень логирования точно выключен в проде?

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

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →