Какие наши продукты задевает эта CVE? Я продолжил заброшенный Minefield и нашёл, что он читал SBOM задом наперёд

Выходит новость о критической уязвимости в популярной библиотеке. Первый вопрос, который задаёт себе любая команда, выпускающая больше одного продукта: а у нас она где? Не «есть ли она в этом репозитории» — на это отвечает любой сканер, — а «какие из наших продуктов её тянут, напрямую или через пять уровней чужих зависимостей, и что из этого чинить первым».
С 11 сентября 2026 года у этого вопроса появился срок. Cyber Resilience Act обязывает производителей продуктов с цифровыми элементами, продающихся в ЕС, сообщать об активно эксплуатируемых уязвимостях в своих продуктах: первое предупреждение — в течение 24 часов. Остальные требования, включая SBOM, вступают в силу в декабре 2027 года.
Под этот вопрос хорошо подходил Minefield от компании BitBom: граф зависимостей из SBOM всех продуктов на roaring bitmaps, с кэшем транзитивных зависимостей за O(n + m) и научной статьёй про этот кэш. У проекта 733 звезды. В августе 2025 года репозиторий заархивировали без объяснений.
Я продолжил его под именем Sapper (GitHub, Apache-2.0) и добавил то, чего не хватало для ответа на вопрос из первого абзаца: отчёт по уязвимостям с приоритетами по CISA KEV и EPSS. А по дороге нашёл, что граф, на котором всё держится, строился неправильно. В статье — как устроен Minefield, какие ошибки нашлись и как проверить, что исправление действительно исправление.
Как устроен Minefield
Идея простая. Каждый пакет из каждого SBOM становится узлом графа, имя узла — package URL (pkg:golang/golang.org/x/net@v0.23.0). Узел хранит два множества: кто от него зависит и от кого зависит он сам. Хранятся они не списками, а roaring bitmaps — сжатыми битовыми множествами идентификаторов узлов:
type Node struct {
Metadata any `json:"metadata"`
Children *roaring.Bitmap `json:"child"`
Parents *roaring.Bitmap `json:"parent"`
Type string `json:"type"`
Name string `json:"name"`
ChildData []byte `json:"childData"`
ParentData []byte `json:"parentData"`
ID uint32 `json:"ID"`
}
Уязвимости из OSV — тоже узлы, типа vuln, с ребром к каждому уязвимому пакету. Тогда вопрос «какие продукты задевает CVE» превращается в обход графа вверх по родителям от узла уязвимости.
Главное в Minefield — кэш. Обходить граф для каждого запроса дорого, поэтому команда cache заранее считает для каждого узла транзитивные множества зависимостей и зависимых. Наивно это O(n·(n + m)): обход из каждого узла. Minefield сначала находит сильно связные компоненты алгоритмом Тарьяна (в реальных графах зависимостей есть циклы), схлопывает их, а по получившемуся ациклическому графу считает множества динамическим программированием, объединяя bitmaps детей. Отсюда O(n + m) операций над множествами. Объединение roaring bitmaps быстрое, так что после кэша запросы вида
dependents library pkg:golang/golang.org/x/net@v0.23.0 and dependents library pkg:golang/golang.org/x/crypto@v0.21.0
отвечаются за миллисекунды.
Отдельно Minefield гордился моделью «air-gapped»: граф ничего не скачивает сам, все данные — файлы, которые ему дали. Для инструмента, который хранит состав всех ваших продуктов, это правильное решение, и я его сохранил.
Что сломалось за год
Проект, который никто не трогает, ломается от окружения. Здесь это было так.
SQLite требовал cgo. Драйвер mattn/go-sqlite3 — обёртка над C-библиотекой. Без компилятора C не собираются ни бинарник, ни тесты хранилища. Я перешёл на драйвер на чистом Go (glebarez/sqlite поверх modernc.org/sqlite), и Sapper собирается в один статический бинарник для шести платформ.
Попутно нашлась ловушка: база :memory: в SQLite живёт в рамках одного соединения, а database/sql держит пул. Второе соединение открывало новую пустую базу, и данные «пропадали» между запросами. Для базы в памяти пул теперь из одного соединения, а для файла включены WAL и busy_timeout.
База по умолчанию жила в памяти. Перезапустил сервер — граф пропал. Теперь по умолчанию это файл sapper.db в папке данных пользователя.
SQLite-хранилище было недоделано. Метод для произвольных данных (на нём держатся рейтинги и всё, что не вершины и не рёбра) в SQLite возвращал not implemented. Всё это работало только с Redis.
Зависимости. govulncheck находил достижимые уязвимости в golang.org/x/net и x/text. После обновления всех модулей он молчит. И вот тут началось интересное: обновление protobom — библиотеки, которая читает SBOM, — сломало сквозные тесты.
SBOM, прочитанный задом наперёд
После обновления protobom с 0.5 до 0.6 один из сквозных тестов вместо 6 зависимых пакетов стал находить 60. Первая мысль — сломался тест. Но тест был тривиальный: загрузить тестовые SBOM, построить кэш, спросить, кто зависит от пакета.
Оказалось, что ошибок две, и одна прятала другую.
protobom 0.5 при чтении CycloneDX терял рёбра dependsOn: из графа зависимостей SBOM оставались только рёбра вложенности. Граф получался бедным, но хотя бы непротиворечивым. Версия 0.6 эти рёбра читает. И вот тогда стало видно, что код Minefield читает любое ребро одинаково:
for _, edge := range nodeList.Edges {
// ...
for _, to := range edge.To {
fromNode.SetDependency(storage, toNode) // "From зависит от To"
}
}
Для CycloneDX это верно: A dependsOn B значит, что A зависит от B. А SPDX описывает связи в обе стороны: есть DEPENDS_ON, а есть DEPENDENCY_OF, RUNTIME_DEPENDENCY_OF, DEV_DEPENDENCY_OF, CONTAINED_BY и так далее. protobom сохраняет направление SPDX как есть: A runtimeDependency B означает «A — runtime-зависимость B», то есть B зависит от A. Код читал это как «A зависит от B».
В одном SBOM встречаются обе формы. Одна половина рёбер смотрела вверх, другая вниз, в графе появлялись циклы. Тарьян честно схлопывал их в компоненты, и почти любой пакет становился «зависимым» почти от любого другого. Отсюда 60 вместо 6.
Исправление — таблица направлений. Каждый тип ребра protobom либо означает зависимость в прямую сторону, либо в обратную, либо вообще не зависимость (например, describes или generates):
func dependencyDirection(t sbom.Edge_Type) edgeDirection {
switch t {
case sbom.Edge_contains, sbom.Edge_dependsOn, sbom.Edge_prerequisite,
sbom.Edge_staticLink, sbom.Edge_dynamicLink:
return fromDependsOnTo
case sbom.Edge_contained_by, sbom.Edge_dependencyOf, sbom.Edge_prerequisiteFor,
sbom.Edge_buildDependency, sbom.Edge_devDependency, sbom.Edge_runtimeDependency,
sbom.Edge_testDependency, sbom.Edge_optionalDependency,
sbom.Edge_optionalComponent, sbom.Edge_providedDependency:
return toDependsOnFrom
default:
return notADependency
}
}
После этого два ожидания в сквозных тестах изменились: не на прежние цифры, а на новые. Как понять, что новые цифры правильные, а не просто другие?
Я посчитал их независимо: короткий скрипт на Python читает те же JSON-файлы SBOM напрямую, без protobom и без Go, строит граф по спецификациям CycloneDX и SPDX и обходит его. Цифры совпали. Для инструмента, который отвечает на вопрос «задевает ли нас уязвимость», такая проверка обязательна: ошибка в направлении рёбер не падает с исключением, она просто даёт правдоподобный неправильный ответ.
Ложные срабатывания в диапазонах OSV
Вторая часть графа — уязвимости. Запись OSV описывает затронутые версии событиями:
"ranges": [{
"type": "SEMVER",
"events": [
{ "introduced": "0" },
{ "fixed": "0.17.0" }
]
}]
Чтобы понять, уязвима ли версия, события сортируют по версии и идут по ним: introduced включает уязвимость, fixed выключает. Сортировка в Minefield выглядела так:
sortedEvents := make([]Event, len(events))
copy(sortedEvents, events)
sort.Slice(sortedEvents, func(i, j int) bool {
vi := getVersionFromEvent(events[i]) // не тот срез
vj := getVersionFromEvent(events[j])
return compareVersions(vi, vj, eventType, ecosystem) < 0
})
sort.Slice переставляет элементы sortedEvents, а функция сравнения смотрит в исходный events. После первой же перестановки индексы указывают не на те элементы, и порядок получается случайным. На записях с несколькими диапазонами это давало версии «между» диапазонами, помеченные как уязвимые.
Исправил, запустил на полной базе Go с osv.dev — и получил новую пачку ложных срабатываний: golang.org/x/net версии 2024 года «уязвим» к CVE 2019 года. Причина красивая. "introduced": "0" в OSV означает «с самой первой версии». Но Go-модули без тегов версионируются псевдоверсиями вида v0.0.0-20190620200207-3b0461eec859, а по правилам semver 0.0.0-что-угодно — это пре-релиз, и он меньше 0. Правильная сортировка ставила fixed раньше introduced, и после прохода по событиям уязвимой оставалась любая версия выше introduced: 0, то есть все.
Теперь introduced: "0" всегда стоит первым и покрывает все версии. Заодно исправлено сравнение для диапазонов типа ECOSYSTEM (PyPI, Maven, RubyGems): версии сравнивались как строки, и 10.0 было меньше 9.1. Теперь они сравниваются по сегментам: 10.0 > 9.1, 1.0rc1 < 1.0, 1.0.post1 > 1.0.
Загрузка OSV: с десятков минут до секунд
Полная база Go с osv.dev — это 9351 запись. В Minefield загрузка каждой записи перечитывала весь граф, чтобы найти узлы подходящего пакета. На моих данных загрузка не закончилась и за десять минут.
Теперь сервер держит индекс «имя пакета → узлы» и перестраивает его, только когда добавляются SBOM или узлы. Та же база загружается за 7–8 секунд.
Ещё одна находка из той же функции: zip-архивы распаковывались во временную папку и не удалялись, причём на каждый файл архива оставался открытый дескриптор. Один прогон с базой Go оставлял около 50 МБ в %TEMP%. Теперь архивы читаются в памяти, с ограничением размера файла внутри архива.
Отчёт: какие продукты, по какому пути, что первым
Граф был, запросы были, а ответа на вопрос из начала статьи не было: его надо было собирать руками из запросов. В Sapper появилась команда sapper report.
Продукт — это узел, от которого никто не зависит: корневой компонент SBOM. Для каждой уязвимости отчёт идёт обходом в ширину вверх по родителям от уязвимых пакетов до продуктов и запоминает, откуда пришёл, — так для каждого продукта получается кратчайший путь до уязвимого пакета. Одна уязвимость часто приходит несколькими записями (GHSA, GO-2023-..., CVE) со ссылками друг на друга через aliases; отчёт склеивает их в одну находку через систему непересекающихся множеств.
Приоритеты берутся из двух источников:
CISA KEV — каталог уязвимостей, эксплуатация которых подтверждена. Для CRA это главный фильтр: сообщать надо именно об активно эксплуатируемых.
EPSS — оценка вероятности эксплуатации в ближайшие 30 дней, пересчитывается ежедневно.
Порядок: сначала всё из KEV, затем по EPSS, затем по числу затронутых продуктов. Если у вас есть OpenVEX-документ, где сказано, что продукт не затронут (уязвимый код не вызывается) или уже исправлен, продукт уходит в отдельный список и не мешает.
Вот что получается на 30 тестовых SBOM из репозитория, полной базе Go с osv.dev и свежих KEV и EPSS:
$ sapper report --limit 4
┌──────────────────────────────────────┬──────────┬─────────────────────┬───────┬──────────┬──────────────────────────────────────────────────────────────────────┐
│ VULNERABILITY │ SEVERITY │ KEV │ EPSS │ PRODUCTS │ EXAMPLE PATH │
├──────────────────────────────────────┼──────────┼─────────────────────┼───────┼──────────┼──────────────────────────────────────────────────────────────────────┤
│ GHSA-qppj-fm5r-hxr3 / CVE-2023-44487 │ MODERATE │ yes, due 2023-10-31 │ 1.000 │ 4 │ cloudprober > golang.org/x/net@v0.0.0-20210503060351-7fd8e65b6420 │
│ GHSA-45x7-px36-x8w8 / CVE-2023-48795 │ MODERATE │ │ 0.933 │ 2 │ cloudprober > golang.org/x/crypto@v0.0.0-20201012173705-84dcc777aaee │
│ GHSA-4v7x-pqxf-cx7m / CVE-2023-45288 │ MODERATE │ │ 0.920 │ 4 │ cloudprober > golang.org/x/net@v0.0.0-20210503060351-7fd8e65b6420 │
│ GHSA-39qc-96h7-956f / CVE-2019-9512 │ HIGH │ │ 0.834 │ 2 │ credstore > golang.org/x/net@v0.0.0-20181217023233-e147a9138326 │
└──────────────────────────────────────┴──────────┴─────────────────────┴───────┴──────────┴──────────────────────────────────────────────────────────────────────┘
Первая строка — HTTP/2 Rapid Reset, уязвимость, через которую в 2023 году шли рекордные DDoS-атаки на Google, Cloudflare и AWS. Она в KEV, EPSS 1.0, и задевает четыре продукта из тридцати. Всего находок 143, отчёт строится за 0,4 секунды. Обратите внимание на колонку SEVERITY: по CVSS эта уязвимость «средняя», и сортировка по одной только критичности отправила бы её вниз списка.
Та же информация — в Markdown для тикета или в JSON для своей автоматизации:
sapper report CVE-2023-44487 --format markdown
sapper report --kev-only --format json > kev.json
Sapper не решает, о чём сообщать регулятору, — он даёт факты: какие продукты, какие версии пакетов, по какому пути, эксплуатируется ли уязвимость.
Как попробовать
go install github.com/Perruer/sapper@latest # или бинарник из релизов, или Docker
sapper server &
sapper ingest sbom ./sboms # CycloneDX 1.3–1.7 или SPDX 2.x, JSON; файлы, папки, zip
sapper ingest osv ./Go-all.zip # https://osv-vulnerabilities.storage.googleapis.com/Go/all.zip
sapper ingest kev known_exploited_vulnerabilities.json
sapper ingest epss epss_scores-current.csv.gz
sapper cache
sapper report
Всё работает с локальными файлами: скачать KEV, EPSS и базу OSV можно на машине с интернетом и перенести в закрытый контур. Docker-образ — ghcr.io/perruer/sapper, для общего сервера можно подключить Redis вместо SQLite.
Бонус для тех, кто не хочет учить язык запросов: команда sapper llm переводит вопросы на обычном языке в запросы к графу. В Minefield она требовала ключ OpenAI и векторную базу на диске сервера. Теперь подсказки о языке запросов идут в системный промпт, а модель может быть любой с OpenAI-совместимым API, включая локальную через Ollama:
sapper llm --base-url http://localhost:11434/v1 --model qwen2.5-coder
Как это проверяется
Раз уж главная претензия к исходному коду — «тихо даёт неправильный ответ», проверки тут важнее обычного:
сквозной тест загружает тестовые SBOM, строит кэш и проверяет ответы на запросы; ожидания сверены с независимым подсчётом на Python;
для диапазонов OSV есть тесты на порядок событий,
introduced: 0с псевдоверсиями и сравнение версий ECOSYSTEM;тест отчёта проверяет пути, склейку записей, VEX и порядок KEV → EPSS;
в CI тесты идут на Linux (с
-race), macOS и Windows, отдельно с настоящим Redis, плюс govulncheck, CodeQL, OSV-Scanner и прогон Docker-образа: загрузить SBOM, построить отчёт, перезапустить контейнер и убедиться, что данные на месте.
Что дальше
В планах — CycloneDX VEX наряду с OpenVEX, GitHub Action, который обновляет отчёт при каждом релизе, и загрузка SBOM прямо из экспорта графа зависимостей GitHub. Если вы разбираете уязвимости по многим продуктам или готовитесь к CRA — буду рад issue с вашим сценарием: какие данные у вас есть и какой ответ нужен.
Код: github.com/Perruer/sapper. Minefield создан командой BitBom, и Sapper сохраняет его историю, лицензию и авторство; с BitBom проект не связан.
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.