Секреты, окружения и OIDC
Учётные данные CI живут как замаскированные secret-ы, ограниченные по environment с protection rules. Fork по умолчанию не получает secret-ов — а OIDC заменяет долгоживущие ключи облака коротким подписанным token, который облако меняет на временные креды.
Контрибьютор открывает дружелюбный с виду pull request со своего fork: он добавляет тест, который печатает пару переменных окружения «для отладки». Если бы твои deploy-ключи попали в эту сборку, логи PR теперь содержали бы твои production-креды AWS — выгруженные в fork, который ты не контролируешь, в job, запущенной незнакомцем. GitHub блокирует ровно это, не передавая secret-ы в workflow, запущенные из fork. Обжигаются те команды, кто тянется к pull_request_target, чтобы «заставить secret-ы работать на PR», не понимая, что они только что снова включили.
Secret-ы: зашифрованы, замаскированы и по имени
Прежде чем вставить API-ключ прямо в YAML workflow, спроси себя: что получит атакующий, если репозиторий завтра станет публичным? Этот вопрос определяет каждое проектное решение в этом разделе.
Secret (секрет) — это зашифрованное значение, которое GitHub хранит и впрыскивает в job во время выполнения. Ты никогда не вставляешь кред прямо в YAML; ты ссылаешься на него как ${{ secrets.NAME }}, и GitHub расшифровывает его в окружение этого одного шага. Secret-ы существуют на трёх уровнях, вложенных от широкого к узкому: organization-secret-ы (общие для репозиториев, под контролем владельца), repository-secret-ы и environment-secret-ы (доступны, только когда job нацелена на этот конкретный environment). Уже область — меньше радиус поражения: паролю production-базы место на environment production, а не свободно на уровне репозитория, где его может прочитать любой workflow.
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Publish
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }} # расшифрован только в этот шаг
run: npm publishGitHub автоматически маскирует зарегистрированные значения secret-ов в логах: если буквальный текст secret-а появляется в выводе, он заменяется на ***. Это страховка, а не гарантия. Маскирование сопоставляет точную строку, поэтому secret, который кто-то закодировал в base64, экранировал в URL или разбил по строкам, проходит насквозь нескрытым. Отсюда абсолютное правило: никогда не делай echo secret-а, никогда не передавай его как CLI-аргумент, который попадает в лог, и никогда не надейся, что маскирование спасёт сессию отладки с set -x. Относись к secret-у как к оголённому проводу под напряжением.
▸Почему это работает
Маскирование — это сопоставление строк в stdout/stderr, поэтому любое преобразование его побеждает. echo $TOKEN замаскирован; echo $TOKEN | base64 — нет, закодированная форма никогда не совпадала с зарегистрированным значением. Тот же зазор объясняет, почему secret, случайно записанный в артефакт, в coredump или в сообщение об ошибке, которое его интерполирует, может утечь в открытом виде. Маска — последняя линия обороны; первая — не класть secret туда, где его можно наблюдать.
Ловушка fork: почему PR из fork не получают secret-ов
Вот граница безопасности, которая определяет безопасный CI. Когда workflow запускается по pull_request из fork-репозитория, GitHub не передаёт твои secret-ы на runner (единственное исключение — GITHUB_TOKEN с правами только на чтение). Причина прямая: PR из fork выполняет код, написанный атакующим — любой незнакомец может предложить diff, добавляющий вредоносный шаг. Если бы этот шаг мог прочитать ${{ secrets.* }}, открыть PR было бы достаточно, чтобы украсть каждый кред в репозитории. Невыдача secret-ов делает fork-CI безопасным для автоматического запуска: сборка может скомпилироваться и прогнать тесты, но не может тронуть ничего, что даёт реальную власть.
Ловушка — это «фикс». Мейнтейнер замечает, что шаги deploy или загрузки покрытия падают на PR из fork (нет secret-ов), и переключает триггер на pull_request_target. Это событие выполняется в контексте базового репозитория — поэтому оно получает secret-ы и token с правами на запись. Но оно всё ещё делает checkout и может выполнить недоверенный код PR. Ты снова открыл ту самую дыру, которую закрывал дефолт, теперь с доступом на запись. Команда безопасности самого GitHub назвала этот класс «pwn requests». Если тебе действительно нужно обогатить PR из fork привилегированной работой, разбей это: запусти недоверенный код в непривилегированной job pull_request, а привилегированный шаг сделай в отдельной job pull_request_target (или workflow_run), которая никогда не делает checkout и не запускает код fork.
Environments: именованные цели с ограждениями
Environment — это именованная цель деплоя — staging, production — которую ты привязываешь к job через environment: production. Environment-ы делают две работы сразу: ограничивают secret-ы (job видит secret-ы environment, только когда нацелена на него) и применяют protection rules до того, как job вообще разрешено стартовать:
- Required reviewers — job ставится на паузу, пока названный человек не одобрит деплой.
- Wait timer — принудительная задержка (например, 10 минут) перед деплоем, окно, чтобы отменить плохой релиз.
- Deployment branch restrictions — только
main(или шаблон тега) может деплоить вproduction, поэтому feature-ветка не отгрузится.
Вместе эти правила превращают «кто может деплоить в prod» из политики, живущей в вики, в механическое ограничение, которое применяется до старта job. Без них любой workflow с нужным secret может отгрузить что угодно в любой момент из любой ветки.
jobs:
release:
runs-on: ubuntu-latest
environment: production # ворота: reviewers, wait timer, правило ветки
steps:
- run: ./deploy.sh
env:
API_KEY: ${{ secrets.API_KEY }} # secret уровня productionТак ты делаешь «слить может любой, отгрузить в прод — не любой» механическим фактом, а не политикой, которую люди должны помнить соблюдать.
OIDC: вообще перестань хранить ключи облака
Апгрейд уровня сеньора — удалить долгоживущие креды облака из secret-ов целиком. Статический AWS_SECRET_ACCESS_KEY, лежащий в secret-ах репозитория, — это постоянная угроза: он может утечь, его надо ротировать, и у одного ключа часто широкий радиус поражения. OpenID Connect (OIDC) заменяет его коротким подписанным token. Ты один раз настраиваешь облако, чтобы оно доверяло OIDC-издателю GitHub для конкретных workflow; затем каждая job чеканит свежий token, отдаёт его облаку, а облако меняет его на временные креды, ограниченные ролью, — действительные только для этой job, а потом исчезающие. Ничего долгоживущего не хранится, поэтому ничего долгоживущего не утечёт, и нечего ротировать.
Workflow запрашивает token, требуя право id-token: write:
permissions:
id-token: write # разрешить job запросить OIDC-token
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
aws-region: us-east-1 # никаких ключей AWS нигде не хранится
- run: aws s3 sync ./dist s3://my-bucketБезопасность даёт trust policy на стороне облака: она пинит iss token-а (издателя GitHub) и его claim sub к твоему конкретному репозиторию, ветке или environment (например, repo:acme/app:ref:refs/heads/main). Fork или другой репозиторий не сможет принять роль, потому что subject их token-а не совпадёт. Сам обмен:
| Измерение | Статический secret (ключ AWS в secret-ах репо) | Обмен OIDC-token |
|---|---|---|
| Что хранится | Долгоживущий ключ + secret, зашифрованы в GitHub | Ничего — только trust policy на стороне облака |
| Время жизни | Пока кто-то не ротирует (часто: никогда) | Чеканится на job, истекает за ~1 час |
| Ротация | Ручная, склонна к ошибкам, легко забыть | Нет — каждая job получает свежий token |
| Радиус поражения при утечке | Полный, пока не заметят и не отзовут | Минуты; привязан к claim-ам одной job |
| Стоимость настройки | Вставить ключ — тривиально, потом вечный долг | Разовая настройка IAM/провайдера identity |
PR из fork контрибьютора добавляет шаг, который делает `echo $AWS_SECRET_ACCESS_KEY | base64`. Workflow использует дефолтный триггер `pull_request`. Что произойдёт?
Почему настройка OIDC безопаснее, чем хранение долгоживущего ключа AWS как secret?
Твоей deploy-job нужны креды AWS, чтобы пушить в production. Выбери подход, который отгружает сеньор.
- 01Почему workflow по pull request из fork по умолчанию не получают secret-ов, и почему переключение на pull_request_target — это ловушка?
- 02Пройди по обмену OIDC-token и объясни, что делает его безопаснее хранимого ключа облака.
Secret-ы — это зашифрованные значения, которые GitHub впрыскивает во время выполнения и на которые ты ссылаешься как ${{ secrets.NAME }}, ограниченные на всю organization, на репо или — лучше всего — на environment, чтобы сократить радиус поражения. GitHub маскирует зарегистрированные secret-ы в логах, но маскирование — это сопоставление точной строки, которое побеждает любое кодирование или разбиение, поэтому ты никогда не делаешь echo secret-а и не доверяешь сети. Определяющая граница: workflow, запущенные по pull_request из fork, не получают secret-ов (только GITHUB_TOKEN с правами на чтение), потому что выполняют недоверенный код; классическая ошибка — переключиться на pull_request_target, чтобы «вернуть» secret-ы, что снова выдаёт их плюс доступ на запись коду, написанному атакующим, — дыра pwn-request. Environment-ы добавляют protection rules — required reviewers, wait timer и ограничения по ветке деплоя — которые контролируют, какие job и какие ветки могут отгружать в production. Ход сеньора — OIDC (OpenID Connect — федеративная аутентификация через подписанный токен без хранения долгоживущих ключей): job запрашивает id-token: write, GitHub подписывает короткий token, а облако меняет его на временные креды, ограниченные ролью и привязанные trust policy по claim sub — без хранимого ключа, нечего ротировать, и крошечное окно, если что-то когда-либо перехватят. Теперь, когда видишь workflow с триггером pull_request_target, воспринимай это как красный флаг — до тех пор пока не убедишься, что он не делает checkout и не запускает код fork.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.