Сборка внутренней библиотеки внезапно стала долгой: вместо секунд - минуты, и в журнале появились обращения наружу. Своя, все зависимости тоже свои, ходить в интернет ей было незачем.

Первая версия - прокси-репозиторий перестал кешировать. Полез в его настройки, там всё в порядке.

Оказалось, что менеджер пакетов запрашивал наш собственный пакет не только у внутреннего репозитория, но и у публичного. И делал он это по конфигурации: второй источник был прописан в настройках.

Прописали его, кстати, не по глупости. Появляется он там ровно тогда, когда внутренний прокси проксирует не всё: часть внешних зависимостей он не отдаёт, сборка падает, и вместо того чтобы чинить прокси, в конфигурацию дописывают публичный индекс. Дальше это живёт годами.

Имя пакета - это просто имя

В большинстве экосистем пакет идентифицируется строкой. Не подписью, не издателем, не доменом - строкой вроде company-auth-utils. Кто первым занял имя в публичном репозитории, тот им и владеет.

Внутренние библиотеки называют по-человечески: billing-common, auth-client, internal-logger. Ровно эти имена в публичном репозитории обычно свободны - потому что никому не приходило в голову, что их надо занять.

Дальше вступает вторая деталь - то, как менеджер пакетов выбирает между источниками.

У pip индексы, перечисленные в настройках, просто объединяются в один список, и побеждает бо́льший номер версии. Понятия «свой источник важнее» там нет вовсе; именно здесь и была суть проблемы, когда её впервые показали публично.

У npm нескольких реестров для одного имени не бывает - но тот же результат даёт прокси, который на запрос имени, которого нет в его хранилище, подмешивает ответ публичного реестра.

Из этих двух свойств складывается вся конструкция: тот, кто опубликовал в публичном репозитории пакет с вашим внутренним именем и версией 99.0.0, оказывается в вашей сборке.

Это уже проверяли на настоящих компаниях

Механизм показал Алекс Бирсан в феврале 2021 года. Он собрал имена внутренних пакетов из открытых репозиториев и из сообщений об ошибках в чужих сборках, опубликовал под этими именами пакеты с заведомо высокими номерами версий и стал ждать.

Пакеты попали в сборки более чем 35 крупных компаний, среди которых Microsoft, Apple, PayPal, Shopify, Netflix, Tesla и Uber. Внутри был безобидный код, который сообщал автору исследования факт запуска (разбор Snyk).

Дыра при этом не в конкретном менеджере пакетов и не в конкретной компании - а в самой схеме «спрашиваем и там, и там, берём версию побольше».

Почему установка - это уже выполнение кода

Отдельная деталь, без которой картина неполная. Многие считают, что чужой пакет опасен только с момента, когда его код вызвали.

Это не так, и здесь есть тонкость.

В Python код выполняется при сборке пакета из исходного дистрибутива - тогда запускается setup.py. Установка готового собранного пакета (колеса) кода не запускает, а колёсами сегодня отдаётся большинство пакетов. Но выбор между этими двумя вариантами делает не разработчик, а то, что лежит в репозитории: если опубликован только исходный дистрибутив, собирать придётся. Атакующему достаточно опубликовать именно его.

В npm проще: обработчики этапов установки выполняются штатно. В обоих случаях код идёт от имени того, кто ставит пакет, с его правами и в его окружении.

Значит, в сборочном агенте код чужого пакета получает то же, что есть у агента: переменные окружения с токенами, доступ к внутренней сети, ключи для выкладки. Набор такой, что его и перечислять неловко, - и именно туда это приезжает.

Отсюда, кстати, следует, что «мы не выкатили сборку в прод, значит, обошлось» - неверный вывод. Выполнение уже произошло.

Соседний приём: опечатка в имени

Та же механика без всяких внутренних имён. Публикуется пакет с именем, отличающимся на символ или на разделитель от популярного: перепутанные дефис и подчёркивание, лишняя буква, единица вместо l.

Дальше работает не техника, а человек: разработчик набирает имя по памяти, торопится, читает подтверждение установки бегло. На ревью такая строка тоже проскакивает - в манифесте написано что-то очень похожее на правильное, а глаз читает знакомое слово целиком, а не по буквам.

Ловит это автоматика: список разрешённых пакетов, проверка имён из манифеста на близость написания к именам известных пакетов. Плюс сам факт, что новая строка в манифесте - это событие, которое видно в diff и требует объяснения: lock-файл с хешами не даст ей появиться молча.

Что действительно закрывает проблему

Один источник для сборки. Сборка ходит во внутренний прокси-репозиторий и никуда больше. Прокси сам определяет, что тянуть из публичного репозитория и что кешировать. Публичный репозиторий из настроек сборки убирается совсем - это основная мера там, где прокси вообще есть.

Правило приоритета в прокси. Для имён из внутреннего пространства прокси обязан отвечать только из внутреннего хранилища и не подмешивать публичные ответы, каких бы версий они ни были.

Именное пространство. В экосистемах, где есть области видимости - @company/package в npm, - область регистрируется на компанию, и посторонний не может опубликовать в ней пакет. Организация в npm бесплатна, пока пакеты в ней публичные; приватные - платный тариф.

Сама по себе область не спасает: нужно ещё привязать её к вашему реестру (@company:registry=), иначе сборка так и будет спрашивать про @company/* у публичного реестра.

Занять свои имена в публичном репозитории. Для экосистем без областей видимости - опубликовать пустышки под своими внутренними именами. Приём грубый, но работает.

Фиксация с хешами. В сборку идёт lock-файл, где для каждого пакета записана контрольная сумма. Тогда подмена содержимого при том же имени и версии ломает установку.

Отключение скриптов установки там, где это возможно. В npm это --ignore-scripts. Часть пакетов без них не соберётся, и придётся разбираться, но начинать разговор стоит с этого, а не считать выполнение кода при установке неизбежным.

Ограничения

Прокси-репозиторий - это инфраструктура, которую надо поднять, обслуживать и держать доступной: он становится единой точкой отказа для всех сборок. Это настоящая цена, и в маленькой команде она может перевесить.

Если прокси вам не по силам, порядок другой: сначала область видимости с привязкой к реестру, потом lock-файл с хешами, потом занятые имена в публичном репозитории. Это слабее одного источника, но заметно лучше, чем ничего.

Регистрация имён в публичном репозитории защищает от занятия имени, но не от того, что аккаунт настоящего сопровождающего чужого пакета уведут. Это другой сценарий, и против него работают только фиксация версий с хешами и осознанное обновление.

Отключение скриптов установки ломает совместимость с частью экосистемы. Это компромисс, а не бесплатное улучшение.

И ни одна из мер не помогает, если разработчик ставит пакет руками на своём компьютере в обход сборки. Его рабочее место в этой теме - самое слабое звено, и закрывают его не техникой, а тем, что ставить зависимости мимо общего механизма незачем.

Что посмотреть у себя

Найти настройки менеджера пакетов в сборке и посмотреть, сколько источников там указано. Больше одного - это и есть условие, при котором всё описанное работает.

Проверить, есть ли ваши внутренние имена пакетов в публичных репозиториях. Проверяется поиском по имени в самом репозитории.

Посмотреть, попадают ли имена внутренних пакетов в открытый доступ: в опубликованные конфигурации сборки, в сообщения об ошибках на форумах, в файлы, случайно оказавшиеся в публичном репозитории кода. Это исходные данные для всей схемы.

Посмотреть, что лежит в переменных окружения сборочного агента. Список того, что получит любой пакет в момент установки, обычно длиннее ожидаемого.