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

GITHUB_TOKEN: дефолтные права, least privilege и почему write-all — это footgun

Каждый job получает авто-минченный GITHUB_TOKEN, чей дефолтный scope может быть write-all на репо. Урезай его per-job через permissions до contents:read и поднимай лишь тот job, кому надо.

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

Инцидент начался с Action для форматирования комментариев, который никто не запинил. Он запускался на каждом pull request, включая PR из форков, и в репо никогда не задавали блок permissions: — так что авто-минченный GITHUB_TOKEN приходил с легаси-дефолтом: запись почти на всё в репо. У этого Action была транзитивная зависимость, которую тремя релизами раньше тихо перевыпустил скомпрометированный аккаунт мейнтейнера. На следующем форк-PR эта зависимость использовала выданный ей токен, чтобы запушить коммит в релиз-ветку и открыть тег. Ни секрет не украли, ни пароль не сфишили. CI просто сделал ровно то, что разрешал его токен, а токен разрешал всё. Фикс — четыре строки в начале воркфлоу: permissions: contents: read. Однострочник пост-мортема: радиус поражения от supply-chain-компрометации в CI — это всё, что может токен job, а дефолтный токен мог почти всё.

Что это за токен и откуда его scope

Когда видишь supply-chain-инцидент в CI, первый вопрос всегда: что мог сделать скомпрометированный код? Ответ целиком определяется scope токена — поэтому стоит точно понять, откуда этот scope берётся, прежде чем писать первый блок permissions:.

В начале каждого job GitHub минтит свежий installation access token (токен доступа на уровне установки приложения) и отдаёт его как secrets.GITHUB_TOKENgithub.token). Он привязан к текущему репо и истекает по окончании job — максимум на время прогона, никогда не сохраняется. Это хорошая новость. Плохая — его широта. У токена набор scope-прав — contents, pull-requests, issues, packages, actions, id-token и другие — каждый независимо read, write или none.

Откуда дефолтятся эти scope? Два слоя. Исторически репозитории создавались с пермиссивным дефолтом: токен получал read/write почти на все scope (contents: write, issues: write, pull-requests: write, …). Позже GitHub дал орг-ам переключить настройку орг/репо на ограниченный дефолт (contents: read, остальное none), и новые орги всё чаще стартуют с него — но полагаться на это нельзя. Единственный scope, что ты контролируешь наверняка, — тот, что пишешь сам:

permissions:
  contents: read          # склонировать репо, и не более

Объяви permissions: — и каждый неперечисленный scope падает в none. Этот единственный блок превращает ambient write-all токен в read-only независимо от дефолтной настройки репо.

# Top-level: применяется ко всем job как пол
permissions:
  contents: read

jobs:
  release:
    permissions:          # job-level: оверрайд только для этого job
      contents: write     # этот один job может запушить тег
      id-token: write     # и сминтить OIDC-токен (следующий урок)
    runs-on: ubuntu-latest
    steps: [...]
Викторина

В воркфлоу вообще нет блока permissions:. Репо создан годы назад, и орг никогда не менял дефолтную настройку токена. Что может GITHUB_TOKEN в его job-ах?

Least privilege, per job

Дисциплина двухчастна. Сначала задай ограничительный пол в начале воркфлоу — permissions: contents: read (или даже {} для нуля scope) — чтобы каждый job стартовал без того, что ему не нужно. Затем поднимай узко: только job, что публикует релиз, получает contents: write; только job, что комментирует PR, получает pull-requests: write; только job, что аутентифицируется в облако, получает id-token: write. Job-level блок permissions: полностью заменяет унаследованный набор для этого job — он не аддитивен, так что перечисление одного scope роняет все остальные в none.

Это важно из-за того, как токен достаёт код. Каждый шаг в job — каждый сторонний Action, каждый run:-скрипт, каждая транзитивная npm- или pip-зависимость, что они выполняют — работает с тем же токеном job, доступным в окружении и для GitHub API. Per-step-песочницы токена нет. Так что scope токена — это объединение доверия, что ты распространяешь на каждую строку кода в этом job. Пиннинг Action-ов к полному commit SHA (uses: owner/action@<40-символьный-sha>) закрывает половину какой код запускается; least-privilege permissions: закрывает половину что этот код может. Нужны обе: пиннинг без скоупинга всё ещё выдаёт write-all токен запиненному-но-позже-скомпрометированному коду, а скоупинг без пиннинга всё ещё гоняет произвольный изменяемый код, лишь с меньшим охватом.

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

Зачем дефолтить top-level read-only пол вместо того, чтобы выдавать каждому job нужное с нуля? Потому что failure mode — это забыть. Новый job, добавленный через полгода, наследует top-level пол автоматически; если пол — contents: read, этот новый job безопасен-по-дефолту и становится опасным, лишь если кто-то сознательно его поднимет — видимая, ревьюимая строка в дифе. Без пола новый job молча наследует тот дефолт репо, что есть, и никто не замечает write-all токен до пост-мортема. Пол делает «безопасно» путём наименьшего сопротивления.

Викторина

Воркфлоу задаёт permissions: contents: read в начале. Одному job надо запушить тег. Ревьюер спрашивает, почему не выдать contents: write на top-level, ведь так проще. Что верное возражение?

Вспомните перед уходом
  1. 01
    Откуда берётся scope прав GITHUB_TOKEN, когда у воркфлоу нет блока permissions, и почему это рискованно?
  2. 02
    Зачем ограничивать contents:write одним job вместо выдачи на top-level, и как это взаимодействует с пиннингом Action-ов?
Итог

GitHub минтит свежий GITHUB_TOKEN в начале каждого job, привязанный к текущему репо и истекающий по окончании job — но опасна его широта. Каждый scope (contents, issues, pull-requests, packages, actions, id-token) независимо read/write/none, и без блока permissions токен наследует дефолт репо/орга, что может быть легаси-пермиссивным write-all набором, а не ограниченным read-only. Объявление блока permissions делает каждый неперечисленный scope none, так что дисциплина: задай top-level contents:read (или {}) пол, затем поднимай узко на уровне job — лишь релиз-job получает contents:write, лишь облако-аутентификация-job получает id-token:write. Job-level блоки заменяют, не добавляют. Это важно, ведь токен делится между каждым шагом, Action и транзитивной зависимостью в job без per-step-песочницы, так что его scope есть доверие, что ты распространяешь на каждую строку кода там. Пинь Action-ы к commit SHA, чтобы контролировать, какой код запускается, и ограничивай permissions, чтобы контролировать, что он может; любая по отдельности оставляет реальную дыру. Read-only пол также делает «безопасно» дефолтом для job-ов, добавленных позже — они наследуют безопасность и становятся опасными лишь через видимое, ревьюимое поднятие в дифе. Теперь, когда встречаешь воркфлоу без блока permissions:, ты знаешь, что видишь: неявный write-all грант, который ждёт одной скомпрометированной зависимости, чтобы воспользоваться им.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.