Разбор инцидента: компрометация цепочки поставок npm-пакетов TanStack
- title
- Разбор инцидента: компрометация цепочки поставок npm-пакетов TanStack
- type
- summary
- summary
- Как три известные уязвимости привели к 84 вредоносным релизам @tanstack/* в npm, и что разрушает эту цепочку.
- parent
- supply-chain-security
- tags
- security, supply-chain, github-actions, npm, ci-cd, incident
- created
- 2026-05-12
- updated
- 2026-05-12
- lang
- ru
- translation_of
- tanstack-npm-supply-chain-postmortem
- source_updated
- 2026-05-12
- translated
- 2026-09-01
- translator
- lllm/antigravity/gemini-3.7-flash-medium
11 мая 2026 года между 19:20 и 19:26 UTC злоумышленник опубликовал 84 вредоносные версии в 42 npm-пакетах @tanstack/*. Ни один npm-токен украден не был, а официальный шаг Publish Packages в релизном workflow даже не запускался. В своём постмортеме Таннер Линсли детально разбирает всю цепочку и предельно прямо объясняет, что именно защитники могли сделать для её разрыва на каждом этапе.
Суть этой страницы - не хронология событий. Главное здесь - поверхность защиты, поскольку каждое звено в цепочке представляет собой известный паттерн с понятным способом устранения.
Цепочка атаки в одном абзаце
PR из форка запустил событие pull_request_target для workflow бенчмарков. Этот workflow сделал checkout merge-ссылки PR из форка и запустил сборку, из-за чего код под контролем форка выполнился в контексте прав базового репозитория. Вредоносный код записал отравленный pnpm-store в кэш GitHub Actions именно под тем ключом, который позже должен был вычислить workflow release.yml. Затем PR перезаписали через force-push до пустого изменения и закрыли - видимый diff остался чистым. Спустя несколько часов обычный merge в main запустил release.yml, который восстановил отравленный кэш, выполнил бинарники атакующего на этапе тестов, сделал дамп памяти процесса GitHub Actions Runner.Worker, извлёк OIDC-токен, выпущенный для npm Trusted Publishing, и отправил пакеты напрямую в registry.npmjs.org - полностью в обход фактического шага публикации в workflow.
Три уязвимости, ни одна из которых не нова и ни одна из которых сама по себе не была достаточна:
- Паттерн pwn-request-pattern в workflow с
pull_request_target, выполнявшем checkout и сборку кода из форка - github-actions-cache-poisoning через границу доверия "форк -> база -> продакшен"
- ci-runner-token-extraction OIDC-токена из памяти процесса runner'а
Атакующий не изобретал новых техник. Вектор с отравлением кэша описал Аднан Хан ещё в мае 2024 года. Скрипт для извлечения данных из памяти был взят дословно - даже с сохранением комментария об авторстве - из инцидента с tj-actions/changed-files в марте 2025 года.
Как предотвратить подобную атаку
Список того, "что можно было сделать лучше", от самих мейнтейнеров, дополненный выводами из структуры самой цепочки о действительно эффективных мерах защиты. Расположено примерно по убыванию отдачи на час работы.
1. Никогда не запускайте код из форка под pull_request_target
pull_request_target выполняется в контексте базового репозитория с его секретами и правами, но триггерится PR из форка. Весь смысл этого триггера - дать возможность выполнять доверенные операции (ставить метки, писать комментарии, проводить проверки) над недоверенными PR. В тот момент, когда workflow с pull_request_target выполняет actions/checkout@v… with: ref: refs/pull/.../merge и запускает сборку, вы даёте постороннему человеку возможность выполнить код с правами вашего базового репозитория.
Конкретное правило: если вам нужно запускать бенчмарки или сборки для PR из форков, используйте паттерн с двумя workflow.
- Workflow, запускаемый по
pull_request_target, не делает ничего, что связано с выполнением кода форка. Он расставляет метки, оставляет комментарии и запускает другие события. - Отдельный workflow, запускаемый по событию
workflow_runили вручную черезworkflow_dispatch, выполняет сборку, явно осознавая наличие повышенных привилегий. Для запуска на коде новых контрибьюторов требуется подтверждение.
Собственное руководство GitHub Security Lab описывает это с 2021 года. В workflow TanStack попытались разделить уровни доверия внутри одного файла (разделив benchmark-pr и comment-pr), что является правильным порывом, но такое разделение слишком гранулярно - как только выполнился actions/checkout для merge-ссылки форка, вы уже проиграли.
2. Относитесь к кэшу Actions как к поверхности атаки с усилением записи
Сохранение кэша на post-шаге actions/cache@v… не ограничивается блоком permissions:. Запись в кэш использует внутренний токен runner'а, а не GITHUB_TOKEN самого workflow. Настройка permissions: contents: read не предотвращает изменение кэша. Область видимости кэша привязана к репозиторию и разделяется между запусками pull_request_target и push-событиями в main.
Что это означает на практике: любой workflow, выполнивший post-шаг actions/cache с кодом под контролем атакующего, мог отравить кэш для продакшен-пайплайнов. Способы защиты в порядке предпочтения:
- Не делите кэш между разными границами доверия. Продакшен- и релизные workflow должны скачивать зависимости заново либо восстанавливать их из кэша, запись в который происходит исключительно по событию
pushв защищённые ветки, но никогда не через события, вызванные PR. Это самое эффективное исправление, которое стоит всего нескольких минут времени CI на каждый релиз. - Привязывайте ключи кэша к контексту конкретного коммита. Если ключ кэша в релизном workflow содержит только
hashFiles('**/pnpm-lock.yaml'), то любой предыдущий запуск, записавший данные по этому ключу, окажется первым. Добавьте компонент области видимости (${{ github.ref }}для доверенных веток или префикс ключа, с которым запуски PR структурно не смогут совпасть), чтобы атакующий не мог предсказать продакшен-ключ и нацелиться на него. - Проведите аудит того, что и чем может быть отравлено. Пост Аднана Хана и инструменты вроде
zizmorподсвечивают опасные паттерны. Достаточно запустить аудит один раз - на выходе получится небольшой список.
3. Проверка источника происхождения (provenance) при публикации в npm
Trusted Publishing (OIDC) избавляет от долгоживущих токенов - это правильно и в целом соответствует тому, к чему призывает концепция long-lived-keys. Однако OIDC в его текущей реализации не имеет проверки на уровне отдельных публикаций. Как только ваш репозиторий привязан, любой путь исполнения в workflow с правами id-token: write может выпустить токен с правом на публикацию. У реестра npm нет способа определить, пришёл ли запрос на публикацию из вашего шага Publish Packages или из команды npm install, запущенной на этапе тестов.
Два метода защиты, которые явно выделяет команда TanStack:
- Вынесите публикацию в отдельный workflow с
id-token: writeтолько в одном job'е и без сторонних зависимостей внутри него. Скачивайте предварительно собранные tar-архивы из артефактов предыдущего job'а, проверяйте их хэши по манифесту, подписанному в другом месте, и публикуйте. Job, владеющий токеном, не должен выполнять никакой код контрибьюторов. - Добавьте метаданные provenance с путём к файлу workflow и ID шага, чтобы реестр мог отклонять публикации из непредусмотренных шагов. Для этого требуется поддержка со стороны самого реестра. У npm уже есть базовые кирпичики через флаг
--provenance, но сторона проверки пока недостаточно развита.
Общий принцип: минимальные привилегии на уровне шага, а не на уровне workflow.
4. Фиксируйте сторонние экшены по commit SHA, а не по тегам
Запись uses: actions/checkout@v6.0.2 выглядит зафиксированной, но это не так. Теги можно перезаписать через force-push. Запись uses: actions/checkout@8d1ad... (40-символьный SHA) действительно фиксирует код. Используйте Dependabot или Renovate для обновления зафиксированных SHA - оба инструмента поддерживают этот паттерн.
Плавающие ссылки (@v6, @main) были единственным механизмом атаки на tj-actions/changed-files - там не требовался даже pull_request_target, достаточно было запушить новый тег.
Инструменты для автоматизации: pinact (разовое применение), zizmor (линтер), стратегия Dependabot action-update со значением pinned.
5. Мониторьте собственные публикации
Команда TanStack узнала о компрометации от стороннего исследователя через 20 минут после публикации. На самом деле это быстро - и всё же это 20 минут, в течение которых вредоносные архивы могли устанавливаться любыми CI-джобами по всему миру.
Как устроен внутренний мониторинг:
- Вебхук или запланированная задача, опрашивающая
registry.npmjs.org/-/v1/search?text=maintainer:<your-scope>и отправляющая оповещения о новых версиях, созданных не ожидаемым ID запуска workflow. - Проверка через
npm packи diff при каждой публикации: любой файл, не объявленный в"files", является тревожным сигналом (файл атакующегоrouter_init.jsне входил в список разрешённых вfiles, но всё равно попал в tar-архив, потому что npm pack игнорирует этот список для некоторых служебных путей - проверяйте, что именно упаковывает ваш инструментарий). - Подписка на оповещения Socket.dev / StepSecurity / Phylum для собственных пакетов. Они непрерывно сканируют реестр, а попадание в поле их зрения ничего не стоит.
6. Сократите поверхность атаки через токены мейнтейнеров
В скоупе TanStack было семь мейнтейнеров. Каждый из них - отдельная цель для кражи учётных данных с одинаковым радиусом поражения. Переход на Trusted Publishing в основном решает эту проблему для публикации, но учётные записи мейнтейнеров по-прежнему сохраняют права на снятие с публикации, объявление устаревшими и передачу пакетов.
- Держите список мейнтейнеров минимальным, достаточным для покрытия bus-фактора.
- Требуйте 2FA с аппаратным ключом (не SMS и не TOTP-приложение на устройстве, где также установлен npm CLI).
- Проводите ежеквартальный аудит
npm token listиgh auth status. Долгоживущие токены имеют свойство накапливаться.
7. Сделайте снятие вредоносных версий с публикации реалистичным
Политика npm запрещает снимать версию с публикации, если от неё зависят другие пакеты. В случае вредоносной версии эта политика работает с точностью до наоборот: каждую минуту, пока вредоносный архив доступен, его устанавливает всё больше CI-сборок. Приходится ждать, пока команда безопасности npm удалит архивы на стороне сервера, что добавляет часы задержки.
Для издателя здесь нет простого решения. Защита на стороне потребителя заключается в фиксации версий при установке и периодах выдержки зависимостей: не обновляйтесь автоматически в первые 48 часов после релиза. Инструменты для настройки задержек описаны в supply-chain-security (cooldown в Dependabot, minimumReleaseAge в Renovate).
Что сыграло на руку атакующему (и защитникам)
В постмортеме прямо говорится о факторе везения. Атакующий выбрал полезную нагрузку, ломавшую тесты, из-за чего легитимный шаг публикации был пропущен. Именно это сделало атаку достаточно заметной, чтобы обнаружить её за 20 минут. Более аккуратный взломщик, не сломавший тесты, мог бы оставаться незамеченным часами. Отсюда два вывода:
- Окно обнаружения в действительно худшем сценарии измеряется часами, а не минутами. Период выдержки в 48 часов защищает от этого. Правило "автоматический merge, если тесты зелёные" - нет.
- Успешное прохождение тестов в CI не является доказательством чистоты кода. Публикация произошла вообще за пределами шага публикации.
Второй элемент везения: атакующий переиспользовал публично известные техники вместо написания собственного кода. Сигнатуры индикаторов компрометации (IOC) совпали за считанные часы, поскольку скрипт дампа памяти и отпечаток в optionalDependencies уже присутствовали в базах данных инструментов безопасности. Распознавание уникальной реализации заняло бы гораздо больше времени.
Чего этот инцидент не меняет
Это компрометация уровня CI, а не взлом реестра npm, исходного кода TanStack или механизма Trusted Publishing. В частности:
- OIDC / Trusted Publishing всё ещё значительно лучше долгоживущих токенов. Атакующий не взломал привязку OIDC - он просто выполнился внутри workflow, который легитимно владел этим токеном. Выводы в long-lived-keys остаются верными.
- Триггер
pull_request_targetпо-прежнему полезен для задач, ради которых он создавался (простановка меток, комментарии, ссылки на preview развёртывания). Уязвимость возникает только из-за сочетания этого триггера с выполнением кода из форка. - Реестр npm действовал корректно на основе полученных входных данных. Подписанная публикация пришла из workflow с валидной привязкой OIDC. Проблема лежит не на стороне реестра, а в отсутствии контроля происхождения внутри самого workflow.
Ссылки
- supply-chain-security - общая концепция безопасности цепочек поставок
- pwn-request-pattern - первое звено цепочки
- github-actions-cache-poisoning - второе звено
- ci-runner-token-extraction - третье звено
- open-source-security-astral - защита Astral от большинства этих паттернов на практике
- long-lived-keys - Trusted Publishing как верное направление и оставшиеся в нём пробелы
- marius-bitwarden-not-recommended - предыдущий крупный инцидент с npm и CI (Bitwarden CLI / Shai-Hulud)
- tanstack - затронутый проект