Секрет утёк в лог сборки: шесть часов ротации ключей
В пятницу вечером security-бот, который раз в сутки прогоняет gitleaks по архиву job-логов CI, прислал в чат алерт: в логе одной из сборок найдена строка, похожая на AWS-ключ. Дальше был вечер, который никто из команды не планировал — шесть часов на то, чтобы понять масштаб, отбросить неверные версии и перевыпустить всё, что могло быть скомпрометировано. Расскажу, как мы это разбирали, какие гипотезы отпали и что изменили в пайплайне, чтобы не повторить.
Содержание
Что сломалось
У нас self-hosted GitLab CE с раннерами на отдельном VPS: типовая связка — GitLab CI собирает Docker-образ, пушит его в приватный registry, деплоит на прод через SSH-скрипт. Ничего экзотического, конфигурация похожа на то, что описано в настройке GitLab CI/CD на VPS.
Несколько недель назад один из разработчиков ловил странный баг: деплой-джоба падала без внятной причины, переменные окружения вроде бы подставлялись не в том порядке. Чтобы разобраться, он добавил в .gitlab-ci.yml отладочный шаг:
deploy:
stage: deploy
script:
- echo "DEBUG environment dump:"
- env | sort
- ./deploy.sh
Баг он нашёл и исправил в тот же день. А строчку env | sort — забыл убрать. Она осталась в конфиге и с тех пор при каждом деплое честно печатала в лог job'ы всё окружение раннера: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, POSTGRES_PASSWORD, TELEGRAM_BOT_TOKEN, CLOUDFLARE_API_TOKEN — весь набор секретов, которые пробрасывались в job через CI/CD Variables.
Сама по себе утечка в лог — это ещё не значит "лог утёк наружу". Но в GitLab по умолчанию видимость job-логов совпадает с видимостью пайплайнов проекта: если у пользователя есть роль Reporter или выше, он видит логи всех джоб, включая деплой. А Reporter в этом проекте на тот момент был не только у штатных разработчиков, но и у внешнего подрядчика, которого подключали полгода назад для разовой задачи и не отозвали доступ — тема, которая заслуживает отдельного разбора в статье про оформление доступа сотрудников к продакшену.
Что мы увидели в логах и метриках
Первым делом подняли сам алерт gitleaks — он был предельно конкретен: указывал job ID, номер строки и маскированный фрагмент найденного секрета (первые и последние 4 символа). Дальше пошли по цепочке:
- Открыли raw-лог указанной job'ы — там действительно был полный
env | sort, включая все секретные переменные в открытом виде. - Проверили, с какого коммита появилась строка
env | sortв.gitlab-ci.yml—git log -p -- .gitlab-ci.ymlпоказал дату три недели назад. - Посчитали, сколько деплоев прошло с тех пор — 26 успешных джоб деплоя, у каждой свой лог, у каждого лога retention по умолчанию 30 дней. То есть секреты были видны в логах почти всё это время, не в одном месте, а в 26 разных.
- Подняли CloudTrail по AWS-ключу, который засветился (он использовался для S3-бэкапов) — на первый взгляд ничего аномального: обращения шли из диапазонов раннера, всё легитимно. Это немного успокоило, но не сняло вопрос: если лог был доступен людям без доступа к проду, ключ теоретически мог утечь дальше.
- Проверили Grafana/метрики API Cloudflare (через токен, который тоже засветился) — ошибок 401/403 или всплесков запросов не было.
То есть прямых следов эксплуатации не нашли, но это не аргумент "значит всё в порядке" — это аргумент "нужно исходить из худшего сценария и ротировать всё, что засветилось", потому что отсутствие сигнала в логах может означать и то, что атакующий просто не спешил, и то, что мы не туда смотрим.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверГипотезы, которые отбросили
Прежде чем понять, что дело в env | sort, перебрали несколько версий, которые казались логичнее:
- Секрет закоммитили в репозиторий. Первая и самая частая причина утечек —
.env-файл или ключ, случайно попавший в git. Прогнали gitleaks по всей истории репозитория (gitleaks detect --source . --log-opts="--all") — чисто. Секретов в коде и истории коммитов не было. - Скомпрометирован ноутбук разработчика. У человека, который последним трогал деплой-скрипт, есть локальная копия
.envдля тестов. Проверили: антивирус чист, подозрительных процессов нет, VPN-логи подключений не показывают ничего необычного — вопрос закрыли после сверки журналов подключений. - Утечка через bucket-политику S3. Бакет с бэкапами теоретически мог быть неправильно настроен на публичный доступ. Проверили ACL и bucket policy —
Block Public Accessвключён, посторонних grants нет. - Утечка через артефакты сборки. Подумали, что секрет мог попасть не только в лог, но и в сам Docker-образ — если кто-то пробрасывал пароль через
--build-argвместо секретного mount. ПроверилиDockerfile— там секретов не было, только сама переменнаяenv | sortв скрипте деплоя, доdocker buildона не добиралась.
Только после того, как все "внешние" версии не подтвердились, стали читать сами job-логи построчно — и вот тут нашли env | sort в самом начале деплой-джобы. Простое объяснение оказалось верным: не взлом, а забытая отладочная строка, которая тихо печатала секреты в лог при каждом запуске.
Как нашли причину и оценили масштаб
Как только нашли источник, стало ясно: масштаб — не одна утечка, а системная, потому что каждая из 26 job'ов с этой строкой хранит секреты открытым текстом в собственном логе, и логи доступны всем с ролью Reporter+. Дальше собрали список того, что нужно считать скомпрометированным:
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY — доступ к S3-бакету бэкапов
POSTGRES_PASSWORD — пароль приложения к боевой БД
TELEGRAM_BOT_TOKEN — бот для алертов в канал
CLOUDFLARE_API_TOKEN — управление DNS-записями домена
DEPLOY_SSH_KEY (passphrase) — деплой по SSH на прод-сервер
Пять секретов, пять разных мест, где их нужно было заменить, и везде — сервис, который нельзя просто выключить на время замены. Составили короткий план: сначала отозвать всё, что можно отозвать мгновенно и без даунтайма (API-токены), потом заняться тем, что требует координации (пароль БД, SSH-ключ).
Шесть часов ротации: что и как меняли
Хронология получилась примерно такой (ориентир по памяти команды, не секундомер):
| Секрет | Что сделали | Время | Даунтайм |
|---|---|---|---|
| Cloudflare API token | Отозвали в дашборде, выпустили новый с ограниченным scope (только DNS zone edit) | ~15 мин | нет |
| Telegram bot token | Перевыпустили через BotFather, обновили в CI Variables и в systemd-юните алертера | ~20 мин | алерты молчали ~10 мин |
| AWS ключ | Деактивировали старый ключ в IAM (не удалили сразу — на случай если что-то ещё им пользуется), создали новый, обновили во всех местах, где он использовался, потом удалили старый | ~1.5 часа | нет, S3 недоступен не был |
| Пароль PostgreSQL | Сменили пароль роли (ALTER ROLE app_user WITH PASSWORD '...'), обновили секрет в CI и в конфиге приложения, раскатили с graceful restart через пул соединений | ~2 часа | точечные обрывы соединений на секунды при рестарте воркеров |
| SSH deploy key | Сгенерировали новую пару, положили публичный ключ на прод, убрали старый из authorized_keys, обновили приватный ключ в защищённой CI-переменной | ~1 час | нет |
Дольше всего провозились с паролем БД — не потому что сама смена сложная, а потому что приложение держит пул соединений и часть инстансов подхватила новый пароль не сразу: пришлось руками рестартовать несколько подов, которые продолжали ходить со старым кэшированным конфигом. Отдельно потратили время на то, чтобы убедиться, что новый AWS-ключ действительно везде подхватился, прежде чем гасить старый — если сделать наоборот, рискуешь остановить бэкапы посреди недели.
Параллельно с ротацией сразу же почистили сами логи: в GitLab это делается через удаление job artifacts и, при необходимости, ручную чистку лога конкретной job'ы через API, если требуется убрать секрет из уже существующего вывода. Полагаться только на истечение retention нельзя — 30 дней это долго, а секрет нужно считать скомпрометированным с момента появления в логе, а не с момента истечения TTL.
Что изменили после инцидента
Ротацией дело не закончилось — иначе через месяц кто-то снова добавит отладочный env в другую джобу и мы вернёмся к тому же. Изменения разбили на три уровня.
Сам пайплайн. Убрали env | sort из .gitlab-ci.yml, а на будущее договорились: если нужен дебаг переменных, печатать только имена, без значений (env | cut -d= -f1 | sort), и убирать такие шаги сразу после того, как баг найден — с ревью в MR как за любым другим кодом. Добавили pre-commit хук, который блокирует пуш, если в diff .gitlab-ci.yml встречается env, printenv или set -x без явного комментария # noqa: debug-env, требующего согласования в код-ревью.
Обращение с секретами в CI. Все переменные, помеченные как секретные, перевели в статус Masked и Protected в GitLab CI/CD Settings — это не защищает от env | sort (маскирование в логах работает по точному совпадению строки, а не по факту происхождения из переменной), но добавляет защитный слой для случаев попроще. Отдельно пересмотрели, как секреты попадают в Docker-образы: раньше кое-где ещё использовали --build-arg для паролей, что оставляет их в истории слоёв образа — переписали через BuildKit-секреты:
# было
ARG DB_PASSWORD
RUN some-init-script.sh
# стало
RUN --mount=type=secret,id=db_password \
DB_PASSWORD=$(cat /run/secrets/db_password) some-init-script.sh
build:
script:
- docker build --secret id=db_password,env=DB_PASSWORD -t app:latest .
Секрет монтируется только на время выполнения конкретного RUN-шага и не попадает ни в один слой итогового образа. Практику для секретов вообще, не только для CI, свели в один документ — по сути конспект из статьи про ротацию секретов и ключей на практике, с добавлением конкретно наших случаев.
Права доступа и мониторинг. Ролевую модель в GitLab пересмотрели: доступ Reporter к проекту с боевым деплоем теперь выдаётся отдельно от доступа к самому репозиторию, а внешним подрядчикам — только на срок задачи, с автоматическим напоминанием на ревью доступов раз в квартал. Про инструменты для централизованного хранения секретов вместо переменных CI начали смотреть в сторону HashiCorp Vault — конкретно про его модель и подводные камни есть отдельный разбор в статье про Vault и управление секретами, но это уже следующий шаг, не то, что можно сделать за один вечер. Сократили retention job-логов с 30 до 7 дней там, где длинная история логов не нужна для отладки, а сам gitleaks-скан логов, который и поймал утечку, перевели с раза в сутки на каждый новый лог сразу после завершения job'ы — чтобы окно между утечкой и обнаружением измерялось минутами, а не часами.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Помогло бы маскирование переменных в GitLab с самого начала?
Частично. Masked-переменные в GitLab CI не выводятся в лог, если совпадают с точным значением, но env | sort печатает значение в составе более длинной строки (KEY=value), и в части версий GitLab маскирование по подстроке работает не всегда надёжно. Маскирование — дополнительный слой, а не замена аккуратности в самих скриптах.
Нужно ли было менять SSH-ключ деплоя, если пароль от него не хранился в переменных, а был захардкожен в passphrase на этапе генерации?
Да — passphrase тоже пробрасывалась через переменную окружения для автоматической разблокировки ключа в CI, и она попала в тот же дамп env | sort. Если секрет использовался в переменных окружения job'ы, его нужно считать скомпрометированным вне зависимости от того, для чего именно он нужен.
Как понять, что секрет действительно был кем-то использован, а не просто "теоретически виден"?
По логам доступа целевого сервиса — CloudTrail для AWS, слоу-логи и pg_stat_activity для PostgreSQL, логи API для Cloudflare. Отсутствие аномалий не доказывает, что утечки не было, но при наличии четкого списка легитимных IP и паттернов использования такую проверку стоит делать перед тем, как решать, поднимать ли инцидент до уровня "уведомлять клиентов".
Стоит ли вообще запускать CI-раннеры на своём сервере, если поддержка логов и доступов — это дополнительная головная боль?
Self-hosted раннер даёт контроль над retention, доступом и сетевым окружением, которого нет у части SaaS-решений, но это ответственность, а не бонус по умолчанию. Если решаете разворачивать GitLab CI runner на VPS, разумно сразу закладывать ролевую модель и ротацию секретов в чек-лист запуска, а не добавлять постфактум, когда что-то уже утекло.
Что делать в первые 15 минут, если получили похожий алерт от секрет-скана?
Не тратить время на выяснение "было ли реальное использование" — сразу отзывать или деактивировать засветившийся секрет, если это возможно без длительного даунтайма (API-токены, ключи доступа), и только потом разбираться в масштабе и причине. Порядок действий "сначала отзыв, потом разбор" экономит именно то время, в которое риск максимален.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →