Тег GitHub Action передвинули на вредоносный коммит: как найти утечку секретов и перейти на pin по полному SHA
`uses: owner/action@v4` — не версия, а ярлык, который владелец или укравший его токен может передвинуть на любой коммит, и ваш CI выполнит новый код при следующем запуске. Защищает только pin по полному SHA. Ниже — как за час найти пострадавшие запуски, что ротировать первым и как перейти на SHA без паралича обновлений.
Почему тег @v4 в GitHub Actions — не версия
Начну с механики, потому что именно её недопонимание и рождает всю боль — и с неё я начинаю любой разговор про информационную безопасность разработки у клиентов. В git тег — это просто указатель на коммит. Обычный файл в .git/refs/tags/, внутри которого лежит хеш. Владелец репозитория делает git tag -f v4 <новый-коммит> && git push --force origin v4 — и всё, тег теперь смотрит в другое место. Никакой магии, никакой защиты. Это не баг GitHub и не баг git, это штатное поведение, на котором держится вся практика «плавающих» мажорных тегов у самих экшенов.
Теперь наложим на это, как работает раннер. Когда в workflow написано uses: tj-actions/changed-files@v44, раннер в момент старта job идёт в GitHub, резолвит ref v44 в SHA и выкачивает содержимое. В момент запуска. Не в момент, когда вы писали workflow, не в момент, когда его ревьюили. Штатного lock-файла для uses: в GitHub Actions нет — в отличие от npm с package-lock.json или composer с composer.lock. Единственный lock, который у вас есть, — это сам SHA, вписанный руками в строку uses:.
GitHub в разделе о безопасном использовании Actions формулирует прямо: закрепление экшена на полном коммит-SHA — на сегодня единственный способ использовать экшен как неизменяемый релиз. Аргумент простой: чтобы подсунуть вам другой код под тем же SHA, атакующему нужно сгенерировать SHA-1-коллизию для валидного git-объекта. Это не «сложно», это «на практике неосуществимо для человека, который просто украл чей-то токен».
И вот здесь честность: внутри самого GitHub единого мнения нет. Документ по версионированию экшенов в репозитории actions/toolkit до сих пор рекомендует привязываться к мажорной версии, а SHA-пин держать как аварийную меру «на случай непредвиденных поломок». То есть команда, которая пишет экшены, и команда, которая пишет security-гайд, говорят разное. Я в этом споре однозначно на стороне security-гайда — 2025 год расставил всё по местам.
- Тег в git — перемещаемый указатель, `--force` двигает его без следа в истории
- Раннер резолвит ref в SHA в момент запуска job, а не в момент коммита workflow
- Штатного lock-файла для `uses:` в GitHub Actions нет
- Ветка (`@main`) — ещё хуже тега: она двигается штатно, без всякого force
Как подменили теги tj-actions/changed-files в 2025 году
Мартовская история 2025 года — лучший учебник по теме, и её стоит разобрать по шагам, потому что вывод из неё контринтуитивный. 11 марта 2025 года, между 18:42 и 20:31 UTC, был скомпрометирован reviewdog/action-setup@v1. В install.sh добавили base64-полезную нагрузку, которая дампила память процесса раннера и печатала найденные секреты в лог сборки. Это CVE-2025-30154, CVSS 8.6 High. Под удар попали и все экшены, которые внутри себя дёргали action-setup@v1: action-shellcheck, action-staticcheck, action-typos, action-ast-grep, action-composite-template.
Дальше — самое интересное. Среди пострадавших оказался репозиторий tj-actions/changed-files: в его собственных workflow гонялся tj-actions/eslint-changed-files, который внутри использовал тот самый reviewdog/action-setup@v1, и гонялся он под персональным токеном tj-actions-bot. Токен утёк в лог. С ним атакующий получил право писать в репозиторий changed-files.
14–15 марта этим токеном переписали теги v1.0.0, v35.7.7-sec и v44.5.1 — все три стали указывать на один и тот же вредоносный коммит 0e58ed8671d6b60d0890c21b07f8835ace038e67. Коммит выкачивал memdump.py с gist.githubusercontent.com, сканировал память Runner.Worker и выкладывал найденное в лог, закодированное base64 дважды — именно так обходилось штатное маскирование секретов. Advisory прямо советует раскодировать подозрительные строки командой base64 -d | base64 -d. Затронуто более 23 000 репозиториев. Это CVE-2025-30066, тоже 8.6 High; уязвимыми считаются версии по 45.0.7 включительно, исправление — v46.0.1. Обе CVE позже внесли в каталог CISA KEV.
А теперь вывод, ради которого я это всё пересказываю. В официальном advisory по reviewdog стоит формулировка, которую надо распечатать и повесить над столом: пострадали производные экшены «независимо от версии и способа закрепления». Если вы честно запинили reviewdog/action-shellcheck по полному SHA — вас это не спасло, потому что внутри у него был плавающий @v1. Pin по SHA фиксирует один слой, а не всё дерево.
- 11.03.2025, 18:42–20:31 UTC — компрометация `reviewdog/action-setup@v1` (CVE-2025-30154)
- Утечка PAT `tj-actions-bot` через транзитивный вызов в чужом workflow
- 14–15.03.2025 — переписаны теги `v1.0.0`, `v35.7.7-sec`, `v44.5.1` (CVE-2025-30066)
- Полезная нагрузка: дамп памяти Runner.Worker + двойной base64 в лог
- Патч: `tj-actions/changed-files` v46.0.1
Кейс: 24 репозитория и 11 незнакомых авторов в CI
Условный клиент — краудфандинговая платформа «Вместе сильнее», 20 рабочих мест: бэкенд на Python, веб-фронт, мобильное приложение, GitHub Team и свои стенды в дата-центре. Мы ведём у них инфраструктуру, а CI исторически писали сами разработчики, без надзора — «ну это же просто сборка». Пришли к нам с формулировкой «нас напугали новостями про подменённые экшены, посмотрите, у нас всё нормально?». Для платформы, через которую идут деньги жертвователей, вопрос совсем не праздный.
Первое, что сделали, — инвентаризацию строк uses: по всей организации. Получилось 86 вхождений в 24 репозиториях: 61 по конкретному тегу вида @v4.1.7, 17 по мажорному @v4, 5 по ветке @main и ровно 3 по SHA — их поставил один разработчик, пришедший из финтеха. Уникальных сторонних экшенов — 14 от 11 разных авторов. Два из них — личные репозитории с одним мейнтейнером и последним коммитом двухлетней давности. То есть 11 человек, о которых в компании никто ничего не знает, имели право выполнять произвольный код рядом с продовыми ключами.
Дальше — упражнение «как если бы». Скомпрометированных экшенов у них не оказалось, но мы прогнали разбор на произвольном двухдневном окне: 17 запусков в трёх репозиториях. В окружении этих запусков жили токен с правом записи в реестр контейнеров, статический ключ объектного хранилища, приватный SSH-ключ к прод-хосту и — самое неприятное — боевой API-ключ платёжного шлюза, который зачем-то нужен был интеграционным тестам. Ни один ключ не был ограничен по IP, ни один не ротировался с момента создания; самому старому было 2 года 4 месяца.
Чем закончилось. Перевели всю организацию на SHA-пины за три вечера: сама замена — полчаса, остальное ушло на прогон и починку двух сломавшихся сборок. Два «мёртвых» экшена выкинули и заменили пятью строками bash, включили allow-list на уровне организации, ротировали все 9 долгоживущих секретов, платёжный ключ в CI заменили тестовым, деплой в облако перевели на OIDC. Всего около 10 часов работы. После реального инцидента умножайте на три и добавляйте бессонную ночь и письмо в платёжный шлюз.
- 86 строк `uses:` → 3 закреплены по SHA (3,5 %)
- 14 сторонних экшенов от 11 авторов, два репозитория заброшены
- 9 долгоживущих секретов, включая боевой ключ платёжного шлюза
- Около 10 часов на профилактику против суток и более при реальном инциденте
Как за час найти пострадавшие запуски и утечки в логах
Порядок действий при новости «такой-то экшен скомпрометирован» у меня всегда один: инвентаризация → пересечение с окном инцидента → выемка логов → грепанье. Всё делается через GitHub CLI gh и не требует ничего, кроме токена с правом чтения репозиториев и Actions. Первый шаг — собрать все строки uses: по организации:
gh repo list example-org --limit 200 --json nameWithOwner -q '.[].nameWithOwner' \
| while read -r r; do
gh api "repos/$r/git/trees/HEAD?recursive=1" -q '.tree[].path' 2>/dev/null \
| grep -E '^\.github/(workflows/.*|actions/.*/action)\.ya?ml$' \
| while read -r f; do
gh api "repos/$r/contents/$f" -H 'Accept: application/vnd.github.raw' 2>/dev/null \
| grep -nE '^[[:space:]]*-?[[:space:]]*uses:' | sed "s|^|$r/$f:|"
done
done | tee uses-inventory.txtОбратите внимание: я граблю не только .github/workflows/, но и .github/actions/*/action.yml — свои composite-экшены внутри репозитория люди забывают проверять примерно всегда, а вложенные uses: там точно такие же плавающие. Дальше — список запусков в окне инцидента. Фильтр created понимает диапазон дат:
gh api --paginate \
"repos/example-org/api/actions/runs?created=2025-03-14..2025-03-16&per_page=100" \
-q '.workflow_runs[] | [.id, .name, .event, .head_branch, .created_at] | @tsv' \
| tee runs-window.tsvИ третий шаг — выемка логов и поиск характерной полезной нагрузки. Ищем длинные base64-блобы и пробуем раскодировать их дважды, ровно как рекомендовало advisory:
mkdir -p logs
cut -f1 runs-window.tsv | while read -r id; do
gh run view "$id" --repo example-org/api --log > "logs/$id.txt" 2>/dev/null
done
grep -ohE '[A-Za-z0-9+/=]{200,}' logs/*.txt | sort -u | while read -r b; do
printf '%s' "$b" | base64 -d 2>/dev/null | base64 -d 2>/dev/null
done | grep -aE 'ghp_|github_pat_|gho_|AKIA|-----BEGIN [A-Z ]*PRIVATE KEY'Две оговорки, о которые спотыкаются все. Первая: gh run view --log вернёт пустоту, если логи уже удалены по ретеншену — по умолчанию 90 дней, для приватных репозиториев настраивается до 400, для публичных максимум те же 90. Если инцидент старше — логи вы не восстановите, и придётся исходить из худшего. Вторая: на организации в сотню репозиториев этот перебор идёт долго и упирается в rate limit. Начинайте с тех репозиториев, где в Settings → Secrets вообще что-то лежит, — остальные подождут. Если параллельно у вас стоит вопрос «а не утекло ли что-то из кластера», логика та же, что я описывал для проверки утечки Secrets после патча ingress-nginx: ищем следы использования, а не только следы кражи.
- Инвентаризация: `uses:` в workflows И в собственных composite-экшенах
- Окно: `GET /repos/{owner}/{repo}/actions/runs?created=ОТ..ДО`
- Логи: `gh run view <id> --log`, скачивать до истечения ретеншена
- Поиск: длинные base64-блобы, декодировать дважды
- Retention по умолчанию — 90 дней; приватные репозитории можно поднять до 400
Какие секреты ротировать первыми после инцидента
Здесь начинается место, где люди тратят сутки не на то. Ротировать всё подряд — значит на день положить все деплои и половину интеграций. Я расставляю приоритеты так. Первый эшелон, менять немедленно: всё, что даёт запись наружу — PAT и deploy-ключи с правом write, токены реестров (GHCR, Docker Hub, приватный registry), статические ключи облаков, приватные SSH-ключи к прод-хостам, ключи подписи пакетов и релизов. Второй эшелон, в течение суток: токены read-only к внутренним сервисам, ключи APM и мониторинга, webhook'и уведомлений. Третий — всё остальное, по расписанию.
А теперь то, что успокоит: GITHUB_TOKEN ротировать не надо. Это одноразовый токен, который создаётся раннером на время job и аннулируется по её завершении. К моменту, когда вы читаете новость, он давно мёртв. То же самое с OIDC-токенами: если ваш деплой ходит в облако по федерации, а не по статическому ключу, то ротировать в буквальном смысле нечего — выданный доступ жил минуты и уже протух. Это, на мой взгляд, самый весомый аргумент за OIDC вообще: он превращает катастрофу «утёк ключ от прода» в лёгкий испуг.
Порядок операций тоже важен и его постоянно путают. Сначала отзываем старый секрет на стороне сервиса, потом выпускаем новый, потом кладём его в Actions Secrets. Не наоборот. Если сделать наоборот, то в промежутке живут два валидных ключа, и один из них — у атакующего. И фиксируйте в задачнике: какой секрет, кем выпущен, когда отозван старый, где ещё он использовался кроме CI (спойлер: почти всегда где-то ещё используется — в скрипте на ноутбуке разработчика или в cron на стейдже). Тот же подход к ротации — сначала инвентаризация, потом отзыв, потом новые значения — я применял и в разборе ротации секретов после FortiCloud SSO: чужой CI или чужой SSO, принцип не меняется.
Чистка логов делается одной командой на запуск, ответ 204 без тела:
cut -f1 runs-window.tsv | while read -r id; do
gh api -X DELETE "repos/example-org/api/actions/runs/$id/logs" \
&& echo "purged $id"
done- Немедленно: PAT/deploy-keys с write, токены реестров, статические ключи облаков, SSH к проду
- В течение суток: read-only токены, ключи мониторинга, webhook'и
- Не трогать: `GITHUB_TOKEN` и OIDC — они уже недействительны
- Порядок: отозвать → выпустить → положить в секреты, никогда не наоборот
- Удаление логов: `DELETE /repos/{owner}/{repo}/actions/runs/{run_id}/logs`
Как перейти на pin по SHA и не остаться без обновлений
Главное возражение против SHA-пинов — «мы перестанем получать обновления и застрянем на дырявых версиях». Возражение справедливое ровно до тех пор, пока вы правите пины руками. Как только процесс автоматизирован, оно исчезает. Руками не переписывайте ничего: есть утилиты ratchet и pin-github-action, обе умеют превращать теги в SHA по всему дереву workflow. Синтаксис ниже — для ratchet; перед запуском сверьтесь с ratchet --help своей версии.
# заменить все теги на полные SHA с комментарием-версией
ratchet pin .github/workflows/*.yml
# посмотреть, какие версии зашиты под этими SHA
ratchet unpin .github/workflows/ci.yml
# подтянуть закреплённые SHA до актуальных тегов
ratchet update .github/workflows/*.ymlРезультат должен выглядеть так — полный SHA плюс обязательный комментарий с человекочитаемой версией (хеши в примере заменены заглушками: берите их из вывода утилиты, а не из статьи). Без комментария через полгода никто в команде не поймёт, что зашито, и обновлять будет страшно:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: step-security/harden-runner@<40-символьный-SHA> # v2.x.y
with:
egress-policy: audit
- uses: actions/checkout@<40-символьный-SHA> # v4.x.y
with:
persist-credentials: false
- uses: actions/setup-node@<40-символьный-SHA> # v4.x.yОбновления вешаем на Dependabot — он умеет работать с SHA-пинами и подтягивает комментарий-версию вместе с хешем. Группировка обязательна, иначе получите по PR на каждый экшен и через месяц перестанете их читать:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
actions:
patterns: ["*"]И проверка в самом CI, чтобы новые плавающие теги не заезжали обратно. Здесь я использую статический анализатор zizmor: у него есть аудит unpinned-uses с настраиваемыми политиками и очень полезный ref-version-mismatch, который ловит расхождение между SHA и комментарием-версией рядом. Дополнительно stale-action-refs подсвечивает пины на коммиты, которым не соответствует ни один тег, — обычно это чей-то ручной эксперимент, забытый в main.
pipx install zizmor
zizmor .github/workflows/# zizmor.yml — свои экшены можно по тегу, чужие только по хешу
rules:
unpinned-uses:
config:
policies:
"example-org/*": ref-pin
"*": hash-pinПолитики unpinned-uses настраиваются начиная с zizmor v1.6.0; если правил несколько, побеждает самое специфичное, независимо от порядка. И параллельно закройте периметр на уровне организации: Settings → Actions → General → «Allow OWNER, and select non-OWNER, actions and reusable workflows». Синтаксис списка — OWNER/REPOSITORY@TAG-OR-SHA, поддерживаются шаблоны и запрещающие правила: octocat/*, !octocat/action@*. Это не заменяет SHA-пины, но резко сокращает круг авторов, которым вы вообще даёте слово.
- `ratchet pin` / `pin-github-action` — массовая замена тегов на SHA
- Комментарий `# v7.0.1` рядом с хешем — обязателен, иначе обновлять никто не решится
- Dependabot с `package-ecosystem: github-actions` и группировкой в один PR
- `zizmor`: аудиты `unpinned-uses`, `ref-version-mismatch`, `stale-action-refs`
- Org-level allow-list с шаблонами и `!`-исключениями
Чего pin по SHA не закрывает
SHA-пин фиксирует ровно один слой: содержимое конкретного экшена на момент конкретного коммита. Всё, что этот экшен подтягивает дальше, остаётся плавающим, и именно на этом сгорели пользователи reviewdog. Если внутри action.yml стороннего composite-экшена написано uses: someone/setup@v1 — вы не контролируете этот шаг никак. Для критичных экшенов (тех, что выполняются в job с секретами) я открываю action.yml руками и смотрю, что там внутри. Это пять минут на экшен и делается один раз.
Второе — docker-based экшены. Если в action.yml стоит using: docker и image: docker://alpine:3.20, то тег образа точно такой же перемещаемый ярлык, как git-тег. Пинить надо по digest: docker://alpine@sha256:.... То же касается container: и services: в самом workflow — их люди забывают вообще всегда. Для образов в container: и services: у zizmor есть аудит unpinned-images.
Третье — то, что экшен тянет в рантайме: curl | bash из install.sh, npm i -g, pip install, go install. SHA здесь бессилен по определению, потому что скачивается свежее. Единственная защита — контроль исходящего трафика раннера. step-security/harden-runner с egress-policy: audit сначала показывает, куда ваши сборки вообще ходят, а после недели наблюдений переводится в block с явным allowed-endpoints. В марте 2025 года именно harden-runner первым и подсветил обращения к gist.githubusercontent.com из tj-actions.
Четвёртое, и это отдельный разговор: self-hosted runner, на котором выполняются workflow от pull_request_target или от PR из форков. Это не проблема supply chain, это дыра размером с ворота — любой человек с интернета получает исполнение кода на вашей машине в вашей сети. Если у вас так — бросайте статью и идите чинить прямо сейчас, это важнее всех пинов вместе взятых. Как правильно изолировать собственные раннеры, я подробно разбирал в статье про self-hosted runners для GitHub Actions.
И пятое, из хороших новостей. GitHub 28 октября 2025 года вывел в общую доступность immutable releases: у релизов, опубликованных с включённой настройкой, ассеты нельзя подменить или удалить, а теги таких релизов защищены от перемещения и удаления. Включается на уровне репозитория или организации, распространяется на новые релизы — старые остаются изменяемыми, пока их не перевыпустят. Для ваших собственных экшенов и релизов это надо включить сегодня. Но чужие репозитории вы этой настройкой не почините, поэтому SHA-пины никуда не деваются.
- Транзитивные `uses:` внутри чужих composite-экшенов — читать `action.yml` руками
- Docker-образы в `image:`, `container:`, `services:` — пинить по `@sha256:`
- Скачивание в рантайме — закрывается только egress-контролем (harden-runner)
- `permissions:` по минимуму в каждой job, `persist-credentials: false` в checkout
- Immutable releases (GA 28.10.2025) — включить на своих репозиториях
- Self-hosted runner + PR из форков — чинить в первую очередь
Частые вопросы
Можно ли доверять мажорному тегу у известных экшенов вроде actions/checkout?
Риск ниже: репозитории actions/* живут под организацией GitHub с защищёнными ветками и ревью. Но механика та же — тег перемещаем. Я пиню всё без исключений, а послабления, если нужны, фиксирую явно политикой ref-pin в конфиге zizmor.
Не застрянем ли мы на уязвимых версиях экшенов, если всё запинить по SHA?
Застрянете, если обновлять руками. Настройте Dependabot с package-ecosystem: github-actions, недельным расписанием и группировкой в один PR — он обновляет и хеш, и комментарий с версией. zizmor с аудитом stale-action-refs подсветит пины, которым не соответствует ни один тег.
Надо ли ротировать GITHUB_TOKEN после такого инцидента?
Нет. GITHUB_TOKEN выпускается на время job и аннулируется после неё. Ротировать нужно долгоживущее: PAT, deploy-ключи, ключи реестров и облаков, SSH-ключи. При деплое через OIDC статических ключей для ротации нет.
В логах все секреты закрыты звёздочками. Значит, ничего не утекло?
Не значит. GitHub маскирует точное значение секрета, но не его производные: атака 2025 года обходила маскирование двойным base64. Ищите аномалии — длинные base64-строки, обращения к посторонним доменам, необъяснимо долгие шаги.
Может, проще форкнуть все сторонние экшены к себе в организацию?
Иногда. Форк снимает риск подмены тега чужим автором, но обязывает вас переносить security-фиксы из апстрима. Я форкаю точечно — экшены одного физлица, которые работают в job с продовыми секретами. Остальное — SHA-пин плюс Dependabot.
Логи инцидента уже удалились по ретеншену. Что делать?
Исходить из худшего: считать скомпрометированными все секреты, доступные workflow из окна инцидента. Удалённые логи не восстановить. Для приватных репозиториев ретеншен можно поднять до 400 дней, для публичных потолок — 90.
Источники
- GitHub Docs — Secure use reference (Security hardening for GitHub Actions) — Раздел «Using third-party actions»: «Pinning an action to a full-length commit SHA is currently the only way to use an action as an immutable release», а также требования по маскированию и удалению логов с секретами. https://docs.github.com/en/actions/reference/secure-use-reference
- GHSA-mrrh-fwg8-r2c3 / CVE-2025-30066 — tj-actions/changed-files — GitHub Advisory Database: компрометация версий по 45.0.7 включительно, вредоносный коммит 0e58ed8671d6b60d0890c21b07f8835ace038e67, переписанные теги v1.0.0 / v35.7.7-sec / v44.5.1, окно 14–15 марта 2025, патч v46.0.1, CVSS 8.6. https://github.com/advisories/GHSA-mrrh-fwg8-r2c3
- GHSA-qmg3-hpqr-gqvc / CVE-2025-30154 — reviewdog/action-setup — GitHub Advisory Database: компрометация 11 марта 2025 с 18:42 до 20:31 UTC, производные экшены затронуты «regardless of version or pinning method», вредоносный коммит f0d342d, исправление 3f401fe, CVSS 8.6. https://github.com/advisories/GHSA-qmg3-hpqr-gqvc
- GitHub Changelog — Immutable releases are now generally available — GA 28 октября 2025: у immutable-релизов ассеты нельзя добавить, изменить или удалить, теги защищены от перемещения и удаления; включается на уровне репозитория или организации. https://github.blog/changelog/2025-10-28-immutable-releases-are-now-generally-available/
- GitHub REST API — Workflow runs — `GET /repos/{owner}/{repo}/actions/runs` с фильтрами `created`, `actor`, `event`; `GET .../runs/{run_id}/logs`; `DELETE /repos/{owner}/{repo}/actions/runs/{run_id}/logs` (204 No Content). https://docs.github.com/en/rest/actions/workflow-runs
- zizmor — Audit rules — Аудиты unpinned-uses (политики hash-pin / ref-pin / any в rules.unpinned-uses.config.policies, с v1.6.0), ref-version-mismatch, stale-action-refs, unpinned-images. https://docs.zizmor.sh/audits/



