Чистим шумный SBOM: как убрать неэксплуатируемые уязвимости в Kubernetes с помощью VEX

Во время эпидемии WannaCry британские медики отменяли плановые операции, а у Renault встало производство. А в 2021 году в США из-за атаки на оператора трубопроводов Colonial Pipeline взлетели цены на бензин и началась паника. Это лишь пара примеров, но они показывают, к чему может привести эксплуатация уязвимостей в ПО. Однако далеко не все уязвимости — это угроза: они автоматически подтягиваются в образы, но эксплуатировать многие из них невозможно. Только вот сканеры безопасности не умеют оценивать уязвимости и бьют тревогу по любому поводу, засоряя CI/CD. Но если ПО поставляется клиентам, контейнеры нужно сканировать постоянно, а значит, постоянно придётся сталкиваться с шумом в security-отчётах. Или не придётся?
Привет, Хабр! Меня зовут Денис Романенко, я работаю в компании «Флант» и отвечаю за развитие нашей Kubernetes-платформы — Deckhouse Platform. В прошлом году я выступал на конференции DevOops, где рассказывал, как справляться с шумом в отчётах сканеров уязвимостей и ложноположительными срабатываниями. Эта статья основана на моём докладе.
Дисклеймер. Я буду рассказывать, какие инструменты мы используем у себя в компании и как они работают именно в нашей платформе. Но точно такого же результата можно добиться другими способами, в том числе стандартными Open Source-утилитами, так что подход, который я тут опишу, применим к любой реализации.
Как искать уязвимости и что с ними делать
Прежде всего посмотрим, как происходит поиск уязвимостей в ПО.
Здесь есть три элемента: само ПО, сканер уязвимостей и база данных известных уязвимостей. Сканер анализирует ПО и формирует SBOM (Software Bill of Materials) — документ со списком компонентов, модулей и зависимостей софта с указанием их версий, лицензий и потенциальных уязвимостей. В заголовке SBOM-документа указываются имя компонента и его версия, а в теле содержится массив зависимостей с именами библиотек, версиями, типами лицензий и известными дефектами безопасности.

Ниже — пример SBOM-отчёта:
{
"product": {
"name": "ExampleApp",
"version": "1.2.3"
},
"components": [
{
"name": "LibraryX",
"version": "4.5.6",
"supplier": "OpenSourceCommunity",
"licenses": [
"MIT"
],
"dependencies": [
"LibraryY"
],
...
{
...
"components": [
{
"name": "LibraryX",
"version": "4.5.6",
"supplier": "OpenSourceCommunity",
"licenses": [
"MIT"
],
"dependencies": [
"LibraryY"
],
"vulnerabilities": [
{
"cve": "CVE-2023-0001",
"description": "Buffer overflow in LibraryX",
"recommendation": "Update to version 4.5.7"
}
]
}
]
{
...
"vulnerabilities": [
{
"cve": "CVE-2023-0001",
"description": "Buffer overflow in LibraryX",
"recommendation": "Update to version 4.5.7"
}
]
}
],
...
}Для генерации SBOM есть разные открытые утилиты, например Syft — легковесный инструмент для сбора SBOM из готовых контейнеров и файловых систем. Мы в своей практике используем Trivy — он умеет самостоятельно формировать SBOM и сразу же сканировать его на уязвимости. Наша версия Trivy отличается от апстрима тем, что мы добавили интеграцию с БДУ ФСТЭК России наряду с международными базами, а также реализовали корректное определение отечественных дистрибутивов: «Альт», «РЕД ОС», Astra Linux. Впрочем, этот подход можно без изменений повторить и на стандартной версии Trivy.
Итак, после сканирования мы получаем отчёт со списком уязвимостей. Есть три варианта:
Уязвимость подтверждена и эксплуатируема. Если ПО уязвимо и его можно взломать, то дефект необходимо устранять.
Уязвимость подтверждена, но устранить её прямо сейчас невозможно. Например, не хватает экспертизы, вендор ещё не выпустил патч или исправление требует несоразмерных затрат.
Наглядный пример — уязвимость в библиотеке OpenTelemetry в Kubernetes 1.29. Её исправление требовало глубокой переработки кодовой базы, что в апстриме сделали только ближе к версии 1.31. Самостоятельный бэкпорт в 1.29 — колоссальный объём работы.
Если устранить уязвимость оперативно нельзя, её нужно митигировать — внедрить организационно-технические меры, которые сделают её эксплуатацию невозможной.False Positive (ложноположительное срабатывание). Сканер сообщает об уязвимости, которой в действительности в продукте нет.
Важно отметить: статус уязвимости может меняться и напрямую зависит от контекста эксплуатации. Возвращаясь к примеру с OpenTelemetry в Kubernetes: эта уязвимость реальна, но если в вашем кластере сбор телеметрии в control plane отключён на уровне конфигурации, то эксплуатировать её нельзя — этот кейс для вас становится ложноположительным.
Почему сканеры ошибаются
Разберём, почему сканеры безопасности выдают ложные результаты.
Для примера напишем простую утилиту на Go: на вход через аргументы командной строки она принимает имя Docker-образа и выводит его манифест на экран.
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote"
)
func main() {
ref, err := name.ParseReference(os.Args[1])
if err != nil {
panic(err)
}
...
...
img, err := remote.Image(ref,
remote.WithContext(context.Background()))
if err != nil {
panic(err)
}
manifest, err := img.Manifest()
if err != nil {
panic(err)
}
jsonManifest, err := json.Marshal(manifest)
if err != nil {
panic(err)
}
fmt.Printf("%s\n", jsonManifest)
}Обратите внимание: для чтения манифеста используется функция remote.Image из пакета github.com/google/go-containerregistry/pkg/v1/remote. Зафиксируем эту деталь.
Вот пример работы нашей утилиты: программа запрашивает манифест образа Alpine и успешно его печатает. Логика предельно проста.
./getManifest alpine | jq
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"size": 581,
"digest":
"sha256:9234e8fb04c47cfe0f49931e4ac7eb76fa904e33b7f8576aec0501c085f02516"
},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"size": 3799689,
"digest":
"sha256:9824c27679d3b27c5e1cb00a73adb6f4f8d556994111c12db3c5d61a0c843df8"
}
],
...
...
"annotations": {
"com.docker.official-images.bashbrew.arch": "amd64",
"org.opencontainers.image.base.name": "scratch",
"org.opencontainers.image.created": "2025-07-15T11:01:16Z",
"org.opencontainers.image.revision": "01dd5fbd09e25f6c040627eedf18bbfccfa9ad6e",
"org.opencontainers.image.source":
"https://github.com/alpinelinux/docker-alpine.git#01dd5fbd09e25f6c040627eedf18bbfccfa9ad6e:x86_64",
"org.opencontainers.image.url": "https://hub.docker.com/_/alpine",
"org.opencontainers.image.version": "3.22.1"
}
}Теперь соберём утилиту и проверим её сканером Trivy.
go.mod (gomod)
Total: 5 (UNKNOWN: 0, LOW: 1, MEDIUM: 3, HIGH: 0, CRITICAL: 1)
Итак, сканер сообщает об уязвимости CVE-2021-41092 в библиотеке github.com/docker/cli: при определённых условиях может произойти утечка данных из реестра. Но есть нюанс: мы напрямую не объявляли зависимость от github.com/docker/cli — это транзитивная (indirect) зависимость, которую подтянула одна из базовых библиотек. Кроме того, наша программа вообще не вызывает функционал docker-cli. Но всё это не мешает сканеру бить тревогу.
Большинство современных сканеров работают примитивно: они не анализируют, какие конкретно методы вызываются и задействован ли уязвимый код приложения. Они строят SBOM со списком всех прямых и транзитивных библиотек: если библиотека признана уязвимой, весь итоговый артефакт тоже автоматически помечается уязвимым. Остаётся надеяться, что со временем сканеры научатся детально анализировать достижимость кода (reachability analysis).
Как убрать ложноположительные результаты из отчётов: формат VEX
Чтобы централизованно фиксировать статус уязвимостей софта и обмениваться информацией о них, используется формат VEX (Vulnerability Exploitability eXchange).
Типичный документ VEX содержит идентификатор уязвимости (ссылку на CVE), статус (подвержен софт угрозе или нет), оценку риска, рекомендации по митигации и технические подробности.
Стандарт VEX базируется на двух общепринятых системах:
CVE (Common Vulnerabilities and Exposures) — общемировой стандарт каталогизации и идентификации конкретных уязвимостей. Он обеспечивает универсальность: сканеры по всему миру понимают эти идентификаторы, что упрощает сопоставление информации.
CWE (Common Weakness Enumeration). Если CVE привязан к конкретному ПО, то CWE классифицирует общие типы слабостей в архитектуре и коде. Пример CWE — переполнение буфера. Это не конкретный баг в отдельной программе, а класс уязвимости, позволяющий выйти за границы памяти и нарушить разграничение прав. CWE помогает категоризировать уровень опасности дефекта и приоритизировать исправления.
VEX-документ совмещает данные CVE и CWE и в итоге даёт стандартизированное машиночитаемое описание рисков, с которым удобно работать с помощью профильных утилит. Как я уже писал выше, мы во «Фланте» работаем с Trivy — он поддерживает два формата VEX: CycloneDX и OpenVEX. Мы для себя выбрали OpenVEX, так как нам удобнее работать с JSON и сопутствующими OpenVEX инструментами.
Структура OpenVEX-документа включает метаданные (автор, дата, версия) и блок со списком уязвимостей. В секции products перечисляются целевые библиотеки, а в поле status указывается их текущее состояние.
Ниже — пример OpenVEX-документа:
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "https://openvex.dev/docs/example/vex-9fb3463de1b57",
"author": "Wolfi J Inkinson",
"role": "Document Creator",
"timestamp": "2023-01-08T18:02:03.647787998-06:00",
"version": 1,
"statements": [
{
"vulnerability": {
"name": "CVE-2014-123456"
},
... "statements": [
{
"vulnerability": {
"name": "CVE-2014-123456"
},
"products": [
{"@id": "pkg:apk/distro/git@2.39.0-r1?arch=armv7"},
{"@id": "pkg:apk/distro/git@2.39.0-r1?arch=x86_64"}
],
"status": "fixed"
}
] }
"products": [
{"@id": "pkg:apk/distro/git@2.39.0-r1?arch=armv7"},
{"@id": "pkg:apk/distro/git@2.39.0-r1?arch=x86_64"}
],
"status": "fixed"
} ] }Идентификация компонентов выполняется через формат PURL (Package URL).
Сейчас будет небольшое отступление об архитектуре нашего продукта — я дам немного контекста, который поможет понять дальнейшие примеры.
Итак, технически Deckhouse Platform — это набор связанных OCI-контейнеров, которые распространяются исключительно через OCI Registry — наш единственный источник правды.
Обращение к образам происходит по неизменяемым дайджестам SHA-256. В едином образе контроллера хранится карта соответствия между сервисами и хеш-суммами их образов.
Мы выделяем два ключевых сценария сканирования безопасности:
На этапе сборки в CI/CD-пайплайнах, чтобы не допустить попадания уязвимого софта в релиз.
Непрерывный аудит в рантайме. В CI/CD мы используем консольный Trivy, а в рантайме — Trivy Operator.
Вернёмся к нашей Go-утилите. Мы обнаружили ложноположительную уязвимость и хотим зафиксировать её статус в OpenVEX с помощью утилиты vexctl.
В документе указываем автора (Deckhouse), идентификатор уязвимости по базе CVE, статус not_affected и обоснование component_not_present (уязвимый компонент фактически отсутствует в итоговом бинарнике).
Компонент описывается через PURL: схема pkg, тип экосистемы (golang) и путь к пакету.
В текстовом поле impact_statement оставляем комментарий, что уязвимые вызовы не задействованы. Утилита генерирует файл known_vulnerabilities.vex.
vexctl create
--author "Deckhouse <contact@deckhouse.io>"
--product "pkg:golang/github.com/docker/cli"
--vuln="CVE-2021-41092"
--status="not_affected"
--justification="component_not_present"
--impact-statement="Affected functions not used"
> known_vulnerabilities.vexТеперь запустим сканирование заново, передав созданный файл через флаг --vex:
trivy fs . --vex=known_vulnerabilities.vex
go.mod (gomod)
Total: 4 (UNKNOWN: 0, LOW: 1, MEDIUM: 2, HIGH: 0, CRITICAL: 1)
Как видно, ложноположительная уязвимость больше не отображается в основном отчёте.
Если добавить флаг --show-suppressed, подавленная уязвимость отобразится в отдельном блоке.
trivy fs . --vex=known_vulnerabilities.vex --show-suppressed
go.mod (gomod)
Total: 4 (UNKNOWN: 0, LOW: 1, MEDIUM: 2, HIGH: 0, CRITICAL: 1)
Suppressed Vulnerabilities (Total: 1)
Такой подход позволяет не блокировать CI/CD-пайплайны ложными срабатываниями, но при этом сохранять историю и возможность изменить решение, если поменялся контекст.
Как встроить работу с уязвимостями в процесс разработки и поставки
Стандартный флоу прост: разработчик пушит код в Git-репозиторий, запускается пайплайн CI/CD, готовые образы сохраняются в OCI Registry.
Шаг Trivy Scan в пайплайне блокирует релиз при обнаружении критических проблем.

Поскольку источником правды для исходников служит Git, VEX-файлы должны храниться в репозитории рядом с кодом. Источник правды для готовых образов — это реестр, поэтому VEX-манифесты к собранным образам также нужно публиковать в OCI Registry.
Trivy поддерживает загрузку OpenVEX-документов напрямую из контейнерного реестра.
Например, в Deckhouse Platform каждый модуль состоит из набора образов. VEX-файл с описанием исключений помещается в сборочную директорию конкретного образа.
- Modules
- module
- images
- image1
- исходный код
- сборочные инструкции
- known_vulnerabilities.vex
- image2
- ...Чтобы Trivy автоматически находил VEX-документ в реестре, он оформляется как специальный OCI-артефакт (аттестация). Тег такого артефакта формируется по правилу: sha256-<хеш_целевого_образа>.att. Например, если образ имеет дайджест registry.deckhouse.ru/deckhouse/ce@sha256:abc..., то VEX-артефакт публикуется с тегом registry.deckhouse.ru/deckhouse/ce:sha256-abc....att.
Посмотрим на структуру модуля common/kubernetes в нашем репозитории.
Сборка описывается в werf.yaml. Мы используем собственный сборочный инструмент werf и решение Deckhouse Delivery Kit.
Delivery Kit автоматически формирует служебные OCI-образы с VEX-манифестами в реестре и подписывает их.
Этот процесс можно реализовать и с помощью стандартных Open Source-утилит: собирать образы через Docker, а публиковать и подписывать VEX-аттестации с помощью Cosign из проекта Sigstore. Мы и сами использовали Cosign на этапе PoC.
Рядом с кодом компонентов Kubernetes размещается файл known_vulnerabilities.vex.

Проверим работу схемы: возьмём дайджест образа kube-apiserver версии 1.29.
trivy image registry.example/sys/deckhouse-oss@sha256:b38222f133bb950e72b0e5905a876e524dcfe8253b2dd749ab77a0836c4658af
usr/bin/kube-apiserver (gobinary)
Total: 8 (UNKNOWN: 0, LOW: 0, MEDIUM: 4, HIGH: 4, CRITICAL: 0)При обычном сканировании отображается ошибка OpenTelemetry, которую мы не можем пропатчить на уровне кода в рамках этой версии.

Добавим флаги --vex oci и --show-suppressed.
trivy image registry.example/sys/deckhouse-oss@sha256:b38222f133bb950e72b0e5905a876e524dcfe825 3b2dd749ab77a0836c4658af --vex=oci --show-suppressed
usr/bin/kube-apiserver (gobinary)
Total: 8 (UNKNOWN: 0, LOW: 0, MEDIUM: 4, HIGH: 4, CRITICAL: 0)
Suppressed Vulnerabilities (Total: 1)Trivy автоматически выкачивает VEX-аттестацию из реестра по хешу, видит наше обоснование и переносит уязвимость в категорию подавленных.

Итак, полностью избавиться от ложных срабатываний сканеров уязвимостей, увы, пока нельзя. На текущем этапе мы можем только изолировать шум, чтобы он не засорял отчёты и не мешал процессам CI/CD. Моя схема решения этой задачи сработает в любой инфраструктуре и на базе открытых инструментов — надеюсь, она будет кому-то полезна.
P. S.
Читайте также в нашем блоге:
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.