Трендовые github проекты в нашем телеграм канале. Подпишись → Шум в отчётах сканера уязвимостей и формат OpenVEX
Сканер в CI/CD падает на CRITICAL, релиз блокируется, а разбор показывает, что уязвимая функция лежит в транзитивной зависимости и из вашего кода не вызывается ни разу. Через неделю та же CVE снова валит пайплайн, и кто-то в очередной раз объясняет в чате, почему её можно игнорировать. Формат VEX (Vulnerability Exploitability eXchange) решает именно эту задачу: решение по конкретной уязвимости записывается в машиночитаемый файл, хранится рядом с кодом и автоматически применяется сканером.
Три исхода после сканирования
Сканер (например, Trivy или Syft для генерации SBOM) строит SBOM — список всех прямых и транзитивных компонентов с версиями и лицензиями — и сверяет его с базами уязвимостей. Каждая найденная CVE попадает в одну из трёх категорий:
- Подтверждена и эксплуатируема. Чинить: обновлять зависимость, патчить код.
- Подтверждена, но быстро исправить нельзя. Патча от вендора нет или исправление требует несоразмерной работы. Пример — уязвимость в OpenTelemetry внутри Kubernetes 1.29: в апстриме её закрыли только ближе к 1.31 после глубокой переработки кода, а самостоятельный бэкпорт в 1.29 — огромный объём работы. Такие уязвимости митигируют конфигурацией или организационными мерами.
- False positive. Уязвимого кода в продукте фактически нет.
Статус зависит от контекста. Та же уязвимость OpenTelemetry реальна, но если сбор телеметрии в control plane выключен в конфигурации, эксплуатировать её в этом кластере нельзя, и для него она становится ложноположительной.
Откуда берутся ложные срабатывания
Простой пример — утилита на Go, которая принимает имя образа и печатает его манифест. Для этого используется remote.Image из github.com/google/go-containerregistry/pkg/v1/remote, больше ничего:
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()
Trivy на этом проекте показывает:
go.mod (gomod)
Total: 5 (UNKNOWN: 0, LOW: 1, MEDIUM: 3, HIGH: 0, CRITICAL: 1)
В списке есть CVE-2021-41092 в github.com/docker/cli — при определённых условиях возможна утечка учётных данных реестра. Эту зависимость никто явно не объявлял: её подтянул go-containerregistry как indirect. Код утилиты функциональность docker-cli не вызывает.
Большинство сканеров не делают reachability analysis — не проверяют, достижим ли уязвимый код из точки входа программы. Логика простая: библиотека в SBOM есть, по ней есть CVE, значит весь артефакт уязвим. Пока анализ достижимости не стал нормой, решение «эта CVE к нам не относится» приходится принимать человеку и где-то его фиксировать.
Что лежит внутри VEX
VEX-документ привязывает идентификатор уязвимости (CVE) к конкретному продукту и указывает статус: затронут продукт или нет, с обоснованием и комментарием. Компоненты идентифицируются через PURL (Package URL), например pkg:golang/github.com/docker/cli или pkg:apk/distro/git@2.39.0-r1?arch=x86_64.
Trivy понимает два формата VEX: CycloneDX и OpenVEX. OpenVEX — компактный JSON с метаданными (автор, время, версия) и массивом statements:
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "https://openvex.dev/docs/example/vex-9fb3463de1b57",
"author": "Wolfi J Inkinson",
"timestamp": "2023-01-08T18:02:03.647787998-06:00",
"version": 1,
"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"
}
]
}
Для статуса not_affected указывается justification — машинное обоснование, например component_not_present.
Подавляем CVE через vexctl
Руками JSON писать не нужно — его генерирует vexctl из проекта OpenVEX:
vexctl create \
--author "Team <security@example.com>" \
--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)
CVE-2021-41092 из основного отчёта ушла. С --show-suppressed она выводится отдельным блоком Suppressed Vulnerabilities (Total: 1), так что решение видно при аудите, и его легко пересмотреть, когда изменится контекст — например, если код начнёт вызывать docker-cli.
Где хранить VEX: Git и OCI Registry
Логичное правило — VEX лежит там же, где источник правды для артефакта:
- для исходников — в Git, в директории сборки конкретного образа:
modules/
module/
images/
image1/
<исходный код и сборочные инструкции>
known_vulnerabilities.vex
image2/
- для собранных образов — в OCI Registry, как аттестация к образу.
Trivy умеет забирать OpenVEX прямо из реестра. Аттестация публикуется как отдельный OCI-артефакт с тегом по схеме sha256-<digest_образа>.att. Для образа registry.example/app@sha256:abc... VEX-артефакт будет лежать под тегом registry.example/app:sha256-abc....att. Публиковать и подписывать такие аттестации можно через Cosign из Sigstore, образы собирать обычным Docker — специфичный тулинг не нужен.
Проверка на kube-apiserver 1.29, где сидит та самая неисправимая уязвимость OpenTelemetry:
trivy image registry.example/sys/platform@sha256:b38222f1... \
--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)
С --vex=oci Trivy по дайджесту образа сам находит аттестацию в реестре, применяет обоснование и переносит CVE в подавленные. В CI для этого хватает консольного Trivy, для непрерывного аудита в рантайме подходит Trivy Operator.
Схема для пайплайна
- Разработчик пушит код; пайплайн собирает образ и кладёт его в реестр по неизменяемому дайджесту SHA-256.
- Шаг
trivy image ... --vex=ociблокирует релиз только на уязвимостях, по которым нет VEX-решения. - Новая CVE разбирается один раз: если она неэксплуатируема или митигирована, в репозиторий рядом с образом коммитится запись в
known_vulnerabilities.vex, ревью идёт обычным merge request. - При сборке VEX публикуется как подписанная аттестация
sha256-<digest>.att. - Периодически стоит прогонять отчёт с
--show-suppressedи пересматривать подавленные CVE: обоснованиеcomponent_not_presentустаревает, как только кто-то начинает использовать уязвимый пакет.
Ложные срабатывания этот подход не устраняет — сканер по-прежнему их находит. Зато решение по каждому из них принимается один раз, хранится с историей в Git и в реестре и перестаёт блокировать CI/CD.