Во время эпидемии 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. В едином образе контроллера хранится карта соответствия между сервисами и хеш-суммами их образов.

Мы выделяем два ключевых сценария сканирования безопасности:

  1. На этапе сборки в CI/CD-пайплайнах, чтобы не допустить попадания уязвимого софта в релиз.

  2. Непрерывный аудит в рантайме. В 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.

Читайте также в нашем блоге:

Комментарии (0)