SSD Nodes Learn 🎉 VPS від $5.50/міс
Посібники Matt ConnorВід Matt Connor

Атаки на ланцюг постачання npm на VPS

Як шкідливі patch-версії, postinstall, typosquat-пакети та встановлення latest потрапляють у Node-застосунок на VPS і як deploy-практика це зупиняє.

Що таке атака на ланцюг постачання 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, розгорнутий двічі в один день, може встановити два різні набори коду. Ця розбіжність і є поверхнею атаки. Для її появи не потрібно зламувати жодну систему на вашому комп’ютері.

Про шкідливі релізи зазвичай повідомляють, після чого їх вилучають, але вилучення відбувається вже після того, як користувачі їх встановили. Той, хто виконав розгортання протягом цього періоду, уже має цей код на диску. Pipeline, який під час кожного запуску визначає версії для діапазонів, автоматично потрапляє в цей період кілька разів на тиждень, без окремого рішення з боку користувача.

Форма 2: сценарій інсталяції запускається від імені користувача, який виконує розгортання

У блоці scripts пакета його package.json може оголошувати preinstall, install, postinstall і prepare. npm запускає їх під час інсталяції. Вони не ізольовані, і ніхто їх не перевіряє. Це команди оболонки, які виконуються від імені користувача, що ввів команду інсталяції, у домашньому каталозі цього користувача, з його мережевим доступом і повним середовищем цієї оболонки.

Тому корисне питання полягає не в тому, що може робити пакет. Потрібно з’ясувати, що може читати цей користувач. На звичайному сервері розгортання це включає ~/.npmrc, де зберігається токен реєстру, ~/.ssh/id_ed25519, який використовується як ключ розгортання для SSH (secure shell), ~/.aws/credentials, ~/.docker/config.json і кожну експортовану змінну оболонки. Саме там зазвичай зберігається DATABASE_URL.

Для такого payload не потрібні ані механізм постійної присутності, ані підвищення привілеїв. Він читає кілька файлів, надсилає їх на хост через HTTPS і завершує роботу зі статусом 0. Ви нічого не бачите, оскільки npm типово приховує вивід сценаріїв інсталяції. Вимкніть це приховування та перевірте, що саме запускається:

npm ci --foreground-scripts

foreground-scripts використовує стандартні потоки вводу, виводу та помилок процесу npm. Тому сценарії збирання виводять повідомлення безпосередньо у ваш термінал, а не в буфер, який npm відкидає після успішної інсталяції.

Форма 3: typosquat-пакети та назва, яку ви ввели не зовсім правильно

Typosquat — це пакет, опублікований під назвою, схожою на популярну, у розрахунку на помилково набрану або вставлену команду встановлення. Механізм атаки полягає саме в команді, а не в коді, тому lockfile тут не допоможе: ви один раз додасте неправильну назву, і надалі lockfile сумлінно закріплюватиме саме її.

Варіант, який створює проблеми для команд, а не окремих користувачів, називається dependency confusion. Ваш внутрішній пакет називається billing-utils і зберігається в приватному registry. Якщо в public registry немає пакета з назвою billing-utils, будь-хто може його опублікувати. npm зіставляє назви без scope із default public registry, тому пріоритет може отримати публічна копія. Рішення — scope, яким ви володієте, і mapping цього scope на registry у .npmrc:

@yourorg:registry=https://npm.yourorg.example/
//npm.yourorg.example/:_authToken=${NPM_TOKEN}

Тепер @yourorg/billing-utils завжди завантажується лише з цього хоста, оскільки mapping scope на registry перевіряється перед default registry. Для внутрішньої назви без scope mapping немає, тому вона не захищена.

Перш ніж додавати нову залежність, перегляньте сам пакет, а не його лічильник завантажень:

npm view some-lib repository.url maintainers time.created time.modified

Пакет, створений минулого місяця й опублікований обліковим записом, який не можна пов’язати з публічним repository, становить інший ризик, ніж пакет із шестирічною історією. Жоден із цих фактів не є доказом. Обидва легко перевірити.

Форма 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

Перша форма виводить лише назви змінених файлів. Цього достатньо, щоб швидко перевіряти кожне оновлення потрібного вам пакета. Patch-реліз, який змінює build script, додає файл у корені пакета або редагує блок scripts, варто повністю переглянути до того, як він потрапить на ваш сервер.

Збірка з зафіксованим lockfile за допомогою npm ci

package-lock.json містить точну версію кожного пакета в дереві залежностей, URL-адресу, з якої його отримано, хеш цілісності sha512 для кожного tarball і пакет, якому він потрібен. Додайте цей файл до системи контролю версій. Це єдиний файл, який показує, що саме ви фактично тестували.

Потім встановлюйте залежності за допомогою npm ci, а не npm install, на будь-якій машині, яка не є ноутбуком розробника:

npm ci --omit=dev --ignore-scripts

npm ci відрізняється від npm install у кількох важливих аспектах. Для його роботи має існувати lockfile. Перед початком він видаляє наявний node_modules, тому залишки від попереднього розгортання не потраплять у поточне. Він не записує зміни до package.json або lockfile, тому встановлення не може непомітно перейти на новішу версію. Якщо lockfile і package.json не збігаються, команда завершується з помилкою, а не намагається узгодити відмінності.

Ця помилка є перевагою, а не проблемою. Вона означає, що зміна залежності має надійти як commit, перевірений іншою людиною, а не як побічний ефект розгортання о 02:00.

Хеш цілісності перевіряється під час кожного завантаження. Якщо байти tarball не відповідають записаному хешу, встановлення завершується з помилкою code EINTEGRITY, а tarball не розпаковується. Важливо точно розуміти, що це гарантує: отриманий файл є тим самим файлом, який зафіксовано в lockfile. Це та сама гарантія, яку дає перевірка завантажень за контрольними сумами, і вона має те саме обмеження. Вона нічого не говорить про те, чи була зафіксована версія шкідливою на момент публікації.

Окремо про --omit=dev: ці пакети все одно визначаються та записуються в lockfile. Вони лише не розміщуються на диску. Менша кількість пакетів на диску означає менше install scripts і менше коду, завантаженого під час виконання, тому це варто робити. Але залежність не видаляється з дерева.

Розглядайте скрипти встановлення як код і знайте, як від них відмовитися

Скрипти встановлення можна вимкнути. Додайте це до .npmrc проєкту й закомітьте файл поруч із lockfile:

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, тому діапазон версій для розв’язання залежностей випадково не потрапить до маніфесту.

Це може порушити роботу, тому перед увімкненням потрібно розуміти наслідки. Пакети, які компілюють native addon або завантажують попередньо зібраний бінарний файл, виконують цю роботу в скрипті встановлення. Якщо скрипти вимкнено, саме встановлення завершується успішно, а помилка з’являється пізніше, під час виконання, коли модуль не може завантажити файл binding. Рішенням є allowlist:

npm ci --ignore-scripts
npm rebuild better-sqlite3

npm rebuild <package> запускає скрипти збирання лише для цього пакета. Тепер ви приймаєте рішення окремо для кожного пакета, замість того щоб безумовно надавати право на виконання кільком сотням невідомих розробників.

Щоб дізнатися, наскільки великим є такий дозвіл зараз, виконайте запит до npm:

npm query ":attr(scripts, [postinstall])"

Ця команда виводить усі пакети в установленому дереві, які містять скрипт postinstall. У типовому застосунку цей список коротший, ніж очікують, і саме тому allowlist практично застосовувати.

Відокремте складання від процесу, який обслуговує мережевий трафік

Користувач deploy має мати права на запис у 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.target

ProtectSystem=strict монтує всю файлову систему в режимі лише для читання для цього сервісу, крім /dev, /proc, /sys і шляхів, перелічених у ReadWritePaths. Тому спроба застосунку записати дані в node_modules завершується помилкою EROFS: read-only file system. Ви можете відтворити її у власних журналах приблизно за хвилину. NoExecPaths стосується доступного для запису каталогу завантажень: сервіс може записувати туди файли, але ядро не дозволяє їх виконувати. Для цього параметра потрібен systemd 249 або новіший. В Ubuntu 24.04 постачається версія 255.

У цьому unit-файлі є дві типові помилки. По-перше, не додавайте MemoryDenyWriteExecute=yes. Цей параметр є в більшості списків посилення захисту systemd, але він не дає Node запуститися, оскільки V8 під час виконання компілює JavaScript у машинний код і потребує сторінок пам’яті, які одночасно доступні для запису та виконання. По-друге, візьміть шлях ExecStart із command -v node. Якщо Node встановлено за допомогою менеджера версій, він розташований у домашньому каталозі користувача deploy. Параметр ProtectHome=yes приховує цей каталог від сервісу, і unit негайно завершується з 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/probe

systemd-analyze security перелічує всі параметри посилення захисту та рівень їхнього впливу, щоб ви могли побачити, які з них досі мають значення за замовчуванням. Команда touch має завершитися помилкою Permission denied, оскільки nodeapp не володіє жодним об’єктом у current. Якщо команда виконується успішно, права власності на файли налаштовано неправильно, а параметри systemd непомітно компенсують цю помилку.

Окремо зверніть увагу на EnvironmentFile: systemd читає цей файл від імені root, перш ніж переходить до користувача User=nodeapp. Тому права цього файлу можуть мати значення root:root і режим 600. Застосунок усе одно отримує змінні. Будь-хто, хто має shell від імені nodeapp, може прочитати їх через /proc/<pid>/environ. Отже, цей захист діє для секрету, що зберігається на диску, але не для процесу, який уже працює.

Не зберігайте облікові дані для розгортання в середовищі збирання

Скрипти встановлення успадковують середовище. Саме цей факт має визначати, де ви виконуєте збирання.

Найнадійніший варіант — виконувати збирання не на production-сервері, а в іншому місці та копіювати готовий каталог на сервер. Тоді на машині збирання зберігається лише токен реєстру з правом read-only. SSH-ключа для розгортання, ключа доступу до хмарного сервісу, пароля бази даних і облікових даних для входу до реєстру контейнерів там немає.

npm token create --read-only

Токен із правом read-only може завантажувати пакети, але не може їх публікувати. Якщо такий токен викрадуть із середовища збирання, наслідком буде лише можливість завантажувати публічні пакети.

Якщо потрібно виконувати збирання на сервері, робіть це від імені користувача deploy із навмисно обмеженим середовищем. Секрети середовища виконання зберігайте в /etc/nodeapp/env, до якого deploy не має доступу для читання. Це саме міркування стосується і систем автоматизації збирання, які ви розміщуєте самостійно: self-hosted runner GitHub Actions зберігає токени та виконує довільний опублікований код під час кожного завдання, тому в невеликому розгортанні це машина з найвищою цінністю для зловмисника. До цієї самої категорії належить будь-яка написана не вами програма, яка отримує доступ до всього вашого середовища. Саме тому не зберігати секрети в середовищі AI-агента — це та сама проблема, але з іншою програмою посередині.

Зафіксуйте версію або додайте пакет до репозиторію, якщо ви не можете його перевірити

Зафіксована залежність — це залежність, версія якої не може змінитися без коміту. Закомічений lockfile уже робить це для всього дерева. Для двох випадків потрібні додаткові заходи.

Перший випадок — транзитивні залежності. Ви не контролюєте, від чого залежать ваші залежності. overrides у package.json примусово встановлює певну версію в будь-якій частині дерева:

{
  "overrides": {
    "some-transitive-lib": "1.4.2"
  }
}

Після додавання виконайте npm install один раз, щоб записати результат у lockfile, а потім закомітьте обидва файли.

Другий випадок — пакет, який ви не можете перевірити й не можете вилучити. Додайте його до репозиторію. npm pack завантажує точний tarball, який повернув би registry, а залежність 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"
  }
}

Тепер tarball зберігається у вашому репозиторії та не може непомітно змінитися. Водночас ви назавжди відповідаєте за його оновлення, тому використовуйте цей підхід для невеликого покинутого пакета, від якого ви залежите, а не для вашого web framework.

Є також період очікування, який нічого не коштує:

npm install --before=2026-08-01

Параметр before перебудовує дерево, використовуючи лише версії, опубліковані в цю дату або раніше. Під час оновлення залежностей встановіть дату на тиждень або два раніше. Так ви пропустите період, коли небезпечний реліз уже доступний, але про нього ще не повідомили. Це грубий інструмент, оскільки він також затримує справжні security fixes. Використайте його для розв’язання діапазонів версій, перевірте зміни, а потім закомітьте lockfile.

Як визначити, яку версію насправді було розгорнуто?

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 читає встановлений manifest за шляхом. Це працює навіть для пакетів, у яких поле exports блокує імпорти підшляхів, і виводить одну версію без деревоподібного оформлення.

Для іншої частини порівняння прочитайте git:

git log --oneline -- package-lock.json
git show <commit>:package-lock.json | grep -A3 '"node_modules/some-lib"'

Зробіть відповідність між цими двома значеннями постійною, додавши commit до структури розгортання. Розгортайте реліз у /srv/nodeapp/releases/<short commit sha> і вкажіть /srv/nodeapp/current на нього за допомогою symlink. Відповіддю на запитання «що запущено зараз» буде readlink /srv/nodeapp/current. Цю інформацію можна перевірити о 03:00, навіть якщо перевірку виконує не той, хто виконав розгортання.

Нарешті, перевірте, за що поручиться registry:

npm audit signatures

Це перевіряє підписи registry для пакетів у встановленому дереві, а також attestation походження для пакетів, які їх мають. Provenance пов’язує опублікований tarball із публічною збіркою безперервної інтеграції (CI), яка його створила. Тому перевірена attestation дає змогу відстежити код до commit, а не до невідомого ноутбука. Покриття не є повним, тому відсутність attestation слід трактувати як «інформація відсутня», а не як «пакет ненадійний».

Що робити після того, як небезпечний реліз потрапив на сервер

Почніть із того, що саме виконувалося і від імені якого користувача.

Якщо код виконувався під час інсталяції, вважайте скомпрометованими всі дані, доступні користувачу збірки для читання. Замініть registry token, SSH-ключі в його home directory, cloud credentials і будь-який секрет, експортований у цій shell-сесії. Ротація — єдина коректна відповідь, оскільки ви не можете довести, що файл не було прочитано.

Якщо код виконувався під час роботи застосунку від імені обмеженого service account, доступний набір значно менший: власні змінні середовища застосунку та все, до чого можна дістатися через його мережевий доступ. Саме тому сервіси слід запускати від імені непривілейованих користувачів на VPS. Це не запобігає компрометації. Це визначає, до якої частини машини отримає доступ зловмисник і чи збережеться цей доступ після перезапуску.

Після цього виконайте повторне розгортання замість очищення. Видаліть node_modules, зафіксуйте для ураженого пакета версію нижче небезпечної у package.json, один раз виконайте npm install, щоб оновити lockfile, закомітьте зміни та розгорніть їх за допомогою npm ci. Не намагайтеся виправити дерево залежностей на місці. Ви не можете перелічити всі дії, які виконав install script.

Також зафіксуйте часовий інтервал: перший deploy, який міг завантажити цю версію, і deploy, який її видалив. Цей діапазон визначає, які саме власні журнали потрібно перевірити. Визначити його можна лише тоді, коли ваші релізи мають назви на основі комітів.

Що все це не усуває

Lockfile не робить залежність безпечною. Він перетворює момент прийняття цієї залежності на датоване рішення, яке пройшло перевірку, а не на побічний ефект розгортання. Кожна з описаних вище практик виконує те саме перетворення: замінює випадковість свідомим вибором.

npm audit тут не є захистом. Він порівнює ваше дерево залежностей із базою даних опублікованих вразливостей, тому знаходить проблеми, які вже оприлюднені та мають назву. Атака на ланцюг постачання протягом усього періоду, коли вона дає зловмиснику користь, залишається невідомою. Запускайте npm audit для пошуку старих відомих помилок і не очікуйте від нього жодної інформації про реліз, опублікований чотири години тому.

Зменшення кількості залежностей допомагає більше, ніж будь-який інструмент у цьому посібнику, але це найменш популярна порада. Кожен пакет, який ви не додаєте, означає ще одного видавця, якого не можна обманом змусити надати доступ від вашого імені, і ще один install script, який ніколи не запуститься від імені вашого deploy user.

Усе це стосується не лише npm. Ті самі чотири підходи застосовні до PyPI, RubyGems, container images і package manager вашого дистрибутива. У npm проблема помітніша, тому що дерева залежностей глибші, а install scripts запускаються за замовчуванням. Обсяг оточення, яке ви повинні захищати, залежить від місця запуску. Це є частиною ширшого питання про безпеку VPS hosting.

FAQ

Чи захищає npm ci від скомпрометованого пакета npm?

Він захищає від непомітної зміни версії. npm ci встановлює саме те, що зафіксовано в package-lock.json, перевіряє кожний tarball за його хешем цілісності sha512 і завершує роботу з помилкою, якщо package.json та lockfile не збігаються, замість того щоб узгоджувати різницю. Це нічого не говорить про безпечність зафіксованої версії. Якщо ви закомітили lockfile, у якому зафіксовано шкідливу версію, npm ci щоразу сумлінно встановлюватиме її на кожному вашому сервері.

Чи слід установити ignore-scripts=true для всіх пакетів?

Установіть його, а потім додайте потрібні пакети до allowlist. ignore-scripts=true у .npmrc проєкту забороняє виконання скриптів встановлення залежностей. Це усуває найпряміший шлях від шкідливого пакета до облікових даних користувача розгортання. Пакетам, які компілюють native addon або завантажують попередньо зібраний бінарний файл, такі скрипти справді потрібні. Якщо скрипти вимкнено, вони завершаться помилкою пізніше, під час виконання, через відсутній файл binding, а не під час встановлення. Виконайте npm ci --ignore-scripts, а потім npm rebuild <package> для кількох пакетів, яким ви вирішили довіряти. npm query ":attr(scripts, [postinstall])" показує, скільки їх насправді.

Як дізнатися, яку версію пакета мій сервер фактично встановив?

Перевірте дані на диску, а не lockfile. npm ls <package> повідомляє, що саме наявне в node_modules, а node -e "console.log(require('./node_modules/<package>/package.json').version)" виводить лише рядок версії. Lockfile у git відповідає на інше запитання: що мало бути встановлено. Саме порівняння цих двох значень і є метою. Розгортання в каталог, названий за git commit, дає змогу зберегти обидві відповіді на майбутнє, навіть через кілька місяців.

Чи виявляє npm audit атаки на ланцюг постачання?

Ні. npm audit порівнює ваше дерево залежностей із базою даних опублікованих вразливостей. Тому він виявляє лише проблеми, які вже опубліковано та яким надано ідентифікатор. Шкідливий реліз може залишатися неповідомленим протягом годин або днів, коли його встановлення має значення. npm audit signatures є кориснішою командою: вона перевіряє підписи реєстру для всього встановленого дерева та перевіряє атестації походження, якщо видавець їх надав. Це дає змогу визначити, що tarball походить із публічної системи збірки, а не з невідомої машини.

Чому важливо запускати застосунок від непривілейованого користувача, якщо атака відбувається під час встановлення?

Тому що ці два типи зламу мають різний радіус дії, і ви захищаєтеся від обох. Код під час встановлення виконується від імені користувача розгортання та може прочитати його SSH-ключі, токени реєстру й облікові дані хмарних сервісів. Код під час виконання працює від імені облікового запису сервісу, а з User=nodeapp, ProtectSystem=strict і без облікових даних на диску його доступ обмежується власним середовищем застосунку та його базою даних. Розділення облікових записів також означає, що процес, який обслуговує мережевий трафік, не може перезаписати node_modules. Тому злам під час виконання усувається під час наступного перезапуску, а не стає постійним.