АйТи Фреш
Главная / Статьи / Информационная безопасность
Информационная безопасность

Тег GitHub Action передвинули на вредоносный коммит: как найти утечку секретов и перейти на pin по полному SHA

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~20 мин чтения
Перемещаемый тег GitHub Action против закрепления по полному SHA коммита — защита цепочки поставки CI
Тег указывает туда, куда его повернули сегодня; 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 год расставил всё по местам.

Если у вас в репозитории стоит `uses: someone/action@v4` — вы при каждом запуске скачиваете то, что автор (или тот, кто увёл его токен) положил под этот тег хоть пять минут назад. И в логах это будет выглядеть ровно так же, как вчера.
Памятка: Почему тег @v4 в GitHub Actions — не версия — схема
Памятка: Почему тег @v4 в GitHub Actions — не версия. Открыть схему в полном размере

Как подменили теги 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 фиксирует один слой, а не всё дерево.

Двойной base64 — это не изящество, это обход маскирования. GitHub заменяет звёздочками известное ему значение секрета, но не его перекодированные производные. Раз секрет попал в память процесса — считайте, что он попал наружу.
Тег GitHub Action передвинули на вредоносный коммит: как найти утечку секретов и перейти на pin по полному SHA — схема
Схема к статье. Открыть схему в полном размере
Схема атаки на GitHub Actions: от reviewdog/action-setup к подмене тегов tj-actions/changed-files
Транзитивный плавающий тег внутри чужого экшена обходит даже ваш собственный SHA-пин.

Кейс: 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 часов работы. После реального инцидента умножайте на три и добавляйте бессонную ночь и письмо в платёжный шлюз.

Ключевая метрика в этой истории — не количество CVE, а «сколько посторонних людей имеют право выполнять код в вашем CI». Посчитайте её у себя прямо сейчас, она обычно шокирует сильнее любой новости.
Цифры и версии: Кейс: 24 репозитория и 11 незнакомых авторов в CI — схема
Цифры и версии: Кейс: 24 репозитория и 11 незнакомых авторов в CI. Открыть схему в полном размере
Инвентаризация uses в GitHub Actions: из 86 ссылок только 3 закреплены по SHA
Главная метрика — сколько посторонних людей могут выполнять код рядом с вашими ключами.

Как за час найти пострадавшие запуски и утечки в логах

Порядок действий при новости «такой-то экшен скомпрометирован» у меня всегда один: инвентаризация → пересечение с окном инцидента → выемка логов → грепанье. Всё делается через 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: ищем следы использования, а не только следы кражи.

Не ищите в логах сам секрет открытым текстом — его замаскируют звёздочками, и вы успокоитесь зря. Ищите аномалию: неожиданный длинный base64, обращение к постороннему домену, шаг, который отработал дольше обычного.

Какие секреты ротировать первыми после инцидента

Здесь начинается место, где люди тратят сутки не на то. Ротировать всё подряд — значит на день положить все деплои и половину интеграций. Я расставляю приоритеты так. Первый эшелон, менять немедленно: всё, что даёт запись наружу — 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
Удаление логов — это гигиена, а не спасение. Если секрет побывал в публичном или доступном подрядчикам логе, считайте его скомпрометированным независимо от того, что вы потом удалили. Чистим логи, чтобы утечка не расползалась дальше внутри компании, а не чтобы «отменить» её.
Приоритеты ротации секретов CI после компрометации GitHub Action: что менять сразу и что не трогать
Сначала всё, что даёт запись наружу; временные токены уже умерли сами.

Как перейти на 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-пины, но резко сокращает круг авторов, которым вы вообще даёте слово.

Не начинайте с включения жёсткой политики `hash-pin` на весь org — вы просто уроните все сборки в понедельник. Порядок такой: сначала прогон `ratchet pin` и зелёный CI, потом Dependabot, и только третьим шагом — блокирующая проверка в pre-merge.
Порядок действий: Как перейти на pin по SHA и не остаться без обновлений — схема
Порядок действий: Как перейти на pin по SHA и не остаться без обновлений. Открыть схему в полном размере

Чего 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-пины никуда не деваются.

Мой минимальный обвес для любого workflow, который видит секреты: `permissions: contents: read` на уровне job, harden-runner первым шагом, checkout с `persist-credentials: false`, все сторонние экшены по SHA, деплой через OIDC. Пять строк, которые превращают потенциальный инцидент в скучный лог.

Частые вопросы

Можно ли доверять мажорному тегу у известных экшенов вроде 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.

Столкнулись с похожей задачей? Обращайтесь — решим

Если у вас происходит что-то из описанного в этой статье — или любая другая проблема с ИТ-инфраструктурой, — обращайтесь в любое время. Мои специалисты и я лично разберём ситуацию, найдём настоящую причину и доведём до решения.

Возьмёмся и за разовую задачу, и за постоянное обслуживание. Первичная консультация — бесплатно и без обязательств.

📞 +7 903 729-62-41 💬 MAX: +7 903 729-62-41 ✈ Telegram @ITfresh_Boss

С уважением, Семёнов Евгений Сергеевич, директор «АйТи Фреш» — IT-аутсорсинг для компаний до 50 рабочих мест, 15+ лет практики

Источники

© ООО «АйТи-Фреш» · Москва · Все статьи