Dependency confusion в npm и PyPI: аудит private-пакетов у трёх клиентов и настройка Nexus
После волны dependency confusion атак 2021 года проверяем имена private-пакетов трёх клиентов на конфликт с публичными реестрами и настраиваем Nexus с explicit allow-list.
Исследователи массово публикуют методику dependency confusion - захват внутренних пакетов через публичные реестры npm/PyPI, волна атак затронула крупные компании включая Microsoft и Apple
В феврале 2021 года исследователь Алекс Бирсан опубликовал детальный разбор атаки, которую он назвал dependency confusion. Суть простая и оттого неприятная: если ваша сборочная среда ищет пакеты сначала в публичном реестре, а потом во внутреннем, достаточно зарегистрировать в npm или PyPI пакет с тем же именем, что у вашего внутреннего - и при следующей сборке он будет скачан и выполнен вместо нужного. Бирсан провёл это против реальных компаний в рамках bug bounty - Microsoft, Apple, PayPal, Netflix - и получил выплаты. Публикация разошлась быстро, и с тех пор волна повторений от разных охотников за уязвимостями не прекращается.
Мы прочитали разбор в феврале и сразу подумали: а что у наших клиентов с именами пакетов? Мысль зафиксировали, но до дела дошло только сейчас - когда в октябре снова пошли публикации с новыми случаями. Взяли трёх клиентов с npm и Python-зависимостями и провели проверку.
Как работает атака
Механика зависит от конфигурации менеджера пакетов. По умолчанию npm при установке пакета обращается к публичному реестру registry.npmjs.org. Если организация использует внутренний scope (@company/package) - это относительно безопасно: scoped-пакеты ищутся в реестре, привязанном к scope, и подменить их через публичный npmjs сложнее. Но если внутренние пакеты называются без scope - просто utils, api-client, config-helpers - они технически существуют в том же пространстве имён, что и публичные пакеты.
Ситуация с pip аналогичная. Если используется корпоративный PyPI-прокси с --extra-index-url, pip при разрешении зависимостей смотрит в оба реестра и берёт версию с наибольшим номером. Исследователи научились публиковать в публичный PyPI пакет с версией 9999.0.0 - и pip радостно тянет его вместо внутреннего 1.2.3.
Что нашли у клиентов
У всех трёх клиентов картина оказалась похожей, хотя и в разной степени запущенности.
Первое - незащищённые имена в npm. У одного клиента внутренние пакеты назывались без scope: logging-utils, http-middleware, ещё пара похожих названий. Ни один из них не был зарегистрирован в публичном npmjs - то есть любой мог занять это имя прямо сейчас. Мы проверили через npm view <имя> - пакетов нет, позиция свободна. Это и хорошо (пока не атакованы), и плохо (ничего не мешает).
Второе - --extra-index-url без приоритетов. У другого клиента pip-конфиг выглядел невинно: корпоративный Artifactory как дополнительный индекс, публичный PyPI как основной. Именно та конфигурация, которую Бирсан описывал как уязвимую. Несколько внутренних пакетов с короткими именами - мы проверили на PyPI, часть имён занята посторонними пакетами с нулевой активностью. Это уже интереснее: занято, но не обязательно вредоносно. Тем не менее вектор открыт.
Третье - отсутствие проверки целостности. У третьего клиента package-lock.json не всегда коммитился в репозиторий. Это отдельная история, не про dependency confusion напрямую, но в связке с ней - если нет зафиксированных хешей, замечить подмену пакета сложнее.
Что настроили: Nexus с explicit allow-list
Самый надёжный способ закрыть вектор - контролировать, какие имена пакетов вообще могут прийти из публичного реестра. Для этого подходит Nexus Repository Manager с настройкой проксирующих репозиториев.
Схема, которую мы применили:
npm install ---> Nexus (hosted group)
|
+-- internal (hosted) <- ваши пакеты
|
+-- npm-proxy <- npmjs.org ТОЛЬКО по allow-list
В Nexus группа репозиториев выдаёт внутренние пакеты из hosted-репозитория с приоритетом над проксирующим. Это уже снижает риск, но не убирает его полностью - если имя пакета есть и внутри, и в npm, Nexus отдаёт внутренний. Но если во внутреннем нет - идёт в прокси.
Дополнительно мы настроили content selectors и routing rules в Nexus, чтобы запросы на конкретные имена внутренних пакетов не уходили во внешний npm вообще. По сути - explicit allow-list наоборот: эти имена доступны только из internal, в прокси не проваливаются.
Для PyPI аналогичная история: уходим от --extra-index-url в сторону единственного --index-url, указывающего на Nexus, который уже сам решает, откуда брать пакет. --extra-index-url с двумя реестрами - конфигурация, которую надо убирать.
Параллельно зарегистрировали занятые имена в публичных реестрах от имени клиента - как defensive registration. Это не защита сама по себе, но убирает возможность для атакующего занять имя. Бирсан, кстати, именно так и работал в своём исследовании: находил свободные имена, публиковал пустышки и ждал, что прилетит в его сторону.
Что ещё проверили
Несколько наблюдений по ходу работы, которые вышли за рамки исходной задачи.
Scoped-пакеты в npm (@company/...) действительно снижают риск dependency confusion, если scope привязан к приватному реестру. Но это не абсолютная защита: если scope не настроен явно в .npmrc или в Nexus - npm может попробовать его через публичный реестр. Проверили конфиги - в одном месте scope был объявлен, но не закреплён за конкретным реестром в .npmrc. Поправили.
Логирование в Nexus пригодилось. По access-логам мы увидели несколько запросов к именам, которые не должны были уходить в прокси. Один из них оказался опечаткой в requirements.txt, другой - легаси-зависимостью, про которую все забыли. Без логов не нашли бы.
Где сейчас
На двух клиентах из трёх изменения внедрены - Nexus перенастроен, package-lock.json зафиксирован в репозитории, defensive registration сделана. У третьего клиента процесс ещё идёт: там инфраструктура сложнее, несколько сборочных окружений с разными конфигурациями pip, и согласование правил займёт время.
Тема supply chain уже который месяц не уходит из повестки. После атаки на Kaseya и разговоров про SBOM dependency confusion выглядит логичным продолжением: атакующие ищут точку, в которой доверие встроено по умолчанию. Сборочная среда, которая автоматически тянет зависимости из публичного реестра, - именно такая точка.
Если вы используете внутренние пакеты без scope в npm или --extra-index-url в pip - проверьте имена через npm view и поиск на PyPI. Это занимает полчаса и даёт ответ на вопрос, есть ли у вас открытая позиция прямо сейчас. Аудит зависимостей - не разовое мероприятие, но начинать надо с чего-то конкретного.
- SBOM на практике: Syft + Grype для container images и интеграция в CI · 13 сентября 2021
- Kaseya VSA и REvil: когда ваш RMM становится вектором атаки на клиентов · 12 июля 2021