Dependency confusion и тайпсквоттинг
Две атаки на разрешение имён, превращающие шаг install в RCE: dependency confusion (публичный пакет совпадает по имени с внутренним, но версия выше) и тайпсквоттинг (опечатка популярного имени). Пинни scope к реестру, ставь через npm ci, блокируй скрипты.
Билд был зелёным годами. И вот однажды утром CI начал ронять проверку фрода ниже по конвейеру, дежурный инженер поднял логи раннера — и увидел, что шаг install звонит на хост, который никто не узнаёт. Пакет назывался ровно как одна из внутренних библиотек компании — имя, которое встречается только внутри монорепы и в стек-трейсах. Только эта копия пришла из публичного реестра, несла номер версии на порядок выше всего, что команда когда-либо выкатывала, и запустила postinstall-скрипт в тот самый момент, когда npm install её разрешил. Никто не добавлял зависимость. Никто не менял версию. Атакующий просто опубликовал пакет под собственным внутренним именем компании, выставил версию 99.9.9 и подождал, пока билд-машина её предпочтёт. Этот урок — про две атаки, которые превращают в оружие то, как пакетный менеджер преобразует имя в артефакт: dependency confusion и тайпсквоттинг.
Dependency confusion: побеждает старшая версия в общем пространстве имён
Когда пакетный менеджер разрешает имя, он спрашивает у реестра доступные версии и по умолчанию предпочитает старшую, удовлетворяющую диапазону. Ловушка в том, что «реестр» часто во множественном числе: многие конфигурации трактуют публичный реестр (npm, PyPI) как fallback (резервный источник, опрашиваемый при неудаче в основном) в том же пространстве имён, что и приватный. Так что если твой внутренний пакет acme-internal-utils существует приватно на 1.4.0, а атакующий публикует публичный пакет с идентичным именем на 99.0.0, резолвер сравнивает их в одном пространстве имён и берёт старшую версию — версию атакующего.
Единственная предпосылка для атакующего — твоё внутреннее имя. А эти имена утекают постоянно: package.json, закоммиченный в публичный репозиторий, дерево зависимостей, напечатанное в CI-логе, путь в стек-трейсе, вставленном в issue. Ничто из этого не секрет и ничто из этого не ощущается как уязвимость — пока не станет семенем атаки.
# Что резолвер по сути делает для БЕЗ-scope внутреннего имени:
# запрос к приватному реестру -> acme-internal-utils@1.4.0 (твой)
# запрос к публичному реестру -> acme-internal-utils@99.0.0 (атакующего, выше)
# "побеждает старшая версия" -> ставит 99.0.0 из публичного
# Затем install/postinstall-скрипт исполняется с секретами и сетью CI-джобы.Полезная нагрузка исполняется потому, что пакетные менеджеры запускают lifecycle-скрипты (postinstall, preinstall — хуки, автоматически вызываемые пакетным менеджером до и после установки) во время установки по дизайну — именно так нативные модули собираются. На CI-раннере это значит, что код атакующего исполняется со всем, что держит джоба: токены реестра, облачные креды, возможность дотянуться до внутренних хостов. Корневая причина структурна, а не баг: единое пространство имён разрешения, где публичный реестр — тихий fallback и побеждает старшая версия, тогда как твои внутренние имена не зарезервированы публично.
Фикс, который реально закрывает дыру: scope + пиннинг реестра
Решающая защита — сделать твои внутренние имена неразрешимыми из публичного реестра, чтобы сравнение выше вообще не происходило. Делаешь это, помещая внутренние пакеты под scope (@acme/...) и пинуя этот scope к приватному реестру в .npmrc:
# .npmrc — scope @acme НИКОГДА не проваливается в публичный реестр
@acme:registry=https://registry.internal.acme.com
//registry.internal.acme.com/:_authToken=${INTERNAL_NPM_TOKEN}Теперь запрос на @acme/utils маршрутизируется только во внутренний реестр. Резолвер даже не спрашивает публичный реестр об этом scope, так что нет никакого публичного @acme/utils@99.0.0, чтобы победить — ловушка сравнения версий устранена структурно. Для легаси без-scope внутренних имён эквивалент — гарантировать, что резолвер никогда не проваливается в публичный для них (прокси, который fail-closed, или миграция их под scope). Приватность реестра не то же самое, что изоляция пространства имён; изоляцию даёт только пин.
▸Почему это работает
Почему ты всё ещё уязвим просто имея внутренний пакет, если не пинишь scope к своему реестру? Потому что большинство резолверов трактуют публичный реестр как fallback в ТОМ ЖЕ пространстве имён, и «побеждает старшая версия» — так что публичный пакет с твоим внутренним именем и более высокой версией предпочитается твоему приватному. Сделать реестр приватным защищает его содержимое; с именем это не делает ничего. Резолвер всё так же охотно спрашивает публичный об имени, которое не может удовлетворить приватно, и атакующий, знающий имя, публикует там старшую версию. Только явный пин scope к твоему реестру (чтобы резолвер никогда не спрашивал публичный об этом scope) делает имя неподделываемым. Резервирование публичного имени или scope — публикация безобидной заглушки, чтобы никто другой не мог его заявить, — это второй слой «ремень и подтяжки».
Lockfile, integrity и install-скрипты: слои под пином
Пин закрывает пространство имён; ещё три слоя ограничивают радиус поражения, если что-то всё же проскочит.
Коммить lockfile и ставь через npm ci, а не npm install. npm install имеет право разрешать — он может подтянуть новую старшую версию и переписать lockfile, ровно то поведение, которое эксплуатирует dependency confusion. npm ci ставит только то, что зафиксировано в lockfile, и проверяет каждый артефакт против хэша integrity (SHA субресурсной целостности, записанный во время лока). Если реестр отдаёт подменённый артефакт под ту же версию, проверка хэша падает и установка прерывается.
# npm install: может разрешить старшую версию и ПЕРЕПИСАТЬ lockfile (окно атаки)
npm install
# npm ci: ставит ТОЧНО зафиксированные версии, проверяет каждый integrity-хэш, падает на несовпадении
npm ci --ignore-scripts # а также отказывает lifecycle-скриптам, где билд это позволяетОтключай install-скрипты, где можешь (--ignore-scripts). Рычаг вредоносного пакета почти всегда — его postinstall; если скрипты не бегут, подменённый или сконфуженный пакет — это инертный код на диске, а не исполняемый код. Скрипты переразрешаешь намеренно для тех немногих зависимостей, которым реально нужна компиляция.
Тайпсквоттинг: атака-сестра на тот же шаг установки
Dependency confusion целится в твои имена; тайпсквоттинг целится в популярные имена. Атакующий публикует пакеты на одну опечатку от чего-то известного — expresss, lodahs, crossenv — и ждёт, пока разработчик ошибётся в команде установки или скопирует опечатку из блога. Засквоттенный пакет затем запускает тот же вредоносный install-скрипт. Защита слоится иначе, потому что нет scope, который можно запинить: проверяй точное имя перед установкой, прогоняй установки через приватный прокси-реестр (Artifactory, Verdaccio), отдающий только проверенные пакеты из allow-list, сканируй зависимости инструментами (Socket, npm audit) и пинни всё через lockfile, чтобы опечатка не могла тихо попасть в воспроизводимый билд.
Ты должен остановить dependency confusion для твоих внутренних @acme-пакетов — публичный пакет с тем же именем и более высокой версией не должен никогда дойти до билда. Какой подход реально закрывает дыру?
Как атакующий через dependency confusion заставляет свой код исполниться внутри твоего CI-билда?
Почему `npm ci` на закоммиченном lockfile безопаснее `npm install` для сопротивления этой атаке?
- 01Объясни механизм dependency confusion от начала до конца: что нужно атакующему, почему резолвер берёт его пакет и где исполняется его код.
- 02Разложи слоистые защиты и почему каждая нужна: scope+пин реестра, резервирование имени, lockfile + npm ci + integrity, ignore-scripts и вариант с тайпсквоттингом.
Две атаки на разрешение имён превращают рутинный шаг установки в удалённое выполнение кода. Dependency confusion злоупотребляет тем, как резолвер преобразует имя в артефакт: многие конфигурации трактуют публичный реестр как fallback в ТОМ ЖЕ пространстве имён, что и твой приватный, и «побеждает старшая версия» — так что атакующий, узнавший твоё внутреннее имя (утёкшее в публичном package.json, CI-логе или стек-трейсе), публикует одноимённый ПУБЛИЧНЫЙ пакет на версии 99.0.0, резолвер предпочитает его твоей приватной 1.4.0, и его install/postinstall-скрипт бежит в CI с секретами и сетью джобы. Решающий фикс — scope + пиннинг реестра: помести внутренние пакеты под scope (@acme/…) и запинь этот scope к приватному реестру в .npmrc, чтобы резолвер даже не спрашивал публичный об @acme/* и не было старшей публичной версии, чтобы победить — приватность реестра не изоляция пространства имён, изоляция только в пине. Зарезервируй публичный scope как второй слой. Под этим коммить lockfile и ставь через npm ci (не npm install): npm ci ставит ровно зафиксированные версии и проверяет каждый integrity-хэш, отказывая подменённому артефакту, тогда как npm install может пере-разрешить до вредоносной старшей версии и переписать lockfile. Блокируй install-скрипты через —ignore-scripts, где билд позволяет, чтобы сконфуженный или подменённый пакет был инертным, а не исполняемым. Тайпсквоттинг — атака-сестра на популярные имена (expresss, lodahs, crossenv) в ставке на опечатку; без scope для пина защищайся проверкой имён, прогоном установок через приватный прокси-реестр, отдающий только allow-list, сканированием зависимостей и пином всего через lockfile. Теперь, видя новый внутренний пакет без scope-префикса, задай вопрос: зарезервировано ли это имя в публичном реестре и запинен ли его scope в .npmrc?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.