Содержание статьи
Сборка внутренней библиотеки внезапно стала долгой: вместо секунд — минуты, и в журнале появились обращения наружу. Своя, все зависимости тоже свои, ходить в интернет ей было незачем.
Первая версия — прокси-репозиторий перестал кешировать. Полез в его настройки, там всё в порядке.
Оказалось, что менеджер пакетов запрашивал наш собственный пакет не только у внутреннего репозитория, но и у публичного. И делал он это по конфигурации: второй источник был прописан в настройках.
Прописали его, кстати, не по глупости. Появляется он там ровно тогда, когда внутренний прокси проксирует не всё: часть внешних зависимостей он не отдаёт, сборка падает, и вместо того чтобы чинить прокси, в конфигурацию дописывают публичный индекс. Дальше это живёт годами.
Имя пакета — это просто имя
В большинстве экосистем пакет идентифицируется строкой. Не подписью, не издателем, не доменом — строкой вроде 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-файл с хешами, потом занятые имена в публичном репозитории. Это слабее одного источника, но заметно лучше, чем ничего.
Регистрация имён в публичном репозитории защищает от занятия имени, но не от того, что аккаунт настоящего сопровождающего чужого пакета уведут. Это другой сценарий, и против него работают только фиксация версий с хешами и осознанное обновление.
Отключение скриптов установки ломает совместимость с частью экосистемы. Это компромисс, а не бесплатное улучшение.
И ни одна из мер не помогает, если разработчик ставит пакет руками на своём компьютере в обход сборки. Его рабочее место в этой теме — самое слабое звено, и закрывают его не техникой, а тем, что ставить зависимости мимо общего механизма незачем.
Что посмотреть у себя
Найти настройки менеджера пакетов в сборке и посмотреть, сколько источников там указано. Больше одного — это и есть условие, при котором всё описанное работает.
Проверить, есть ли ваши внутренние имена пакетов в публичных репозиториях. Проверяется поиском по имени в самом репозитории.
Посмотреть, попадают ли имена внутренних пакетов в открытый доступ: в опубликованные конфигурации сборки, в сообщения об ошибках на форумах, в файлы, случайно оказавшиеся в публичном репозитории кода. Это исходные данные для всей схемы.
Посмотреть, что лежит в переменных окружения сборочного агента. Список того, что получит любой пакет в момент установки, обычно длиннее ожидаемого.
