Секретный токен моего бота три с половиной месяца печатался в логи открытым текстом. Никто не заметил, потому что всё работало
Как это вообще возможно
У платформы есть бот в Telegram — через него менеджеры получают уведомления о
новых заявках, а часть клиентов логинится без пароля. Всё это крутится через
стандартную библиотеку, которая умеет ходить в интернет и обмениваться данными
с серверами Telegram. У этой библиотеки есть полезная привычка: по умолчанию
она пишет в лог адрес каждого запроса — на каком уровне детализации сработал
вызов, к какому адресу он ушёл. Задумано это для отладки: если что-то падает,
разработчик видит, куда именно уходил запрос.
Проблема в том, что токен бота — не отдельный секрет, который передают в
специальном защищённом поле. Он прямо часть адреса: `.../bot<токен>/sendMessage`.
Библиотека, которая честно логирует «на какой адрес ушёл запрос», логирует и
сам токен. Каждый раз. Не только когда что-то ломается — а на КАЖДЫЙ обычный,
успешный вызов, когда бот просто отправляет сообщение менеджеру о новой заявке.
Наш собственный код был написан аккуратно: мы нигде явно не писали в лог
«вот токен, смотрите». Проблема была на уровень ниже — в библиотеке, которую
мы вообще не трогали и о существовании этой её привычки не подозревали.
Три с половиной месяца — не метафора
Я поднял историю: сервер с этим кодом развёрнут с 29 мая. Заплатку, которая
закрыла утечку, мы выкатили только 11 сентября. Это значит, что с конца мая
буквально в каждом успешном обращении бота к Telegram в логах контейнера лежал
рабочий токен — тот самый, которым можно от имени бота слать сообщения,
получать обновления, в общем — управлять им как своим.
Логи контейнера при этом никуда специально не выгружались и не индексировались
на внешнем сервисе — они лежали внутри инфраструктуры. Но «внутри
инфраструктуры» не значит «недоступны никому, кроме меня»: у сервера есть
техническая поддержка хостера, есть доступ у меня самого через SSH, и если бы
кто-то получил доступ к серверу другим путём — токен лежал бы прямо в
истории вывода, никуда не спрятанный.
Утром 12 сентября мы перевыпустили токен бота через BotFather — старый
немедленно перестал работать (правда, ещё минут десять Telegram отвечал ему
`ok`: видимо, кэш на их стороне). Пересобрали и перезапустили контейнеры с
новым ключом. Разослали проверочное сообщение — дошло.
Что меня зацепило больше всего
Это не разовая ошибка кого-то невнимательного. Это встроенное поведение
популярной, качественно написанной библиотеки, которую используют тысячи
проектов. Она делает ровно то, что задумано: логирует адрес запроса на уровне
INFO. Просто в нашем конкретном случае «адрес запроса» и «секретный ключ»
оказались одной и той же строкой.
Самое неприятное — то, что всё это время платформа работала абсолютно
нормально. Ни одной ошибки, ни одной жалобы, ничего подозрительного во
внешнем поведении. Утечка не ломает продукт — она просто тихо копится в
файлах, которые никто специально не читает, пока не начнёт искать именно её.
Мониторинг ошибок здесь бесполезен: смотреть нужно не на то, что сломалось, а
на то, что именно попадает в лог, когда всё работает штатно.
Что сделали, чтобы это не повторилось
Просто убрать конкретный вызов из лога было бы неправильно: завтра появится
другой участок кода с той же болезнью. Поэтому сделали два независимых слоя
защиты.
Первый — заткнули конкретно этот источник: для внутренних технических логгеров
(в том числе того, что пишет адреса запросов) детализация снижена, чтобы адрес
с токеном туда больше не попадал.
Второй, более общий — редактор записей лога. Он проверяет КАЖДУЮ запись перед
тем, как она уйдёт в вывод, и если видит поле с именем вроде «токен»,
«секрет», «пароль», «ключ» (и вариации написания через дефис и без него) —
заменяет значение на звёздочки. Это работает даже если завтра кто-то из нас
по невнимательности сам напишет `log.info(f"токен: {token}")` — редактор
поймает и это.
Отдельно проверили похожий канал утечки: при падении кода в лог иногда попадает
не просто текст ошибки, а вообще все значения локальных переменных в момент
сбоя — удобно для отладки, но если среди переменных был расшифрованный
секретный ключ, он утекает вместе с остальными. Эту опцию тоже выключили.
Чтобы не полагаться на «мы проверили один раз и вроде работает», мы для
каждого слоя защиты написали тест, который специально ломает защиту (убирает
маскирование, включает утечку локальных переменных) и проверяет, что тест на
это реагирует падением. Если тест не покраснел от такой поломки — значит, он
ничего не проверял, и его тоже пришлось переписывать. Так нашли ещё несколько
мест, где похожая маскировка была объявлена в документации, но фактически не
работала ни один раз.
Что я вынес для себя
Я давно понял, что «всё работает» — не то же самое, что «всё в порядке». Но
этот случай — первый, когда «всё работает» и «секрет утекает в лог месяцами»
оказались буквально одновременной правдой, без всякого противоречия. Продукт
не подаёт признаков проблемы именно потому, что проблема никак не мешает
продукту работать.
Теперь у меня в привычке спрашивать не только «есть ли ошибки», но и отдельно
— «что мы пишем в лог, когда ошибок нет». Оказалось, это два разных вопроса
с двумя разными ответами.