MAATRIX / Блог / Ключ от CI пролежал в публичном репозитории 18 минут — этого хватило

Ключ от CI пролежал в публичном репозитории 18 минут — этого хватило

MAATRIX

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

Что сломалось

Внешне всё выглядело как обычный рабочий день. Пайплайн CI гонял сборки по расписанию, деплои на стейджинг шли штатно, мониторинг ничего не показывал — до вечера, когда в биллинге поставщика вычислительных мощностей появилась строка на несколько сотен долларов сверх обычного месячного счёта. Расход был помечен как "compute", регион — не тот, где команда обычно разворачивала инфраструктуру.

Первая реакция — списать на баг в биллинге. Вторая, более трезвая — открыть журнал активности учётной записи. Там нашлось то, чего быть не должно: серия запусков виртуальных машин под тем же аккаунтом сервиса, который CI использовал для деплоя, но с IP-адресов, не принадлежащих ни офису, ни известным раннерам. Машины поднимались пачками, работали недолго и гасились — классический паттерн майнинга на чужой счёт: арендовать чужими деньгами как можно больше мощности, пока доступ не отозвали.

Параллельно у одного из разработчиков сработал алерт от GitHub: "unusual access" на личном форке репозитория, который он сделал неделей раньше для локального эксперимента с новым шаблоном пайплайна. Форк был публичным — по умолчанию GitHub делает форки такими же, как исходный репозиторий, а исходный был открытым учебным проектом внутри организации.

Что показали логи и метрики

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

  • Git history публичного форка. Коммит с файлом .github/workflows/deploy-test.yml, в котором вместо ${{ secrets.DEPLOY_TOKEN }} оказался буквальный токен — разработчик копировал workflow из приватного репозитория для теста и по невнимательности вставил значение секрета вместо ссылки на него. Время коммита зафиксировано в git — это точка отсчёта.
  • Access log учётной записи облака. Первое обращение с использованием этого токена — через 18 минут после пуша. Запрос пришёл не с известного диапазона GitHub Actions, а с адреса, который при проверке оказался частью пула дешёвого VPS-провайдера — типичная инфраструктура для автоматического сканирования.
  • CloudTrail-подобный журнал API-вызовов. Сразу после первого удачного запроса — попытка перечислить доступные ресурсы (list instances, describe images, list buckets), затем — создание нескольких инстансов с GPU-профилем. Это не человек тыкает мышкой, это скрипт, который знает, что искать.
  • Метрики CI. Никакой аномалии в самом пайплайне не было — токен не использовался через CI повторно, он был скомпрометирован не через сборку, а напрямую, через утечку значения.
  • Биллинг. Первые затраты появились примерно через 40 минут после первого использования токена — задержка объясняется тем, что счётчики облака агрегируются не в реальном времени.

Восемнадцать минут между публикацией и первым злоупотреблением — не редкость. Существуют публичные и полуприватные сервисы, которые в реальном времени слушают GitHub Events API, ищут паттерны, похожие на секреты (AKIA, ghp_, sk-, xoxb- и десятки других префиксов), и передают находки ботам, которые сразу пробуют их использовать. Секретный скан GitHub существует именно из-за этого — но он ловит не все форматы токенов, особенно кастомные и внутренние.

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

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

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

Гипотезы, которые отбросили

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

Версия 1: скомпрометирован рабочий ноутбук разработчика. Проверили историю его SSH-сессий, содержимое ~/.ssh/known_hosts, логи VPN-подключений — ничего необычного. Антивирусная проверка и просмотр процессов тоже ничего не дали. От версии отказались, потому что временная метка первого злоупотребления токеном строго синхронизирована с публикацией коммита, а не с активностью на ноутбуке.

Версия 2: брутфорс или подбор токена. Формат токена облачного провайдера — случайная строка достаточной длины, подбор которой перебором за 18 минут технически нереалистичен при разумных лимитах API на аутентификацию. К тому же лимиты на количество неудачных попыток не показали ни одной — токен использовали сразу правильно, с первого запроса.

Версия 3: утечка через логи приложения. Проверили, не попадал ли токен в stdout контейнера, в access-логи nginx как часть query string, в Sentry как контекст ошибки. Ни один из этих каналов токен не подтвердил — он нигде не логировался, потому что использовался только как переменная окружения CI и нигде в коде приложения не фигурировал.

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

Реальная причина

Токен, о котором речь, — не универсальный ключ доступа ко всей инфраструктуре, а DEPLOY_TOKEN: сервисный аккаунт с правами создавать инстансы и хранилища в тестовом окружении для CI. Он хранился в секретах организации на GitHub, и правильный способ его использовать — ссылка ${{ secrets.DEPLOY_TOKEN }} в YAML-описании workflow.

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

  • форк был опубличен по умолчанию (родительский репозиторий — открытый учебный проект команды);
  • коммит с литеральным токеном ушёл в push раньше, чем разработчик успел его убрать;
  • GitHub Secret Scanning не распознал формат — это был не токен самого GitHub, а токен внешнего облачного провайдера с собственным префиксом, который на тот момент не входил в список партнёрских паттернов сканирования;
  • pre-commit хуков с проверкой на секреты в репозитории не было — ни gitleaks, ни git-secrets, ни аналогов;
  • push protection (блокировка пуша при обнаружении секрета на стороне GitHub) не была включена для организации.

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

Как именно уложились в 18 минут

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

  1. GitHub предоставляет публичный поток событий (Events API) — пуши, форки, issues и так далее, без аутентификации, почти в реальном времени.
  2. Существуют сервисы (в том числе легальные — часть программ bug bounty, но также и явно вредоносные скрапер-сети), которые подписаны на этот поток и прогоняют содержимое каждого диффа через набор регулярных выражений на распространённые форматы токенов.
  3. При совпадении — токен сразу же, автоматически, проверяется живым запросом к соответствующему API (whoami-подобный вызов, который почти ничего не стоит по квоте и не выглядит подозрительно сам по себе).
  4. Если токен живой — данные передаются дальше по цепочке: для облачных провайдеров типичный сценарий — быстрое разворачивание GPU/CPU-инстансов под майнинг, потому что это monetesable за минуты, в отличие от, скажем, кражи данных, которая требует разбора структуры инфраструктуры.

Восемнадцать минут в этой механике — не рекорд и не аномалия, а близкий к типичному результату для формата токена, который распознаётся стандартным regex. Более "экзотические" по формату секреты в среднем живут дольше просто потому, что их сложнее опознать автоматически — но это не защита, а отсрочка.

Что изменили после инцидента

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

  • Push protection на уровне организации GitHub. Теперь пуш с распознанным секретом блокируется до того, как он попадёт в историю репозитория, а не после.
  • gitleaks в pre-commit и отдельным шагом в CI. Локальный хук ловит секрет до коммита, а шаг в пайплайне — подстраховка на случай, если хук отключили или обошли (git commit --no-verify).
  • Кастомные паттерны сканирования под токены используемых провайдеров. Стандартные сканеры знают форматы популярных сервисов, но не всегда знают внутренние префиксы конкретного облака — их пришлось добавить вручную в конфиг gitleaks.
  • Скоуп сервисных токенов сузили. DEPLOY_TOKEN для тестового окружения теперь не может создавать инстансы с GPU-профилем — этого права у CI никогда не было в задачах, но раньше никто не удосужился явно его исключить.
  • Правило "форк открытого репозитория не может быть приватным по умолчанию — но и открытые репозитории с рабочими workflow не должны существовать как playground". Экспериментальные пайплайны теперь заводят в отдельном приватном шаблонном репозитории без единого реального секрета — только заглушки.

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

Если вы поднимаете CI/CD на собственном сервере, а не только пользуетесь облачными раннерами GitHub, часть этой поверхности атаки закрывается самой архитектурой: секреты хранятся не в облачном secret store стороннего провайдера, а в переменных окружения раннера или в Vault на вашей инфраструктуре, и вы полностью контролируете, кто и как к ним обращается. Мы описывали, как разворачивать GitLab CI/CD на VPS и как готовить VPS под разработку и CI/CD с нуля — в обоих случаях секреты CI живут на вашем сервере, а не расползаются по форкам чужих аккаунтов.

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

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

# Установка gitleaks локально (macOS/Linux, бинарник)
curl -sSfL https://raw.githubusercontent.com/gitleaks/gitleaks/master/scripts/install.sh | sh

# Проверка текущего репозитория, включая всю историю
gitleaks detect --source . --verbose

# Проверка только staged-изменений перед коммитом (для pre-commit хука)
gitleaks protect --staged --verbose

Пример pre-commit хука (.git/hooks/pre-commit или через фреймворк pre-commit):

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.4
    hooks:
      - id: gitleaks

Дополнительно стоит развести секреты по принципу наименьших привилегий — не один токен "на всё CI", а отдельные токены под конкретные задачи с минимально нужным набором прав:

СекретГде хранитсяПраваСрок жизни
DEPLOY_TOKEN_STAGINGSecrets репозиторияТолько тестовое окружение, без GPU-профилейРотация раз в 90 дней
DEPLOY_TOKEN_PRODОтдельное хранилище (Vault/секреты организации), доступ только у продовых пайплайнов с ручным approvalТолько деплой в прод-namespaceРотация раз в 30 дней
REGISTRY_PULL_TOKENSecrets репозиторияТолько чтение из container registryРотация раз в 180 дней

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

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

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

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

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

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

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

Мог ли GitHub Secret Scanning сам поймать этот токен?

Только если формат токена входит в список партнёрских паттернов GitHub — это охватывает крупных облачных и SaaS-провайдеров, но не все. Для внутренних или менее распространённых форматов нужно добавлять собственные regex-паттерны в сканер вручную, стандартная защита их не покроет.

Помогло бы, если бы репозиторий изначально был приватным?

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

Почему не помог git commit --amend или удаление файла следующим коммитом?

Потому что история git по умолчанию не удаляется — предыдущий коммит с секретом остаётся доступным через git log и прямые ссылки на конкретный commit hash, даже если в текущей ветке файла уже нет. Чтобы вычистить секрет из истории, нужны git filter-repo или BFG Repo-Cleaner, и это делают уже после того, как секрет отозван — сам по себе такой откат не защита.

Как быстро вообще можно узнать об утечке, если нет биллингового алерта?

Помогают канареечные токены (уведомление при первом же использовании), алерты на аномальную активность в облачном аккаунте по географии и объёму запросов, и push protection, которая в идеале не даёт секрету попасть в публичный репозиторий вообще — тогда узнавать после факта не приходится.

Стоит ли использовать OIDC вместо статических токенов в CI?

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

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

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

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