Я искал причину падения и грепал журнал по тексту ошибки. В соседней строке, среди неудачных попыток входа, в поле имени пользователя лежало не имя. Лежала строка, которая именем быть не могла: длинная, со спецсимволами, явно чей-то пароль.
Дальше я выгрузил из журнала все значения поля имени пользователя за месяц по неуспешным попыткам и оставил те, что не совпадают ни с одной существующей учётной записью и не похожи на имя: длинные, со спецсимволами, не повторяющиеся между собой. Набралось около сорока.
Хеширование паролей у нас настроено правильно, в базе всё как надо. Просто до хеширования эти строки не дошли: код их паролями не распознал.
Человек ошибся полем
Самый скучный из механизмов и самый частый. Форма входа - два поля подряд. Менеджер паролей подставил не туда, автозаполнение сработало со сдвигом, человек нажал Tab на один раз меньше - и пароль оказался в поле логина.
Приложение не находит такого пользователя и пишет в журнал: «неуспешная попытка входа, пользователь такой-то». Пароля в этой записи, с точки зрения кода, нет - там имя пользователя. То, что в имя пользователя попал пароль, в коде определить нельзя.
Отсюда важное следствие: маскировать надо не только поле password. Никакое правило вида «не логируем поле пароля» этот случай не ловит, потому что оно тут ни при чём.
Второй разворот того же сюжета - пароль от другого сервиса. Человек вводит на вашем сайте пароль от почты или от банка. В журнале оказывается действующий пароль от чужой системы, и ответственность за него теперь ваша, хотя вы его не просили.
Запрос с параметрами в строке адреса
Форма входа методом GET сегодня редкость, а вот сброс пароля, подтверждение и служебные ручки - сплошь и рядом. Всё, что стоит в строке адреса, попадает в журнал доступа сразу и целиком:
$ curl -s 'http://127.0.0.1:8931/login?user=ivan&password=CorrectHorse42'
127.0.0.1 - - [10/Sep/2026 09:14:02] "GET /login?user=ivan&password=CorrectHorse42 HTTP/1.1" 200 -
Это вывод тестового обработчика на стандартной библиотеке Python; у nginx и Apache формат другой - там дата и время через двоеточие, со смещением зоны, и в конце размер ответа. Но параметр запроса в нём виден ровно так же и целиком.
И вот что важно: такую запись делает каждый слой, через который прошёл запрос. Балансировщик, обратный прокси, сам сервер приложений. И в системе сбора журналов, куда всё это стекается, - ещё одна копия.
Три пути, которыми секрет попадает в журнал сам
Необработанное исключение. Часть библиотек и сред разработки печатает в трассировке значения локальных переменных. Пароль в этот момент лежит в локальной переменной обработчика. В отладочном режиме такой стек попадает ещё и в ответ пользователю.
Отладочный уровень логирования. Клиенты HTTP умеют печатать запрос целиком - с телом и заголовками. Включают такое на время расследования, забывают выключить, а Authorization и тело формы уходят в общий журнал.
Мониторинг производительности. Средства трассировки записывают параметры вызовов, чтобы показать медленные запросы. Параметром метода authenticate идёт пароль. Уезжает он при этом во внешний сервис - то есть за пределы вашего контура, и на его хранение ваша политика не распространяется.
Общее у всех трёх - секрет попадает в журнал по пути, который никто не писал руками. Ревью кода авторизации это не ловит, потому что в коде авторизации всё правильно. Ловится это в других местах: в конфигурации логгера, в настройках сборщика ошибок и средств трассировки, а также тестом, который ищет пароль-маркер во всём, что приложение записало.
Как это выглядело у крупных компаний
Сюжет не редкий, и объявляли о нём сами компании.
В мае 2018 года Twitter сообщил, что пароли попадали в журнал до хеширования, и попросил пользователей их сменить. Это ровно наш случай.
В марте 2019 года Facebook сообщил, что пароли сотен миллионов пользователей годами лежали в читаемом виде во внутренних системах хранения - механизм другой, не журналы, но итог тот же: секрет оказался там, где его быть не должно, и пролежал там годами.
Обе компании написали примерно одно: доступ был только внутренний, признаков злоупотребления не нашли. Разошлись они в выводе: Twitter попросил сменить пароли, Facebook ограничился уведомлением затронутых. Правильный из этих двух - первый, и вот почему: доказать отсутствие копии у того, кто имел доступ, нельзя, а значит, единственное честное предположение - что копия есть.
Что делать
Маскирование на уровне логгера, а не в местах вызова. Фильтр стоит один раз в конфигурации логирования и вырезает значения по списку ключей: password, passwd, secret, token, api_key, otp, pin. Отдельно - заголовки: Authorization и Cookie попадут под фильтр, только если заголовки вообще логируются как набор пар, и это надо проверить отдельно. Правило «не забывай маскировать при записи» не работает: забудут, и виноват будет не человек, а подход.
Строка адреса чистится отдельно. В журнале доступа режется вся строка запроса на путях аутентификации либо целиком, либо по списку параметров. Настраивается это в конфигурации прокси - и на каждом слое отдельно.
Поле логина - тоже секрет, пока не подтверждено. Для неуспешных попыток входа полезно писать не само имя, а его хеш или первые символы. Разбор инцидента от этого не страдает: считать частоту попыток и группировать их по одному значению можно и по хешу.
Одна оговорка про порядок действий. После такой правки поиск подозрительных строк по журналам перестаёт работать - искать будет нечего. Значит, сначала разбираем то, что уже накопилось, и только потом включаем хеширование. Либо оставляем рядом с хешем безопасные признаки: длину и класс символов - по ним пароль в поле логина видно так же хорошо.
Проверка тестом. В интеграционные тесты добавляется вход с заведомым паролем-маркером и поиск этого маркера по всему, что приложение записало: журналы, трассировки, ответы с ошибками. Тест дешёвый и ловит регрессию, которую иначе замечают через год.
Ограничения
Список ключей для маскирования всегда неполон. Появится поле pwd, client_secret или своё внутреннее имя - и оно пройдёт мимо фильтра. Список нужно пересматривать, а тест-маркер как раз и страхует на случай, если о пересмотре забудут.
Маскирование по значению вместо ключа не спасает, и главный сюжет этой статьи - лучшее тому доказательство. Чтобы вырезать строку по совпадению с паролем, надо знать, что это пароль. А в случае, когда человек промахнулся полем, узнать это невозможно в принципе: для кода там имя пользователя, и сравнивать не с чем.
И главное ограничение - оно про то, что делать, когда пароли уже там. Вычистить строки из журналов недостаточно: копии есть в системе сбора, в резервном хранилище, возможно, у внешнего сервиса трассировки. Единственная мера, которая работает наверняка, - считать эти пароли скомпрометированными и потребовать от пользователей их сменить. Дорого, неприятно, объясняться придётся с пользователями. Альтернативы нет.
Что посмотреть у себя
Взять тестовую учётную запись, войти с уникальным паролем-маркером, ошибиться полем - ввести пароль в поле логина. Потом поискать этот маркер во всех журналах, включая систему сбора и внешнюю трассировку.
Проверить, режется ли строка запроса в журнале доступа на путях /login, /reset, /oauth. Проверять на каждом слое отдельно: настроили на одном, на остальных осталось как было.
Посмотреть, какие поля запроса записывает ваш сборщик ошибок. У большинства такие фильтры есть, но включены по умолчанию не везде и не полностью.
Поискать по журналам строки в поле имени пользователя, которые именем быть не могут: длиннее двадцати символов, со спецсимволами, без совпадения с существующими учётными записями. Если найдутся - у вас та же история, и людям, чьи это строки, пора менять пароли.

