Скрывает ли ваш lockfile уязвимости?
Lockfile — самый честный документ в вашем репозитории: в нём перечислены точные версии каждого пакета, который реально устанавливает ваша сборка, без диапазонов, без «latest», без сюрпризов. Но он же, тихо, — список поверхности атаки: каждая закреплённая версия — это версия, в которой кто-то мог найти дыру с тех пор, как вы её закрепили. В этой статье объясняется, как сканер превращает этот список в ответы: как база OSV сопоставляет версии с известными уязвимостями, почему флаг CISA KEV важнее балла CVSS и почему «уязвимостей не найдено» — это утверждение о базе данных, а не о вашем коде. Проверить это можно самостоятельно в SBOM Vulnerability Scanner, который разбирает ваш lockfile в браузере и никогда его не загружает.
Что закрепляет lockfile — и почему это поверхность атаки
Современное программное обеспечение — это по большей части чужой код. Типичное веб-приложение подтягивает сотни пакетов, а каждый из них — ещё больше: прямая зависимость от веб-фреймворка легко превращается в двести транзитивных пакетов, которые вы никогда не выбирали и не проверяли. Lockfile — это запись этого дерева: package-lock.json для npm, полностью закреплённый requirements.txt для Python, go.mod и go.sum для Go, — записанная так, чтобы каждая установка на каждой машине давала одно и то же дерево. Без него «у меня на машине работает» — это надежда, а не факт.
Та же самая точность делает lockfile правильным местом для поиска известных уязвимостей. База уязвимостей индексируется по имени пакета, экосистеме и точной версии: lodash 4.17.20 уязвим, lodash 4.17.21 — нет, и разница определяется одним запросом. Плавающий диапазон так оценить нельзя — сканеру пришлось бы угадывать, какую версию вы реально установили, а угадывание — не практика безопасности. Поэтому первое правило гигиены lockfile простое и не подлежащее обсуждению: коммитьте lockfile, держите его закреплённым и сканируйте закреплённое дерево, а не список желаний в вашем манифесте.
Как OSV сопоставляет версии с известными уязвимостями
OSV.dev — это публичная база уязвимостей, которая агрегирует advisories из GitHub, NVD и языковых экосистем в один индекс с возможностью запросов. Сканер отправляет ей пакет троек «имя, экосистема, версия» — lodash, npm, 4.17.20 — и получает обратно идентификаторы уязвимостей, затрагивающих именно эту версию. Затем идентификаторы загружаются полностью: описание, затронутые диапазоны с их исправленными версиями, серьёзность в виде вектора CVSS и псевдонимы CVE. С запросом не уходит ничего о вашем проекте — только публичные координаты публичных пакетов.
Совпадение намеренно примитивное, и в этом его сила. Оно не читает ваш код, не оценивает, вызываете ли вы уязвимую функцию, и ему всё равно, как используется пакет. Если в advisory сказано, что затронуты версии до 4.17.21, а вы закрепили 4.17.20, вы совпадаете — даже если ваш код никогда не касается уязвимой функции. Такое избыточное срабатывание — это преимущество: анализ достижимости — более сложный и медленный вопрос, а дешёвый вопрос («известна ли эта версия как плохая?») уже ловит зависимость, которую вы забыли обновить три года назад.
Чего сканер не видит
У проверки, основанной на сопоставлении версий, есть жёсткая граница, и её стоит назвать прямо: она может найти только уязвимости, о которых кто-то уже сообщил, для версии, известной базе. Уязвимость, обнаруженная вчера и ещё не опубликованная, невидима. Пакет, который никогда не был уязвим, но вредоносен — тайпосквоттер популярного имени, скомпрометированный релиз, зависимость, которая передаёт данные домой, — вообще не имеет CVE, и никакой сканер такого рода его не пометит. То же касается вашего собственного кода: сканер оценивает пакеты, которые вы устанавливаете, а не код, который вы пишете.
Именно поэтому результат «уязвимостей не найдено» нужно читать внимательно. Он означает: для этих точных версий, в этой базе данных, на данный момент ничего не зарегистрировано. Это полезный отрицательный результат — чистое сканирование свежего lockfile действительно хорошая новость, — но это не сертификат. Защита от того, чего сканер не видит, — это другие инструменты: закрепление и проверка новых зависимостей до того, как они попадут в дерево, непрерывное отслеживание новых advisories, а не раз в квартал, и подозрительное отношение к внезапному новому релизу тихого пакета. Сканер — это дымовой датчик, а не пожарная команда.
Почему флаг KEV важнее высокого балла CVSS
Балл CVSS отвечает на гипотетический вопрос: насколько плоха была бы эта уязвимость, если бы кто-то её эксплуатировал? Каталог известных эксплуатируемых уязвимостей CISA отвечает на факт: эксплуатирует ли её кто-то в реальных условиях прямо сейчас? Эти два ответа регулярно расходятся. Уязвимость с оценкой 9.8, которую никто не превратил в оружие, может подождать до следующего планового обновления; уязвимость с оценкой 5.3, которая есть в списке KEV — со сроком устранения, установленным CISA, — заслуживает исправления на этой неделе, потому что эксплойт уже существует и уже используется.
Именно эта инверсия — причина, по которой сканер отдельно помечает строки KEV, вместо того чтобы сводить их в сортировку по серьёзности. Порядок сортировки говорит вам, что плохо; значок KEV говорит вам, что срочно. На практике сортировка короткая: сначала исправьте элементы KEV, затем критические с опубликованной исправленной версией, а остальное запланируйте на следующий цикл обновлений. Bulk CVE Triage на этом сайте помещает те же CVE в контекст эксплуатации — статус KEV, вероятность EPSS и CVSS вместе, — когда одного значка недостаточно, чтобы обосновать работу команде.
Коммитьте lockfile, сканируйте его в CI — а не раз в квартал
Lockfile, который не закоммичен, — это lockfile, которого не существует. Если он живёт только на машине одного разработчика, каждая другая машина устанавливает то, во что «latest» разрешается в этот день, и дерево, которое вы просканировали, — не то дерево, которое вы поставляете. Его коммит — самый дешёвый контроль цепочки поставок: он делает установки воспроизводимыми, делает ответы сканера верными для всех и превращает обновление зависимости в рецензируемый diff вместо молчаливого дрейфа.
Вторая половина — это ритм. Уязвимости публикуются каждый день, поэтому сканирование раз в квартал — это снимок, который стареет за дни. Полезная форма — сканирование при каждом pull request, затрагивающем lockfile, плюс плановое сканирование основной ветки — тот же цикл, что и у набора тестов, с тем же свойством: новое advisory делает зелёную сборку красной в день публикации, когда исправление — это повышение версии, а не разбор после инцидента. Браузерный сканер на этом сайте — ручная версия этого цикла: вставьте lockfile после обновления от dependabot или перед релизом и подтвердите дерево, которое собираетесь поставлять.
Исправленные версии, ломающие изменения и когда исправлять, а когда закреплять
Каждое совпадение в сканировании несёт исправленную версию — первый релиз, в котором уязвимости нет, — или честное «не опубликовано». Обычный случай — патч- или минор-релиз, и обновление — это изменение в одну строку в lockfile. Но исправленная версия не всегда безопасна для принятия: переход на мажорную версию может изменить поведение, от которого зависит ваш код, а исправление, выпущенное вчера, прошло лишь день реального тестирования. Решение — то же, что и при любом обновлении зависимости: прочитайте changelog, запустите тесты и сначала обновите пакеты, которые вы реально импортируете.
Когда исправленной версии нет, вариантов остаётся три: удалить зависимость, если она никому не нужна, заменить её поддерживаемой альтернативой или смягчить уязвимый путь в коде — перестать передавать недоверенные данные в затронутую функцию или поставить вызов за проверку, описанную в advisory, — и следить за обновлениями advisory. Чего делать не следует, так это закреплять уязвимую версию и забывать о ней: закрепление — это решение, и оно заслуживает комментария в lockfile или в трекере задач с объяснением почему и датой пересмотра. После любого изменения просканируйте заново; сканер занимает секунды, и второе сканирование — единственное доказательство того, что находки первого сканирования исчезли.