Гигиена зависимостей и секретов: закаляем сам CI
CI-раннер держит каждый твой секрет, поэтому закаляй сам CI: пинни actions по полному SHA коммита (тег можно переставить на вредоносный код), сканируй и блокируй утёкшие креды на push, ставь permissions по минимуму, предпочитай короткоживущий OIDC хранимым ключам.
14 марта 2025 инженер в одном из ~23 000 репозиториев запушил рутинный коммит. Его workflow ссылался на безумно популярный хелпер tj-actions/changed-files@v35 — изменяемый тег. Часами ранее атакующий силой переставил каждый version-тег этого action, от v1 и выше, на единственный вредоносный коммит. Внедрённый код base64-декодировал нагрузку, которая сбрасывала память раннера — AWS-ключи, npm-токены, GitHub PAT, приватные RSA-ключи — прямо в build-лог, где они лежали открытым текстом для любого, кто мог читать вывод Actions. Никто не менял свой workflow. Тег, которому доверяли вчера, сегодня указывал на код атакующего. Репозитории, запинившие action на полный SHA коммита, гоняли старый безопасный код и остались нетронутыми.
Пиннинг: тег — это обещание, SHA — факт
Lock-файл твоего приложения (package-lock.json, pnpm-lock.yaml, poetry.lock) пинит точное байтовое содержимое каждой зависимости по integrity-хэшу (хэш содержимого пакета, гарантирующий неизменность), поэтому npm ci ставит одно и то же дерево на каждой машине. Эта дисциплина всем понятна. Слепое пятно — другие зависимости, которые тянет твой пайплайн: сами GitHub Actions. Запись uses: actions/checkout@v4 выглядит как пин версии, но v4 — это изменяемый git-тег, метка, которую мейнтейнер (или любой, кто его скомпрометировал) может силой переставить на другой коммит в любой момент, без единого изменения в твоём репозитории. Ты пинишь не код; ты пинишь обещание и дальше указывать тегом на хороший код.
Полный SHA коммита — противоположность: это контент-адрес точного дерева, и объектная модель git означает, что переставить его без поломки хэша нельзя. uses: actions/checkout@<40-символьный-sha> гоняет одни и те же байты всегда. Это единственный контроль, который нейтрализовал бы радиус поражения tj-actions — вредоносный код жил за тегами, поэтому каждый потребитель, запинённый по SHA, продолжал гонять старый безопасный коммит. Пинь сторонние actions, и самые рискованные из своих, по полному SHA, с хвостовым комментарием, фиксирующим человекочитаемую версию:
# ПЛОХО — изменяемый тег, дефолтный (часто широкий) токен, секреты доступны всему
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # @v4 можно переставить на вредоносный код
- uses: tj-actions/changed-files@v35 # именно тот action, чьи теги переставили
- run: ./deploy.sh # AWS_KEY в env, доступен каждому шагу выше
# ХОРОШО — запинено по полному SHA, least-privilege токен, OIDC только где нужно
on: push
permissions:
contents: read # по умолчанию всё в read-only
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # выдай OIDC ТОЛЬКО в job, которому он нужен
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
role-to-assume: arn:aws:iam::111122223333:role/ci-deploy # короткоживущий, без хранимого ключа
aws-region: eu-central-1
- run: ./deploy.shАвтоматизируй апдейты, иначе пиннинг гниёт в устаревшие CVE
Пиннинг по SHA покупает безопасность ценой рутины: замороженный дайджест никогда не подхватит апстримный багфикс или патч под следующую CVE. Оставленный вручную, «пинь всё» вырождается в «гоняй уязвимые версии вечно». Решение — дать боту делать бампы. Dependabot и Renovate оба открывают pull request’ы, обновляющие запинённые зависимости — включая SHA actions — и при хорошей настройке обновляют SHA и человекочитаемый комментарий версии вместе. Группируй связанные апдейты, чтобы ревьюить один PR вместо сорока; ставь их по расписанию раз в неделю, чтобы они не перебивали продуктовую работу; и резервируй авто-мёрдж только для по-настоящему низкорисковых классов — patch-бамп с зелёным CI и неизменившимся мейнтейнером. У Renovate авто-мёрдж встроен (automerge: true в package-правиле); Dependabot’у нужен небольшой Actions-workflow с dependabot/fetch-metadata, чтобы гейтить gh pr merge --auto по типу апдейта. Принцип один: машина бампит дайджест, CI доказывает, что всё ещё собирается, человек апрувит всё нетривиальное.
▸Почему это работает
Авто-мёрдж minor- или major-апдейта — или любого апдейта, где сменился мейнтейнер — это ровно та щель, которой хочет атакующий на цепочку поставок. Пакет, переданный новому владельцу, или зависимость, тихо добавившая postinstall-скрипт, уезжает прямо в main, если твоё правило авто-мёрджа слишком щедрое. Держи авто-мёрдж на уровне patch, зелёных тестов, того же мейнтейнера, и требуй человеческих глаз на всё остальное.
Сканирование секретов и push protection: останови утечку на пороге
Пиннинг защищает тебя от чужого скомпрометированного кода; следующий слой защищает пайплайн от ошибок твоей собственной команды. Самая частая утечка прозаична: кто-то хардкодит API-ключ, коммитит его, и он живёт в истории git вечно (и в каждом форке и клоне). Secret scanning сопоставляет запушенное содержимое с большой библиотекой известных форматов кредов — облачные ключи, токены, строки подключения — и алертит на любое обнаруженное. Push protection — проактивная половина: она инспектирует содержимое во время push’а и блокирует коммит ещё до того, как он достигнет remote, когда он совпадает с высокоуверенным паттерном, поэтому секрет вообще не попадает в историю. Автор либо удаляет его, либо, изредка, записывает явное обоснование для обхода. Относиться к заблокированному push’у как к нормальному исходу — а не препятствию, которое надо обойти, — это и есть культурный сдвиг, который держит креды вне репозитория.
Least privilege и короткоживущие креды: ужми радиус поражения
Даже идеально запинённый пайплайн без утечек должен исходить из того, что какая-то зависимость рано или поздно станет вредоносной. Least privilege — это то, что ограничивает ущерб, когда это случится. Важнее всего два рычага. Первый — токен workflow: GITHUB_TOKEN у GitHub по умолчанию read-only с февраля 2023, но многие репозитории всё ещё гоняют с широкими write-скоупами. Объяви permissions: явно в начале workflow, поставь всё по умолчанию в read и выдавай write-скоупы только конкретному job, которому они нужны — так вредоносная зависимость, бегущая в твоём test-job, не сможет запушить в main или опубликовать пакет, потому что у токена в этом job просто нет скоупа. Второй — облачные креды: долгоживущий AWS_SECRET_ACCESS_KEY, лежащий в секретах репозитория, — постоянный приз: утёк один раз (в лог, в скомпрометированный action) — и он работает, пока кто-то не заметит и не ротирует. OIDC (OpenID Connect — протокол федеративной идентичности; вместо долгоживущего ключа раннер получает короткоживущий токен от доверенного провайдера) заменяет его короткоживущим, привязанным к audience токеном, выпускаемым на каждый запуск: workflow запрашивает OIDC-токен (id-token: write), обменивает его у облачного провайдера и получает креды, истекающие за минуты и нигде не хранимые. Красть нечего долговечного.
| Контроль | Какой провал предотвращает | Конкретный механизм |
|---|---|---|
| Пин actions по SHA | Доверенный тег переставлен на вредоносный код (tj-actions) | uses: org/action@<40-символьный-sha> + Dependabot/Renovate для бампа |
| Secret scanning + push protection | Захардкоженный ключ закоммичен в историю | Блок push’а до приземления; алерт на то, что проскользнуло |
| Least-privilege токен | Вредоносная зависимость с write-токеном пушит или публикует | Верхнеуровневый permissions: { contents: read }; расширяй per-job |
| OIDC короткоживущие креды | Утёкший долгоживущий облачный ключ работает до ротации | id-token: write → обмен на минутные креды, ничего не хранится |
Четыре контроля защищают в четырёх разных точках отказа. Вместе они означают: даже когда один ломается — зависимость проскальзывает мимо скана, скоуп токена шире нужного — остальные всё равно ограничивают радиус поражения. Без любого одного из них одна брешь разматывает всю цепь.
Команда внедряет популярный сторонний GitHub Action во все пайплайны, работающие с деплой-секретами. Как на него ссылаться?
Твой workflow использует `actions/checkout@v4`. За ночь, без единого изменения в твоём репозитории, поведение action становится вредоносным и сбрасывает твои секреты в лог. Как такое возможно?
Ты хочешь деплоить в AWS из CI без долгоживущего `AWS_SECRET_ACCESS_KEY`, хранимого в секретах репозитория. Какой сеньорный подход?
- 01Почему `uses: actions/checkout@v4` — более слабый пин, чем `@<full-sha>`, и как инцидент tj-actions это доказал?
- 02Как least-privilege `permissions:` и OIDC по отдельности уменьшают радиус поражения скомпрометированной CI-зависимости?
CI-раннер — высокоценная цель: он держит каждый кред, который ты ему вручил, поэтому закалять надо саму среду, а не только приложение, которое она собирает. Начни с пиннинга. Твой lock-файл уже контент-пинит зависимости приложения, но GitHub Actions — тоже зависимости, а @v4 — изменяемый тег, который можно силой переставить на вредоносный код, ровно тот вектор, что превратил tj-actions/changed-files в эксфильтрующую секреты нагрузку по ~23 000 репозиториев в марте 2025, тогда как запинённые по SHA потребители остались нетронуты. Поэтому пинни actions по полному SHA коммита и пусть Dependabot или Renovate открывают PR на бамп этих SHA (авто-мёрдж только patch-уровня, с зелёными тестами, тем же мейнтейнером), чтобы безопасность и свежесть сосуществовали. Дальше останови собственные утечки: secret scanning алертит на закоммиченные креды, а push protection блокирует их ещё до попадания в историю. Затем ограничь ущерб от того, что всё же просочится, через least privilege — объяви permissions: явно, поставь по умолчанию read-only и расширяй write-скоупы только на тот один job, которому это нужно, чтобы вредоносная зависимость не могла пушить или публиковать. Наконец, замени долгоживущие хранимые облачные ключи на OIDC: короткоживущий, на каждый запуск токен (id-token: write) не оставляет ничего долговечного для кражи. Пинь, сканируй, ограничивай, истекай — четыре слоя, ни один не достаточен в одиночку, эшелонированная защита для пайплайна. Теперь, открывая файл workflow и видя uses: some-action@v3, первый вопрос — запинено ли это по полному SHA коммита и поднимает ли бот PR на бамп этого SHA автоматически?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.