Вход на сайт

Просмотр новости

Найдите то, что Вас интересует

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

Дата публикации: 26-09-2026 08:35:06

Выходит новость о критической уязвимости, и первый вопрос: какие из наших продуктов её тянут и что чинить первым? С 11 сентября 2026 года Cyber Resilience Act требует сообщать об активно эксплуатируемых уязвимостях в течение 24 часов, так что вопрос получил срок.Под него хорошо подходил Minefield — граф SBOM на roaring bitmaps от BitBom, заархивированный в 2025 году. Я продолжил его под именем Sapper, добавил отчёт с приоритетами по CISA KEV и EPSS и по дороге нашёл, что граф строился задом наперёд, а псевдоверсии Go превращали любую версию в уязвимую. Рассказываю, как это нашлось и как проверить, что исправление действительно исправление. Читать дальше

Основное содержимое страницы с новостью.

Уровень сложностиСредний

Время на прочтение9 мин

Охват и читатели5.2K

Кейс

Выходит новость о критической уязвимости в популярной библиотеке. Первый вопрос, который задаёт себе любая команда, выпускающая больше одного продукта: а у нас она где? Не «есть ли она в этом репозитории» — на это отвечает любой сканер, — а «какие из наших продуктов её тянут, напрямую или через пять уровней чужих зависимостей, и что из этого чинить первым».

С 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 проект не связан.

Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку

Схожие новости

#Наименование новостиТональностьИнформативностьДата публикации
111 минут у Backblaze, и неделя бездействия у reg.ru: как компании обрубают инфраструктуру трояна в России и за рубежом08.0828-08-2026
2OWASP Top 10 2025 — от кода к цепочке поставок: расширение границ безопасности016.9816-02-2026
3Хаос, шок, приватные ключи от не менее приватного GitLab029.231-07-2026
4Книга: «Охота за киберугрозами»07.4207-05-2026
5Атаки с подменой адреса: Никто не проверяет 42 символа010.0625-09-2026
6Активность хакерских группировок: блокчейн как C2, business email compomise и антибот-фильтры010.8922-07-2026
7You cannot patch your way out of the CVE exposure window, but you can defend it08.7511-09-2026
8Пять редких техник закрепления хакеров в сети09.8523-06-2026
9Баги на диком западе: топ-10 ошибок в C и C++ проектах за 2025 год08.0630-12-2025
10YApi заброшен с 2022 года. Я продолжил его и нашёл токены, которые может подделать любой участник проекта09.9625-09-2026

Классификация: Экономика. Схожих патентов: 0. Схожих новостей: 10. Тональность: 0. Информативность: 9. Источник: habr.com.