open atlas
↑ К треку
CI/CD-пайплайны CICD · 05 · 04

Безопасность пайплайна и отравленные сборки

CI-раннер держит все секреты и может подменить артефакт, поэтому моделируй его как угрозу. Poisoned Pipeline Execution запускает код атакующего с твоими привилегиями — классически через pull_request_target, запускающий форк-PR. Изолируй сборку, default-deny токен, OIDC.

CICD Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

PR пришёл от аккаунта, которого никто не знал — «контрибьютор» в первый раз, чинит опечатку в README плюс одно несвязанное изменение в test setup. Мейнтейнер пробежался глазами, увидел, что мелочь, и оставил CI прогнать его. Через сорок секунд воркфлоу зачекаутил ветку форка, прогнал сборку, а скрипт prepare в изменённом setup-файле прочитал окружение, base64-закодировал облачный deploy-ключ из secrets и отправил POST-ом на эндпоинт атакующего. Никакого merge не было. Мейнтейнер не одобрял деплой; он вообще ничего не одобрял. Воркфлоу использовал pull_request_target — который выполняется в контексте базового репозитория, с его секретами и write-токеном, — а затем зачекаутил и запустил код атакующего. Раннер сделал ровно то, что ему сказали. Этот урок о том, чтобы относиться к самому пайплайну как к поверхности атаки: раннер держит каждый секрет, который ты ему дал, и может подменить артефакт, который ты отгружаешь, так что скомпрометировать сборку часто проще — и хуже, — чем атаковать прод напрямую.

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

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

CI-раннер — это не «просто сборочная коробка». На время джобы он держит каждый секрет, который ты отдал пайплайну, — deploy-ключи, креды реестра, облачные токены, ключи подписи, — и у него есть две вещи, которых хочет атакующий: исходящий доступ в сеть (чтобы эксфильтрировать) и доступ на запись к артефактам, которые ты вот-вот отгрузишь (чтобы подменить). Эта вторая способность — угроза класса SolarWinds: атакующему, контролирующему сборку, вообще не нужно вламываться в production. Он модифицирует артефакт в момент сборки, твой собственный пайплайн его подписывает и отгружает, и каждый клиент тянет забэкдоренный бинарь, прошедший все твои проверки. SLSA моделирует ровно это — угрозы против процесса сборки и provenance того, что она выдаёт, а не только исходника.

Так что пайплайн — это система, которую надо моделировать как угрозу, как и любую другую. Управляющий вопрос для каждой джобы: если бы код, выполняющийся в этой джобе, был враждебным, до чего бы он дотянулся? Ответ — «до каждого секрета и креда в области видимости плюс артефакт». Именно эта формулировка делает остальные защиты урока очевидными.

Poisoned Pipeline Execution: ключевой режим отказа

Poisoned Pipeline Execution (PPE) (отравленное выполнение пайплайна) — зонтичное название центральной атаки: противник заставляет твой CI выполнить его код с привилегиями пайплайна. Ему не нужно заранее компрометировать раннер или красть креды — он убеждает пайплайн выполнить контролируемый атакующим код в привилегированном контексте, и секреты выпадают бесплатно.

На GitHub каноничный вектор PPE — это выбор триггера. Сравни два pull-request-триггера:

# БЕЗОПАСНЫЙ ДЕФОЛТ для форк-PR: низкие привилегии, нет секретов, read-only токен.
on: pull_request

# ОПАСНО когда запускает PR-код: контекст базового репо, секреты + write-токен.
on: pull_request_target

pull_request выполняется в намеренно ограниченном контексте для форк-PR: нет секретов, read-only GITHUB_TOKEN. Это правильное место, чтобы собирать и тестировать недоверенный код, потому что даже если PR привозит вредоносный postinstall или отравленный тест, в области видимости нет ничего ценного для кражи. pull_request_target — противоположность: он существует для воркфлоу, которым нужно комментировать, проставлять метки или триажить, поэтому он выполняется в контексте базового репозитория — с твоими секретами и write-токеном. Сам триггер — не баг. Баг — это комбинация: воркфлоу pull_request_target, который затем чекаутит head PR-а и выполняет его.

# АНТИ-ПАТТЕРН — так не делай. pull_request_target + чекаут недоверенного head.
on: pull_request_target
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}   # код, контролируемый атакующим
      - run: npm ci && npm test                             # запускает его с секретами в области видимости

В тот момент, когда этот сборочный шаг выполняется, контролируемый атакующим код исполняется в контексте, у которого есть твои секреты. Форк-PR от кого угодно из интернета может прочитать secrets.* и эксфильтрировать. Ни merge, ни одобрения, ни ревью кода, который реально выполнился, — один npm ci уже выполняет lifecycle-скрипты PR-а. Защита структурная: никогда не чекаутить и не запускать недоверенный PR-код в джобе, у которой есть секреты. Держи шаги, несущие секреты, физически отдельно от шагов с недоверенным кодом, требуй ручного одобрения, прежде чем воркфлоу от контрибьютора-новичка вообще запустится, и относись к head PR-а как к враждебному входу, как ты бы относился к телу запроса.

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

Почему pull_request_target, запускающий код PR-а, уникально опасен, когда pull_request, запускающий ровно тот же код, безопасен? Потому что два триггера выполняются в разных контекстах, по замыслу. pull_request намеренно прогоняет форк-PR в ограниченной песочнице: нет открытых секретов, read-only токен — безопасный дефолт именно потому, что код это недоверенный вход. pull_request_target существует для другой нужды: воркфлоу, которым надо комментировать или ставить метки PR-у, нужен доступ на запись и иногда секреты, поэтому он выполняется в контексте БАЗОВОГО репо с тем и другим. Это нормально пока он никогда не выполняет код PR-а — он предназначен действовать на метаданных. Уязвимость рождается в тот миг, когда такой воркфлоу чекаутит head-ref и запускает его: теперь контролируемый атакующим код исполняется внутри контекста, который держит твои секреты и write-токен. Триггер — не изъян; изъян — комбинация привилегированного контекста с выполнением недоверенного кода. Один код, два триггера, противоположный радиус поражения.

Least-privilege GITHUB_TOKEN

Даже когда джоба легитимно выполняет твой собственный код, автоматически предоставляемый GITHUB_TOKEN — это кред, который стоит урезать. По умолчанию токен воркфлоу может быть широким; атакующий, добившийся выполнения кода в джобе с write-scoped токеном, может больше, чем читать, — он может пушить коммиты, нарезать релизы или двигать теги, превращая компрометацию сборки в компрометацию исходника. Фикс — default-deny: установи permissions на верхнем уровне в минимум, затем выдай только то, что нужно каждой джобе, по джобам.

# Default-deny наверху; выдай минимум, по джобам.
permissions: {}                 # ничего по умолчанию
jobs:
  build:
    permissions:
      contents: read            # эта джоба только читает репо
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build

Скоупь токены по джобам, а не по воркфлоу: джоба, выполняющая недоверенный код, должна иметь permissions: {} и никаких секретов, тогда как отдельная publish-джоба получает ровно тот write-скоуп, который ей нужен, и ничего больше. Write-токен в джобе, выполняющей недоверенный код, — это самонанесённый PPE.

OIDC вместо долгоживущих секретов

Более глубокий фикс для «раннер держит крадомые секреты» — вообще перестать хранить крадомый секрет. Долгоживущий облачный ключ, лежащий в CI-секретах, широкий, редко ротируется, и одна эксфильтрация отдаёт его на всё время его жизни. OIDC убирает стоящий секрет: раннер запрашивает короткоживущий подписанный identity-токен, кодирующий, какой репо, ветка и окружение он выполняется как; облачный провайдер сверяет этот токен с trust-политикой и обменивает на временные креды, заскоупленные под эту нагрузку.

# OIDC: нет стоящего облачного ключа в секретах. Раннер чеканит короткоживущую
# идентичность, облачный провайдер обменивает её на временные креды.
permissions:
  id-token: write              # разрешить раннеру запрашивать OIDC-токен
  contents: read
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/deploy-prod
          aws-region: us-east-1

Тут есть реальный размен: OIDC требует настройки trust-политики на стороне облака (какая роль доверяет какому репо и ветке) против вставки одного ключа в секрет. Но радиус поражения украденного креда падает с «широкого долгоживущего ключа» до «токена, истекающего за минуты и работающего только для одной нагрузки» — часто почти ноль. Стоящего секрета, который атакующий бы эксфильтрировал, просто больше не существует.

Выполнение недоверенных зависимостей на этапе сборки

У PPE есть более тихий родственник, которому не нужен ни вредоносный мейнтейнер, ни неверно настроенный триггер: вредоносная зависимость. Когда сборка устанавливает пакеты, их install- и build-lifecycle-скрипты (preinstall, postinstall, build-хуки) выполняются на раннере с привилегиями джобы — тот же радиус поражения, что и у любого другого кода в джобе. Скомпрометированная транзитивная зависимость может прочитать любые секреты, что держит джоба, и эксфильтрировать их, всё во время рутинного npm ci.

Митигации складываются: запускай установки с --ignore-scripts, где можешь, чтобы lifecycle-хуки не срабатывали автоматически; используй эфемерные, изолированные раннеры, чтобы скомпрометированная сборка не могла закрепиться или сделать pivot; применяй egress-фильтрацию, чтобы сборка, которой нечего звонить неизвестному хосту, просто не могла; и — самый дешёвый, самый эффективный контроль — не давай сборочной джобе секреты, которые ей не нужны. Сборке, компилирующей код, не нужен твой deploy-ключ в области видимости. Перенеси deploy-ключ в deploy-джобу, которая не выполняет недоверенный код зависимостей.

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

Тебе нужно собирать и тестировать недоверенные форк-PR в CI — включая запуск их зависимостей и тестового набора — не отдавая атакующему твои секреты. Как ты это структурируешь?

Викторина

Почему CI-раннер — приоритетная цель атаки, хотя это и не production-сервер?

Викторина

Форк-PR от неизвестного аккаунта эксфильтрировал облачный deploy-ключ репо без merge и без одобрения. Какой паттерн это вызвал и каков структурный фикс?

Вспомните перед уходом
  1. 01
    Почему сам CI-пайплайн надо моделировать как угрозу и что такое Poisoned Pipeline Execution? Используй различие pull_request против pull_request_target.
  2. 02
    Пройди защиты: изоляция недоверенной сборки, least-privilege GITHUB_TOKEN, OIDC и митигации недоверенных зависимостей.
Итог

CI-раннер надо моделировать как угрозу, потому что на время джобы он держит каждый секрет, который ты даёшь пайплайну, и имеет доступ на запись к артефакту, который ты отгружаешь, — так что компрометация сборки даёт атакующему украсть креды или подменить отгружаемое (класс SolarWinds, моделируемый SLSA), ни разу не коснувшись production. Ключевой режим отказа — Poisoned Pipeline Execution: заставить CI выполнить код атакующего с привилегиями пайплайна. На GitHub каноничный вектор — триггер: pull_request прогоняет форк-PR в ограниченном контексте без секретов и с read-only токеном (безопасное место для недоверенного кода), тогда как pull_request_target выполняется в контексте базового репо с секретами и write-токеном для действий на метаданных; баг — это воркфлоу pull_request_target, чекаутящий и запускающий head PR-а, выполняя код атакующего там, где живут секреты, так что любой форк-PR может прочитать secrets.* и эксфильтрировать — даже npm ci запускает lifecycle-скрипты PR-а. Защиты структурны: изолируй недоверенную сборку под pull_request без секретов и держи привилегированные шаги в отдельной джобе; default-deny GITHUB_TOKEN через permissions: {} и выдавай минимум по джобе, чтобы скомпрометированная джоба не могла пушить коммиты или нарезать релизы; замени долгоживущие облачные ключи на OIDC, где раннер чеканит короткоживущую заскоупленную под нагрузку идентичность, обмениваемую на временные креды, роняя радиус поражения украденного секрета почти до нуля; и сдерживай выполнение недоверенных зависимостей на этапе сборки через —ignore-scripts, эфемерные изолированные раннеры, egress-фильтрацию и не давая сборочной джобе секреты, которые ей не нужны. Теперь, ревьюируя workflow с pull_request_target, первый вопрос: чекаутит ли эта джоба и выполняет ли код PR-а? Если да — это PPE в ожидании своего часа.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.