8 lines
24 KiB
JSON
8 lines
24 KiB
JSON
{
|
||
"index": 169,
|
||
"slug": "editorial-2023-04-field-dependency-security",
|
||
"title": "Безопасность зависимостей: как проверить транзитивный пакет до релиза",
|
||
"excerpt": "Сканер нашёл пакет, которого нет в package.json. Разбираем путь через npm-дерево, lockfile, production-артефакт и runtime, затем проверяем обновление и откат без ложного PASS.",
|
||
"contentHtml": "<p>Сканер сообщает об уязвимой версии пакета, но в <code>package.json</code> этого имени нет. Команда удаляет случайную строку, получает зелёный install и закрывает задачу. Позже выясняется, что пакет пришёл транзитивно, остался в другом production-артефакте или уже был упакован в образ. Обратный сценарий тоже опасен: обновление без проверки меняет peer-зависимость, требует другой Node.js и останавливает деплой.</p>\n<p>Проверяемый вывод должен быть уже: какой пакет найден, кто его привёл, в каком артефакте он оказался, достигается ли уязвимая ветка в названном сценарии и можно ли вернуться к исходному digest. Предупреждение scanner — вход в расследование, а не доказательство ни безопасности, ни эксплуатации.</p>\n<h2>Не начинайте с удаления строки</h2>\n<p>Зависимость может отсутствовать в корневом манифесте и всё равно входить в установленное дерево. Приложение зависит от <code>http-client</code>, тот — от <code>parser</code>, а advisory указывает на старую версию <code>parser</code>. Нужно проверить не только имя и версию, но и путь от production root до компонента.</p>\n<p>Есть и обратная граница. Запись в lockfile описывает результат разрешения, но не доказывает, что пакет физически попал в конкретный образ. Сборка может исключить dev-зависимость, заменить optional-ветку, собрать другой workspace или использовать другой lockfile. Поэтому после дерева проверяют именно выходной артефакт и его digest.</p>\n<h2>Четыре слоя доказательств</h2>\n<p>Разделите объекты до первого изменения. Манифест описывает намерение проекта и диапазоны прямых зависимостей. Lockfile фиксирует разрешённое дерево. SBOM (Software Bill of Materials) перечисляет компоненты и отношения поставки для названного артефакта. Runtime evidence показывает наблюдение конкретного процесса и сценария. Каждый слой отвечает на свой вопрос и не заменяет соседний.</p>\n<p>Сначала зафиксируйте <code>baseline</code>: commit, package manager, Node.js, настройки <code>.npmrc</code>, команду установки и digest текущего артефакта. Затем назовите <code>candidate</code>: пакет, исходную и целевую версии, advisory и предполагаемый путь обновления. Без baseline нельзя отличить удаление уязвимого узла от его перемещения в другую ветку.</p>\n<div class=\"table-scroll\"><table><caption>Что именно проверяет каждый слой</caption><thead><tr><th scope=\"col\">Слой</th><th scope=\"col\">Вопрос</th><th scope=\"col\">Что сохранить</th></tr></thead><tbody><tr><td><code>package.json</code></td><td>Какие direct dependencies и диапазоны объявлены?</td><td>Commit и diff манифеста</td></tr><tr><td><code>package-lock.json</code></td><td>Какое дерево разрешено зафиксированным проектом?</td><td>Полный diff, integrity и настройки resolver</td></tr><tr><td><code>node_modules</code> / image</td><td>Что физически установлено в проверенном артефакте?</td><td>Имя образа, digest и состав слоя</td></tr><tr><td>SBOM</td><td>Какие компоненты заявлены для named artifact?</td><td>Формат, источник генерации и связь с digest</td></tr><tr><td>Runtime</td><td>Что произошло в названном entry point?</td><td>Команду, вход, ответ, лог и отрицательный путь</td></tr></tbody></table></div>\n<h2>Воспроизводимый маршрут для npm</h2>\n<p>Ниже команды для проекта на npm. Подставьте в <code>PACKAGE</code> имя из advisory и выполняйте их в том workspace, который собирается в production. Если pipeline использует <code>--legacy-peer-deps</code>, workspaces или иной <code>.npmrc</code>, повторите те же настройки: разрешение зависит не только от текста манифеста.</p>\n<pre><code>PACKAGE=parser\n\nnode --version\nnpm --version\nnpm ci\nnpm ls \"$PACKAGE\" --all --omit=dev --json > /tmp/dependency-tree.json\nnpm explain \"$PACKAGE\"\nnpm audit --omit=dev --json > /tmp/npm-audit.json</code></pre>\n<p><code>npm ci</code> — контрольная точка. По официальной документации ему нужен существующий lockfile; при расхождении с <code>package.json</code> команда завершается ошибкой, удаляет имеющийся <code>node_modules</code> и не переписывает манифест или lockfile. Если установка упала, это отдельный результат: сначала разберите peer-диапазоны, версию npm и флаги, с которыми lockfile был создан.</p>\n<p>Не заменяйте <code>npm ci</code> на <code>npm install</code> ради зелёного exit code: обычная установка может пересчитать lockfile. Флаг <code>--omit=dev</code> убирает dev-зависимости с диска, но записи о них остаются разрешёнными в lockfile. Если build использует dev-инструменты, описывайте build-артефакт отдельной проверкой.</p>\n<p><code>npm ls --all --omit=dev --json</code> показывает логическое дерево установленных пакетов и может отметить missing, invalid или extraneous узлы. <code>npm explain</code> даёт обратный путь — почему пакет присутствует. Нулевой вывод не закрывает advisory, пока не проверены правильные workspace, lockfile, режим установки и artifact.</p>\n<h2>Найдите каждый путь в дереве</h2>\n<p>Одна строка <code>parser@1.0.0</code> отвечает только на вопрос о версии. Один пакет может присутствовать несколько раз из-за несовместимых диапазонов. Логическое дерево npm также не равно физическому расположению на диске: deduplication и peer-зависимости меняют картину. Для решения нужны все пути от корня.</p>\n<pre><code>function findPaths(node, wanted, path = []) {\n const name = node.name || '<root>';\n const version = node.version || 'unknown';\n const current = path.concat(name + '@' + version);\n const matches = node.name === wanted\n ? [{ version: version, path: current }]\n : [];\n\n for (const entry of Object.entries(node.dependencies || {})) {\n const childName = entry[0];\n const child = entry[1];\n matches.push(...findPaths(\n Object.assign({ name: childName }, child),\n wanted,\n current,\n ));\n }\n\n return matches;\n}\n\nconst tree = JSON.parse(require('fs').readFileSync(0, 'utf8'));\nconst packageName = process.argv[2] || 'parser';\nconsole.log(JSON.stringify(findPaths(tree, packageName), null, 2));</code></pre>\n<p>Сохраните фрагмент как <code>find-paths.js</code> и выполните <code>node find-paths.js parser < /tmp/dependency-tree.json</code>. Учебный вывод может показать две цепочки — через <code>http-client@4</code> и через <code>markdown-tool@2</code>. Это доказывает две записи в переданном JSON, но не доказывает, что обе попали в image и что опасная ветка вызывается. Учитывайте это ограничение при формулировке результата.</p>\n<h2>Сопоставьте дерево с артефактом</h2>\n<figure><img src='/assets/editorial/2023/dependency-security-2023-lockfile-sbom.svg' alt='Схема границ manifest, lockfile, SBOM и runtime evidence: каждый следующий слой требует отдельной проверки и не является автоматической гарантией предыдущего' loading='lazy' /><figcaption>Manifest выражает намерение, lockfile — разрешённое дерево, SBOM — состав названного артефакта, runtime evidence — наблюдение конкретного сценария. Стрелки означают проверку связи, а не доказанную безопасность.</figcaption></figure>\n<p>SBOM полезен только вместе с идентичностью сборки: commit, digest, временем и понятным источником генерации. Файл с названием <code>sbom.json</code> без связи с release candidate может относиться к соседнему образу. Сверяйте компонент, версию, источник и relationship; расхождение с lockfile — сигнал расследования, а не повод выбрать более удобный результат.</p>\n<p>Сканируйте immutable digest именно публикуемого образа или архива. Локальный <code>node_modules</code>, staging-образ и предыдущий digest не являются заменой. Если сборка создаёт несколько image layers, выясните, где лежит компонент и не остаётся ли старая копия в другом слое.</p>\n<div class=\"table-scroll\"><table><caption>Типовой симптом и следующий тест</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Гипотеза</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Пакета нет в <code>package.json</code></td><td>Он транзитивный или пришёл из другого workspace</td><td><code>npm ls --all</code> и <code>npm explain</code> в production root</td><td>Назвать родителя и проверить каждый путь</td></tr><tr><td>Lockfile изменился, advisory остался</td><td>Осталась другая копия или ветка</td><td>Сравнить все записи и версии в дереве и SBOM</td><td>Обновить каждого владельца либо обосновать исключение</td></tr><tr><td><code>npm ci</code> зелёный, сервис не стартует</td><td>Не совпали Node.js, peer range, ABI или module format</td><td>Smoke в том же image и runtime</td><td>Остановить выпуск и сузить candidate</td></tr><tr><td>Сканер не видит старую версию</td><td>Проверен другой digest или слой</td><td>Сопоставить digest отчёта и release</td><td>Пересканировать публикуемый artifact</td></tr><tr><td>Rollback вернул версию, но данные не читаются</td><td>Изменился формат или внешний протокол</td><td>Проверить обратную совместимость миграции</td><td>Разделить security update и изменение данных</td></tr></tbody></table></div>\n<h2>Отделите наличие от достижимости</h2>\n<p>Наличие пакета и достижимость уязвимого кода — разные утверждения. Назовите entry point, импорт или вызов, входные данные и режим исполнения. Для сервера это HTTP-обработчик, для CLI — команда, для worker — сообщение из очереди. Формулировка «ветка не достигнута в проверенном сценарии» честнее, чем «уязвимости нет».</p>\n<p>Проверьте optional-зависимости на целевой платформе, peer-зависимости, bundled packages, worker, cron и отдельный CLI. Сборщик может исключить статический импорт, а динамический <code>require</code> сохранить путь. Строковый поиск находит имя, но не учитывает условие, экспорт, bundler и конфигурацию.</p>\n<p>Минимальное evidence — связка dependency path, содержимого production-артефакта и названного сценария с ожидаемым результатом. Добавьте отрицательный путь: некорректный вход должен быть отклонён ожидаемым способом, а недоступная optional-часть не должна молча создавать видимость исправности.</p>\n<h2>Обновление как контролируемое изменение</h2>\n<p>Сформируйте небольшой candidate. Обновляйте прямого родителя, если он выпускает совместимую версию, или добавляйте явное разрешение с объяснением владельца риска. Не меняйте одновременно Node.js, package manager и несколько крупных библиотек: rollback перестанет показывать причину отказа.</p>\n<p>Прочитайте весь diff lockfile: resolved URL, integrity, peer и optional-поля, количество копий и новые install scripts. Поле <code>engines</code> выражает заявленный диапазон совместимости. Без <code>engine-strict</code> npm может оставить предупреждение и продолжить, а строгая проверка всё равно не запускает приложение и не проверяет native addon.</p>\n<p>Повторите clean install с параметрами pipeline. Затем запустите startup, импорт компонента, валидный и невалидный вход, сборку, сетевой вызов и затронутый CLI. У каждого теста должны быть команда, окружение и exit code. Фраза «всё прошло» без этого набора не является доказательством.</p>\n<h2>Порядок действий перед выпуском</h2>\n<ol><li>Скопируйте advisory: пакет, затронутый диапазон, исправленную версию и источник. Отделите утверждение advisory от гипотезы о вашем приложении.</li><li>Определите production root и baseline: commit, lockfile, Node.js, npm, <code>.npmrc</code> и digest текущего артефакта.</li><li>Выполните <code>npm ci</code>, затем сохраните <code>npm ls --all --omit=dev --json</code> и <code>npm explain PACKAGE</code>. Разберите каждый путь и копию.</li><li>Соберите candidate с минимальным diff. Проверьте манифест, lockfile, integrity, peer/optional-ветки и install scripts.</li><li>Повторите clean install и тесты на том же runtime. Проверьте успешный и отрицательный сценарий затронутого entry point.</li><li>Соберите image или архив. Сканируйте его immutable digest и сопоставьте результат с SBOM, lockfile и commit.</li><li>Сделайте runtime smoke в том же окружении: старт, безопасный запрос, ожидаемый отказ и нужные worker/CLI paths.</li><li>Проверьте rollback к baseline. При изменении формата данных, миграции или протокола отдельно подтвердите обратную совместимость.</li></ol>\n<h2>Ложные зелёные результаты</h2>\n<p>Зелёный <code>npm audit</code> не означает, что внешний scanner ошибся: базы advisory, области установки и правила инструментов различаются. Красный scanner тоже не доказывает достижимость вызова. Зафиксируйте источник, версию базы, путь пакета и artifact, затем согласуйте решение с владельцем риска.</p>\n<p>Установка с <code>--ignore-scripts</code> не проверяет install script, который нужен приложению. macOS-проверка не заменяет Linux-образ, если native dependency собирается в CI. Локальная папка не заменяет image digest. Эти ограничения пишутся рядом с результатом, иначе отчёт создаёт ложную уверенность.</p>\n<h2>Ограничения применимости</h2>\n<p>Маршрут рассчитан на npm-проекты с <code>package-lock.json</code>. Для Yarn, pnpm, Cargo, Maven или системных пакетов команды и формат lockfile будут другими, хотя разделение manifest, resolved tree, artifact и runtime остаётся полезной моделью. Не переносите npm-команды в другой менеджер без сверки его документации.</p>\n<p>Статья не определяет exploitability и не заменяет threat model, code review или расследование инцидента. Достижимость зависит от кода, конфигурации, прав, входных данных и внешних сервисов. Если не удалось получить точный digest, выполнить сценарий или сопоставить SBOM с build, статус должен быть <code>unknown</code>, а не <code>safe</code>.</p>\n<p>Major-обновление требует отдельной оценки API-изменений. Для native addon добавьте проверку ABI и целевой платформы. Для private registry сохраняйте provenance, но не публикуйте токены и приватные URL в отчёте.</p>\n<h2>Критерий готовности</h2>\n<p>Обновление готово к выпуску, когда одна связанная запись содержит advisory и scope, baseline и candidate, все dependency paths, diff lockfile, clean install, тесты затронутого контракта, runtime smoke, SBOM или иной состав артефакта, сканирование того же digest и проверяемый rollback. У каждого результата есть команда, окружение и exit code либо наблюдаемый ответ.</p>\n<p>Любой пропуск получает явную отметку: <code>not run</code>, <code>not applicable</code> с причиной или <code>unknown</code>. Пока не доказана связь между пакетом, образом и runtime-сценарием, предупреждение не закрыто. Так команда принимает не «самую новую версию», а изменение с понятной областью действия и обратным ходом.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://docs.npmjs.com/cli/v8/commands/npm-ci\" target=\"_blank\" rel=\"noopener noreferrer\">npm Docs: npm ci</a> — требования к lockfile, согласованности с <code>package.json</code>, очистке <code>node_modules</code> и неизменности lockfile во время CI-установки.</li><li><a href=\"https://docs.npmjs.com/cli/v8/commands/npm-ls\" target=\"_blank\" rel=\"noopener noreferrer\">npm Docs: npm ls</a> — логическое дерево установленных пакетов, флаги <code>--all</code>, <code>--json</code> и ограничение: вывод не равен физическому расположению на диске.</li><li><a href=\"https://docs.npmjs.com/cli/v8/commands/npm-explain\" target=\"_blank\" rel=\"noopener noreferrer\">npm Docs: npm explain</a> — обратный ответ на вопрос, почему пакет присутствует в дереве.</li><li><a href=\"https://docs.npmjs.com/cli/v8/configuring-npm/package-json\" target=\"_blank\" rel=\"noopener noreferrer\">npm Docs: package.json</a> — назначение поля <code>engines</code> и его advisory-характер без <code>engine-strict</code>.</li><li><a href=\"https://www.ntia.gov/report/2021/minimum-elements-software-bill-materials-sbom\" target=\"_blank\" rel=\"noopener noreferrer\">NTIA: The Minimum Elements for a Software Bill of Materials</a> — состав и назначение SBOM, включая компоненты, связи поставки и границу: SBOM не решает все проблемы безопасности сам по себе.</li></ul>"
|
||
}
|