
В экосистемах со сложившейся моделью пакетных индексов вроде Python, Java или JavaScript состав проекта обычно начинают анализировать с манифеста, где разработчик перечисляет прямые зависимости проекта и их допустимые версии. Менеджер пакетов читает эти требования, выбирает совместимые версии и подтягивает зависимости, которые нужны самим пакетам. Конкретный результат выбора сохраняется в lock-файле, где фиксируется уже не диапазон, а точные версии прямых и транзитивных зависимостей. В результате получается список всех пакетов, необходимых для работы с проектом.
В проектах на C и C++ такой общей записи, как правило, нет. Одну библиотеку могут устанавливать через менеджер системных пакетов, другую собирать из исходников в отдельном пайплайне, третью копировать прямо в каталог с исходным кодом проекта. Все они могут оказаться в рабочем приложении, хотя ни один источник не обязан перечислять их все в одном месте.
На крупной кодовой базе это быстро становится проблемой. Например, в нашем разборе автоматизации SBOM для LibreOffice в проекте была сотня с лишним C/C++ библиотек, собственная сборочная система и ресурсы вроде шрифтов и словарей, которые тоже попадают в поставку. За каждым компонентом такого SBOM стоит отдельное расследование от файла и команды сборки к имени проекта, версии и источнику. На каждом переходе теряется своя часть данных.
В CodeScoring мы анализируем состав программных продуктов и на практике часто сталкиваемся с ограничениями разных экосистем. Разбор проектов на C/C++ требует особого внимания. В этой статье мы разобрали особенности этой экосистемы, которые осложняют построение SBOM, и постарались рассказать, что позволяют выяснить разные методы анализа.
Без единого источника истины не будет счастья
Установить название и версию библиотеки проще, если её добавили через пакетный менеджер, который сохранил эти сведения. При этом менеджеры зависимостей проекта и системных пакетов хранят данные о разных частях окружения. Conan и vcpkg управляют зависимостями конкретного C/C++ проекта, а системные менеджеры вроде apt или dnf устанавливают программы и библиотеки в операционную систему. Например, в Debian и Ubuntu сведения об установленных файлах хранятся в базе dpkg, а системы семейства RPM используют базу RPM. По такой базе файл можно связать с пакетом и его версией.
Базы dpkg и RPM помогают, если библиотеку установили через системный пакетный менеджер. Для ранее собранной библиотеки связь с исходным проектом, версией и набором патчей приходится сохранять вместе с артефактом. Сам формат статического архива или динамической библиотеки не предъявляет требования к наличии этих сведений.
При включении сторонних исходников и header-only библиотек отдельного артефакта может вообще не быть. Происхождение такого кода приходится устанавливать по исходникам и данным сборки.
Поскольку g++ может выполнять и компиляцию, и линковку, то при разборе сборки приходится смотреть на аргументы вызова. Сборочная система передаёт инструментам пути, параметры и порядок действий. Имя исходного проекта и версия библиотеки для этого могут вообще не понадобиться. Это видно на примере команды линковки:
g++ main.o network.o /opt/libs/libfoo.a -lssl -o app
Полный путь /opt/libs/libfoo.a указывает на конкретный статический архив, а ключ -lssl просит линковщик найти библиотеку в каталогах поиска. При вызове через GCC на выбор влияют параметры -L, переменная LIBRARY_PATH и стандартные пути toolchain.
Команда показывает, какие объектные файлы и архивы переданы линковщику, но для -lssl конкретный файл ещё нужно определить. Ни версия libfoo, ни адрес исходного проекта, ни внесённые перед сборкой изменения из команды не следуют. Даже имя архива остаётся слабой уликой, поскольку файл можно переименовать без изменения содержимого.
Чаще всего эти сведения существовали на этапе сборки самой библиотеки, когда разработчики знали исходный проект, версию и настройки. Затем бинарный артефакт попал во внутреннее хранилище или следующий пайплайн, а его происхождение осталось в предыдущей системе. Восстанавливать такую связь постфактум всегда дороже, нежели сохранить её вместе с артефактом.
Для SBOM конкретного релиза нужно не только установить происхождение библиотек, но и выяснить, какие из них вошли в поставку. Наличие библиотеки в сборочном окружении ещё не означает, что она используется в выпускаемом приложении.
При сборке через Make или CMake без менеджера зависимостей библиотеки могут приходить из системных пакетов, каталогов с исходниками и других пайплайнов. Какие из них понадобятся для конкретного выпуска – зависит от операционной системы, параметров компиляции и выбранных целей сборки.
Список установленных пакетов тоже не всегда отражает состав поставки. К примеру, в документации ChromiumOS по сбору лицензий описаны статические библиотеки из пакетов сборочного окружения. Их код входит в поставляемые программы, хотя сами пакеты отсутствуют в образе. Поэтому для сбора лицензий разработчикам пришлось учитывать и часть пакетов сборочного окружения.
Некоторые сборочные среды уже умеют формировать SBOM из собственных данных. Например, экспериментальная команда install(SBOM) в CMake создаёт документ SPDX для программ и библиотек, которые проект устанавливает в систему. Yocto собирает SBOM по рецептам, где описаны пакеты и шаги их сборки, а vcpkg создаёт SPDX-документ для каждого установленного пакета. Такой SBOM охватывает зависимости, известные соответствующей среде. Если библиотеку передали готовым файлом без метаданных, её происхождение всё равно придётся устанавливать отдельно.
Что же делать?
Когда готового списка зависимостей нет, состав проекта приходится восстанавливать по ходу сборки. Наблюдение за ней позволяет найти используемые библиотеки, а базы системных пакетов, метаданные артефактов и анализ кода помогают установить их происхождение.
В аргументах реально запущенного линковщика видны пути к библиотекам и параметры их поиска. Для библиотек, заданных через -l, конкретный файл ещё нужно определить. Даже после этого найденный путь не сообщает, из какого проекта и какой версии получен артефакт.
Одних команд сборки тоже недостаточно. Заимствованные фрагменты и header-only библиотеки нужно искать в исходниках, а при отсутствии исходного кода и данных сборки остаётся анализировать собранный бинарь.
Чтобы распознать заимствованный код, сканер может сравнить криптографический хеш файла с известным значением. Это даёт точное совпадение для неизменённого файла, но первый же локальный патч меняет хеш целиком. И тут приходит на помощь поиск по нормализованному представлению функций, что требует большой предварительной работы по индексации open source библиотек.
Сигнатура строится по более устойчивым признакам, включая выбранные фрагменты, структуру и другие характеристики кода.
Бинарный сканер сопоставляет секции, символы и строковые константы с признаками известных библиотек. На результат влияют компилятор, его версия и параметры сборки. Например, оптимизатор компилятора может встроить функцию в место вызова, удалить неиспользуемый код или заметно изменить его форму. Утилита strip удаляет из готового файла таблицы символов и отладочные сведения. Поэтому сигнатура чаще доказывает семейство библиотеки или диапазон версий, но не точную версию. Внутрикластерные отличия специфичны и требуют творческого подхода к поиску отличительных признаков версий.
Наличие локального патча добавляет ещё одну неопределённость, поскольку изменённая копия может отличаться от публичного проекта именно исправлением искомой уязвимости, хотя номер версии при этом останется прежним.
Статическая и динамическая линковка в свою очередь по-разному влияют на состав приложения при запуске. При статической линковке линковщик извлекает из архива объектные файлы, необходимые для разрешения символов, и включает их в компоновку. Сам архив после сборки для запуска не нужен. Динамическую библиотеку выбирает динамический загрузчик с учётом путей поиска и настроек среды исполнения. Поэтому при запуске приложение может получить иную совместимую версию библиотеки, нежели та которая была доступна в системе сборки.
Отсюда возникают два вида инвентаря и соответственно два SBOM. Build-time фиксирует сведения, собранные во время компиляции и линковки конкретного выпуска, включая использованные статические библиотеки, выбранные цели и наблюдавшиеся команды. Runtime исследует уже запущенное приложение и показывает динамические библиотеки, доступные ему в конкретной среде.
Два разных SBOM отвечают на разные вопросы. Первый объясняет, из чего собрали артефакт, а второй показывает, с чем он фактически запускается. Свести два списка бывает сложнее, чем получить их по отдельности, но попытка заменить один другим почти гарантированно оставит слепую зону. В итоге приходится отдельно инвентаризировать сборку и среду исполнения, а затем сопоставлять результаты.
Открытые инструменты обычно работают только с одним из этих слоёв. Например, observer-cli наблюдает сборку и пытается установить имя и версию библиотеки по данным пакетного менеджера операционной системы. Другие генераторы читают манифесты, исходный код, готовые программы или контейнерные образы. Универсального открытого инструмента, который достоверно объединяет все пути попадания C/C++-кода и восстанавливает утраченное происхождение произвольного файла, пока нет.
Что нужно знать о С/С++ библиотеке, чтобы добавить её в SBOM
Когда мы определили абстрактный путь вроде /opt/libs/libfoo.a, он ещё не является компонентом SBOM. Сначала нужно установить, из какого исходного проекта получена библиотека, а затем определить её версию и внесённые изменения. Для записи в SBOM также нужны поставщик и идентификатор. Идентификатор служит устойчивым именем, по которому компонент смогут узнать другие инструменты.
Библиотеку из системного пакета можно сопоставить с базой dpkg или RPM и получить проверяемые имя, версию и происхождение. Для библиотек, установленных вручную, дополнительной уликой служит pkg-config. Эта утилита читает текстовые файлы .pc, где разработчик библиотеки может указать её название, версию, пути к заголовкам и параметры линковки.
Внутренний пайплайн может добавить к артефакту метаданные с именем проекта и версией, а затем защитить запись цифровой подписью, чтобы подмена стала заметной. Но если таких сведений нет, то остаются только строки, сигнатуры и эвристики. Последние опираются на косвенные признаки и позволяют сделать лишь вероятный вывод.
Отсюда следует практичное правило – если версию нельзя доказать, то SBOM не должен угадывать её по имени файла. Компонент лучше сохранить с пометкой «версия не установлена», или unresolved, и показать границу знания. CycloneDX позволяет указывать доказательства идентичности, уверенность метода и полноту состава. Да, такая пометка выглядит не так эффектно, как список версий, но зато не создает иллюзий по поводу состава проекта.
Как мы пришли к наблюдению за сборкой
Граница между найденным файлом и доказанным компонентом определила и наш в CodeScoring подход к анализу сборки. Когда у проекта нет манифеста и lock-файла, перечень библиотек приходится восстанавливать по фактическим командам сборки. Поэтому Johnny, консольный агент CodeScoring, умеет наблюдать за сборкой и собирать сведения о файлах, участвующих в создании конкретного артефакта. Этот режим называется scan build.
Для этого Johnny наблюдает за запусками процессов через eBPF. В режиме scan build ebpf Johnny определяет список библиотек по аргументам команд линковки с ld, ld.lld и обёрткой GCC collect2. Формат лога и уровень его подробности при таком наблюдении уже не важны.
Наблюдение через eBPF решает задачу обнаружения библиотек. Для определения их расположения Johnny использует явные пути из команд и системные данные, включая кэш ldconfig. Если библиотека задана через -l, найденный в системном кэше файл может отличаться от выбранного линковщиком с пользовательскими каталогами поиска. Затем агент ищет метаданные в базах dpkg или RPM, а для локальной статической библиотеки может получить версию из файла .pc.
Если версию определить не удалось, то список таких библиотек можно сохранить параметром --unresolved-file ./unresolved.json. Пользователь копирует нужные записи в отдельный JSON-файл, заполняет поле version по проверяемому источнику и передаёт файл Johnny при повторном запуске через --lib-versions ./lib-versions.json. Для таких записей из --lib-versions используется PURL типа generic.
Библиотеки, для которых не удалось полностью определить метаданные, также включаются в результат и SBOM с доступными сведениями и суффиксом unresolved в окружении. Зависимости инструментов сборки отмечаются суффиксом toolchain. Найденный путь при этом не выдаётся за достоверно определённую библиотеку.
Режим scan build связывает сведения о запуске линковщика с компонентами SBOM. eBPF фиксирует команды и их аргументы, а Johnny определяет расположение библиотек и ищет данные об их происхождении. В случае, когда метаданных недостаточно, библиотека остаётся в результате как unresolved. Это позволяет учитывать и зависимости, подключённые вне пакетного менеджера. Команды и ограничения режима описаны в документации CodeScoring.
Ценность такого результата не в количестве найденных файлов, а в том, что для каждой записи можно показать источник вывода.
Инвентаризация – это только половина задачи
Вы могли заметить, что всё это время мы говорили только про инвентаризацию проекта, то бишь про разбор состава. Но SBOM не всегда строят только ради самого перечня компонентов. Следующий шаг связан с поиском уязвимостей, а с этим всё ещё сложнее.
Чтобы перейти от инвентаризации к поиску уязвимостей, найденному компоненту нужен идентификатор, известный как анализатору, так и внешней базе. Package URL, или PURL, записывает сведения о пакете в одной строке. Как правило, в него входят тип экосистемы, название, версия и при необходимости дополнительные признаки сборки. Это достаточно простой и популярный способ определения open source компонентов.
Такая запись надёжна, когда библиотека действительно принадлежит известной пакетной экосистеме. Например, для системных пакетов Debian и RPM используют PURL с типом deb или rpm. Пакетам Conan соответственно присваивают PURL этой экосистемы. Однако произвольно собранная C/C++ библиотека может не иметь записи ни в одной пакетной экосистеме, и одного пути к файлу для создания достоверного PURL недостаточно.
На GitHub часто можно увидеть, как с этой проблемой сталкиваются различные проекты, например Zephyr. Команда west spdx построила SBOM по файлам конкретной прошивки, но при попытке проверить документ через OSV-Scanner инструмент сообщил, что нашёл ноль пакетов. В обсуждении Zephyr причиной как раз и оказались отсутствующие идентификаторы PURL.
В записях об уязвимостях для C/C++ часто встречается другой идентификатор, CPE. Это стандартизованное имя вида программного продукта, составленное из поставщика, названия, версии и дополнительных признаков. Запись в базе уязвимостей сообщает, что проблема относится к продукту с таким CPE, но сама по себе не указывает на конкретный файл или пакет в сборке.
Имена в PURL и CPE чаще всего не совпадают. Например, в Debian 12 файл libssl.so.3 входит в бинарный пакет libssl3, который собирается из исходного пакета openssl. При сопоставлении с CPE нужно установить связь с исходным продуктом OpenSSL. Одного приведения libssl3 к единому написанию недостаточно – нужна связь между файлом, пакетом дистрибутива и исходным проектом. Чем меньше сведений сохранилось о происхождении библиотеки, тем труднее выбрать правильный CPE и отсечь ложные совпадения.
Совпадение с уязвимой upstream-версией ещё не доказывает наличие проблемы. Мейнтейнер компонента мог перенести исправление в свою сборку, сохранив исходный номер версии. Тогда нужно проверить всю ревизию пакета и состав патчей.
Другая ошибка возникает, если анализатор не распознал переименованную копию библиотеки. Он может пропустить уязвимость ещё на этапе определения компонента. Поэтому цепочка поиска уязвимости получается длиннее самой инвентаризации:
файл → компонент → версия → PURL/CPE → запись об уязвимости → применимость к сборке
Каждая связь в этой цепочке требует собственного доказательства. Ошибка в атрибуции файла распространяется до результата анализа уязвимостей, а точное совпадение по версии ещё не учитывает патчи.
А что там насчёт Conan?
После всего вышеописанного использование Conan выглядит относительно просто. Менеджер выбирает конкретные версии прямых зависимостей и пакетов, которые нужны уже им, а также хранит связи между пакетами. Из этих данных можно сформировать SBOM. В Conan 2 есть штатная генерация CycloneDX, хотя документация пока помечает функцию как экспериментальную.
Генерацию запускают через hook в клиенте Conan. После выбранного этапа он вызывает функцию из модуля conan.tools.sbom, которая берёт уже выбранные пакеты и связи между ними и возвращает CycloneDX JSON. Имена и версии известны Conan ещё при установке, поэтому восстанавливать их по готовым файлам не приходится.
В таком сценарии наблюдение за сборкой не нужно для восстановления имён и версий зависимостей. Но отдельной задачей остаётся проверка среды исполнения, где динамический загрузчик может выбрать другую совместимую версию библиотеки, а также работа с поиском уязвимостей в найденных компонентах.
Куда движется практика
Сведения о происхождении библиотек надёжнее сохранять при сборке, пока известны исходники, версии и внесённые изменения. Если след сборки уже потерян, инструменты вынуждены расширять анализ готового результата. После этого остаётся связать полученный SBOM с конкретным выпуском. Открытые проекты развивают эти направления независимо, поэтому их результаты пока приходится сводить.
Например, чтобы сохранить происхождение до его потери, проект BOMsh наблюдает команды сборки и для каждого выходного файла записывает входные файлы, команду и хеши. OmniBOR соединяет такие записи в дерево происхождения, или provenance, по которому можно пройти от готовой программы к участвовавшим в её создании артефактам.
Однако provenance ещё не равен SBOM. Разработчики BOMsh предупреждают, что конфигурационные проверки, тесты и подготовка инструментов порождают дополнительные записи, которые трудно отличить от сборки поставляемого продукта. Историю приходится сокращать, а файлам всё равно назначать имена и версии компонентов.
Если полноценный след сборки уже потерян, то инструменты расширяют поверхность поиска. cdxgen объединяет данные из манифестов, исходников, контейнерных образов и бинарных файлов, а CVE Binary Tool ищет узнаваемые признаки библиотек в готовых файлах.
После сборки SBOM можно связать с конкретным артефактом аттестацией. Например, Syft выпускает CycloneDX и SPDX и умеет создавать подписанные аттестации in-toto. Такая запись связывает хеш артефакта с SBOM и указывает, кто сформировал документ. Подпись подтверждает автора и целостность утверждения, но не исправляет ошибочную атрибуцию внутри SBOM. Ценность аттестации скорее в том, что перечень компонентов перестаёт быть отдельным файлом неизвестного происхождения и входит в проверяемую историю релиза продукта.
В CodeScoring мы хотим прослеживать происхождение стороннего кода во всём C/C++-проекте, каким бы путём он туда ни попал. Наблюдение за сборкой уже позволяет находить зависимости за пределами манифестов. Теперь мы смотрим дальше, на исходный код опенсорс проектов и процесс сборки и упаковки, анализируем собранные библиотеки без метаданных и ищем код, включённый прямо в исходники. Наша цель — связать эти способы анализа, чтобы от найденного файла или фрагмента можно было дойти до исходного проекта, его версии и связанных с ним рисков.
Так мы возвращаемся к исходному вопросу о том, из чего всё-таки собран C/C++ проект. Ответ на этот вопрос редко хранится в одном файле – его приходится складывать из списка пакетов и связей, сохранённых менеджером, следов конкретной сборки, анализа исходников и готовых программ, состава среды исполнения и аттестации выпуска.
Чем раньше связь файла с компонентом и версией закреплена в этой цепочке, тем меньше её приходится угадывать по имени или сигнатуре. Открытые проекты уже закрывают отдельные участки, но инструмента, который объединяет их без потери доказательности и затем определяет применимость уязвимости к конкретной сборке, пока нет. Именно в соединении этих слоёв находится следующий шаг.
ссылка на оригинал статьи https://habr.com/ru/articles/1086376/