Секретный токен моего бота три с половиной месяца печатался в логи открытым текстом. Никто не заметил, потому что всё работало

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

Как это вообще возможно

У платформы есть бот в Telegram — через него менеджеры получают уведомления о

новых заявках, а часть клиентов логинится без пароля. Всё это крутится через

стандартную библиотеку, которая умеет ходить в интернет и обмениваться данными

с серверами Telegram. У этой библиотеки есть полезная привычка: по умолчанию

она пишет в лог адрес каждого запроса — на каком уровне детализации сработал

вызов, к какому адресу он ушёл. Задумано это для отладки: если что-то падает,

разработчик видит, куда именно уходил запрос.

Проблема в том, что токен бота — не отдельный секрет, который передают в

специальном защищённом поле. Он прямо часть адреса: `.../bot<токен>/sendMessage`.

Библиотека, которая честно логирует «на какой адрес ушёл запрос», логирует и

сам токен. Каждый раз. Не только когда что-то ломается — а на КАЖДЫЙ обычный,

успешный вызов, когда бот просто отправляет сообщение менеджеру о новой заявке.

Наш собственный код был написан аккуратно: мы нигде явно не писали в лог

«вот токен, смотрите». Проблема была на уровень ниже — в библиотеке, которую

мы вообще не трогали и о существовании этой её привычки не подозревали.

Три с половиной месяца — не метафора

Я поднял историю: сервер с этим кодом развёрнут с 29 мая. Заплатку, которая

закрыла утечку, мы выкатили только 11 сентября. Это значит, что с конца мая

буквально в каждом успешном обращении бота к Telegram в логах контейнера лежал

рабочий токен — тот самый, которым можно от имени бота слать сообщения,

получать обновления, в общем — управлять им как своим.

Логи контейнера при этом никуда специально не выгружались и не индексировались

на внешнем сервисе — они лежали внутри инфраструктуры. Но «внутри

инфраструктуры» не значит «недоступны никому, кроме меня»: у сервера есть

техническая поддержка хостера, есть доступ у меня самого через SSH, и если бы

кто-то получил доступ к серверу другим путём — токен лежал бы прямо в

истории вывода, никуда не спрятанный.

Утром 12 сентября мы перевыпустили токен бота через BotFather — старый

немедленно перестал работать (правда, ещё минут десять Telegram отвечал ему

`ok`: видимо, кэш на их стороне). Пересобрали и перезапустили контейнеры с

новым ключом. Разослали проверочное сообщение — дошло.

Что меня зацепило больше всего

Это не разовая ошибка кого-то невнимательного. Это встроенное поведение

популярной, качественно написанной библиотеки, которую используют тысячи

проектов. Она делает ровно то, что задумано: логирует адрес запроса на уровне

INFO. Просто в нашем конкретном случае «адрес запроса» и «секретный ключ»

оказались одной и той же строкой.

Самое неприятное — то, что всё это время платформа работала абсолютно

нормально. Ни одной ошибки, ни одной жалобы, ничего подозрительного во

внешнем поведении. Утечка не ломает продукт — она просто тихо копится в

файлах, которые никто специально не читает, пока не начнёт искать именно её.

Мониторинг ошибок здесь бесполезен: смотреть нужно не на то, что сломалось, а

на то, что именно попадает в лог, когда всё работает штатно.

Что сделали, чтобы это не повторилось

Просто убрать конкретный вызов из лога было бы неправильно: завтра появится

другой участок кода с той же болезнью. Поэтому сделали два независимых слоя

защиты.

Первый — заткнули конкретно этот источник: для внутренних технических логгеров

(в том числе того, что пишет адреса запросов) детализация снижена, чтобы адрес

с токеном туда больше не попадал.

Второй, более общий — редактор записей лога. Он проверяет КАЖДУЮ запись перед

тем, как она уйдёт в вывод, и если видит поле с именем вроде «токен»,

«секрет», «пароль», «ключ» (и вариации написания через дефис и без него) —

заменяет значение на звёздочки. Это работает даже если завтра кто-то из нас

по невнимательности сам напишет `log.info(f"токен: {token}")` — редактор

поймает и это.

Отдельно проверили похожий канал утечки: при падении кода в лог иногда попадает

не просто текст ошибки, а вообще все значения локальных переменных в момент

сбоя — удобно для отладки, но если среди переменных был расшифрованный

секретный ключ, он утекает вместе с остальными. Эту опцию тоже выключили.

Чтобы не полагаться на «мы проверили один раз и вроде работает», мы для

каждого слоя защиты написали тест, который специально ломает защиту (убирает

маскирование, включает утечку локальных переменных) и проверяет, что тест на

это реагирует падением. Если тест не покраснел от такой поломки — значит, он

ничего не проверял, и его тоже пришлось переписывать. Так нашли ещё несколько

мест, где похожая маскировка была объявлена в документации, но фактически не

работала ни один раз.

Что я вынес для себя

Я давно понял, что «всё работает» — не то же самое, что «всё в порядке». Но

этот случай — первый, когда «всё работает» и «секрет утекает в лог месяцами»

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

не подаёт признаков проблемы именно потому, что проблема никак не мешает

продукту работать.

Теперь у меня в привычке спрашивать не только «есть ли ошибки», но и отдельно

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

с двумя разными ответами.

1