The Jerusalem PostIsraeli director Erez Tadmor takes ‘Matchmaking 3’ to New York - and reveals its unlikely secretCNN TürkSON DAKİKA | Fon soruşturmasında yeni gelişme!PunchTenth woman found dead in South Africa’s string of killingsBollywood HungamaVicky Kaushal’s FIRST look from Sanjay Leela Bhansali's Love & War is all intensity, no apology!InquirerFlash flood kills 1, leaves 4 high school students missing in Zamboanga SibugayBBC NewsRayner criticises visa rule proposals ahead of Labour conference매일경제추석 귀경길 정체, 내일 새벽 2∼3시께 풀릴 듯…귀성길 교통은 원활Daily Mail'There are worse things in life than infidelity': Jeffrey Archer's wife Mary on the secret to their long-lasting marriage - as the couple give a fizzingly honest interview to mark the release of his final ever novelSportstarIndia in Athletics LIVE Updates, Asian Games 2026: Tejaswin Shankar, Thowfeeq Noushad in action in decathlon high jump; Jyothi Yarraji pulls out of hurdles heats3DNewsИИ-агенты OpenAI оставили миллион следов взлома Hugging Face в открытом интернете7sur7Deux grévistes de la faim palestiniens pris en charge à l’hôpital à Bruxelles경향신문‘예테크’ 시대 돌아왔다···연 8% 파킹통장까지 등장
The Daily Newsstand · Free, Always
Saturday, September 26, 2026

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

Translate

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

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

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.