«Ротируйте всё» — это не план. Разбираемся задела ли атака на цепочку поставок ваш GitHub Actions

Знакомый жанр новостей, правда? В популярный пакет или action подсунули вредоносный код, и все бегут проверять свои сборки. Таких историй за полтора года набралось как минимум три. В марте 2025 года у tj-actions/changed-files перенаправили теги на коммит, который печатал секреты прямо в лог сборки. 31 марта 2026 года в npm на несколько часов попали вредоносные axios@1.14.1 и axios@0.30.4: они тянули зависимость plain-crypto-js со скриптом postinstall, и любая сборка, поставившая их с включёнными install‑скриптами, выполнила чужой код. А 4 августа 2026 года началась волна ChainDrop, и вот тут масштаб был совсем другой: по данным Aikido, начиная с keyv@6.0.0 скомпрометировано не меньше 444 пакетов.
И каждый раз под такой новостью звучит один и тот же совет: проверьте логи и ротируйте все секреты. Совет правильный. А вы сами его когда‑нибудь выполняли? И как успехи? В организации десятки репозиториев и сотни секретов. С какого начинать? Какие точно трогать не нужно? И в какой момент можно выдохнуть и сказать «всё, мы чисты»?
Я предлагаю зайти с другой стороны и задать вопрос поуже: какие именно запуски GitHub Actions в окне инцидента получили плохую версию пакета или action, что эти job’ы могли увидеть и, значит, что ротировать первым. Для ответа вам хватит двух инструментов — gh и jq. Никаких платных сканеров, никакой магии. Давайте пройдём это вместе, по шагам.
И одна просьба: не откладывайте. По умолчанию GitHub хранит логи и артефакты запусков 90 дней, а потом восстановить картину уже нельзя. Для ChainDrop, если срок хранения в репозитории не меняли, это примерно 2 ноября 2026 года.
Что нужно на входе
Прежде чем открывать GitHub, я бы посоветовал примерить на себя роль исследователя — так сделал и я, когда разбирал эти инциденты. Сначала разберитесь в самом инциденте и ответьте себе на два вопроса: какие версии пакетов или какие коммиты action были вредоносными и в каком окне времени их можно было скачать. Окно сразу записывайте в UTC: в нём работают и API GitHub, и реестр npm, и так меньше шансов ошибиться на часовом поясе.
Где это взять? В бюллетенях GitHub Security Advisories, в записях OSV — открытой базы уязвимостей osv.dev (MAL-… для вредоносных пакетов) — и в отчётах компаний, которые разбирали инцидент. Мой совет: каждую версию и каждый SHA подтверждайте хотя бы двумя независимыми источниками. А окно берите с запасом: лишний час обойдётся вам в пару лишних запросов, а пропущенный час может стоить пропущенного запуска.
Для трёх инцидентов из начала статьи у меня получились такие окна:
tj‑actions/changed‑files
Что искать: коммит
0e58ed8671d6b60d0890c21b07f8835ace038e67под тегами action.Окно (UTC): 14.03.2025 ~16:00 — 15.03.2025 14:00.
Источники: GHSA‑mrrh‑fwg8-r2c3, StepSecurity.
axios
Что искать:
axios@1.14.1,axios@0.30.4.Окно (UTC): 31.03.2026 00:21 — ~03:15.
Источники: StepSecurity, OSV MAL-2026-2307.
ChainDrop
Что искать:
keyv@6.0.0и ещё несколько сотен пакетов.Окно (UTC): 04.08.2026 09:35 — 05.08.2026 23:59 (конец с запасом).
Источники: Aikido, OSV MAL-2026-11524 (keyv).
Пара пояснений к этим окнам. У tj‑actions 14:00 15 марта — момент, когда GitHub снял action, так что после него вредоносный коммит скачать было уже нельзя; очищенные версии вернули в 22:00. С ChainDrop сложнее: точного времени, когда из реестра убрали последнюю плохую версию всей волны, в проверенных мной источниках нет, поэтому конец окна взят с запасом. Да и списки пакетов у исследователей расходятся: у Aikido «at least 444 packages», у других больше. Границы tj‑actions и axios здесь даны точно по источникам, запас к ним добавьте сами.
Дальше соберите список «пакет@версия» в файл bad.txt, по одной строке на версию, прямо из записей OSV. И тут вас ждёт первый подвох: запись MAL-2026-11524 описывает только keyv, а для ChainDrop записей MAL-… сотни. Их идентификаторы перечислены в отчётах исследователей. Сложите их в ids.txt, по одному на строку, и пройдите циклом:
while read -r id; do
curl -s "https://api.osv.dev/v1/vulns/$id" |
jq -r '.affected[] | .package.name as $n | .versions[] | "\($n)@\(.)"'
done < ids.txt >> bad.txt
Шаг 1. Найти запуски в окне
Показывать всё буду на учебном репозитории runsweep/runsweep-demo, который я завёл как раз для таких учений. 8 октября 2026 года в нём прошли сборки, а «скомпрометированными» я объявил is-number@7.0.0 и один коммит actions/setup-node. Секреты там фиктивные, так что ничего не утечёт. Вы же для настоящего инцидента подставьте его окно и свой репозиторий.
R=runsweep/runsweep-demo
WIN='2026-10-08T00:00:00Z..2026-10-08T23:59:59Z'
gh api -X GET "repos/$R/actions/runs" -f created="$WIN" -f per_page=100 --paginate \
--jq '.workflow_runs[] | [.id, .head_sha, .path, .event, .run_attempt, .created_at,
(.head_repository.full_name != .repository.full_name)] | @tsv' > runs.tsv
Спросите, зачем -X GET? Если передать параметры через -f, gh api по умолчанию отправит POST, а нам нужен обычный GET. В runs.tsv попадут идентификатор запуска, коммит, файл workflow, событие, номер попытки, время создания и признак того, что код пришёл из другого репозитория, как у запусков из форков:
37725615394 dbaf0a5… .github/workflows/build.yml push 1 2026-10-08T04:03:03Z false
37725497556 890c27c… .github/workflows/build.yml workflow_dispatch 1 2026-10-08T04:01:34Z false
37725491915 890c27c… .github/workflows/build.yml push 1 2026-10-08T04:01:30Z false
А теперь ловушка, о которой я хочу вас предупредить заранее: с параметром created API отдаёт не больше 1000 результатов на один поиск. Если в окне запусков больше тысячи, хвост просто молча не попадёт в выдачу — ни ошибки, ни предупреждения. Вы будете уверены, что проверили всё, а это не так. Поэтому большое окно делите на части и проходите каждую отдельно.
Для организации повторите тот же запрос по каждому репозиторию из gh repo list. Звучит просто, пока не упрётесь в лимиты API. Когда я прогонял проверку по организации nodejs — 102 репозитория, 10 279 запусков, — лимит в 5000 запросов в час закончился примерно за 15 минут. Так что если у вас большая организация, закладывайте время или делите работу.
Шаг 2. Что было в lockfile на коммите запуска
Теперь самое интересное. Как вы думаете, какой lockfile смотреть? Не текущий из main, а тот, что лежал в коммите, из которого собирался запуск, — head_sha. Запусков в окне может быть сотни, но разных коммитов среди них обычно гораздо меньше, поэтому идите циклом по уникальным head_sha. И не забывайте, что lockfile’ов в репозитории может быть несколько: в монорепозитории они лежат в подпапках. Поэтому сначала найдите все:
cut -f2 runs.tsv | sort -u | while read -r sha; do
gh api "repos/$R/git/trees/$sha?recursive=1" < /dev/null \
--jq '.tree[].path | select(test("(^|/)package-lock\\.json$"))' |
while read -r lock; do
gh api -H 'Accept: application/vnd.github.raw' "repos/$R/contents/$lock?ref=$sha" < /dev/null |
jq -r '.packages | to_entries[] | select(.key != "")
| "\(.key | sub(".*node_modules/"; ""))@\(.value.version)"' |
sort -u | grep -Fxf bad.txt | sed "s|^|${sha:0:7} $lock |"
done
done
890c27c package-lock.json is-number@7.0.0
dbaf0a5 package-lock.json is-number@7.0.0
Что здесь происходит. Заголовок application/vnd.github.raw отдаёт файл как есть, без base64. jq превращает раздел packages в строки имя@версия, включая вложенные зависимости. Этот раздел есть в lockfile версий 2 и 3 (npm 7 и новее), а у старого формата версии 1 его нет — там пустой вывод ничего не доказывает, имейте это в виду. А < /dev/null нужен, чтобы gh не съел список коммитов, который идёт в цикл.
У вас pnpm? Тогда то же самое, только устроено иначе. В pnpm-lock.yaml (формат 9) строки имя@версия лежат в ключах раздела packages:
grep -oE "^ '?[^ ]+@[^ (':]+" pnpm-lock.yaml | tr -d "' " | sort -u | grep -Fxf bad.txt
Чтобы встроить это в цикл выше, ищите в дереве pnpm-lock.yaml вместо package-lock.json и поставьте эту цепочку на место jq, только без имени файла: grep прочитает lockfile из конвейера.
А вот где советую быть внимательнее всего — запуски по pull_request. Для этого события job собирает не head_sha, а merge commit ветки PR с базовой веткой. Если PR не менял lockfile, в сборку попал lockfile базовой ветки, поэтому для таких запусков проверьте ещё и её последний коммит на момент запуска. Можно было бы взять его из поля pull_requests в ответе API, но оно ненадёжно: у части запусков оно просто пустое. Проще найти коммит по времени:
gh api -X GET "repos/$R/commits" -f sha=main -f until='2026-10-08T04:01:30Z' -f per_page=1 --jq '.[0].sha'
Здесь main — базовая ветка PR, until — время создания запуска из шестой колонки runs.tsv. В учебном репозитории запусков по pull_request нет, поэтому для примера я взял время push‑запуска 37725491915.
Шаг 3. Что job скачал
Lockfile говорит, что поставилось бы. Но «поставилось бы» и «поставилось» — разные вещи. Я бы советовал считать затронутыми только те job’ы, которые действительно ставили зависимости или скачали плохой action, а это видно только в логе. В секции «Set up job» runner честно пишет, какие action он скачал и к какому коммиту разрешился тег:
gh run view 37725491915 -R "$R" --log > run.log
grep -oE "Download action repository '[^']+' \(SHA:[0-9a-f]{40}\)" run.log
Download action repository 'actions/checkout@3d3c42e5…' (SHA:3d3c42e5…)
Download action repository 'actions/setup-node@949feb24…' (SHA:949feb24…)
Download action repository 'aws-actions/configure-aws-credentials@e1253824…' (SHA:e1253824…)
--log выгружает лог всего запуска, со всеми его job’ами. Каждая строка в нём начинается с имени job’а, так что найденную строку легко отнести к конкретному job’у. А если вам нужен лог одного job’а, есть ключ --job с его идентификатором.
Строка с SHA: — главная улика для инцидентов вроде tj‑actions. Почему? Допустим, в вашем workflow указан тег, например tj-actions/changed-files@v45. Тогда после @ в логе будет тег, а в скобках — коммит, который реально выполнялся. Совпал он с вредоносным коммитом из advisory — job затронут, что бы ни было написано в workflow. В моём учебном примере «скомпрометированным» объявлен как раз actions/setup-node@949feb24…, так что job build этого запуска затронут; та же строка есть в логах двух других запусков из runs.tsv. И ещё мелочь, которую легко упустить: actions, которые вызывает локальный composite action, скачиваются не в «Set up job», а внутри его шага, поэтому grep запускайте по всему логу.
С пакетами, увы, лог помогает меньше. Он покажет, что установка была (npm ci, added 312 packages), но какие версии поставились, по такой строке не узнать. Тут выручает то, как job ставил зависимости. Если через npm ci, установка шла строго по lockfile, и результат шага 2 относится к этому job’у. В учебном workflow так и есть, значит, все три запуска поставили is-number@7.0.0. А если у вас там npm install без lockfile, npx пакет или npm i -g пакет, версия пришла из реестра прямо в момент сборки, и в окне инцидента такой job я советую считать затронутым.
Шаг 4. Что job мог увидеть
Ради этого шага всё и затевалось: что было доступно затронутому job’у? Начните с прав GITHUB_TOKEN — runner пишет их в той же секции «Set up job».
grep -A3 'GITHUB_TOKEN Permissions' run.log
[group]GITHUB_TOKEN Permissions
Contents: read
Metadata: read
[endgroup]
Здесь я убрал из вывода имя job’а, шаг и время в начале строк. Права, как видите, минимальные. Насторожиться стоит, если вы увидите write: Contents: write позволяет менять содержимое репозитория, например создавать релизы, Packages: write — публиковать пакеты в GitHub Packages, Actions: write — например, отменять запуски.
А теперь посмотрите, чего в этой группе нет: строки про id-token, хотя в workflow этого коммита id-token: write задано. Неприятный сюрприз: смотришь на «минимальные права» и думаешь, что всё в порядке, а у job’а при этом был путь в облако. Поэтому OIDC‑право, как и секреты, ищите в самом файле workflow из runs.tsv, на том же head_sha:
gh api -H 'Accept: application/vnd.github.raw' \
"repos/$R/contents/.github/workflows/build.yml?ref=890c27c…" |
grep -oE 'secrets\.[A-Za-z0-9_]+|secrets: inherit|id-token: write' | sort -u
id-token: write
secrets.DEMO_NPM_TOKEN
secrets.DEMO_SLACK_WEBHOOK
С запусками по pull_request здесь та же оговорка, что в шаге 2: если PR не менял workflow, в сборке работал файл базовой ветки, и его стоит открыть тоже.
Как вообще секреты попадают в job? Только если workflow передаёт их через контекст secrets. Исключение — запуски по pull_request из форков: секреты, кроме GITHUB_TOKEN, им не передаются. Такие запуски вы увидите в runs.tsv — в последней колонке у них будет true. В учебном репозитории их нет, так что вживую этот признак я не показываю. Дальше по списку: secrets: inherit в вызове reusable workflow передаёт ему все секреты вызывающего — организации, репозитория и окружения. id-token: write позволяет job’у получить OIDC‑токен GitHub и обменять его на облачный доступ, если в облаке настроено доверие этому репозиторию. И, конечно, job получает GITHUB_TOKEN с правами, которые мы только что нашли.
Дальше я предлагаю исходить из простого правила. Код, выполненный внутри job’а, видел всё, что job получил, а не только переменные своего шага: вредоносный коммит tj‑actions, например, читал память процесса Runner.Worker. Но секреты, которые этому job’у не передавались, в нём и не появлялись. Именно на этом и держится вся идея: ротировать не всё подряд, а то, что действительно было рядом с чужим кодом. Это не лень, а способ сделать главное быстро.
У grep по файлу есть два недостатка, и о них надо помнить. Он берёт секреты всех job’ов файла, а не только затронутого, то есть может завысить список. И он пропускает секреты, которые использует вызываемый reusable workflow, а также записи вида secrets['NAME'], то есть может и занизить. Поэтому вызываемые workflow открывайте так же, по их пути и коммиту.
Порядок ротации
Список собран. Что дальше — хвататься за всё сразу? Не надо. Я бы посоветовал разложить его по тому, сколько вреда можно успеть причинить с утёкшим доступом, пока утечку не заметили. Обычно получается такой порядок.
Облако. Статические ключи облачных провайдеров ротируйте первыми. С OIDC иначе: облачный токен краткоживущий и действует только на время job’а, сам по себе он уже бесполезен. Но за это время код мог создать себе долгоживущий доступ. Поэтому найдите роли, которые доверяют этому репозиторию, посмотрите в аудите облака за окно, не появились ли новые ключи, пользователи или роли, и после этого сузьте trust‑политику.
Публикация и деплой. Токены npm и реестров, ключи деплоя, доступы к Kubernetes. Кроме ротации, проверьте, не было ли неожиданных релизов и публикаций в окне. А если токен публикует популярный пакет, ставьте его самым первым: именно так волны вроде ChainDrop и расползаются дальше.
Сторонние сервисы. Webhook’и, токены Codecov, мессенджеров, мониторинга. Ущерб от них обычно ограничен самим сервисом.
GITHUB_TOKEN. На GitHub‑hosted runner’ах ротировать его не нужно: он истекает, когда job завершается (про self‑hosted — в ограничениях ниже). Но если у него было право записи вcontents,packagesилиactions, проверьте пуши, теги, релизы и публикации пакетов за окно.
Как это выглядит на учебном примере? Затронуты все три запуска: job build скачал «скомпрометированный» setup-node и поставил is-number@7.0.0. Первой по этому порядку идёт облачная роль demo-deploy, которую job мог получить через id-token: write: проверить по аудиту облака, не появилось ли новых ключей, пользователей и ролей, и сузить её trust‑политику. Затем ротировать DEMO_NPM_TOKEN как токен публикации и DEMO_SLACK_WEBHOOK. У GITHUB_TOKEN было только чтение, проверять по нему нечего.
Отдельный случай — инциденты вроде tj‑actions, где вредоносный код печатал секреты прямо в лог сборки. В публичном репозитории такой лог мог открыть любой пользователь GitHub, так что секреты из него считайте раскрытыми и ротируйте сразу, не дожидаясь остального анализа. В приватном репозитории лог видят те, у кого есть доступ к репозиторию на чтение, а также приложения и токены с правом Actions на чтение. Круг меньше, поэтому такую ротацию можно не ставить впереди остальной, но сделать её всё равно нужно. А после ротации такие логи советую удалить, для этого нужен доступ на запись. Особенно если срок хранения у вас увеличен: в приватных репозиториях его можно поднять до 400 дней при 90 по умолчанию.
Как это выглядит на реальных репозиториях
Учебный пример — это хорошо, но вам, наверное, тоже интересно, как всё выглядит в жизни: насколько длинным на самом деле получается этот список «к ротации»? Мне было интересно. Я нашёл три публичных репозитория, которые подтянули пакеты ChainDrop 4 августа: искал по GitHub пулл‑реквесты и коммиты 4–5 августа, которые обновляли зависимости до версий из волны. Считал своей утилитой runsweep, которая делает те же шаги, что описаны выше: вручную на сотнях запусков это слишком долго. Названия не привожу — владельцы ещё могут разбираться с последствиями.
Личный проект. 12 запусков, 24 job’а. Затронуты 6 job’ов: они поставили
@cacheable/memory@2.2.1,cacheable@2.5.1,flat-cache@6.1.24и другие версии из волны, всё изpackage-lock.json. Статических секретов у этих job’ов не было: толькоid-token: writeиGITHUB_TOKENс правом записи в Pages. Ротировать здесь нечего; проверить нужно облачные роли, если они есть, и опубликованный сайт.Монорепозиторий организации на pnpm. 108 запусков, 140 job’ов. Затронуты 3 job’а: они поставили
keyv@6.0.0изpnpm-lock.yaml. Статических секретов у них не было. У одного былid-token: write, уGITHUB_TOKEN— запись в том числе вcontents,packagesиdeployments. Ротировать здесь тоже нечего; проверить нужно облачные роли, если они есть, и главное — не было ли публикаций пакетов в окне.Проект организации на Rust и JS. 568 запусков, 3703 job’а. Затронуты 222 job’а, все через
web/package-lock.json. Они видели два статических токена:CODECOV_TOKENи токен бота, который отмечает подписи CLA. Вот их и нужно ротировать — только эти два.
Итог: во всех трёх случаях список к ротации оказался коротким или вообще пустым. Ноль, ноль и два токена — против «ротируйте всё». Три репозитория — не выборка: я специально искал те, где заражение точно было, и гарантией для вашего репозитория это не служит. Но это хороший аргумент сначала посмотреть, а потом ротировать.
Как не попадать в такую ситуацию
В трёх проверенных репозиториях плохие версии лежали прямо в lockfile на коммите запуска. Я их по этому признаку и искал, так что о том, как обычно приходит заражение, это ничего не говорит. Но кое‑что показывает: lockfile обновили уже после того, как эти версии появились в реестре, и одного npm ci здесь было бы мало. Поэтому я советую защищаться на двух уровнях.
Ставить зависимости через npm ci (или pnpm install --frozen-lockfile). Это защищает от версий, которые подтягиваются в момент сборки мимо lockfile.
Задерживать свежие версии, чтобы заражённая версия не попала и в сам lockfile. В npm с версии 11.10.0 есть min-release-age — минимальный возраст версии в днях. В pnpm с 10.16.0 есть minimumReleaseAge в минутах, а с pnpm 11 по умолчанию он равен 1440, то есть суткам. В yarn есть npmMinimalAgeGate. Вредоносный axios@1.14.1 провисел в реестре около трёх часов, 0.30.4 — чуть больше двух; суточная задержка закрыла бы оба окна целиком.
С ChainDrop суточная задержка тоже сработала бы, и это хорошо видно по моим трём репозиториям: везде lockfile обновили меньше чем через два часа после начала волны в 09:35 UTC. В монорепозитории на pnpm это сделал Renovate через 31 минуту, обновив keyv до 6.0.0. В проекте на Rust и JS — тоже Renovate, через 1 час 16 минут, обновив cacheable до 2.5.1. В личном проекте разработчик сам закоммитил новый package-lock.json в 11:21 UTC. Если бы суточная задержка стояла в том инструменте, который обновлял lockfile, эти версии не попали бы в него ни в одном из трёх случаев.
Заметили? В двух случаях из трёх плохую версию принёс бот — тот самый, которому мы доверяем обновления, чтобы не отставать от исправлений безопасности. Поэтому задержку стоит включить и в нём: CI на ветке его PR ставит зависимости из обновлённого lockfile ещё до слияния. В Renovate это minimumReleaseAge. В пресете config:best-practices она уже включена — "3 days", только для npm и вместе с internalChecksFilter: "strict", так что PR со слишком свежей версией не создаётся. Если же задать одну minimumReleaseAge, Renovate создаст PR сразу и оставит на нём проверку в статусе pending. Чтобы не создавать PR раньше срока, добавьте prCreation: "not-pending" и internalChecksFilter: "strict", а если CI запускается только на pull_request — ещё internalChecksAsSuccess: true, иначе PR может не появиться вовсе.
С пакетами всё, остальное касается actions, прав и логов.
Пиннить actions по полному SHA. Тег можно перенаправить, коммит — нет; на этом и построена история с tj‑actions. С 15 августа 2025 года администратор может требовать SHA‑пиннинг политикой репозитория или организации; workflow, который использует action по тегу, завершится ошибкой.
Урезать права. permissions: на уровне workflow с минимумом и расширение только для job’ов, которым нужно. id-token: write — только там, где идёт деплой. secrets: inherit — только если вызываемому workflow действительно нужен каждый секрет. Чем меньше job видит, тем короче будет ваш список к ротации.
Выгружать логи, если они нужны дольше срока хранения. Иначе проверку придётся успеть за срок хранения логов.
Чего этот способ не видит
Было бы нечестно закончить на том, что метод видит всё. Не видит. Вот что он пропустит, и об этом вам лучше знать заранее:
Запуски и логи старше срока хранения: их уже нет.
Установки, вывод которых полностью подавлен внутри скрипта или JS‑action: в логе просто нечего искать.
Перезапуски. Re‑run выполняется с тем же коммитом, что и исходный запуск, и возможен до 30 дней после него. Запуск, созданный до окна, мог быть перезапущен в окне; такие запуски видны по
run_attemptбольше 1, и их стоит поискать в 30 днях перед окном. Коммит тот же, а значит, и lockfile тот же, поэтому риск здесь в основном у actions, указанных по тегу, и у установок мимо lockfile.Lockfile старого формата (версия 1) и
yarn.lock: команды выше их не разбирают.Self‑hosted runner’ы. Там
GITHUB_TOKENможет жить дольше, до 24 часов, а следы вредоносного кода могут остаться на машине после job’а.Кеши. Зависимости, сохранённые в
actions/cacheво время окна, могут попасть в сборки после него.Контейнерные образы и всё, что job скачивает сам, мимо npm и actions.
Такая проверка превращает «ротируйте всё» в короткий список с причинами: этот job, в это время, с этим пакетом, видел эти секреты. Для одного небольшого репозитория хватает команд выше. Для десятков репозиториев и тысяч запусков я автоматизировал те же шаги в open‑source утилите runsweep: https://github.com/runsweep/runsweep.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.