События и триггеры: что запускает workflow и ловушка pull_request_target
Workflow управляется событиями: блок `on:`, фильтры и concurrency решают, что запускается и что отменяется. pull_request выполняет недоверенный код без секретов; pull_request_target — доверенный код с секретами поверх форка — классическая дыра утечки.
Пост-мортем утечки читался как фокус. Случайный контрибьютор открыл pull request из форка, тронув только тестовый конфиг — ничего подозрительного. CI отработал, стал зелёным, и через двадцать минут NPM_TOKEN репозитория и deploy-ключ оказались на pastebin. Никто ничего не мержил. Фокус был в триггере: кто-то месяцами ранее переключил lint-workflow с pull_request на pull_request_target, чтобы «починить» жалобу о недоступности секретов. Это одно слово полностью сменило модель безопасности — pull_request_target выполняет определение workflow из базовой ветки, но с выгруженным кодом форка, и, главное, с полным доступом к секретам репозитория. Вредоносный pretest-скрипт форка выполнился с токеном в окружении. Лечением был не патч, а понимание: выбранное событие решает, встретится ли управляемый атакующим код с вашими секретами.
Блок on: — это вся точка входа
Workflow не делает ничего, пока событие не совпадёт с его блоком on:. Есть три широких семейства событий, и различие управляет и поведением, и безопасностью:
on:
push:
branches: [main, "release/**"]
paths-ignore: ["docs/**"]
pull_request:
types: [opened, synchronize, reopened]
schedule:
- cron: "17 3 * * 1-5" # 03:17 UTC, пн–пт — смещение, чтобы уйти от давки на верху часа
workflow_dispatch: # вручную, с типизированными inputs
workflow_call: # вызывается как reusable workflow из другогоФильтры комбинируются AND между ключами, OR внутри списка: branches: [main] и paths: [src/**] оба должны выполниться, но любая ветка из списка подходит. paths и paths-ignore взаимоисключающи на одном событии; смешение положительных и отрицательных глобов в одном списке paths — обычный источник «почему мой workflow не запустился». Тонкость, которая кусает команды с required-проверками: если фильтр paths пропускает workflow, required-проверка отчитывается как skipped, а не passed — и правило защиты ветки, требующее её, заблокирует мерж навсегда, пока вы не сделаете проверку сквозной. schedule cron только в UTC и работает по best-effort (по возможности, без гарантии): GitHub явно отбрасывает запланированные запуски под нагрузкой, и запуски на верху часа отбрасываются и задерживаются чаще всего — потому продакшн-расписания используют смещение вроде 17 3.
pull_request против pull_request_target — развилка безопасности
Для PR из форка pull_request выполняет workflow из head-ref PR, в песочнице с read-only GITHUB_TOKEN и без доступа к секретам. Это безопасный дефолт: недоверенный код выполняется, но не дотянется ни до чего ценного. pull_request_target выполняет workflow из базовой ветки (то есть файл workflow доверенный), но в контексте базового репозитория с полными секретами и read-write токеном — при этом код форка — это то, что выгрузится, если наивно сделать actions/checkout head PR. Эта комбинация и есть вектор эксфильтрации из Hook: доверенный триггер, недоверенный код, живые секреты.
# ОПАСНО: секреты в зоне видимости, затем выгружается и выполняется код форка
on: pull_request_target
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # ← код атакующего
- run: npm ci && npm test # ← выполняется с NPM_TOKEN в envЭвристика: используй pull_request_target только для доверенной работы, которая никогда не выполняет код форка (повесить label, оставить комментарий, прогнать проверку, читающую метаданные). В момент, когда нужно собрать или протестировать реальный код контрибьютора, используй pull_request и прими, что секреты недоступны — либо закрой привилегированный шаг за environment с ручным одобрением.
Почему workflow по pull_request_target, выгружающий и тестирующий head-код PR, — дыра в безопасности, а те же шаги под pull_request безопасны?
▸Почему это работает
Зачем GitHub вообще предлагает pull_request_target? Потому что легитимным PR из форков нужна какая-то доверенная автоматизация: автолейблинг, боты «needs-review», публикация комментариев о покрытии — работа, которая должна читать или писать состояние репозитория, но не должна выполнять код контрибьютора. Событие существует, чтобы доверенная работа с метаданными имела учётки, пока путь недоверенного кода (pull_request) остаётся заперт. Провал — слить эти два: делать выполнение недоверенного кода под доверенным триггером. Держи их раздельно — и каждый безопасен.
Concurrency: отмени устаревший запуск, пока он не сжёг раннер
Каждая оживлённая PR-ветка теряет минуты раннеров на запуски, которые будут обогнаны до окончания — и без политики concurrency ты платишь за каждый. Спроси себя: есть ли что-то полезное в прогоне CI-сьюта против коммита, обогнанного сорок секунд назад?
Когда контрибьютор пушит три коммита за пять минут, три запуска становятся в очередь. Без управления concurrency все три выполнятся, а первые два устаревают в момент старта третьего. Блок concurrency это чинит:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: truecancel-in-progress: true отменяет любой выполняющийся запуск той же группы при старте нового — канонический PR-паттерн, с ключом по github.ref, чтобы каждая ветка была своей группой. Это регулярно срезает большую долю впустую потраченных минут на оживлённых ветках. Обратный паттерн тоже важен: для deploy-workflow нужен cancel-in-progress: false, чтобы деплой никогда не убивался на полпути, а группа сериализовала деплои по одному за раз. Когда добавляешь concurrency к deploy-workflow и второй деплой стартует на полпути первого — сначала реши, какой провал хуже: наполовину задеплоенное окружение или деплой в очереди, который просто ждёт. Concurrency — это гейт очереди, а не триггер: событие всё равно срабатывает; concurrency лишь решает, какие запуски выживут.
Deploy-в-прод workflow использует concurrency с cancel-in-progress: true по ключу environment. Второй деплой стартует, пока первый на полпути. Что происходит и того ли вы хотели?
- 01Объясни точную разницу между pull_request и pull_request_target и почему один — известный вектор эксфильтрации.
- 02Как комбинируются фильтры on:, что делает schedule ненадёжным и почему фильтр путей ломает мерж?
Workflow GitHub Actions управляется событиями с первой строки: блок on: называет события — push, pull_request, schedule, workflow_dispatch, workflow_call — и его фильтры комбинируются AND между ключами и OR внутри списка, с взаимоисключающими paths/paths-ignore и пропущенной по paths required-проверкой, блокирующей мержи как «skipped», а не «passed». schedule только в UTC и best-effort, поэтому GitHub отбрасывает запуски под нагрузкой, а джобы на верху часа — самые вероятные жертвы; смещай минуту. Модель безопасности решается на триггере, а не в джобах: pull_request выполняет недоверенный код форка с read-only токеном и без секретов, поэтому его выполнение безопасно; pull_request_target выполняет доверенный workflow базовой ветки с полными секретами и write-токеном, что становится дырой эксфильтрации в момент, когда вы выгружаете и запускаете head-код форка — резервируй его для доверенной работы с метаданными, а выполнение недоверенного кода переноси на pull_request или за environment с ручным одобрением. Concurrency — гейт очереди поверх триггера: cancel-in-progress: true по ключу github.ref убивает устаревшие PR-запуски и возвращает минуты, а деплои переключают его на false, чтобы группа сериализовала и ни один деплой не умер на полпути. Выбирай событие осознанно — оно определяет, что запустится, что отменится и встретится ли код атакующего с твоими секретами.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.