Перечень без читателя — это файл
Перечень состава — SBOM — сегодня есть у многих: генератор вписан в CI, файл прикладывается к сборке, потому что этого потребовала анкета безопасности или внутренний регламент. Продолжите этот ряд честно: кто-нибудь открывал этот файл по своей воле?
Уточним предмет, чтобы дальше не спорить о словах. Цепочка поставок в софте — не логистика: ваш продукт в основном собран из чужого кода, а отвечаете за него вы. Перечень — единственный способ держать эту арифметику видимой: что втянуто, откуда, в какой версии. SBOM — просто машиночитаемая версия списка, который раньше вели в таблице руками.
Если на вопрос из первого абзаца ответ — пауза, то у вас не перечень, а файл. По своим эффектам список, который никто не читает, неотличим от списка, которого нет: он не влияет ни на одну сборку, ни на один разбор инцидента. Безопасность цепочки поставок на этом уровне заканчивается галочкой.
Проверить себя можно без чужих цифр, на своих данных. Сформулируйте вопрос, на который перечень обязан отвечать: в каких наших артефактах есть такая-то библиотека? Попробуйте ответить быстрее, чем пересоберётся ваш сервис. Не выходит — считайте, что перечня нет, есть JSON.
Поэтому предмет статьи — не генерация: это самый дешёвый шаг, одна строчка в пайплайне. Предмет — как провести перечень до читателя: кто, в какой момент и каким действием его потребляет. Дальше — шаги с границами применимости и тупик, с которого начинают по умолчанию.
Тупик: сгенерировать и положить рядом
Ход, который делают первым, разумен — оттого и живуч. Добавить в пайплайн шаг, приложить файл к сборочным артефактам, отметить пункт в чек-листе. И не помогает. Разбор — по устройству процесса, без чужих историй.
Начать стоит с того, где файл лежит. Артефакты CI живут по правилам отладки, а не по правилам запросов: их чистят, их хранят, пока хватает места, к ним нет индекса. Вопрос к перечню приходит не в момент сборки, а в момент чужой беды — когда в библиотеке, которую вы когда-то втянули, находят уязвимость. К этому часу файл либо уже недоступен, либо его придётся искать по всем сборкам руками.
Дальше — потребитель. Ни одна роль не получает задачу прочитать перечень, а шаг, у которого нет следующего шага, в процессной системе не выполняется. Файл генерируется, потому что генерация вписана в пайплайн; не читается — потому что чтение никуда не вписано.
И формат: файл отвечает, что лежит в этой сборке, но не отвечает, где этот пакет по всем сборкам. А спрашивают всегда вторым.
Родственный тупик — уверенность, что сканер уязвимостей всё закрывает. Сканер отвечает на вопрос, уязвима ли сборка, которую он сейчас осматривает. Он не отвечает, в каких уже собранных и запущенных артефактах есть конкретный пакет. Арифметика простая: каждый новый отчёт об уязвимости умножается на число ваших артефактов, и каждый раз это обход. Перечень — индекс, сканер — обход; индекс строится один раз и отвечает запросом. Для дежурства нужен индекс, обход его не заменяет.
И последний вариант того же тупика — назначить человека, который будет перечень читать. Целиком перечень не читает никто и не должен: в нём транзитивные зависимости, которых вы в глаза не видели. Перечень читают запросами — diff между сборками, выборка по идентификатору пакета. Если чтение в вашей процедуре означает открыть файл и листать, процедура не работает.
Что генерировать и из чего
Первый шаг: перечень составляется не из намерений, а из того, что реально поехало. Два честных источника — lock-файл и собранный артефакт: образ, джарник, бандл. Lock-файл говорит, что вы собирались втянуть; артефакт — что втянулось на самом деле: транзитивные зависимости, патчи, скопированные руками бинарники, vendored-код. Потребитель задаёт вопросы про артефакт — значит, и перечень должен быть про артефакт.
Второй шаг: генерируйте оба перечня и сверяйте. Расхождение — список того, чего вы о себе не знали. Одна такая сверка меняет настройку сборки сильнее, чем любой регламент, поэтому делайте её первой, а не когда-нибудь потом.
Третий шаг: поля. Формат берите любой из устоявшихся машиночитаемых — SPDX или CycloneDX, инструменты читают оба. Важен не формат, а минимальный набор полей, без которого перечень не отвечает на вопросы: идентификатор пакета в единой адресации (purl — тип, имя, версия), сама версия, лицензия, хэш. И отдельно — поле «откуда приехал»: имя пакета без источника — половина идентичности. Пакет с тем же именем из неожиданного места — уже не совпадение, а событие, и перечень обязан такое событие показывать.
Условие применимости: если в стеке есть язык без lock-файла и с динамической загрузкой, генерация из артефакта — единственный честный вариант; генератор по манифестам здесь врёт по определению. Вендорские бинарники в перечень сами не попадут — их состав выясняется запросом к вендору, и ответ стоит хранить рядом со своими перечнями.
Три читателя и три процедуры
Читатель перечня — не должность. Это точка процесса, где перечень меняет чьё-то решение. Таких точек три, и процедура у каждой своя.
Первый читатель — машина на сборке, и ей нужен diff. На каждый мерж — сравнение перечня с предыдущим. Новая зависимость — событие того же порядка, что и новый код: кто-то должен посмотреть, что это, зачем, как давно живёт, кто поддерживает. Ушедшая и обновлённая — тоже события: обновление тянет смену поведения, а тихое исчезновение пакета обычно означает, что кто-то уже правит руками. Если diff на каждый мерж выходит шумным, начните с событий «новая зависимость» — обновления пусть отслеживает политика обновлений; про неё у нас есть отдельный материал, «Политика обновления зависимостей, которую соблюдают».
Второй читатель — дежурный, и он читает выборку из хранилища. В момент, когда в конкретной библиотеке находят уязвимость, дежурный обязан ответить, где этот пакет у вас стоит — по всем сервисам и средам. Для этого перечни складываются в одно место с индексом по идентификатору пакета и по артефакту; держать их только рядом со сборками недостаточно. Ответ должен получаться одним запросом, а не обходом репозиториев. Срок жизни перечня — не меньше срока жизни артефакта: перечень, умерший раньше образа, — это снова файл. Условие применимости: пока сервисы пересчитываются по пальцам, хранилище избыточно — хватит папки с перечнями в git и поиска по ней. Момент, когда нужен индекс, узнаётся по времени ответа на вопрос «где это у нас»: как только ответ занимает дольше самого фикса — пора.
Третий — внешний спрашивающий: аудитор, заказчик, команда безопасности уровнем выше. Ему нужен отчёт по конкретному артефакту, и это должен быть экспорт из хранилища, а не новая пересборка и не ручная таблица. Процедура умещается в строку: запрос по артефакту, экспорт, отправка.
Четвёртая процедура — необязательная на старте. После разбора у дежурного остаётся знание: эта уязвимость нас не касается, потому что код не вызывается, фича выключена или исправление уже в сборке. Если знание не фиксировать, каждый следующий инцидент пересуживает тот же вопрос заново. Для фиксации есть читаемый инструментами формат — VEX; он ложится рядом с перечнем и укорачивает следующую выборку. Заводите его, когда один и тот же вопрос приходит повторно; раньше — бюрократия.
Сам перечень — чувствительные данные
Перечень состава — заодно разведкарта: он показывает, что у вас тянется из внешних источников, что устарело, где вероятные точки входа. Поэтому хранилище перечней — внутренние данные, доступ к нему — как к коду, а выкладывать перечни наружу стоит, только если вы осознанно хотите прозрачности в обе стороны. И второе: перечни генерируются в CI, а CI — то самое место, где секреты имеют привычку утекать. Шаг генерации не должен требовать доступа ни к чему, кроме самой сборки. Типовые утечки секретов в CI мы разбирали отдельно — «Секреты в CI: пять типовых утечек».
Позиция редакции
Мы считаем общепринятый порядок в теме цепочки поставок неправильным: сначала сгенерировать SBOM, потом как-нибудь внедрить потребление. Правильный порядок обратный. Сначала выпишите вопросы, на которые перечень должен отвечать, — минимум два: что добавилось в этом мерже и где этот пакет у нас. Потом назначьте роли, которым нужны ответы: ревьюер сборки, дежурный, отвечающий на запросы. И только потом выбирайте генератор и вписывайте его в CI. Если после этого выяснится, что генерация не нужна, — вы сэкономили галочку и не создали мёртвый файл.
Наша позиция неверна там, где перечень не добавляет знаний к тому, что и так видно из сборки: весь софт собирается из одного зафиксированного списка зависимостей, артефакт один и живёт одну итерацию, а вопрос «где этот пакет» вырождается в «в единственной сборке». В такой конфигурации достаточно сканера над lock-файлом, а хранилище с индексом — бюрократия.
Во всех остальных случаях перечень окупается одним, но дорогим моментом: чужая уязвимость становится вашим вопросом «где», и ответ получается запросом, а не переписью. Ради этого момента список и ведут — а читателя назначают до того, как генератор попал в пайплайн.