Как стать автором
Обновить
86.11
Циан
В топ-5 лучших ИТ-компаний рейтинга Хабр.Карьера

Как мы реализовали SCA при помощи SBOM

Время на прочтение7 мин
Количество просмотров678

Чем больше микросервисов в компании, тем веселее жизнь у тех, кто отвечает за безопасность. Количество зависимостей растёт, и в какой-то момент становится нереально уследить, откуда в коде может вылезти критичная уязвимость — будь то старая библиотека или транзитивная зависимость, о которой никто даже не помнит.

Решение этого — SCA (Software Composition Analysis) автоматический анализ зависимостей, который помогает вовремя вылавливать уязвимые библиотеки и понимать, что с ними делать.

Меня зовут Эрик Шахов, я AppSec-инженер в Циан. В этой статье расскажу, как мы перестроили систему SCA, изменили её архитектуру и какие инструменты теперь используем для контроля зависимостей. Поделюсь реальным опытом внедрения SBOM (Software Bill of Materials) и тем, как он помогает нам держать код в порядке.


Дисклеймер: SCA у нас существовал и раньше, но я его прокачал, изменив архитектуру. Будет немного кода — просто примеры, не как эталон. Всё-таки я безопасник, а не разработчик.

Что такое SCA и зачем оно нужно

SCA — это способ разобраться, из чего состоит ваш код и насколько он безопасен. В проекте куча зависимостей, и какая-то из них может тянуть за собой уязвимость. SCA автоматически проверяет библиотеки и помогает понять, что с ними делать: обновить, заменить или хотя бы не забыть, что где-то есть дырка.

Как это работает:

  1. Собираем список зависимостей из lock-файлов.

  2. Проверяем их в базах уязвимостей (например, NVD NIST).

  3. Получаем отчёт, где чётко расписано, какие пакеты под угрозой и что с ними делать.

Крутая штука, но есть нюансы:

  • Поддержка технологий ограничена — если у компании много языков, одного инструмента SCA не хватит.

  • Разные форматы выходных данных — каждый анализатор пишет отчёты по-своему, и их надо сводить в кучу.

  • Транзитивные зависимости непрозрачны — уязвимость может быть не в той библиотеке, которую вы добавили в проект, а в какой-нибудь dependency of dependency, и разработчик не сразу понимает, что обновлять.

Чтобы решить эти проблемы, мы не стали полагаться на стандартные инструменты SCA, а собрали свою систему на основе SBOM. Это по сути "список ингредиентов" для кода, который показывает все зависимости, включая самые глубоко зарытые.

Реализация SCA

Когда проектировали систему, нам нужно было три вещи:

  • Масштабируемость — поддержка всех языков и стеков, которые мы используем.

  • Интеграция в ASOC — чтобы следить за безопасностью в едином месте.

  • Прозрачный путь до уязвимости — чтобы разработчик сразу понимал, какую именно библиотеку обновлять.

Для этого мы выбрали SBOM (Software Bill of Materials), который строит полное дерево зависимостей проекта. В качестве формата используем CycloneDX 1.5.

Схема проекта
Схема проекта
  1. Генерируем SBOM через Trivy и cdxgen.

    • Trivy подходит для Python, Swift, JS и JVM — он строит граф зависимостей по lock-файлам.

    • cdxgen используем для C# и PHP, потому что он умеет восстанавливать зависимости по артефактам (dotnet restore и т.д.).

  2. Проверяем уязвимости через Trivy, кешируя базу данных внутри компании. Если возникнет проблема с официальным Github Container Registry — наш SCA все равно будет работать. 

  3. Интегрируем в CI/CD: SBOM-файл создаётся на этапе сборки в Jenkins, после чего отправляется в сервис обработки данных. Также у нас есть возможность принудительного создания и проверки SBOM-файла, которую мы используем для регулярных проверок всех сервисов раз в неделю.

  4. Передаём результаты: отчёты уходят в Security Center (наш аналог DefectDojo) и MCS Health, который может заблокировать использование уязвимых библиотек.

Почему именно эти инструменты

Когда выбирали генераторы SBOM, ориентировались на три вещи:

  • Поддержка стека — если инструмент не умеет работать с нашими языками, толку от него нет.

  • Построение дерева зависимостей — без него сложно понять, откуда вообще взялась уязвимость.

  • Живой проект — если инструмент обновляется раз в пятилетку, он рано или поздно отвалится.

В итоге выбрали:

  • Trivy — отлично подходит для Python, Swift, JS, JVM, строит граф зависимостей по lock-файлам.

  • cdxgen — используется для C# и PHP, потому что умеет восстанавливать зависимости по артефактам.

  • Trivy как анализатор SBOM, потому что он не уступает конкурентам (bomber, grype, osv-scanner) и у нас уже был внедрён.

Не стали гнаться за хайпом, просто взяли то, что реально работает.

Как устроен наш сервис

Чтобы автоматизировать SCA, мы написали микросервис на Python, который разбирает SBOM-файл и отчёт Trivy, строит путь до уязвимых зависимостей, записывает данные в базу и передаёт их дальше в пайплайн. Для работы с CycloneDX используем его официальную Python-библиотеку. Полный состав данных, которые сервис отправляет дальше, можно глянуть ниже.

class TrivyUploadResults(BaseModel):
   repo: str | None
   """Название просканированного репозитория"""
   branch: str | None
   """Ветка репозитория"""
   sbom: str | dict | None
   """SBOM file"""
   vulnerabilities: dict | None
   """Результат trivy сканирования"""

На выходе разработчик получает отчёт с параметрами уязвимости и точным путём до проблемной библиотеки. Теперь не нужно тратить время на догадки — сервис сразу показывает, что и где обновлять.

Проблемы и их решения

Проблема 1: библиотека CycloneDX не поддерживает новые версии. 

Да, звучит абсурдно, но факт: стандарт обновляется, а библиотека для него — нет. Поэтому пришлось идти на небольшую хитрость. Мы решили загружать в библиотеку только блок dependencies, который отвечает за дерево зависимостей. Пример такого дерева — тут, далее в статье мы будем называть его BOM.

Нам этого достаточно для построения пути до уязвимой библиотеки, а сама спецификация dependencies после версии 1.4 не претерпела критических изменений. В итоге библиотека спокойно работает с этим деревом, не зная, что ей подсовывают данные от более новой версии стандарта.

Пример определения корневых элементов:

def get_bom_from_sbom(sbom) -> Bom:
   json_of_dependencies = {}
   json_validator = JsonStrictValidator(SchemaVersion.V1_5)
   try:
       validation_errors = json_validator.validate_str(str(sbom))
       if validation_errors:
           logging.error('JSON invalid', repr(validation_errors))
   except MissingOptionalDependencyException as error:
       logging.error('JSON-validation was skipped due to', error)
   json_of_dependencies["dependencies"] = sbom["dependencies"]
   bom = Bom.from_json(json_of_dependencies)
   return bom

Проблема 2: поиск корневых элементов. Чтобы построить путь до уязвимого компонента, нужно начинать с корневой зависимости — той, от которой ничего не зависит. Казалось бы, простая задача, но тут библиотека снова удивила.

Она умеет показывать, какие у зависимости есть подзависимости, но не даёт информацию о её родителе. То есть если у нас есть две одинаково названные библиотеки, BOM воспринимает их как разные сущности, и напрямую связать их нельзя.

Решение оказалось таким:

  1. Берём любую зависимость из BOM и считаем её потенциальным корнем.

  2. Перебираем все зависимости и строим путь от каждой до этого корня.

  3. Если такой путь существует, значит, эта зависимость не корневая, идём дальше.

  4. Прогоняем этот процесс по всему BOM (так как корневых зависимостей может быть несколько) и в итоге получаем полный список корней.

Алгоритм поиска корневых элементов
def find_all_roots(dependencies) -> List[str]:
   roots = list()
   for potential_root in dependencies:
       is_root = True
       for node in dependencies:
           if str(node.ref) == str(potential_root.ref):
               continue
           paths = node.path_to_library(str(potential_root.ref))
           if paths:
               is_root = False
               break
       if is_root:
           roots.append(str(potential_root.ref))
   return roots

Из-за особенностей BOM поиск пути до уязвимой библиотеки сделан через рекурсивную функцию, которая идёт от корневых элементов до конца BOM-файла. Логика алгоритма простая: сначала определяем стартовую точку, затем по очереди проверяем зависимости, пока не дойдём до нужного компонента.

Основная функция здесь — find_library. Она ищет все возможные пути от корневой библиотеки до уязвимой. На вход ей подаётся pURL уязвимой зависимости (что такое pURL — см. тут) и список pURL корневых элементов. Также передаём две вспомогательные переменные:

  • path — хранит текущий маршрут, по которому идёт поиск.

  • depth — ограничивает глубину рекурсии, чтобы исключить слишком длинные пути.

Функция начинает обход всего BOM, перебирая зависимости и вызывая path_to_library, которая проверяет, есть ли прямой путь между текущей зависимостью и искомой библиотекой.

Дальше логика такая:

  1. Если пути нет, идём дальше, проверяем следующую зависимость.

  2. Если путь есть, проверяем, является ли эта зависимость корневой.

    • Если да — отлично, путь найден, фиксируем его.

    • Если нет — вызываем find_library ещё раз, но теперь за отправную точку берём найденную зависимость.

Так мы проходим весь BOM и получаем полный список всех путей от корневых библиотек до уязвимой зависимости.

def find_library(self, node_to_find: str, roots: List[str], path: str = "", depth: int = 1) -> List[str]:
   if depth >= limit_of_recursive:
       return None
   dependencies_for_bom = self.dependencies
   result_paths = list()
   for node in dependencies_for_bom:
       if str(node.ref) == node_to_find:
           continue
       paths = node.path_to_library(node_to_find, path)
       if not paths:
           continue
       for p in paths:
           parent_node = p.split("+")[0].strip()
           if parent_node in roots:
               result_paths.append(p)
           else:
               paths = self.find_library(parent_node, roots, p, depth+1)
               if paths:
                   result_paths.extend(paths)
   return result_paths
def path_to_library(self, node_to_find: str, path: str = "") -> str | None | list[str]:
   list_of_paths = list()
   if str(self.ref) == node_to_find:
       return str(self.ref)
   if not self.dependencies:
       return None
   for node in self.dependencies:
       if str(node.ref) == node_to_find:
           if not path:
               list_of_paths.append(str(self.ref))
           else:
               list_of_paths.append(str(self.ref) + " + " + path)
   return list_of_paths

Поиск пути до уязвимой библиотеки иногда выдаёт слишком много вариантов, поэтому я решил ограничить максимальную длину пути. Это сократило время обработки в 10 раз, при этом вся нужная информация остаётся на месте.

Trivy иногда генерирует SBOM с рандомным UUID вместо понятного имени корневого элемента. Для разработчиков это не особо удобно, поэтому мы просто заменяем его на читаемое название, чтобы сразу было понятно, с каким компонентом работаем.

Чтобы корректно отправлять данные в ASOC и избежать дубликатов, мы генерируем уникальный hash для каждой сработки. В него входят название репозитория, ветка, уязвимая зависимость, ID уязвимости и флаг транзитивности. Это позволяет не плодить одинаковые записи и сразу видеть, что именно уже зарегистрировано.

После всех этих этапов сервис начинает работать в полную силу: формирует понятные отчёты для разработчиков и отправляет результаты в ASOC, где их берёт в работу AppSec-команда.

Пример отчета для разработчика
Пример отчета для разработчика

Итог

В итоге процесс стал прозрачнее для разработчиков, а заодно мы реально ускорили исправление уязвимостей, потому что теперь всё чётко видно и не нужно тратить время на разбор зависимостей вручную. Ну и, конечно, это ещё один шаг к Shift Left Security — чем раньше мы ловим проблемы, тем проще их решать.

Надеюсь, статья поможет вам посмотреть на реализацию SCA под другим углом и упростит работу с CycloneDX. Если есть мысли или идеи, как сделать ещё лучше — давайте обсудим в комментариях.  

Теги:
Хабы:
+10
Комментарии4

Публикации

Информация

Сайт
www.cian.ru
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия
Представитель
Zina Bezzabotnova