open atlas
↑ К треку
Node.js с нуля до senior NODE · 07 · 02

Зависимости и риск цепочки поставок

Установка пакета запускает его код. Пинуй и проверяй дерево закоммиченным lockfile и `npm ci`, гейти CVE через `npm audit`, блокируй lifecycle-скрипты на недоверенных установках, валидируй ключи и никогда не считай `vm` песочницей.

NODE Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Команда выкатила маленький CLI в пятницу. За выходные мейнтейнер двумя уровнями глубже в дереве зависимостей опубликовал патч-релиз, чей postinstall-скрипт декодировал из base64 полезную нагрузку, читал process.env и POST-ил каждый найденный секрет на pastebin. Команда никогда не запускала этот код намеренно — он выполнился в тот миг, когда CI сделал npm install, до того как загрузилась хоть одна строка их собственного приложения. Дашборд аудита был зелёным; CVE ещё не было. Брешь была вообще не в их коде. Она была в самом акте установки, который в npm — просто другой способ сказать запуск произвольного кода от незнакомцев.

Установить — значит запустить код

Главный факт, переформулирующий безопасность зависимостей: npm install выполняет произвольный код. Задумайся об этом в следующий раз, когда потянешься за новой удобной библиотекой — каждая транзитивная зависимость, которую она тянет за собой, имеет те же привилегии. Пакет может объявить lifecycle-скрипты (хуки установки) — preinstall, install, postinstall — и npm запускает их на твоей машине, с правами твоего пользователя, в тот момент, когда пакет приземляется. Нет ни ревью-гейта, ни песочницы, ни диффа, который ты прочитаешь заранее. У postinstall полный доступ к файловой системе, сети и process.env, а в CI именно там живут твои registry-токены, облачные ключи и деплой-креды. Это не теоретический пограничный случай: это самый прямой вектор цепочки поставок, потому что он срабатывает до кода твоего приложения — и любых тестов или сканеров, которые могли бы поймать вредоносное поведение.

Защитная позиция — перестать считать установку инертной. Для недоверенных установок и особенно в CI запускай со скриптами, выключенными по умолчанию:

# Установить строго из lockfile И пропустить все lifecycle-скрипты
npm ci --ignore-scripts

# Или сделать это дефолтом на всю машину для недоверенного окружения
npm config set ignore-scripts true

--ignore-scripts пропускает lifecycle-хуки каждого пакета. Компромисс реален: некоторым легитимным пакетам (нативные аддоны, компилирующие биндинги, например сборки node-gyp) скрипт действительно нужен для работы, и тихая их блокировка даёт сломанную установку, а не вредоносную. Поэтому ты проверяешь небольшой набор тех, кому скрипты нужны, и разрешаешь их намеренно, вместо того чтобы выдавать тотальное право на выполнение всему транзитивному дереву. А более глубокий рычаг — количество: бóльшая часть кода, который едет в Node-сервисе, — сторонняя, часто сотни транзитивных пакетов от авторов, которых ты никогда не проаудируешь. Каждая удалённая зависимость — это удалённая поверхность атаки.

Lockfile — твоя граница целостности

package.json записывает версии, которые ты хочешь (часто диапазоны вроде ^4.2.0). package-lock.json записывает точные версии, которые ты реально получил, — и, что критично, SRI-хеш целостности (SRI — Subresource Integrity, проверка подлинности по хешу содержимого; sha512) для тарбола каждого пакета. Этот хеш — свидетельство неподделанности: если байты, скачанные из реестра, не хешируются в записанное значение, установка падает. Вот почему lockfile — это не досада, которую надо в .gitignore: это и есть твоя граница воспроизводимости и целостности, и она должна быть закоммичена.

Команда, которая это обеспечивает, — npm ci, а не npm install:

# CI: детерминированно, с проверкой целостности, только из lockfile
npm ci

npm ci устанавливает строго из package-lock.json, громко падает, если package.json и lockfile рассинхронизированы (вместо того чтобы тихо «починить» lock, как делает npm install), сначала удаляет node_modules ради чистого дерева и проверяет каждый хеш целостности. npm install, напротив, может разрезолвить новые версии, мутировать lockfile и подтянуть свежеопубликованный вредоносный патч внутри твоего диапазона ^. Правило механическое: npm ci в CI и воспроизводимых сборках, npm install только локально, когда ты намеренно меняешь зависимости — и ревью получившегося диффа lockfile как кода.

УгрозаМеханизмГлавная защита
Lifecycle-скриптpostinstall бежит при установке, читает process.envnpm ci —ignore-scripts; проверь тех, кому нужны скрипты
Дрейф lockfilenpm install резолвит новый вредоносный патч в диапазонекоммить lockfile; npm ci; ревью диффов lock
Известная CVEу транзитивной зависимости опубликована уязвимостьгейт npm audit —audit-level=high; Dependabot/Renovate
Prototype pollutionproto во вводе заражает Object.prototypeallowlist ключей; Object.create(null); —disable-proto
Typosquat / confusionвредоносное имя подменяет реальный / приватный пакетscoped-пакеты; пин registry/scope; проверка имён
Недоверенный кодrealm у vm сбегаем через прототипыотдельный процесс/worker, контейнер или isolated-vm

Сканирование известных уязвимостей

Закоммиченный lockfile делает сборки воспроизводимыми, но он же замораживает известные уязвимости на месте — пиннинг безопасен, только если ты продолжаешь следить за тем, что запинил. npm audit сверяет твоё установленное дерево с базой advisory реестра и сообщает о CVE с серьёзностью и путём фикса:

# Сообщать об уязвимостях; ронять CI только на high/critical, чтобы срезать шум
npm audit --audit-level=high

# Применить безопасные (semver-совместимые) фиксы; --force может взять breaking-мажоры
npm audit fix

Механизм — это дифф дерево-vs-advisory, так что его слабость та же, что у любого сигнатурного сканера: он видит только опубликованные, известные проблемы, несёт ложные срабатывания и шум («critical» в dev-only транзитивной зависимости, которую ты никогда не вызываешь), а npm audit fix --force может тихо поднять зависимость через мажорную версию и сломать тебя. Поэтому гейти CI на --audit-level=high, чтобы снизить усталость от алертов, автоматизируй churn апгрейдов через Dependabot или Renovate (которые открывают ревьюабельные PR) и положи сверху поведенческий инструмент — Snyk или Socket — который инспектирует install-скрипты и поведение пакета, а не только номера версий, чтобы поймать совсем свежий вредоносный релиз, у которого ещё нет CVE. Аудит — это пол, а не потолок.

Prototype pollution и недоверенный ввод

Не каждый сбой цепочки поставок — это украденный секрет; некоторые — это порча логики. Prototype pollution (отравление прототипа — ключ __proto__ в данных мутирует общий Object.prototype для всего процесса) случается, когда контролируемый атакующим ввод, содержащий ключи __proto__ или constructor.prototype, мержится в объект уязвимым кодом — наивным рекурсивным deep-merge, вольным циклом Object.assign или парсером query-строки. Поскольку почти каждый объект наследуется от Object.prototype, запись через __proto__ мутирует этот общий прототип, и загрязнение протекает в каждый объект процесса: подложенный isAdmin: true может перевернуть проверку авторизации, читающую user.isAdmin, а в худших случаях загрязнённое свойство кормит путь кода, доходящий до child_process или шаблонизатора, и становится RCE.

// УЯЗВИМО: наивный deep-merge ведёт __proto__ прямо в Object.prototype
function badMerge(target, src) {
  for (const k in src) {
    if (typeof src[k] === "object") badMerge(target[k] ??= {}, src[k]);
    else target[k] = src[k];        // k === "__proto__" загрязняет всё
  }
}

// ЗАЩИТА: отклоняй опасные ключи и используй map с null-прототипом
function safeMerge(target, src) {
  for (const k of Object.keys(src)) {
    if (k === "__proto__" || k === "constructor" || k === "prototype") continue;
    target[k] = src[k];
  }
  return target;
}
const lookup = Object.create(null); // нет прототипа, который можно загрязнить

Защиты складываются: валидируй и allowlist-и ключи ввода (валидатор схемы отклоняет __proto__ сразу), используй Object.create(null) для объектов-словарей, чтобы не было прототипа для порчи, держи merge/clone-библиотеки пропатченными и на уровне процесса рассмотри Object.freeze(Object.prototype) или запуск с флагом --disable-proto=delete, чтобы убрать аксессор __proto__ целиком.

Почему это работает

Typosquatting и dependency confusion — это атаки на именование. Typosquat — это вредоносный пакет, названный на одно нажатие клавиши мимо популярного (cross-env vs crossenv), в расчёте на твою опечатку. Dependency confusion острее: если твой внутренний пакет @acme/auth не опубликован, а твоя установка может дотянуться до публичного реестра, атакующий публикует публичный @acme/auth с более высокой версией, и неверно настроенный резолвер подтягивает самозванца поверх твоего приватного. Защиты: всегда используй scoped-пакеты для внутреннего кода, пини каждый scope к его приватному реестру в .npmrc (@acme:registry=...), проверяй точные имена пакетов перед добавлением и полагайся на lockfile, чтобы неожиданное имя не проскользнуло без ревью.

Выбери лучший вариант

Твой CI-пайплайн устанавливает зависимости перед запуском тестов, и в репозитории есть закоммиченный lockfile. Какую команду установки должен использовать пайплайн?

Нельзя засендбоксить недоверенный код через vm

Когда нужно запустить код, которому не доверяешь — присланную пользователем формулу, плагин, — соблазнительный неверный ответ — встроенный модуль vm в Node. vm — это не песочница безопасности. Так говорит его собственная документация. Он запускает код в отдельном контексте V8, но этот контекст делит прототипы и ссылки с хостом: недоверенный код может вскарабкаться от конструктора переданного объекта вверх к Function realm-а хоста и реконструировать полный доступ, целиком сбежав из «песочницы».

const vm = require("node:vm");
// Побег: достать конструктор Function хоста через цепочку переданного объекта.
const escape = "this.constructor.constructor('return process')().env";
// vm.runInNewContext(escape, { /* "sandboxed" */ }) // → утечёт process.env

Реальная изоляция требует реальной границы. Запускай недоверенный код в отдельном процессе или worker-потоке со сброшенными привилегиями, внутри контейнера или microVM с ограниченными syscall-ами и без сети, или в специально построенном изоляте вроде isolated-vm (настоящий отдельный V8-изолят) или закалённого SES/vm2-наследника. Важен тот механизм, что недоверенный код не должен делить ни граф объектов, ни процесс с твоими секретами — другой контекст V8 на той же куче этой границей не является.

Викторина

Почему CI-пайплайн должен использовать `npm ci`, а не `npm install`, для репозитория с закоммиченным lockfile?

Викторина

Почему модуль `vm` в Node небезопасен как песочница для запуска недоверенного пользовательского кода?

Расставь шаги по порядку

Расставь шаги, чтобы запереть CI-установку от атак цепочки поставок — от предпосылки до последней линии обороны:

  1. 1 Закоммить package-lock.json, чтобы точные версии и sha512-хеши целостности были запинены в репозитории
  2. 2 Используй `npm ci` (а не `npm install`), чтобы сборка шла только из lockfile и падала на дрейфе
  3. 3 Добавь `--ignore-scripts` для установок, которым не до конца доверяешь, проверив те немногие пакеты, которым скрипты реально нужны
  4. 4 Гейти пайплайн через `npm audit --audit-level=high` и автоматизируй апгрейды через Dependabot/Renovate
  5. 5 Положи сверху поведенческий сканер (Snyk/Socket), чтобы ловить вредоносные релизы, у которых ещё нет CVE
Вспомните перед уходом
  1. 01
    Мейнтейнер публикует вредоносный патч-релиз транзитивной зависимости, чей postinstall читает process.env. Какие две твои защиты его блокируют и как каждая работает?
  2. 02
    Почему модуль vm в Node — не песочница безопасности и как правильно запускать недоверенный пользовательский код?
Итог

Безопасность зависимостей начинается с одного факта: npm install запускает произвольный код, потому что пакеты могут объявлять lifecycle-скрипты preinstall/install/postinstall, которые выполняются с твоими правами и могут читать process.env до того, как побежит твой собственный код или какой-либо сканер — так что блокируй их через npm ci --ignore-scripts (или npm config set ignore-scripts true) для недоверенных установок и ужимай поверхность атаки минимизацией количества зависимостей и транзитивной глубины, поскольку бóльшая часть твоего кода — сторонняя. Lockfile — твоя граница целостности: package-lock.json пинит точные версии и sha512-SRI-хеши, а npm ci ставит строго из него, падает на дрейфе package.json/lock и проверяет целостность — так что коммить его и используй npm ci в CI, никогда не npm install, который может мутировать lock и подтянуть вредоносный патч в диапазоне. Положи сверху детекцию: npm audit --audit-level=high гейтит известные CVE (шумно и только по сигнатурам), Dependabot/Renovate автоматизируют апгрейды, а Snyk/Socket инспектируют поведение, чтобы ловить релизы без CVE. Защищай ввод рантайма от prototype pollution — отклоняй ключи __proto__/constructor/prototype, используй map-ы Object.create(null) и рассмотри --disable-proto=delete — и предпочитай scoped-пакеты, запиненные к приватному реестру, чтобы разбить typosquatting и dependency confusion. Наконец, никогда не считай модуль vm песочницей: он делит прототипы с хостом и сбегаем, поэтому запускай по-настоящему недоверенный код в отдельном процессе, контейнере или isolated-vm, где он не дотянется ни до твоего графа объектов, ни до твоих секретов. Теперь, когда откроешь PR с новой зависимостью или настроишь CI, ты знаешь, что три команды — npm ci, --ignore-scripts, npm audit — это неотменяемый базис.

Практика

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

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.