SBOM образа честно перечисляет компонент. Сканер уязвимостей по тому же образу находит сотни проблем — и ни одной по этому компоненту. А внутри лежит FFmpeg с известной уязвимостью, и обработка недоверенных файлов идёт именно через него.
Сразу разведём три разные вещи, которые часто валят в одну кучу:
- Dependabot работает с манифестами и lock-файлами репозитория, а для Docker отслеживает ссылки на образы в ваших
Dockerfileи манифестах — но не анализирует содержимое файловых систем этих образов (документация GitHub). Всё, что попало внутрь чужого образа, вне его области по определению. - SCA по исходникам (govulncheck, osv-scanner по манифестам) смотрит на объявленные зависимости проекта.
- Сканеры образов (grype, trivy, osv-scanner v2 в режиме
scan image) читают собранный артефакт. База пакетов дистрибутива — их главный, но не единственный источник: они разбирают и метаданные языковых экосистем, и файловые классификаторы, а syft умеет опознавать известные бинарники по сигнатурам. Это важная деталь — ниже будет видно, что именно она однажды сработает.
Эта статья — про слепое пятно третьей категории, самой близкой к продакшену. Dependabot я не запускал: для вендоренного бинарника внутри чужого образа он неприменим по устройству, и проверять тут нечего.
Слепое пятно возникает не в экзотике: значимая часть сложных self-hosted-образов собирает собственные бинарники. В разборе PixelSmash уязвимость нашли именно в такой сборке — собственном jellyfin-ffmpeg внутри Jellyfin.
Я собрал стенд и прогнал четыре инструмента по пяти образам. Главный результат получен на контрольной паре, где один и тот же файл упакован под разными именами: 65 уязвимостей против нуля.
В статье
- Что такое манифест и что проходит мимо него
- Методика: показательные случаи и контроль
- Контрольный эксперимент: 65 против нуля
- Как это выглядит на реальном образе
- Почему инструменты расходятся
- Побочная находка: тег образа не равен версии внутри
- Что с этим делать
- Итог
- Источники
Что такое манифест и что проходит мимо него
Почти вся автоматика вокруг зависимостей опирается на манифест — декларацию «из чего собрано». Для приложения это go.mod, package.json, pom.xml; для образа одним из главных источников служит база пакетов дистрибутива (/var/lib/dpkg, база apk или rpm) — именно из неё сканер узнаёт большую часть того, что внутри.
Мимо манифеста ведёт множество дорог:
- вендорская сборка — проект собирает собственный форк и кладёт его в образ (
jellyfin-ffmpegи подобные); - бинарник, скачанный в
Dockerfile—curl,tar -x,COPYиз стороннего образа; - статическая линковка — компонент вкомпилирован в исполняемый файл, отдельной записи о нём нет вообще;
- бандл внутри другого компонента — QtWebEngine тащит внутри Chromium со своим FFmpeg (по CVE-2026-8461 Red Hat трекает и
qt5-qtwebengine/qt6-qtwebengine).
Общее у всех дорог одно: файл в образе есть, а записи о нём в манифесте либо нет, либо она выглядит не так, как ожидают базы advisory. Дальше — что об этом файле знают инструменты.
Методика: показательные случаи и контроль
Цели стенда делятся на две группы, и смешивать их выводы нельзя.
Показательные случаи — как бывает в жизни. Здесь различаются и версии FFmpeg, и дистрибутивы, поэтому по ним нельзя изолировать влияние имени пакета от всего остального:
| Цель | Как поставлен FFmpeg |
|---|---|
distro |
apt install ffmpeg в Debian 12 — контрольная точка, сканер обязан найти |
vendored |
бинарник скопирован в образ мимо пакетного менеджера |
jellyfin |
jellyfin/jellyfin:10.11.9 — настоящая вендорская сборка |
Контролируемый эксперимент — здесь зафиксировано всё, кроме одной переменной:
| Цель | Пакет в базе dpkg |
|---|---|
deb-canonical |
ffmpeg 8.1 |
deb-renamed |
acmecorp-ffmpeg7 8.1 |
Один и тот же бинарник (побайтово — стенд сверяет sha256sum), одна база ubuntu:24.04, одна версия, один digest донора. Отличается только имя пакета. Именно эта пара позволяет говорить о причине, а не о корреляции.
Инструменты: syft (SBOM), grype, trivy и osv-scanner v2 в режиме scan image — все четыре получают на вход один и тот же образ. Версии пиновые, донор и базовая система закреплены по digest, базы уязвимостей кэшируются. Каждый артефакт прогона проверяется на структурную валидность: пустой JSON из-за сорвавшейся загрузки базы легко принять за «уязвимостей нет» — на первом прогоне это ровно и случилось с одной целью. Полный стенд с сырыми артефактами: security/untrusted-input/inventory.
Оговорюсь о границе воспроизводимости: повторяется механизм, а не конкретные числа. Базы advisory живые, поэтому «657 находок» — снимок на 31.07.2026. Доказательством служит не абсолютная величина, а разница внутри одного прогона.
Перед сканированием стенд проверяет главную предпосылку — FFmpeg физически присутствует в каждом образе. Утверждение «сканер не нашёл» ничего не стоит, если находить нечего.
Контрольный эксперимент: 65 против нуля
Начнём с пары, где переменная одна. «Находки» — записи матчера; уникальные идентификаторы считаются отдельно, потому что один CVE может дать несколько записей:
| Цель | Пакет | grype: находки / по FFmpeg / уник. CVE | trivy | osv-scanner v2 |
|---|---|---|---|---|
deb-canonical |
ffmpeg 8.1 |
174 / 65 / 65 | 83 / 41 / 41 | 197 / 75 |
deb-renamed |
acmecorp-ffmpeg7 8.1 |
109 / 0 / 0 | 42 / 0 / 0 | 122 / 0 |
Файл один и тот же. Переименование пакета обнуляет покрытие у всех трёх сканеров сразу.
Целевой CVE ведёт себя так же. Проверка структурная — идентификатор уязвимости и имя пакета берутся из одной записи, а не ищутся по всему JSON:
| Цель | К какому пакету grype отнёс CVE-2026-8461 |
|---|---|
deb-canonical |
ffmpeg@8.1 |
deb-renamed |
— |
Обратите внимание на колонку «находки»: у deb-renamed их 109 — инструмент отработал, образ увидел, уязвимости в системных пакетах нашёл. Он просто не знает, что acmecorp-ffmpeg7 — это FFmpeg.
Как это выглядит на реальном образе
Теперь показательные случаи. Сначала инвентаризация — видит ли инструмент компонент вообще:
| Цель | Что нашёл syft | Тип записи | Всего компонентов |
|---|---|---|---|
distro |
ffmpeg@7:5.1.9-0+deb12u1 и 5 библиотек libav* |
deb |
288 |
vendored |
ffmpeg@8.1 |
binary |
29 |
jellyfin |
jellyfin-ffmpeg7@7.1.3-6-trixie |
deb |
264 |
Syft находит FFmpeg во всех трёх случаях. У цели vendored он опознал бинарник по сигнатуре, без всякого пакетного менеджера. Инвентаризация справляется везде.
А сопоставление — нет:
| Цель | grype: находки / по FFmpeg / уник. CVE | trivy | osv-scanner v2 |
|---|---|---|---|
distro |
657 / 168 / 28 | 663 / 168 / 28 | 662 / 252 |
vendored |
13 / 7 / 7 | 0 / 0 / 0 | 0 / 0 |
jellyfin |
380 / 0 / 0 | 393 / 0 / 0 | 384 / 0 |
Контроль пройден: у distro все инструменты уверенно находят уязвимости FFmpeg. Механизм исправен.
И на этом фоне — Jellyfin. Grype находит 380 уязвимостей в образе и ноль по FFmpeg. Trivy — 393 и ноль. Osv-scanner — 384 и ноль. Компонент виден в SBOM, физически присутствует, версия известна; версия 7.1.3 совпадает с той, что разбирали в отчёте JFrog. Уязвимостей по нему — ни одной у трёх инструментов сразу.
Строка vendored любопытна отдельно: кустарно скопированный бинарник оказался виднее аккуратно упакованного deb из Jellyfin. Syft присвоил ему pkg:generic/ffmpeg@8.1 — имя совпало с тем, что знают базы, и grype нашёл 7 уязвимостей, включая CVE-2026-8461. Дело не в способе доставки, а в идентификаторе — и это ровно то, что независимо подтвердила контрольная пара.
Почему инструменты расходятся
Ответ виден в идентификаторах. Сопоставление идёт не по содержимому файла, а по идентичности компонента, которую сканер собирает из имени, версии, типа пакета, namespace дистрибутива, purl и CPE — плюс собственных правил матчинга:
| Цель | name | version | type | purl |
|---|---|---|---|---|
distro |
ffmpeg |
7:5.1.9-0+deb12u1 |
deb | pkg:deb/debian/ffmpeg@…?distro=debian-12.15 |
vendored |
ffmpeg |
8.1 |
binary | pkg:generic/ffmpeg@8.1 |
jellyfin |
jellyfin-ffmpeg7 |
7.1.3-6-trixie |
deb | pkg:deb/debian/jellyfin-ffmpeg7@7.1.3-6-trixie?… |
deb-canonical |
ffmpeg |
8.1 |
deb | pkg:deb/ubuntu/ffmpeg@8.1?distro=ubuntu-24.04 |
deb-renamed |
acmecorp-ffmpeg7 |
8.1 |
deb | pkg:deb/ubuntu/acmecorp-ffmpeg7@8.1?distro=ubuntu-24.04 |
Две последние строки различаются одним полем — и дают 65 уязвимостей против нуля. В базах advisory есть записи про ffmpeg, но нет ни одной про acmecorp-ffmpeg7 или jellyfin-ffmpeg7. Формально сканер прав: пакета с таким именем в его базе не значится. Практически — уязвимый декодер стоит в конвейере обработки пользовательских файлов, и об этом никто не узнает.
Ни grype, ни trivy, ни osv-scanner не смотрят внутрь бинарника, чтобы выяснить, «а не FFmpeg ли это на самом деле». Syft в режиме бинарной классификации отчасти умеет — но только когда файл распознан как самостоятельный компонент, а не спрятан в пакете под чужим именем.
Побочная находка: тег образа не равен версии внутри
Пока собирался стенд, всплыла деталь, стоящая отдельного абзаца. Образ jrottenberg/ffmpeg:8.1.2-alpine320 (digest sha256:1e7192a3…, получен 31.07.2026) содержит FFmpeg, который представляется как 8.1, и маркера фикса CVE-2026-8461 в нём нет.
Оговорю границу утверждения: я не знаю, какую семантику вкладывает автор образа в этот тег — возможно, он означает ветку сборки, а не версию FFmpeg внутри. Утверждение здесь узкое и проверяемое: содержимое не соответствует ожидаемой upstream-версии, и полагаться на тег как на доказательство версии нельзя.
Проверял по маркеру из коммита 374b726f: он добавил сообщение об ошибке slice_height ... is not aligned to chroma vertical subsampling, которого до фикса не существовало.
| Источник | aligned to chroma |
impossible slice height |
|---|---|---|
исходники FFmpeg n8.1.2 |
есть | есть |
исходники FFmpeg n8.1 |
нет | есть |
собранный пакет Alpine, ffmpeg-libavcodec 8.1.2 |
есть | — |
jrottenberg/ffmpeg:8.1.2-alpine320 |
нет | есть |
Две строки здесь закрывают возражения, без которых вывод был бы шатким.
impossible slice height присутствует и до фикса. Выбери я её маркером — получил бы обратный и неверный результат. Маркер нужно сверять с исходниками, а не брать первую подходящую строку.
Третья строка отвечает на второе возражение: «может, строка просто не пережила компиляцию». В реальной собранной 8.1.2 из репозитория Alpine она на месте, и контрольный запрос по той же библиотеке находит magicyuv — метод рабочий. Независимо это подтверждает grype: он помечает бинарник из образа как подверженный CVE-2026-8461.
# Маркер фикса CVE-2026-8461 (появился в n8.1.2)
docker run --rm --entrypoint sh <образ> -c \
'strings -a $(find / -name "libavcodec.so*" | head -1) | grep "aligned to chroma"'
# Контроль метода: MagicYUV должен находиться в той же библиотеке.
# Если контроль пуст — метод не работает, и вывод «фикса нет» ничего не значит.
docker run --rm --entrypoint sh <образ> -c \
'strings -a $(find / -name "libavcodec.so*" | head -1) | grep -ci magicyuv'Что с этим делать
Генерируйте SBOM по образу, а не по репозиторию. Манифест репозитория не знает, что попало в образ на этапе сборки. Syft по готовому образу видит и бинарники, которых нет в базе пакетов.
Держите список вендоренных компонентов руками. Всё, что попадает в образ мимо пакетного менеджера или под собственным именем, стоит записать: что это, какая версия апстрима, откуда взято. Список короткий, а закрывает он ровно то, чего автоматика не видит. Подписку на advisory заводите на апстрим, а не на пакет: уязвимости приходят в ffmpeg, а не в jellyfin-ffmpeg7.
Проверяйте артефакт, а не тег. Версия из --version, маркер конкретного фикса, digest образа. Тег может означать что угодно, включая «ветка, из которой собирали».
Не выносите вердикт по номеру версии. Дистрибутивы бэкпортят патчи, не поднимая upstream-версию, — об этой ловушке подробно в разборе PixelSmash. Обратная сторона той же монеты — тег образа, не соответствующий содержимому.
Считайте нулевой результат гипотезой, а не доказательством. «Уязвимостей не найдено» означает лишь «не найдено тем способом, которым инструмент умеет искать». Полезная привычка — спросить: а всё ли, что реально исполняется в этом образе, вообще попало в отчёт?
Дальше — то, что превращает разовую проверку в процесс.
Правьте идентичность компонента там, где сканер ошибся. Прямого универсального override «вендорский пакет → upstream-пакет» ни grype, ни trivy не предлагают, поэтому чинить приходится на уровне SBOM: сгенерировать его, дополнить корректным purl или CPE для вендоренного компонента и скормить сканеру уже обогащённый документ. Работоспособность такой схемы обязательно проверьте на своём наборе инструментов — важно, сохраняет ли сканер добавленные метаданные при импорте SBOM. Это ровно тот случай, когда стоит потратить полчаса на эксперимент вместо доверия к чужому совету, включая мой.
Документируйте применимость через VEX. Обратная сторона обогащения: часть находок к вам не относится — уязвимый код не собран, не вызывается, не достижим. VEX (CycloneDX VEX, OpenVEX) фиксирует это машиночитаемо, вместо того чтобы каждый раз заново вспоминать, почему находку решили не чинить.
Контролируйте происхождение вендорского бинарника. Откуда он взялся, кем собран, из какого коммита, есть ли подпись или контрольная сумма. COPY --from по изменяемому тегу и curl | tar без проверки суммы — доверие без основания. Такие конструкции стоит запретить политикой в CI: пины по digest и обязательные контрольные суммы.
Отслеживайте расхождение форка с апстримом. Вендорская сборка живёт своей жизнью: свои патчи, свой график, свои бэкпорты. Фраза «у нас 7.1.3» ничего не говорит о наличии конкретного фикса. Проверка по маркеру, как выше, — грубый, но рабочий способ.
Автоматизируйте инвентаризацию вендоренного. Renovate с custom managers умеет следить за версиями, не описанными стандартным манифестом (регуляркой по Dockerfile, по переменной с версией). Если и это не покрывает — собственная периодическая задача, сверяющая ваш список вендоренных компонентов с их upstream-advisory, надёжнее надежды на общий сканер.
Смежное на сайте: Сканирование уязвимых зависимостей (SCA) — про то, как расходятся базы advisory и методы детекта; Supply-chain безопасность: SBOM, подпись, сканСкоро — про инвентаризацию и доверие к артефактам.
Итог
- Инвентаризация и сопоставление — разные операции. Компонент можно видеть и при этом не знать ни одной его уязвимости. «Сканер отработал» не равно «компонент покрыт».
- Причина установлена контролем, а не догадкой. Один и тот же бинарник в пакете
ffmpegдаёт 65 уникальных CVE у grype (41 у trivy, 75 находок у osv-scanner), а в пакетеacmecorp-ffmpeg7— ноль у всех трёх. Менялось только имя пакета. - Сопоставление идёт по идентичности компонента — имя, версия, тип пакета, namespace дистрибутива, purl/CPE и правила матчинга, — а не по содержимому файла. В базах advisory нет записей про вендорские имена.
- На реальном образе это выглядит так: у
jellyfin/jellyfin:10.11.9grype находит 380 уязвимостей и 0 по FFmpeg, trivy — 393 и 0, osv-scanner — 384 и 0. При этом syft компонент видит:jellyfin-ffmpeg7@7.1.3-6-trixie. - Способ доставки вторичен. Кустарно скопированный бинарник оказался виднее аккуратно упакованного deb — потому что сохранил каноническое имя.
- Тег образа не равен версии внутри:
jrottenberg/ffmpeg:8.1.2-alpine320содержит 8.1 без фикса CVE-2026-8461. Проверяйте артефакт по маркеру фикса, а сам метод — контрольным запросом. - Практика: SBOM по образу, ручной список вендоренного с подпиской на upstream-advisory, обогащение идентичности с проверкой на своём стеке, VEX для применимости, provenance и digest-пины политикой в CI.
Следующая часть серии — про то, что происходит, когда уязвимость всё-таки срабатывает, а сервис об этом молчит.
Источники
- Стенд статьи с сырыми артефактами прогона:
security/untrusted-input/inventory - GitHub Docs, поддерживаемые Dependabot экосистемы: https://docs.github.com/en/code-security/reference/supply-chain-security/supported-ecosystems-and-repositories
- NVD, запись CVE-2026-8461: https://nvd.nist.gov/vuln/detail/CVE-2026-8461
- FFmpeg, коммит-фикс
374b726f(маркерaligned to chroma): https://github.com/FFmpeg/FFmpeg/commit/374b726ffa878ee1cadb987bd1e1e20cc7ed8845 - JFrog Security Research, разбор PixelSmash: https://jfrog.com/blog/pixelsmash-critical-ffmpeg-vulnerability-turns-media-files-into-weapons/
- Red Hat Bugzilla, CVE-2026-8461 в
qt5-qtwebengine/qt6-qtwebengine: https://bugzilla.redhat.com/show_bug.cgi?id=2490308 - Спецификация purl (Package URL): https://github.com/package-url/purl-spec
- OpenVEX: https://github.com/openvex/spec
- syft, grype: https://github.com/anchore/syft, https://github.com/anchore/grype
- trivy: https://github.com/aquasecurity/trivy
- osv-scanner (v2, режим
scan image): https://google.github.io/osv-scanner/
Комментарии