OIDC в облако: короткоживущие токены вместо вечных AWS-ключей и пиннинг sub-клейма
OIDC даёт job сминтить короткоживущий (~1ч) облачный токен через AssumeRoleWithWebIdentity вместо хранения вечного AWS-ключа. Trust policy обязан пинить sub-клейм, иначе любой репо ассамит твою роль.
Утёкший ключ нашли случайно — security-исследователь, гонявший сканер публичных репо, пингнул команду: пара AWS_ACCESS_KEY_ID и AWS_SECRET_ACCESS_KEY лежала в CI-логах форка из дебаг-дампа env, закоммиченного двумя годами ранее. Ключ был статическим кредом IAM-пользователя — без истечения, с полными деплой-правами — скопированным в GitHub Secrets, потому что это был очевидный способ дать CI пушить в S3 и обновлять ECS. Он был валиден всё это время. Никто его не ротировал, потому что не было причины; статические ключи не истекают, а ротация — ручной труд. Ремедиация была не «ротируй и едем дальше». Она была «вообще перестать хранить облачные ключи в CI». Перешли на OIDC: теперь job запрашивает свежий кред у AWS STS в рантайме, привязанный к одной роли, валидный час, криптографически связанный с воркфлоу этого репозитория. Секрета для утечки больше не было — а написанный trust policy гарантировал, что никакой другой репо не сможет запросить ту же роль.
Размен: хранимый ключ vs минченный токен
Старая модель хранит долгоживущий облачный кред в GitHub Secrets. У IAM access key нет истечения: он работает, пока кто-то вручную не ротирует или удалит. Если он утечёт — лог-дамп, скомпрометированный Action, скриншот — это стоячий кред с полными правами, что атакующий использует на досуге, и часто узнаёшь о нём от третьей стороны. Ротация ручная, так что на практике эти ключи живут годами.
OIDC инвертирует это. GitHub Actions запускает OIDC identity provider. В рантайме job может запросить подписанный JSON Web Token у GitHub, что утверждает факты о прогоне — какой репо, какой ref, какой воркфлоу, какое окружение — в своих клеймах. Job отдаёт этот JWT облачному token-сервису; облако проверяет подпись GitHub, сверяет клеймы с trust policy, что ты сконфигурировал, и возвращает короткоживущий кред. На AWS вызов — sts:AssumeRoleWithWebIdentity, и возвращаемые креды по дефолту валидны один час (настраивается, ограничено max session duration роли). Нигде не хранится секрет: JWT минтится свежим каждый прогон и бесполезен после истечения.
permissions:
id-token: write # ОБЯЗАТЕЛЬНО — даёт job запросить OIDC JWT
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@<запиненный-sha>
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
aws-region: eu-central-1
# нет aws-access-key-id / aws-secret-access-key — их не существуетЭто право id-token: write — гейт: без него job не может запросить JWT, ровно поэтому ограничиваешь его лишь тем job, что деплоит.
Команда мигрирует с хранимого AWS_SECRET_ACCESS_KEY на OIDC. Какая конкретная security-разница в том, что атакующий получает от компрометации CI?
Клейм, что решает всё: sub
Прежде чем писать trust policy (политику доверия роли), спроси себя: что именно проверяет облако? Если ответ — «подпись», у тебя открытая роль. Подпись доказывает лишь, что GitHub Actions сминтил токен — не какой из сотен миллионов репозиториев запустил прогон. Единственное условие, закрывающее эту брешь, — клейм sub.
GitHub подписывает JWT, так что облако знает, что он действительно от GitHub Actions. Но воркфлоу каждого репозитория GitHub получает токен, подписанный тем же эмитентом. Если твой AWS trust policy проверяет лишь «этот токен от OIDC-эмитента GitHub?», то воркфлоу любого пользователя GitHub может запросить твою роль и ассамить её. Контроль, что ограничивает доверие твоим репо, — клейм sub (subject), что кодирует личность прогона, и пинишь его в условии trust policy:
{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
}
}
}Строка sub — это замок. repo:my-org/my-repo:ref:refs/heads/main означает, что лишь тот репо, лишь на ветке main, может ассамить роль. Можно сузить ещё (:environment:production) или, для тег-релизов, :ref:refs/tags/*. Классический провал — слишком свободный паттерн: repo:my-org/* даёт любому репо в орг-е ассамить роль, а wildcard вроде repo:* (или полный пропуск условия sub) даёт GitHub Actions всего интернета ассамить её. Пинь и aud: дефолтная аудитория GitHub — сконфигурированное значение, и её проверка не даёт токену, минченному для другой relying party, быть переигранным на твоём STS.
▸Почему это работает
Почему забыть условие sub так катастрофично, ведь токен всё равно GitHub-подписан? Потому что подпись доказывает лишь, что его сминтил GitHub Actions — не чей Actions. OIDC-эмитент общий для всего GitHub. Атакующий создаёт одноразовый репо, пишет однострочный воркфлоу, что запрашивает токен с aud: sts.amazonaws.com, и получает идеально валидный GitHub-подписанный JWT. Если твой trust policy роли проверяет лишь эмитента и аудиторию, AWS принимает этот токен и выдаёт атакующему креды твоей роли. Клейм sub — единственное, что говорит «этот прогон принадлежит my-org/my-repo, не someone-else/evil-repo». Пиннинг его — не хардненинг поверх OIDC, а та часть OIDC, что вообще делает его безопасным.
AWS trust policy для GitHub-OIDC-роли проверяет клейм аудитории и что токен от token.actions.githubusercontent.com, но его условие sub — StringLike repo:my-org/*. В чём экспозиция?
- 01Проведи через обмен OIDC-в-AWS и назови один облачный контроль, что реально ограничивает доверие твоим репо.
- 02Почему условие sub вида repo:my-org/* или отсутствующее условие sub опасно, и как ограничить его правильно?
OIDC убирает долгоживущий облачный ключ из CI целиком. Вместо хранения неистекающего AWS-ключа в GitHub Secrets — стоячий кред с полными правами, что утекает через лог-дампы или скомпрометированные Action и живёт годами, ведь ротация ручная — job минтит свежий кред в рантайме. Он объявляет id-token: write, запрашивает GitHub-подписанный JWT, ограниченный аудиторией, и обменивает его через sts:AssumeRoleWithWebIdentity на короткоживущий (~1ч) кред. Нет ничего статического для кражи. Но безопасность OIDC живёт и умирает на облачном trust policy. GitHub подписывает JWT для каждого репозитория на GitHub одним общим эмитентом, так что подпись сама по себе доказывает лишь, что токен сминтил GitHub Actions, не чей. Клейм sub — repo:my-org/my-repo:ref:refs/heads/main — это замок, что ограничивает роль твоим репо и ref; пинь его в StringLike-условии и пинь aud тоже. Свободный паттерн вроде repo:my-org/* доверяет всему орг-у, а отсутствующее условие sub даёт одноразовому воркфлоу любого пользователя GitHub ассамить твою роль с идеально валидным токеном. Так что: ограничь id-token: write одним деплой-job, пинь sub и aud, сузь до ref или окружения — и у тебя кред, что нельзя утечь, ведь он никогда не сохраняется, и нельзя позаимствовать, ведь trust policy называет тебя. Теперь, когда встречаешь любой trust policy для OIDC, первое, что проверяешь, — есть ли условие sub и пинит ли оно точный репо: либо эта строка там есть, либо роль фактически публична.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.