Атаки на цепочку поставок npm: как защитить Node.js сервер
Узнайте, как вредоносные пакеты npm проникают на сервер через postinstall скрипты и typosquatting. Разберитесь, почему автоматическая установка обновлений опасна для VPS.
Что такое атака на цепочку поставок npm на вашем сервере
Атака на цепочку поставок npm проникает на ваш сервер через пакет, который вы решили установить. В этом процессе не задействованы открытые порты или этапы эксплуатации уязвимостей. npm (node package manager) устанавливает код, а установка кода означает его выполнение. Поэтому небольшое приложение на Node подтягивает несколько сотен пакетов, которые вы никогда не просматривали, и любой из них может выпустить новую версию через час.
Ваш процесс развертывания загружает вредоносную версию, так как команда установки запрашивает самую новую подходящую версию. Этот код затем выполняется с привилегиями того пользователя, который запустил установку. Все, что описано ниже, вытекает из этих двух утверждений.
Типы атак упорядочены по частоте их воздействия на человека, развертывающего одно Node-приложение на одном VPS. Этот порядок отличается от того, который использовала бы крупная компания, так как у крупной компании есть внутренний реестр, команда проверки и зеркало публичного реестра. У вас есть только скрипт развертывания.
Сценарий 1: учетная запись мейнтейнера скомпрометирована и публикует патч
Реестр npm не позволяет изменять содержимое уже существующей версии. Злоумышленник, который получил доступ к учетной записи мейнтейнера через фишинг или украл токен публикации, не может перезаписать 4.18.2. Он публикует 4.18.3.
Посмотрите на ваш package.json. Строка вида "express": "^4.18.2" не означает версию 4.18.2. Символ каретки означает «любая версия 4.x, начиная с этой или выше», а ~4.18.2 означает «любая версия 4.18.x». npm install разрешает этот диапазон в момент запуска, поэтому один и тот же git commit, развернутый дважды в течение одного дня, может установить два разных набора кода. Этот разрыв и является поверхностью атаки. Для её использования не требуется компрометация вашей машины.
О вредоносных релизах обычно сообщают, и их удаляют, но удаление происходит уже после того, как пользователи успели их установить. Тот, кто выполнил развертывание в этом временном окне, уже имеет этот код на диске. Конвейер (pipeline), который разрешает диапазоны при каждом запуске, автоматически попадает в это окно несколько раз в неделю, без участия человека.
Вариант 2: скрипт установки выполняется от имени пользователя, осуществляющего развертывание
В package.json пакета можно объявить preinstall, install, postinstall и prepare в блоке scripts. npm выполняет их во время установки. Они не изолированы в «песочнице», и их никто не проверяет. Это обычные shell-команды, которые запускаются от имени пользователя, введшего команду установки, в его домашнем каталоге, с его сетевым доступом и полным окружением текущей оболочки.
Поэтому правильный вопрос заключается не в том, что может сделать пакет, а в том, что может прочитать этот пользователь. На обычном сервере развертывания ответ включает ~/.npmrc, где хранится токен реестра, ~/.ssh/id_ed25519, используемый как ключ развертывания для SSH (secure shell), ~/.aws/credentials, ~/.docker/config.json, а также все переменные окружения оболочки, где обычно находится DATABASE_URL.
Полезная нагрузка такого типа не требует закрепления в системе или повышения привилегий. Она считывает несколько файлов, отправляет их на хост по протоколу HTTPS и завершается с кодом 0. Вы ничего не заметите, так как npm по умолчанию скрывает вывод скриптов установки. Отключите это поведение, чтобы увидеть, что именно выполняется:
npm ci --foreground-scriptsforeground-scripts использует общие стандартные потоки ввода, вывода и ошибок с процессом npm, поэтому скрипты сборки выводят данные прямо в ваш терминал, а не в буфер, который npm очищает после успешной установки.
Тип 3: тайпосквоттинг и имя, которое вы ввели не совсем верно
Тайпосквоттинг — это публикация пакета под именем, похожим на популярное, в расчете на опечатку или ошибку при копировании команды установки. Механизм атаки заключается в самой команде, а не в коде, поэтому файл блокировки (lockfile) здесь не поможет: вы один раз добавляете неверное имя, и с этого момента lockfile будет исправно фиксировать его версию.
Вариант, который нацелен на команды, а не на отдельных разработчиков, называется подменой зависимостей (dependency confusion). Ваш внутренний пакет называется billing-utils и находится в приватном реестре. Если в публичном реестре нет ничего с именем billing-utils, любой может опубликовать такой пакет. npm разрешает имена без области видимости (unscoped) через стандартный публичный реестр, поэтому публичная копия может оказаться приоритетнее. Решение заключается в использовании собственной области видимости (scope) и привязке реестра для этой области в файле .npmrc:
@yourorg:registry=https://npm.yourorg.example/
//npm.yourorg.example/:_authToken=${NPM_TOKEN}Теперь @yourorg/billing-utils всегда будет загружаться только с этого хоста, так как привязка области к реестру проверяется до обращения к стандартному реестру. Внутреннее имя без области видимости не имеет такой привязки, а значит, и защиты.
Прежде чем добавлять новую зависимость, изучите её, а не только значок количества загрузок:
npm view some-lib repository.url maintainers time.created time.modifiedПакет, созданный в прошлом месяце и опубликованный учетной записью, которую невозможно связать с публичным репозиторием, несет иные риски, чем пакет с шестилетней историей. Ни один из этих фактов не является доказательством безопасности. Однако оба легко проверить.
Вариант 4: зависимость, владелец которой сменился без уведомления
Сопровождающие передают пакеты другим лицам. Кто-то прекращает поддержку, появляется новый участник, права на публикацию переходят к нему, но проекты, зависящие от этого пакета, не получают никаких уведомлений. Компрометации не происходит. Доверие, которое вы оказали в 2021 году, теперь принадлежит другому человеку.
Это самый медленный и труднообнаружимый вариант, и для него не существует прямой команды. Две меры позволяют сузить круг поиска. Перед тем как внедрить пакет, проверьте, кто имеет право на его публикацию, с помощью строки npm view, приведенной выше. Затем, когда пакет, от которого вы действительно зависите, обновляется, изучайте diff:
npm diff --diff-name-only --diff=some-lib@1.4.2 --diff=some-lib@1.4.3
npm diff --diff=some-lib@1.4.2 --diff=some-lib@1.4.3Первая форма выводит только имена измененных файлов, что достаточно быстро для проверки при каждом обновлении важного для вас пакета. Патч-релиз, который затрагивает скрипт сборки, добавляет файл в корень пакета или изменяет блок scripts, стоит прочитать целиком, прежде чем он попадет на ваш сервер.
Сборка из зафиксированного lock-файла с помощью npm ci
package-lock.json записывает точную версию каждого пакета в дереве зависимостей, URL источника, sha512-хеш целостности для каждого архива и информацию о том, какой пакет потребовал данную зависимость. Добавляйте этот файл в репозиторий. Это единственный файл, который подтверждает, что именно вы тестировали.
Затем выполняйте установку с помощью npm ci, а не npm install, на любой машине, которая не является рабочей станцией разработчика:
npm ci --omit=dev --ignore-scriptsnpm ci отличается от npm install по ряду важных для данного случая параметров. Команда требует наличия lock-файла. Она удаляет существующую директорию node_modules перед началом работы, поэтому остатки предыдущих развертываний не попадут в текущую сборку. Команда никогда не вносит изменения в package.json или в сам lock-файл, поэтому установка не может незаметно обновить версию пакета. Если lock-файл и package.json противоречат друг другу, команда завершается с ошибкой, вместо того чтобы пытаться разрешить конфликт.
Эта ошибка — полезная функция, а не помеха. Она означает, что изменение зависимостей должно быть оформлено как коммит, который кто-то проверил, а не как побочный эффект развертывания в 02:00.
Хеш целостности проверяется при каждой загрузке. Если байты архива не совпадают с записанным хешем, установка прерывается с ошибкой code EINTEGRITY вместо распаковки. Важно понимать, что это дает: гарантию того, что полученный файл соответствует файлу, зафиксированному в lock-файле. Это та же гарантия, которую дает проверка загрузок по контрольным суммам, и она имеет те же ограничения. Она не гарантирует, что зафиксированная версия не была вредоносной в момент публикации.
Одна деталь о --omit=dev: эти пакеты по-прежнему разрешаются и записываются в lock-файл. Они просто не размещаются на диске. Меньшее количество пакетов на диске означает меньше скриптов установки и меньше кода, загружаемого во время выполнения, поэтому это стоит делать. Данная опция не удаляет зависимость из вашего дерева.
Относитесь к скриптам установки как к коду и умейте их блокировать
Вы можете отключить выполнение скриптов установки. Добавьте следующие параметры в файл .npmrc проекта и зафиксируйте его в системе контроля версий рядом с lock-файлом:
ignore-scripts=true
save-exact=trueПараметр ignore-scripts=true запрещает npm выполнять скрипты, объявленные в зависимостях. Параметр save-exact=true заставляет npm install some-lib записывать 1.4.2 в package.json вместо ^1.4.2, чтобы диапазон версий при разрешении зависимостей случайно не попал в ваш манифест.
Это может привести к сбоям, поэтому перед включением данных настроек важно понимать механизм их работы. Пакеты, которые компилируют нативные модули или загружают готовые бинарные файлы, выполняют эту работу именно через скрипты установки. При отключенных скриптах сама установка завершится успешно, но ошибка проявится позже, во время выполнения, когда модуль не сможет загрузить свой файл привязки. Решением является использование «белого списка» (allowlist):
npm ci --ignore-scripts
npm rebuild better-sqlite3Параметр npm rebuild <package> запускает скрипты сборки только для указанного пакета. Теперь вы принимаете решение по каждому пакету индивидуально, вместо того чтобы предоставлять неограниченные права на выполнение кода сотням незнакомых разработчиков.
Чтобы узнать, насколько широк текущий круг разрешений, выполните команду npm:
npm query ":attr(scripts, [postinstall])"Она выведет список всех пакетов в дереве зависимостей, которые содержат скрипт postinstall. В типичном приложении этот список оказывается короче, чем ожидается, что и делает использование «белого списка» практичным решением.
Отделите процесс сборки от процесса обслуживания трафика
Пользователю, выполняющему развертывание, необходимы права на запись в node_modules. Процессу, отвечающему на HTTP-запросы, они не нужны. Если это одна и та же учетная запись, код, выполняемый при установке, может перезаписать код, обслуживающий пользователей, а код, выполняемый во время работы, может сделать то же самое.
Разделите их. Выполняйте сборку от имени одного пользователя, а обслуживание — от имени другого, и сделайте каталог с кодом доступным только для чтения для учетной записи, обслуживающей процесс:
sudo useradd --system --home-dir /srv/nodeapp --shell /usr/sbin/nologin nodeapp
sudo chown -R deploy:nodeapp /srv/nodeapp
sudo chmod -R o-rwx /srv/nodeappЗатем обеспечьте соблюдение этого правила через systemd. Создайте /etc/systemd/system/nodeapp.service:
[Unit]
Description=Node application
After=network-online.target
[Service]
User=nodeapp
Group=nodeapp
WorkingDirectory=/srv/nodeapp/current
EnvironmentFile=/etc/nodeapp/env
ExecStart=/usr/bin/node server.js
Restart=on-failure
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/srv/nodeapp/shared
NoExecPaths=/srv/nodeapp/shared
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
[Install]
WantedBy=multi-user.targetProtectSystem=strict монтирует всю файловую систему в режиме только для чтения для этого сервиса, за исключением /dev, /proc, /sys и путей, указанных в ReadWritePaths. Поэтому попытка приложения записать данные в node_modules завершится ошибкой EROFS: read-only file system, которую вы сможете обнаружить в своих логах примерно через минуту. NoExecPaths защищает каталог для загружаемых файлов: сервис может записывать туда файлы, но ядро запрещает их исполнение. Эта опция требует systemd версии 249 или новее, а в Ubuntu 24.04 используется версия 255.
В этом юнит-файле есть две ловушки. Во-первых, не добавляйте MemoryDenyWriteExecute=yes. Она встречается в большинстве списков по усилению безопасности systemd, но она блокирует запуск Node, так как V8 компилирует JavaScript в машинный код во время выполнения и требует страницы памяти, которые одновременно доступны для записи и исполнения. Во-вторых, возьмите путь к ExecStart из command -v node. Если Node был установлен через менеджер версий, он находится в домашнем каталоге пользователя, выполняющего развертывание; ProtectHome=yes скроет этот каталог от сервиса, и юнит немедленно завершится с ошибкой status=203/EXEC и записью в логе о том, что исполняемый файл не найден.
Проверьте результат, вместо того чтобы полагаться на содержимое файла:
sudo systemctl daemon-reload
sudo systemctl enable --now nodeapp
sudo systemd-analyze security nodeapp.service
sudo -u nodeapp touch /srv/nodeapp/current/probesystemd-analyze security выводит список всех настроек безопасности и их влияние, поэтому вы можете увидеть, какие из них остались со значениями по умолчанию. Команда touch должна завершиться с ошибкой Permission denied, так как nodeapp не владеет ничем в current. Если она выполняется успешно, значит, права владения файлами настроены неверно, а настройки systemd лишь скрывают эту проблему.
Примечание по поводу EnvironmentFile: systemd считывает этот файл от имени root перед тем, как сменить пользователя на User=nodeapp, поэтому файл может иметь владельца root:root с правами 600. Приложение все равно получит переменные окружения. Любой, кто имеет доступ к оболочке от имени nodeapp, все еще может прочитать их из /proc/<pid>/environ, поэтому этот метод защищает секрет в состоянии покоя, а не в работающем процессе.
Изоляция учетных данных для развертывания от среды сборки
Скрипты установки наследуют переменные окружения. Этот факт должен определять выбор места для сборки.
Наиболее надежный подход — выполнять сборку не на продуктовом сервере, а на отдельной машине, после чего копировать готовый каталог. В таком случае на сборочном узле хранится только токен реестра с правами на чтение. Там не должно быть SSH-ключей для развертывания, ключей доступа к облаку, паролей от баз данных или учетных данных для входа в реестр контейнеров.
npm token create --read-onlyТокен с правами только на чтение позволяет загружать пакеты, но не дает возможности публиковать их. Если такой токен будет похищен из среды сборки, ущерб ограничится лишь возможностью скачивания публичных пакетов.
Если сборка на сервере неизбежна, выполняйте её от имени пользователя deploy с намеренно ограниченным окружением, а секреты для времени выполнения храните в /etc/nodeapp/env, к которым у deploy нет доступа. Та же логика применима к системам автоматизации сборки, которые вы размещаете самостоятельно: self-hosted GitHub Actions runner хранит токены и выполняет произвольный опубликованный код при каждом задании, что делает его наиболее ценной целью в небольшой инфраструктуре. Любая программа, которую вы не писали, но которая получает доступ ко всему вашему окружению, относится к той же категории. Именно поэтому изоляция секретов от среды AI-агента — это та же самая проблема, просто с другой программой в качестве посредника.
Фиксация или вендоринг зависимостей, которые невозможно проверить
Зафиксированная зависимость — это зависимость, версия которой не может измениться без фиксации изменений (commit). Зафиксированный файл блокировок (lockfile) уже обеспечивает это для всего дерева зависимостей. Однако в двух случаях требуются дополнительные меры.
Первый случай — транзитивные зависимости. Вы не контролируете, от чего зависят ваши зависимости. overrides в package.json принудительно задает версию для любого компонента в дереве:
{
"overrides": {
"some-transitive-lib": "1.4.2"
}
}Выполните npm install один раз после добавления, чтобы результат был записан в файл блокировок, а затем зафиксируйте оба файла.
Второй случай — пакет, который вы не можете проверить (audit) и не можете исключить. Используйте вендоринг. npm pack загружает именно тот архив (tarball), который предоставил бы реестр, а зависимость file: устанавливается из вашей локальной копии:
npm pack some-lib@1.4.2
mkdir -p vendor && mv some-lib-1.4.2.tgz vendor/{
"dependencies": {
"some-lib": "file:vendor/some-lib-1.4.2.tgz"
}
}Теперь архив находится в вашем репозитории и не может измениться без вашего ведома. Вы также берете на себя ответственность за его обновления на постоянной основе, поэтому используйте этот метод для небольших заброшенных пакетов, от которых вы зависите, а не для вашего веб-фреймворка.
Существует также период «охлаждения», который не требует затрат:
npm install --before=2026-08-01Опция before перестраивает дерево, используя только те версии, которые были опубликованы до указанной даты включительно. Установите её на неделю или две назад при обновлении зависимостей, чтобы избежать периода, когда вредоносный релиз уже доступен, но о нём ещё не сообщили. Это грубый инструмент, так как он также задерживает получение важных исправлений безопасности. Используйте его для разрешения диапазонов версий, ознакомьтесь с изменениями, а затем зафиксируйте файл блокировок.
Как узнать, какая версия была фактически выпущена?
Файл блокировки (lockfile) в git указывает, что должно было быть установлено. Диск показывает, что установлено на самом деле. Только второе является доказательством.
npm ls some-lib
node -e "console.log(require('./node_modules/some-lib/package.json').version)"npm ls считывает node_modules, поэтому утилита сообщает о том, что физически присутствует в системе, а не о том, что было запланировано в lockfile. Строка node -e считывает установленный манифест по пути, что работает даже для пакетов, чье поле exports блокирует импорт подпутей, и выводит одну версию без построения дерева зависимостей.
Для второй части сравнения обратитесь к git:
git log --oneline -- package-lock.json
git show <commit>:package-lock.json | grep -A3 '"node_modules/some-lib"'Сделайте связь между этими двумя состояниями постоянной, поместив коммит в структуру развертывания. Выпустите релиз в /srv/nodeapp/releases/<short commit sha> и укажите на него с помощью символической ссылки /srv/nodeapp/current. Ответ на вопрос «что запущено прямо сейчас» становится readlink /srv/nodeapp/current, и эта информация доступна в 03:00 любому человеку, даже если он не занимался развертыванием.
Наконец, проверьте, что подтверждает реестр:
npm audit signaturesЭта команда проверяет подписи реестра для пакетов в установленном дереве, а также проверяет аттестации происхождения (provenance attestations) для тех пакетов, у которых они есть. Происхождение связывает опубликованный архив с публичной сборкой в системе непрерывной интеграции (CI), которая его создала. Таким образом, проверенная аттестация означает, что вы можете проследить код до конкретного коммита, а не до неизвестного ноутбука. Покрытие не является универсальным, поэтому отсутствие аттестации следует интерпретировать как «информация отсутствует», а не как «плохой пакет».
Что делать, если на сервер попал скомпрометированный релиз
Действуйте, исходя из того, что именно было запущено и от имени какого пользователя.
Если код выполнялся во время установки, считайте, что все файлы, доступные пользователю, от имени которого шла сборка, скомпрометированы. Смените токен реестра, SSH-ключи в домашней директории этого пользователя, облачные учетные данные и любые секреты, которые были экспортированы в этой оболочке. Ротация — единственный честный ответ, так как вы не сможете доказать, что файл не был прочитан.
Если код выполнялся во время работы под ограниченной сервисной учетной записью, область доступа значительно меньше: это собственные переменные окружения приложения и всё, до чего оно может дотянуться по сети. В этом и заключается основной аргумент в пользу запуска сервисов от имени непривилегированных пользователей на VPS. Это не предотвращает взлом. Это определяет, какая часть системы будет затронута и сохранится ли доступ после перезагрузки.
Затем выполните пересборку вместо очистки. Удалите node_modules, зафиксируйте версию пакета ниже проблемной в package.json, один раз запустите npm install для обновления lock-файла, закоммитьте изменения и выполните деплой с помощью npm ci. Не пытайтесь исправлять дерево файлов на месте. Вы не сможете перечислить всё, к чему обращался скрипт установки.
Также зафиксируйте временной интервал: первый деплой, который мог загрузить эту версию, и деплой, который её удалил. Этот диапазон укажет, какие именно логи нужно изучить, и это возможно только в том случае, если ваши релизы названы по идентификаторам коммитов.
Что это не исправляет
Lockfile не делает зависимость безопасной. Он превращает момент принятия этой зависимости в зафиксированное и проверенное решение, а не в побочный эффект развертывания. Каждая из описанных выше практик выполняет одно и то же преобразование: превращает случайность в осознанный выбор.
npm audit здесь не является защитой. Этот инструмент сравнивает ваше дерево зависимостей с базой данных известных уязвимостей, поэтому он находит проблемы, которые уже были опубликованы и классифицированы. Атака на цепочку поставок остается безымянной на протяжении всего своего полезного срока жизни. Запускайте npm audit для поиска старых известных ошибок, но не ждите от него ничего в отношении релиза, который вышел четыре часа назад.
Сокращение количества зависимостей помогает больше, чем любой инструмент из этого руководства, и это самый непопулярный совет, который можно дать. Каждый пакет, который вы не добавили — это еще один издатель, которого не смогут взломать от вашего имени, и еще один скрипт установки, который никогда не запустится от имени вашего пользователя для развертывания.
Ничто из этого не является специфичным только для npm. Те же четыре принципа применимы к PyPI, RubyGems, образам контейнеров и менеджеру пакетов вашего дистрибутива. В npm это проявляется наиболее ярко, потому что деревья зависимостей там самые глубокие, а скрипты установки выполняются по умолчанию. Какая часть окружающей инфраструктуры остается под вашей защитой, зависит от того, где она работает, что является частью более широкого вопроса о безопасности VPS-хостинга.
FAQ
Защищает ли npm ci от скомпрометированного npm-пакета?
Он защищает от изменения версии без вашего ведома. npm ci устанавливает именно то, что зафиксировано в package-lock.json, проверяет каждый архив по его sha512-хешу целостности и завершается с ошибкой, если package.json и lock-файл не совпадают, вместо попытки разрешить конфликт. Это не гарантирует безопасность зафиксированной версии. Если вы добавите в репозиторий lock-файл, в котором закреплена вредоносная версия, npm ci будет исправно устанавливать её на каждый ваш сервер при каждом развёртывании.
Стоит ли устанавливать ignore-scripts=true для всего?
Установите его, а затем сформируйте список разрешённых (allowlist). ignore-scripts=true в .npmrc проекта предотвращает выполнение скриптов установки зависимостей, что устраняет самый прямой путь от вредоносного пакета к учётным данным пользователя, от имени которого выполняется деплой. Пакетам, которые компилируют нативные модули или загружают готовые бинарные файлы, это действительно необходимо, и при отключении скриптов они завершатся с ошибкой во время выполнения из-за отсутствия файла привязки, а не на этапе установки. Запустите npm ci --ignore-scripts, а затем npm rebuild <package> для тех немногих пакетов, которым вы решили доверять. npm query ":attr(scripts, [postinstall])" покажет, сколько их на самом деле.
Как узнать, какая версия пакета фактически установлена на сервере?
Читайте данные с диска, а не из lock-файла. npm ls <package> сообщает о том, что находится в node_modules, а node -e "console.log(require('./node_modules/<package>/package.json').version)" выводит только строку версии. Lock-файл в git отвечает на другой вопрос: что должно было быть установлено, и сравнение этих двух источников — основная задача. Развёртывание в директорию, названную по хешу git-коммита, позволяет сохранить оба ответа доступными спустя месяцы, когда они вам понадобятся.
Находит ли npm audit атаки на цепочку поставок?
Нет. npm audit сравнивает ваше дерево зависимостей с базой данных известных уязвимостей, поэтому находит только те проблемы, которые уже были опубликованы и получили идентификатор. Вредоносный релиз остаётся нераскрытым в течение тех часов или дней, когда его установка наиболее опасна. npm audit signatures — более полезная команда: она проверяет подписи реестра во всём установленном дереве и проверяет аттестации происхождения (provenance attestations), если издатель их предоставил. Это подтверждает, что архив был получен в результате публичной сборки, а не с неизвестной машины.
Почему запуск приложения от непривилегированного пользователя важен, если атака происходит на этапе установки?
Потому что эти два типа сбоев имеют разный масштаб воздействия, и вы защищаетесь от обоих. Код, выполняемый при установке, работает от имени пользователя, осуществляющего деплой, и может прочитать его SSH-ключи, токены реестра и облачные учётные данные. Код, работающий во время выполнения, запускается от имени сервисной учётной записи, и при использовании User=nodeapp, ProtectSystem=strict и отсутствии учётных данных на диске, которые он мог бы прочитать, его возможности ограничены окружением приложения и его базой данных. Разделение учётных записей также означает, что процесс, обрабатывающий трафик, не может перезаписать node_modules, поэтому компрометация во время выполнения исчезнет после следующего перезапуска, а не станет постоянной.