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

Секреты, окружения и OIDC

Учётные данные CI живут как замаскированные secret-ы, ограниченные по environment с protection rules. Fork по умолчанию не получает secret-ов — а OIDC заменяет долгоживущие ключи облака коротким подписанным token, который облако меняет на временные креды.

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

Контрибьютор открывает дружелюбный с виду 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 publish

GitHub автоматически маскирует зарегистрированные значения 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. Выбери подход, который отгружает сеньор.

Вспомните перед уходом
  1. 01
    Почему workflow по pull request из fork по умолчанию не получают secret-ов, и почему переключение на pull_request_target — это ловушка?
  2. 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-уровень. Открой, попробуй, потом открой ответ.

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.