Код был в порядке. Издатель — нет
Две записи в реестре, одна версия разницы. Axios 1.14.0 опубликовал GitHub Actions через доверенного издателя, и у релиза есть provenance. Axios 1.14.1, вышедший в 00:21 UTC 31 марта 2026 года, выложили вручную, без всего этого. Эта разница и есть вся история взлома axios в npm, и она лежала в метаданных реестра. [2]
Факты коротко. Версии axios 1.14.1 и 0.30.4, опубликованные с разницей в 39 минут, добавили одну новую зависимость, plain-crypto-js@4.2.1, чей скрипт postinstall ставил троян удалённого доступа на любую машину, где выполнялась установка. К 03:15 UTC npm удалил обе версии. [1][2] Google с высокой уверенностью приписывает операцию UNC1069, группе, связанной с КНДР, а Microsoft называет актора Sapphire Sleet. [3][4]
Я называю это атакой на цепочку доверия. Атака на цепочку поставок ПО меняет код, который вы запускаете, не меняя ни строчки из того, что вы ревьюили: она компрометирует канал доставки зависимости. Уязвимость в исходниках axios не использовали. Каждое звено — чьё-то решение доверять, и именно так эту историю полезно читать. Сам я ничего из этого не воспроизводил: разбор построен на отчёте сопровождающих, материалах вендоров и записях реестра по состоянию на 7 октября 2026 года.
axios@1.14.0 published by CI
_npmUser GitHub Actions
trustedPublisher github
gitHead 46bee3dea75ef53a8eae49f3b7487e6341de6074
dist.attestations SLSA provenance v1
axios@1.14.1 2026-03-31T00:21:58Z, published by hand
_npmUser the lead maintainer's account
trustedPublisher absent
gitHead absent
GitHub commit/tag none; the release exists only on npmЦепочка началась на ноутбуке и закончилась на ноутбуке
Первым звеном были не npm и не GitHub. По отчёту сопровождающих, атакующий добрался до компьютера ведущего сопровождающего через целевую социальную инженерию и RAT-вредонос, а это дало учётные данные npm, которыми и опубликовали релизы. [1] RAT на машине разработчика породил токен. Токен породил RAT на каждой машине разработчика и каждом CI-раннере, где поставили отравленную версию. Один и тот же класс вредоносов на обоих концах.
Сопровождающие высказались прямо: публикация из личной учётной записи была риском, которого можно было избежать, а процесс OIDC и неизменяемые релизы, к которым они переходят сейчас, должны были появиться до инцидента. [1] Эта фраза мне нравится. Она называет настоящий урок, и он не про axios, а про то, где живут права на публикацию.
Я читаю инцидент как шесть звеньев, они на схеме ниже. В каждом звене можно было порвать цепочку, и у каждого свой владелец. Часть звеньев принадлежит издателю. Большая часть — вам. Дальше иду по ним по порядку.
1 maintainer laptop social engineering + RAT -> npm credentials
2 registry gate direct token publish accepted -> axios 1.14.1, 0.30.4
3 decoy dependency plain-crypto-js 4.2.0 -> 4.2.1 -> new line in package.json
4 your resolver fresh install, no cooldown -> poisoned tarball fetched
5 install script postinstall: node setup.js -> dropper runs
6 runner or laptop reachable secrets, open egress -> RAT, then whatever it can reachПочему OIDC trusted publishing не остановил украденный токен?
Потому что это была не единственная дверь. Trusted publishing позволяет реестру принимать публикацию только от указанного CI-процесса, по краткоживущему OIDC-токену вместо хранимого секрета. Релизы axios 1.x уже шли этим путём, поэтому у 1.14.0 есть доверенный издатель и provenance. [2][6] Вредоносную 1.14.1 опубликовали напрямую из учётной записи: без доверенного издателя, без gitHead и без соответствующего коммита или тега в репозитории. [2]
Хороший путь публикации не отменяет плохой. Пока пакет принимает токены, любой, у кого токен есть, обходит workflow целиком. Закрывает это настройка пакета «Require two-factor authentication and disallow tokens»: документация npm рекомендует включать её после настройки trusted publishers, и сами trusted publishers продолжают работать как раньше. [6] StepSecurity читает отсутствующие метаданные как кражу долгоживущего классического токена; сопровождающие говорят лишь, что атакующий получил учётные данные аккаунта. [1][2] В любом случае публикация workflow не касалась.
Моё мнение: включить OIDC и не отключить токены — это полконтроля, и команды ставят галочку после первой половины. Если вы публикуете пакеты, проверьте обе половины сегодня. Если вы их потребляете, метаданные реестра — сигнал, который можно читать: версия, которую раньше публиковал доверенный издатель, а теперь нет, заслуживает паузы. Npm затягивает гайки и с другой стороны: с января 2027 года гранулярные токены, обходящие двухфакторную аутентификацию, потеряют возможность публиковать напрямую. [7]
Зависимость, которую никто не импортирует, — это находка
Атакующий подготовил почву за день. plain-crypto-js@4.2.0 появился в 05:57 UTC 30 марта как чистая приманка: копия настоящего кода crypto-js без хука установки. Его единственная задача — дать пакету историю публикаций, чтобы он не выглядел новым. Вредоносная 4.2.1 вышла в 23:59 UTC, за 22 минуты до axios 1.14.1. [2]
Теперь улика. StepSecurity просмотрел все 86 файлов в axios@1.14.1 и обнаружил, что plain-crypto-js нигде не импортируется и не подключается через require. [2] Коду, который поставлял axios, эта зависимость была не нужна. Из релиза к тому же пропал скрипт prepare с husky, что согласуется с ручной публикацией мимо обычных инструментов выпуска. [2] Проще говоря: патч-релиз добавил зависимость времени выполнения, которую пакет не использует. На такой строке ревьюер останавливается.
Эту проверку я бы автоматизировал первой. При каждом обновлении сравнивайте манифест старой и новой версии. Новая зависимость в патч-релизе уходит человеку. Затем три вопроса: насколько стар пакет, кто его сопровождает и использует ли его родитель хоть где-то? Команды ниже отвечают на все три меньше чем за минуту. Бот обновлений этих вопросов за вас не задаёт.
# what changed in the manifest between two versions
npm diff --diff=<pkg>@<old> --diff=<pkg>@<new> package.json
# does the package actually use the new dependency?
npm pack <pkg>@<new> && tar -xzf <pkg>-<new>.tgz && grep -R "<new-dep>" package/
# how old is the new dependency, and who maintains it?
npm view <new-dep> time maintainers --jsonПочему одной команды npm install хватило, чтобы отдать машину?
Потому что npm выполняет код при установке. Lifecycle-скрипт — это команда, которую пакет объявляет в package.json и которую менеджер пакетов запускает автоматически; у plain-crypto-js@4.2.1 был объявлен postinstall: node setup.js. [3] Он срабатывает до того, как приложение что-либо импортирует, поэтому проект, который в рантайме вообще не трогал эту библиотеку, всё равно оказался под ударом. StepSecurity зафиксировал первое обращение к серверу атакующего в течение двух секунд после npm install. [2]
Обфускация в setup.js тоньше, чем кажется. Строки разворачиваются, декодируются из Base64 и XOR-ятся с ключом OrDeR_7077 и константой 333. [5] Деобфускация StepSecurity показывает, что ключ проходит через Number(): буквы превращаются в NaN, XOR с нулём ничего не меняет, и реальный ключ — 0,0,0,0,0,0,7,0,7,7. [2] Это приём против сканеров строк, а не криптография. Вывод: статический анализ скриптов установки — полезная сигнализация, но слабая граница.
Дальше дроппер ветвится по платформе и просит у сервера атакующего вторую стадию. Тело POST сообщает серверу, какую отдать: product0 для macOS, product1 для Windows, product2 для Linux. [2] Куда ложится каждая, показано в таблице. Все три варианта шлют маяк раз в 60 секунд, JSON в Base64, с User-Agent от Internet Explorer 8 на Windows XP. [3] Поскольку RAT подтягивается на лету, в tarball вредоносного кода как такового нет: пакет — загрузчик. Поэтому решает исходящий трафик, а IE8 в User-Agent у сборочного раннера — самое дешёвое оповещение, которое вам доведётся написать. [3]
platform stage two lands in started by
win32 %TEMP%\6202033.ps1 VBScript; powershell.exe copied to %PROGRAMDATA%\wt.exe
darwin /Library/Caches/com.apple.act.mond Mach-O binary, chmod +x, zsh in the background
linux /tmp/ld.py nohup python3
all POST http://sfrclak[.]com:8000/6202033
body: packages.npm.org/product0 (macOS), product1 (Windows), product2 (Linux)
beacon: Base64 JSON every 60 seconds
User-Agent: mozilla/4.0 (compatible; msie 8.0; windows nt 5.1; trident/4.0)
win32 persistence: HKCU Run key "MicrosoftUpdate" and %PROGRAMDATA%\system.batПочему после атаки папка выглядела чистой?
Потому что дроппер за собой прибрал. После запуска setup.js удалял сам себя и вредоносный package.json, затем переименовывал заранее подготовленный package.md в package.json. [2] Этот подменный манифест объявляет версию 4.2.0 без хука установки. Инженер, открывший потом node_modules/plain-crypto-js, видит безобидную 4.2.0, хотя исполнялась 4.2.1. [2] Приманка, подложенная днём раньше, окупается второй раз.
Поэтому я говорю командам: проверяйте lockfile, а не node_modules. Запись о plain-crypto-js в lockfile, версии 4.2.0 или 4.2.1, означает, что граф установки включал атаку, и по рекомендации Google с этого момента машину считают скомпрометированной. [3] Что сохранить и как доказать восстановление, разобрано в моей прошлой статье об Axios и доказательствах сборки. Здесь остаюсь на механизме.
Что делал RAT? В разборе Google полезная нагрузка названа WAVESHAPER.V2; она умеет разведку системы, листинг каталогов, выполнение произвольных команд и внедрение бинарных файлов. [3] Это доступ, а не кража. Что делает оператор с доступом к ноутбуку разработчика или CI-раннеру — читает токены, ключи и файлы .env, которые там лежат; ставки видны на примере OpenAI: workflow GitHub Actions в процессе подписи macOS-приложений запустил axios 1.14.1, имея рядом сертификат подписи и материалы нотаризации. OpenAI не нашла свидетельств доступа к данным пользователей или изменения ПО и в качестве меры предосторожности заменила сертификат. [12]
Кому на самом деле стоит волноваться?
Меньшей части вашего хозяйства, чем следовало из заголовков, и вполне конкретной. Поставляемое приложение не жертва. Ничто не импортирует plain-crypto-js, поэтому у клиентского бандла, собранного из axios 1.14.1, не было причин содержать нагрузку; жертвой стала машина, выполнившая установку. [2] Окно тоже было коротким: с 00:21 UTC до удаления в 03:15 UTC, меньше трёх часов. [1]
Чтобы вас задело, должны были совпасть три условия. Свежая установка разрешила axios 1.14.1 или 0.30.4 в это окно: установка без подходящего lockfile или бот обновлений, смёрживший PR за часы. Скриптам установки разрешили выполняться — это поведение по умолчанию. И на машине было что-то, до чего стоило дотянуться. Во время инцидента Microsoft советовала закрепить точные версии и приостановить автоматические боты зависимостей. [4] Если вы можете ответить на эти три вопроса по каждому раннеру и ноутбуку, вы знаете свою экспозицию. Если не можете — это и есть главная находка.
Какая мера рвёт какое звено?
По одной на звено, и таблица ниже — вся модель. Чтобы остановить конкретную атаку, достаточно порвать одно звено. Выбрать, какое звено следующий атакующий оставит целым, вы не можете, поэтому закрывайте больше одного.
Звено пять изменилось в этом году. Npm 12, общедоступный с 8 июля 2026 года, больше не запускает скрипты preinstall, install и postinstall зависимостей, если проект их не разрешил, а разрешения записаны в списке allowScripts, который вы коммитите. [7][8] На npm 12 хук plain-crypto-js без явного разрешения не сработал бы. Но проверьте, что реально стоит в вашем CI: образ node:24-alpine по состоянию на 7 октября 2026 года содержит npm 11.19.0, а npm 11 по умолчанию всё ещё выполняет скрипты зависимостей. Pnpm блокирует их по умолчанию с десятой версии, и даже эту настройку приходилось проверять: до версии 10.26.0 git-зависимости всё равно могли выполнять код при установке, это уязвимость высокой серьёзности. [9][10] Настройка становится границей, когда вы видели, как она что-то заблокировала.
Для звена четыре тоже есть дешёвая мера. В npm 11.10.0 в феврале 2026 года добавили min-release-age, задержку в днях. У pnpm параметр minimumReleaseAge задаётся в минутах, и в pnpm 11 по умолчанию это один день. [10][11] Отравленные версии axios прожили меньше трёх часов, так что даже суточная задержка их переживает. Задержка — ставка на то, что кто-то заметит угрозу в этом окне, и против терпеливого атакующего она бессильна. Добавьте trustPolicy: no-downgrade из pnpm: он отказывается ставить версию, у которой уровень доверия упал по сравнению с прежними релизами. [10] Это сделано ровно под форму 1.14.1, но я не прогонял эту атаку через настройку: 1.14.1 уже нет в реестре. Проверьте на пакете, который вы контролируете.
link control owner
1 maintainer laptop no publish credential on a workstation publisher
2 registry gate trusted publisher + "disallow tokens" publisher
3 decoy dependency diff the manifest; question new deps in patches you
4 fresh resolution npm ci + lockfile; min-release-age / pnpm cooldown you
5 install script npm 12 allowScripts; pnpm 10+; --ignore-scripts you
6 reachable secrets short-lived creds, split jobs, egress allow-list you
+ trust downgrade pnpm trustPolicy: no-downgrade youВосемь проверок перед следующим npm install
Вот версия этой статьи, по которой можно действовать. Начните со своих репозиториев, затем образы CI, затем пакеты, которые вы публикуете. Список ниже упорядочен по скорости отдачи.
Когда я ревьюю кодовую базу, манифест зависимостей, lockfile и конфигурация выпуска входят в объём работ: важные границы доверия часто лежат там, а не в написанном вами коде. Обычный пентест эту часть цепочки, как правило, не затрагивает. Если вам нужен взгляд атакующего на то, как ваш продукт доверяет зависимостям и пути выпуска, расскажите, что вы поставляете. Если считаете, что вас задело, сохраните доказательства до пересборки и привлеките специалиста по реагированию на инциденты: это другая работа, не моя.
1 lockfile grep -nE "plain-crypto-js" package-lock.json # 4.2.0 or 4.2.1 means act
2 CI install npm ci, never npm install; no bot merges within hours of a release
3 scripts npm 12: review allowScripts | older npm: --ignore-scripts, allow by name | pnpm 10+: allowBuilds
4 cooldown npm config set min-release-age 3 | pnpm: minimumReleaseAge
5 trust pnpm: trustPolicy: no-downgrade
6 publishing your packages: trusted publisher on, "disallow tokens" on, no publish token on a laptop
7 egress build runners: default-deny outbound; alert on node spawning curl, powershell or python3
8 secrets the install job holds no deploy, signing or cloud credentialsИсточники
- [1] Сопровождающие Axios: разбор инцидента
- [2] StepSecurity: компрометация axios в npm, технический анализ
- [3] Google Threat Intelligence: группировка с связями с КНДР атакует axios
- [4] Microsoft: меры защиты от компрометации axios в npm
- [5] JFrog Security Research: анализ вредоносного plain-crypto-js
- [6] Документация npm: trusted publishers
- [7] Журнал изменений GitHub: защита npm при установке и отказ от токенов с обходом 2FA
- [8] Документация npm: скрипты установки в npm 12
- [9] Бюллетень безопасности pnpm GHSA-379q-355j-w6rj: обход блокировки lifecycle-скриптов
- [10] Документация pnpm: настройки безопасности цепочки поставок
- [11] Релиз npm CLI v11.10.0: min-release-age
- [12] OpenAI: компрометация инструмента разработки axios
Нужна такая проверка для вашего продукта?
Аудит безопасности кода