
npm install — такая привычная многим из читателей команда, но за последние пару месяцев она обернулась сущим кошмаром для инженеров по безопасности. И ладно бы всё сводилось к проверке 5 пакетов из package.json , но у каждой зависимости по 10 своих зависимостей, а у тех ещё по 10. В итоге мы тянем 2000, а не 5 пакетов, и тут, кажется, уже руками не проверишь. И именно на этой боли всех безопасников, поддерживающих JS-проекты, сыграла команда TeamPCP. В этой статье я хочу подробно, от А до Я, разобраться, в чём опасность, почему так произошло и как от этого защититься.
Небольшая теоретическая справка
Перед тем как погружаться в тему, предлагаю освежить в голове основные понятия, на которых и строится вся атака.
Зачем нам пакеты?
Большую часть того, что мы можем придумать, уже давно изобрели до нас. Поэтому, например, для того чтобы распарсить дату, не нужно писать логику с нуля — мы просто берём готовую библиотеку и используем её. В результате имеем 2 строчки кода, а не 100.
npm (Node.js Package Manager) — самый крупный склад пакетов для JavaScript/Node.js. Является публичным реестром, т. е. любой может опубликовать и скачать пакет.
Зависимости, package.json и node_modules
Если один проект использует чужой пакет, то этот пакет — зависимость проекта. В Node.js список зависимостей хранится в файле package.json :
npm использует этот файл, чтобы тянуть из реестра нужные пакеты, когда ты используешь команду npm install .
Lockfile — фиксация точных версий
В примере выше мы использовали ^1.160.0 . Эта запись значит, что мы берём версию 1.160.0 и выше в рамках мажорной версии. И при запуске npm install , если он увидит более новую версию в рамках этого правила, то её подтянет. Но в пакетном менеджере это, к счастью, предусмотрено: создаётся lockfile — файл package-lock.json , в котором зафиксировано, какие сейчас используются версии каждого пакета.
В противовес npm install есть команда npm ci (clean install), которая ставит только те версии, которые зафиксированы в lockfile .
Lifecycle-хуки — дырка, в которую ползет червь
Пакеты в package.json могут содержать скрипты жизненного цикла (lifecycle scripts) — команды, которые npm выполняет в заданные относительные моменты времени:
Они были созданы в благих целях: билдить нативные модули, тянуть нужные бинари под специфическую ОС и т. п. Но стали для злоумышленников путём запуска их кода на наших тачках. Для особо тревожных разработчиков на этот случай был добавлен ключ --ignore-scripts , к которому мы ещё вернёмся.
Секреты в GitHub
В наших CI мы используем секреты — особо чувствительные значения (пароли, токены, ключи). Закидываем в настройках репозитория новое значение, а потом раннер сам тянет его как переменную окружения. И, казалось бы, GitHub маскирует их в логах, но есть нюанс: в памяти процессора на самом раннере они лежат в открытом виде.
Токены npm
Чтобы разработчику опубликовать новую версию какого-то пакета, он должен подтвердить, что публикует доверенное лицо, — для этого существует токен публикации. Они могут быть классическими, гранулярными, автоматизационными, но нас интересует конкретный вид — токены с обходом 2FA. Если вы видите bypass_2fa: true , значит, перед вами именно такой токен. Это отличное решение для автоматизации и гроб для безопасности.
OIDC и Trusted Publishing — «логин без пароля»
Умные инженеры как-то посидели, подумали и решили, что хранить сам токен неудобно, да и не очень безопасно, и придумали OIDC (OpenID Connect) и Trusted Publishing .
Идея до безумия проста: мы не говорим «эти изменения публикует доверенный автор», мы говорим: «эти изменения публикуются из доверенного репозитория, доверенной ветки». В ходе исполнения CI у нас просто создаётся токен, который перестаёт действовать после его завершения, и этому токену доверяет npm.
Круто! Мы избавились от userpass, но появилась новая боль: в стандартной реализации мы проверяем только, откуда пришло обновление, а не что именно в этом обновлении, и этой дыркой червяк ещё воспользуется.
SLSA-провенанс и Sigstore
Напоследок в этом душном первом модуле глянем на самое современное подписание новой публикации.
Провенанс — это справка, которая, в отличие от прошлой, говорит нам: «где собирался этот артефакт и как он собирался». Стандарт называется SLSA (уровни Build Level 1–3: чем выше — тем строже гарантии неподделываемости сборки). Для подписывания используется sigstore — подпись без приватного ключа: мы просто получаем одноразовый сертификат по нашей OIDC-личности вместо приватного ключа.
Однако есть один интересный момент: атака TeamPCP впервые в истории npm обошла и эту защиту. Выпущенные ими пакеты были с валидным провенансом Build Level 3.
На этом наша небольшая теоретическая часть завершена, и предлагаю перейти к самому интересному — самой атаке.
Хронология событий
Коротко по фактам (даты — по отчётам Endor Labs, Wiz, Socket, StepSecurity, Akamai, Snyk):
11 мая 2026 TeamPCP запустили волну, начав с экосистемы TanStack. За пять часов — 400+ зловредных версий в 172 пакетах. Зацепило Mistral AI, OpenSearch, UiPath, Guardrails AI.
11 мая 2026 TeamPCP запустили волну, начав с экосистемы TanStack. За пять часов — 400+ зловредных версий в 172 пакетах. Зацепило Mistral AI, OpenSearch, UiPath, Guardrails AI.
12 мая они выложили червя в открытый доступ и анонсировали на BreachForums «соревнование по supply-chain атакам». Поехали копикэты.
12 мая они выложили червя в открытый доступ и анонсировали на BreachForums «соревнование по supply-chain атакам». Поехали копикэты.
19 мая — следующий залп: 300+ версий в 323 пакетах за 22 минуты , полностью на автомате.
19 мая — следующий залп: 300+ версий в 323 пакетах за 22 минуты , полностью на автомате.
К началу июня дотянулись до npm-пакетов Red Hat с ~80k загрузок в неделю.
К началу июня дотянулись до npm-пакетов Red Hat с ~80k загрузок в неделю.
Говорящее имя
Shai-Hulud — гигантские песчаные черви из вселенной «Дюны» Фрэнка Герберта. Название подобрано не случайно, так как здесь мы имеем дело с самораспространяющимся вредоносом. Он заражает одного мейнтейнера и расползается дальше по всем его пакетам и связанным с ними.
Помимо Shai-Hulud злоумышленники используют тематику «Дюны» и в других местах. Так, например, ветки, через которые они подкидывают червей, могут называться fremen , harkonnen или sandworm .
Изначально песчаный червь был не особо умён. И пайплайн атаки выглядел примерно так:
Крадём npm-токен разработчика.
Крадём npm-токен разработчика.
Находим все пакеты, которые можно релизнуть с этим токеном.
Находим все пакеты, которые можно релизнуть с этим токеном.
Дописываем вредоносный код.
Дописываем вредоносный код.
Повторяем на новых тачках. В результате на машинах жертв селился TruffleHog, который собирал секреты и сливал их в публичную Git-репу прямо в аккаунте жертвы. Звучит страшно, но всё-таки шаг с получением токена не тривиален, и вот именно тут TeamPCP шагнули вперёд и прокачали червя до очень опасного злодея.
Повторяем на новых тачках. В результате на машинах жертв селился TruffleHog, который собирал секреты и сливал их в публичную Git-репу прямо в аккаунте жертвы. Звучит страшно, но всё-таки шаг с получением токена не тривиален, и вот именно тут TeamPCP шагнули вперёд и прокачали червя до очень опасного злодея.
Удар №1: точкой входа стал не токен, а кривой workflow
Раньше, чтобы опубликовать версию, нужен был npm-токен, но TeamPCP решили зайти через другую уязвимость. Они воспользовались тем, что в GitHub Actions есть возможность отправлять кэш из PR:
Злоумышленник создаёт PR из своего форка.
Злоумышленник создаёт PR из своего форка.
CI имеет права на общий кэш сборки и после его запуска он прокидывает код в этот самый кэш.
CI имеет права на общий кэш сборки и после его запуска он прокидывает код в этот самый кэш.
Спустя время мейнтейнер запускает CI с новым релизом и из кэша забирает вредоносный код.
Спустя время мейнтейнер запускает CI с новым релизом и из кэша забирает вредоносный код.
Вредоносный код исполняется в CI, который считается доверенным.
Вредоносный код исполняется в CI, который считается доверенным.
Результат: никто не пытался взломать вашу учётную запись, в логах npm нет ничего подозрительного. А это всё последствия плохо настроенного проекта, в котором с целью «главное, что работает» были слишком широко настроены права.
Удар №2: маскировка секретов не значит ничего
После того как вредоносный код пролез, он исполняется по lifecycle-хуку. Однако там же есть средства мониторинга, которые через --require перехватывают лишние скрипты. Но TeamPCP и это не остановило: они просто используют Bun — аналог Node.js, для которого никакого мониторинга нет.
Дальше самое весёлое. Червь, находясь на раннере, находит процесс GitHub Actions и через /proc/<pid>/mem забирает все секреты. И несмотря на то, что в логах CI они все замаскированы, в памяти они лежат в открытом виде. И вот тут важно запомнить для себя, что любой код, который крутится на раннере, может видеть секреты в открытом виде. Не стоит вестись на эти *** в логах.
Не стоит забывать, что червь очень жаден, поэтому помимо секретов из GitHub Actions он также тянет все возможные креды по путям: ~/.ssh/* , ~/.npmrc , ~/.docker/config.json , ~/.kube/config , ~/.aws/credentials . Также просматривается история оболочки, и динамически дёргается IMDS ( 169.254.169.254 ) за облачными ключами, а также выполняются обращения к локальному Vault. Ну и на десерт: ~/.claude.json и .claude/mcp.json . AI теперь стал нормой в разработке и может быть точно такой же точкой, на которую будут давить злоумышленники.
Удар №3: OIDC и провенанс — против вас
Самая неприятная часть. Мы все постепенно переходим с обычных токенов на Trusted Publishing через OIDC . И всё выглядит очень безопасно, ведь если нет токена, то и красть нечего. Но TeamPCP использует окружение релизного раннера, в котором npm сам отдаёт тот самый временный токен публикации в обмен на OIDC-переменные, которые червь успешно подхватил. И OIDC честно авторизует его, ведь всё было по правилам.
OIDC подтверждает, кто пришёл, а не что он делает. И если вредонос запускается в заверенном пайплайне, то OIDC будет на его стороне.
Как я уже говорил выше, TeamPCP удалось подделать и SLSA-провенанс Build Level 3 . Червь из нужного окружения идёт в Fulcio и забирает оттуда валидные сертификаты на свои вредоносы. И тут опять игра по правилам, ведь SLSA подтверждает: да, собрано нужным пайплайном, а не то, что в пайплайне всё прошло так, как было задумано. Провенанс необходим, но, к сожалению, недостаточен.
Удар №4: скорость и тайники
После того как червь находит все токены с bypass_2fa: true , он запрашивает у реестра все пакеты этого разработчика и публикует в каждом из них заражённую версию. В результате на графике релизов это выглядит как двойной релиз, где две версии публикуются с разницей в минуту, обычно ночью, когда инженеры дремлют. Так TanStack и UiPath делили общие креды, и от одного перетекло к другому.
Чтобы обойти egress-фильтр, украденное летит не на эндпоинт злоумышленников, а в тайники: CDN мессенджера Session ( filev2.getsession.org ) и dead-drop в GitHub через GraphQL-коммиты в подконтрольные репозитории. Коммиты делаются от имени claude@users.noreply.github.com с сообщением chore: update dependencies , а ветки называют в формате dependabot/github_actions/format/<что-то из Дюны> . В итоге всё выглядит как обычный проект, если не вглядываться.
Для повторения добавляются разные варианты автоматизации: SessionStart в .claude/settings.json , задача folderOpen в .vscode/tasks.json , systemd-сервис gh-token-monitor , ну и .github/workflows/codeql_analysis.yml , который сливает toJSON(secrets) .
Грабли, на которые я бы наступил сам
Самое коварное. В описании токена, который создаёт сам червь, есть строка IfYouRevokeThisTokenItWillWipeTheComputerOfTheOwner . И если вы попробуете отозвать токен, то в течение минуты прилетит rm -rf ~/ .
Рефлекс «увидел плохой токен — удалил» приведёт к очень плачевным последствиям. Поэтому, если столкнётесь с подобным, обязательно сначала делайте снапшот.
Мы познакомились с очень неприятным червём, который обошёл все методы, которым я действительно доверял. В заключение хочу подвести пару выводов, которые сделал для себя, чтобы не столкнуться с подобным на своих проектах.
Что делаем
По возможности пользуемся npm ci и --ignore-scripts .
Скорость атаки
Карантин новых версий: мы не отдаём её с прокси, пока ей не стукнет определённое количество дней.
permissions: contents: read ставим по умолчанию, id-token: write — точечно. Обработка PR и релиза — разные процессы, разносим их. Стараемся использовать эфемерные раннеры, которые живут одну джобу.
bypass_2fa не используем при публикации и везде 2FA.
Egress-allowlist на раннерах, runtime-детект /proc/mem и левого egress. Для eBPF-мониторинга отлично подходит Falco.
Сначала снапшот, потом всё остальное.
© 2026 ООО «МТ ФИНАНС»