{ "index": 169, "slug": "editorial-2023-04-field-dependency-security", "title": "Уязвимая зависимость в дереве: как доказать безопасное обновление", "excerpt": "Почему предупреждение сканера не равно доказанной уязвимости в runtime и как проверить обновление зависимости по дереву, окружению, сценарию запуска и откату.", "contentHtml": "

Сканер сообщает об уязвимой версии пакета. Пакет не указан в package.json, поэтому команда решает, что он не используется. Через несколько часов обновление ломает сборку: изменилось транзитивное дерево, peer-зависимость перестала разрешаться, а новый пакет требует другой Node.js. Цена ошибки — остановленный деплой, срочный откат и неясный ответ на вопрос, какой артефакт уже попал в окружение.

\n

Обратная ошибка тоже дорогая. Команда удаляет пакет из манифеста, получает зелёный install и закрывает предупреждение. Но уязвимый модуль остаётся в lockfile или в другом production-артефакте. Исправление должно отвечать на два разных вопроса: входит ли компонент в поставляемый артефакт и что изменится после его обновления.

\n

Тезис: версия не является доказательством

\n

Безопасное обновление — это не замена одной строки в манифесте. Это проверяемая цепочка: идентифицированный компонент, зафиксированное дерево, воспроизводимая установка, проверка приложения в целевом runtime и готовый путь возврата.

\n

Уязвимость обычно приходит через несколько уровней. Приложение зависит от http-client. Он зависит от parser. Advisory указывает на старую версию parser. Вызов метода может находиться далеко от корневого кода, но пакет всё равно входит в установленное дерево. Обратное также верно: запись в lockfile ещё не доказывает, что компонент вошёл в собранный образ или реально загружен процессом.

\n

Как устроена проверка

\n

Сначала отделите четыре объекта. Манифест описывает намерение проекта. Lockfile фиксирует разрешённое дерево и версии записей. SBOM описывает состав конкретного артефакта, если его построили из этого артефакта. Runtime-наблюдение показывает, что произошло при запуске. Эти источники отвечают на разные вопросы и не заменяют друг друга.

\n

Начните с baseline. Запишите commit, package manager, версию Node.js, команду установки и digest исходного артефакта. Затем назовите candidate: пакет, исходную версию, новую версию и advisory. Для транзитивной зависимости добавьте прямую цепочку родителей. Без baseline нельзя понять, удалил ли update уязвимый узел или только переставил его в другое место.

\n
Проверки безопасности обновления
ПроверкаЧто она доказываетЧто сохранить
Dependency treeКакие записи разрешил установщик и кто приводит к уязвимому пакетуDiff lockfile и команду получения дерева
Clean installЧто зафиксированный проект устанавливается в чистой средеВерсию инструмента, exit code и лог
Application testsЧто выбранные контракты приложения не изменилисьНабор тестов и окружение запуска
Runtime smokeЧто сервис стартует и проходит важный сценарийЗапрос, ответ, лог и trace или метрику
Artifact scanЧто проверенный образ или архив не содержит запрещённую версиюDigest артефакта и результат сканирования
RollbackЧто команда может вернуть baseline без новой догадкиСтарый digest, lockfile и условие остановки
\n

Пример с транзитивным пакетом

\n

Следующий фрагмент — учебный. Он не читает настоящий lockfile и не устанавливает пакеты. Он показывает, почему проверка должна искать не только прямые зависимости. В реальном проекте результат нужно получить командой package manager и сопоставить с образом, который будет выпущен.

\n
const tree = {\n  name: 'checkout-service',\n  version: '1.4.0',\n  dependencies: {\n    'http-client': {\n      version: '4.2.0',\n      dependencies: {\n        parser: { version: '1.0.0' }\n      }\n    }\n  }\n};\n\nfunction findPackage(node, name, path = [node.name]) {\n  if (node.name === name) return { version: node.version, path };\n\n  for (const [childName, child] of Object.entries(node.dependencies ?? {})) {\n    const found = findPackage(child, name, [...path, childName]);\n    if (found) return found;\n  }\n\n  return null;\n}\n\nconsole.log(findPackage(tree, 'parser'));\n// { version: '1.0.0', path: [\n//   'checkout-service', 'http-client', 'parser'\n// ] }
\n

Результат примера означает только одно: узел найден в заданной структуре. Он не доказывает наличие CVE, достижимость опасного кода, состав production-образа или безопасность версии 1.0.1. Чтобы сделать вывод о конкретной системе, добавьте источник advisory, реальный lockfile и digest артефакта.

\n

Иллюстрация цепочки доказательств

\n
\"Цепочка
Порядок проверок отделяет состав дерева от поведения приложения. Зелёный install не открывает выпуск без runtime smoke и проверки итогового артефакта.
\n

Симптом → причина → проверка → действие

\n
Диагностика типичных ложных выводов
СимптомПричинаПроверкаДействие
Пакет не виден в package.jsonОн транзитивныйПостроить дерево от production rootОбновить родителя или добавить безопасное разрешение с проверкой результата
Lockfile обновился, но advisory осталсяResolver выбрал другую ветку или копию пакетаНайти все записи имени и версииРазобрать каждого родителя и пересчитать artifact contents
npm ci зелёный, сервис не стартуетНе совпал runtime, peer range, native addon или module formatЗапустить smoke в том же образе и с тем же Node.jsОстановить выпуск, сузить update или подготовить совместимый runtime
Сканер образа не находит старый пакетПроверен не тот digestСопоставить digest скана и release candidateПересканировать именно публикуемый артефакт
Откат возвращает версию, но данные не читаютсяUpdate изменил формат или внешний протоколПроверить обратную совместимость миграцииРазделить изменение формата и замену зависимости
\n

Порядок действий

\n
  1. Скопируйте advisory и укажите источник, пакет, затронутый диапазон и дату проверки. Не называйте компонент уязвимым для приложения, пока не проверили его путь в артефакте.
  2. Зафиксируйте baseline: commit, lockfile, package manager, Node.js, platform и digest текущего кандидата на выпуск.
  3. Постройте dependency tree для production-команды. Найдите прямой путь к каждой копии компонента и проверьте optional и peer-ветки.
  4. Сформируйте минимальный candidate update. Измените только необходимые записи, затем прочитайте весь diff lockfile: версии, integrity, источники, scripts и соседние разрешения.
  5. В чистом окружении выполните установку с теми же флагами, которые использует pipeline. Сохраните команду и exit code. Не превращайте warning о runtime в PASS.
  6. Запустите тесты границ, которых касается пакет: startup, обработка входных данных, сетевой вызов, сборка, CLI или browser bundle. Название теста должно объяснять проверяемый контракт.
  7. Сделайте smoke в образе-кандидате. Проверьте старт, один безопасный сценарий и отрицательный путь. Например, некорректный вход должен получить ожидаемый отказ, а не попасть в обработчик.
  8. Сканируйте образ или архив по immutable digest. Сверьте состав скана с lockfile и SBOM, если SBOM создаёт ваш pipeline. Различие источников — сигнал к расследованию, а не повод выбрать удобный результат.
  9. Перед публикацией проверьте rollback на уровне артефакта. Если изменение затрагивает миграцию, формат данных или внешний протокол, остановите независимый откат версии и подготовьте совместимый план.
\n

Почему engines и lockfile недостаточны

\n

Поле engines выражает заявленный диапазон. Оно не запускает приложение, не проверяет native addon и не показывает, какая ветка разрешилась в конкретной платформе. При мягкой настройке package manager несовпадение может остаться предупреждением. Поэтому engine check полезен как ранний фильтр, но не как итог.

\n

Lockfile даёт воспроизводимую точку для установки. Он не является снимком уже работающего процесса. Сборка может исключить пакет, добавить его в другой слой, заменить optional dependency или использовать иной lockfile. Состав проверяйте на выходном артефакте, а поведение — в целевом runtime.

\n

Ограничения и отрицательный путь

\n

Dependency scanner не знает автоматически, может ли атакующий достичь уязвимого вызова. Runtime smoke не доказывает отсутствие всех опасных путей. SBOM не доказывает, что его создали из того же digest, который публикуют. Результаты нужно связывать с конкретным commit и артефактом.

\n

Если компонент найден, но его путь не достигается в вашем сценарии, это не разрешение игнорировать advisory. Зафиксируйте границу утверждения: «путь не достигнут в проверенном сценарии». Затем проверьте другие entry point, worker, CLI, background job и режимы сборки. Если среда не позволяет выполнить нужный сценарий, статус должен остаться неизвестным. Не заменяйте его словом «безопасно».

\n

Если clean install не проходит, не чините результат добавлением случайного флага. Сначала сравните manifest и lockfile, peer range и runtime. Если smoke не проходит после установки, возврат к baseline предпочтительнее выпуска с неясной совместимостью. Если digest скана не совпадает с digest релиза, остановите публикацию.

\n

Проверяемый критерий готовности

\n

Обновление готово к выпуску, когда команда может предъявить один набор связанных доказательств: advisory и scope, baseline и candidate, полный diff дерева, успешную чистую установку, тесты затронутого контракта, smoke в целевом образе, результат сканирования того же digest и проверяемый rollback. Каждый результат имеет команду, окружение и exit status или наблюдаемый ответ.

\n

Любой пропущенный элемент помечается явно: not run, not applicable с причиной или unknown. Слово PASS допустимо только для реально выполненной проверки. Если доказательства относятся к другому commit, образу или runtime, обновление не готово.

\n

Проверяемые источники

\n" }