
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:
{ "name": "Тестовый стенд", "dependencies": { "@tanstack/react-router": "^1.160.0" } }
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 выполняет в заданные относительные моменты времени:
{ "scripts": { "preinstall": "node before.js", "postinstall": "node after.js", "prepare": "node build.js" } }
Они были созданы в благих целях: билдить нативные модули, тянуть нужные бинари под специфическую ОС и т. п. Но стали для злоумышленников путём запуска их кода на наших тачках. Для особо тревожных разработчиков на этот случай был добавлен ключ --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.
12 мая они выложили червя в открытый доступ и анонсировали на BreachForums «соревнование по supply-chain атакам». Поехали копикэты.
19 мая — следующий залп: 300+ версий в 323 пакетах за 22 минуты, полностью на автомате.
К началу июня дотянулись до npm-пакетов Red Hat с ~80k загрузок в неделю.
Говорящее имя
Shai-Hulud — гигантские песчаные черви из вселенной «Дюны» Фрэнка Герберта. Название подобрано не случайно, так как здесь мы имеем дело с самораспространяющимся вредоносом. Он заражает одного мейнтейнера и расползается дальше по всем его пакетам и связанным с ними.
Помимо Shai-Hulud злоумышленники используют тематику «Дюны» и в других местах. Так, например, ветки, через которые они подкидывают червей, могут называться fremen, harkonnen или sandworm.
Изначально песчаный червь был не особо умён. И пайплайн атаки выглядел примерно так:
Крадём npm-токен разработчика.
Находим все пакеты, которые можно релизнуть с этим токеном.
Дописываем вредоносный код.
Публикуем
Повторяем на новых тачках. В результате на машинах жертв селился TruffleHog, который собирал секреты и сливал их в публичную Git-репу прямо в аккаунте жертвы. Звучит страшно, но всё-таки шаг с получением токена не тривиален, и вот именно тут TeamPCP шагнули вперёд и прокачали червя до очень опасного злодея.
Удар №1: точкой входа стал не токен, а кривой workflow
Раньше, чтобы опубликовать версию, нужен был npm-токен, но TeamPCP решили зайти через другую уязвимость. Они воспользовались тем, что в GitHub Actions есть возможность отправлять кэш из PR:
Злоумышленник создаёт PR из своего форка.
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 ~/.
Рефлекс «увидел плохой токен — удалил» приведёт к очень плачевным последствиям. Поэтому, если столкнётесь с подобным, обязательно сначала делайте снапшот.
Выводы
Мы познакомились с очень неприятным червём, который обошёл все методы, которым я действительно доверял. В заключение хочу подвести пару выводов, которые сделал для себя, чтобы не столкнуться с подобным на своих проектах.
Зона |
Что делаем |
|---|---|
Зависимости |
По возможности пользуемся |
Скорость атаки |
Карантин новых версий: мы не отдаём её с прокси, пока ей не стукнет определённое количество дней. |
GitHub Actions |
|
Токены/подписи |
|
Рантайм/сеть |
Egress-allowlist на раннерах, runtime-детект |
Реагирование |
Сначала снапшот, потом всё остальное. |
© 2026 ООО «МТ ФИНАНС»
d3d14
Поделом. Надо было думать, когда создавали систему общественных репозиториев. Это касается не только npm. Это все закономерно.