GitHub Actions и утечка токенов: аудит CI/CD цепочки поставки
Инцидент Heroku/Salesforce с кражей OAuth-токенов через GitHub Actions заставил нас прочесать CI/CD пайплайны клиентов: permissions, OIDC, форки - разбираем что нашли.
Апрель 2022: Heroku/Salesforce подтверждают кражу OAuth-токенов через GitHub Actions; началась серия инцидентов с утечкой секретов в CI/CD цепочках поставки
В начале апреля Heroku и Salesforce подтвердили то, о чём ходили слухи уже несколько недель: злоумышленники получили OAuth-токены интеграции GitHub, воспользовавшись доступом через GitHub Actions. Детали инцидента ещё выясняются, но основная механика уже понятна - скомпрометированный токен с избыточными правами, долгий срок жизни, и цепочка поставки как вектор атаки.
Мы занимались аналогичными разборами с Log4Shell - там реестр зависимостей оказался ключевым инструментом. Здесь другой класс проблем: не уязвимости в библиотеках, а конфигурация самих пайплайнов. Решили не ждать, когда клиент сам придёт с вопросом, и прошлись по CI/CD активно.
Что ищем
Аудит CI/CD-пайплайнов фокусировался на трёх вещах.
Первое - permissions в workflow-файлах. По умолчанию GitHub Actions выдаёт токену GITHUB_TOKEN права write на всё - contents, packages, pull-requests, deployments. Большинству джобов это не нужно. Если в workflow нет явного блока permissions, токен получает максимум. Мы смотрели на каждый .github/workflows/*.yml и выставляли permissions: read-all на уровне workflow с переопределением для конкретных джобов, которым нужно что-то записать. Принцип минимальных привилегий - ничего нового, но в CI/CD он соблюдается хуже, чем в production-конфигурациях.
Второе - долгоживущие токены в секретах. Классическая схема: создали персональный токен или сервисный аккаунт, положили в Secrets, забыли. Токен живёт год или вообще бессрочно, попасть к нему можно через взломанный workflow, через форк с вредоносным PR, через компрометацию одного из action-зависимостей. OIDC (OpenID Connect) решает это принципиально: облачный провайдер выдаёт краткоживущие credentials напрямую джобе по запросу, никакого статичного секрета не существует. GitHub Actions поддерживает OIDC для AWS, GCP, Azure. Там, где клиенты деплоили в эти облака с долгоживущими ключами, переходили на OIDC. Занимает пару часов на один пайплайн, разница в профиле риска существенная.
Третье - триггер pull_request от форков. Репозитории с открытыми PR из форков - отдельная история. Когда джоба запускается на pull_request от внешнего форка, по умолчанию в ней нет доступа к секретам репозитория - GitHub это ограничивает. Но если где-то стоит pull_request_target вместо pull_request, или явно прописан secrets: inherit для форков - это дыра. Нашли несколько таких конфигураций у клиентов с публичными репозиториями. Отдельно: workflow_run как триггер с pull_request_target - популярная схема, которая выглядит безопасно но таковой не является, если не понимаешь что делаешь.
Что нашли
Картина не катастрофическая, но показательная. Ни один из клиентов не настраивал permissions явно - везде стоял дефолт с write-доступом. Долгоживущие токены для деплоя в AWS нашли в половине пайплайнов, которые деплоят в облако. OIDC не использовал никто - не потому что против, а потому что не знали о такой опции или не дошли руки разобраться.
Форковые сценарии с pull_request_target нашли в двух репозиториях - оба публичные, оба с CI который запускает линтер и тесты на PR. Конфигурация досталась от шаблона и никто особо не читал что там написано.
Ни одного подтверждённого инцидента у наших клиентов нет. Но ценность аудита именно в том, чтобы находить такие вещи до инцидента, а не разбирать почему токен утёк.
Что поправили
Типичный пайплайн после аудита стал выглядеть примерно так:
permissions:
contents: read
jobs:
build:
permissions:
contents: read
packages: write # только если нужен push в registry
steps:
- uses: actions/checkout@v3
Для деплоя в AWS заменили AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY в секретах на OIDC:
- uses: aws-actions/configure-aws-credentials@v1
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions-deploy
aws-region: us-east-1
IAM-роль с AssumeRoleWithWebIdentity - и никакого долгоживущего ключа в репозитории.
Форковые пайплайны переписали с pull_request_target на pull_request - те, кому это позволяла логика. Где без pull_request_target не обойтись, добавили явные условия на github.event.pull_request.head.repo.full_name == github.repository, чтобы джоба с доступом к секретам не запускалась из форка.
Где находимся
Инцидент с Heroku ещё расследуется, полный разбор атаки на цепочку поставки не опубликован. Но базовые меры не требуют ждать постмортема - конфигурация пайплайнов с минимальными правами и без долгоживущих секретов полезна независимо от конкретного вектора атаки.
Аудит CI/CD как отдельная процедура - не то, о чём клиенты спрашивают сами. Обычно это вытаскивается как часть общего аудита безопасности инфраструктуры. После этой недели думаем сделать из него отдельный чеклист и проходиться по нему при онбординге новых клиентов.
- Log4Shell: завершаем январский аудит и считаем хвосты · 7 января 2022
- Kubernetes 1.24: dockershim удалён, миграция на containerd обязательна · 8 апреля 2022