{ "index": 169, "slug": "editorial-2023-04-field-dependency-security", "title": "Уязвимая зависимость в дереве: как доказать безопасное обновление", "excerpt": "Почему предупреждение сканера не равно доказанной уязвимости в runtime и как проверить обновление зависимости по дереву, окружению, сценарию запуска и откату.", "contentHtml": "
Сканер сообщает об уязвимой версии пакета. Пакет не указан в package.json, поэтому команда решает, что он не используется. Через несколько часов обновление ломает сборку: изменилось транзитивное дерево, peer-зависимость перестала разрешаться, а новый пакет требует другой Node.js. Цена ошибки — остановленный деплой, срочный откат и неясный ответ на вопрос, какой артефакт уже попал в окружение.
Обратная ошибка тоже дорогая. Команда удаляет пакет из манифеста, получает зелёный install и закрывает предупреждение. Но уязвимый модуль остаётся в lockfile или в другом production-артефакте. Исправление должно отвечать на два разных вопроса: входит ли компонент в поставляемый артефакт и что изменится после его обновления.
\nБезопасное обновление — это не замена одной строки в манифесте. Это проверяемая цепочка: идентифицированный компонент, зафиксированное дерево, воспроизводимая установка, проверка приложения в целевом runtime и готовый путь возврата.
\nУязвимость обычно приходит через несколько уровней. Приложение зависит от http-client. Он зависит от parser. Advisory указывает на старую версию parser. Вызов метода может находиться далеко от корневого кода, но пакет всё равно входит в установленное дерево. Обратное также верно: запись в lockfile ещё не доказывает, что компонент вошёл в собранный образ или реально загружен процессом.
Сначала отделите четыре объекта. Манифест описывает намерение проекта. 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 и условие остановки |
Следующий фрагмент — учебный. Он не читает настоящий lockfile и не устанавливает пакеты. Он показывает, почему проверка должна искать не только прямые зависимости. В реальном проекте результат нужно получить командой package manager и сопоставить с образом, который будет выпущен.
\nconst 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 артефакта.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Пакет не виден в 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 изменил формат или внешний протокол | Проверить обратную совместимость миграции | Разделить изменение формата и замену зависимости |
Поле engines выражает заявленный диапазон. Оно не запускает приложение, не проверяет native addon и не показывает, какая ветка разрешилась в конкретной платформе. При мягкой настройке package manager несовпадение может остаться предупреждением. Поэтому engine check полезен как ранний фильтр, но не как итог.
Lockfile даёт воспроизводимую точку для установки. Он не является снимком уже работающего процесса. Сборка может исключить пакет, добавить его в другой слой, заменить optional dependency или использовать иной lockfile. Состав проверяйте на выходном артефакте, а поведение — в целевом runtime.
\nDependency 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Обновление готово к выпуску, когда команда может предъявить один набор связанных доказательств: advisory и scope, baseline и candidate, полный diff дерева, успешную чистую установку, тесты затронутого контракта, smoke в целевом образе, результат сканирования того же digest и проверяемый rollback. Каждый результат имеет команду, окружение и exit status или наблюдаемый ответ.
\nЛюбой пропущенный элемент помечается явно: not run, not applicable с причиной или unknown. Слово PASS допустимо только для реально выполненной проверки. Если доказательства относятся к другому commit, образу или runtime, обновление не готово.