GITHUB_TOKEN: дефолтные права, least privilege и почему write-all — это footgun
Каждый job получает авто-минченный GITHUB_TOKEN, чей дефолтный scope может быть write-all на репо. Урезай его per-job через permissions до contents:read и поднимай лишь тот job, кому надо.
Инцидент начался с 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_TOKEN (и github.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, ведь так проще. Что верное возражение?
- 01Откуда берётся scope прав GITHUB_TOKEN, когда у воркфлоу нет блока permissions, и почему это рискованно?
- 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.