Перейти к содержимому

Sergei Ozeranskii

Supply chain раннер-образов: как проверить образ, который исполняет ваш CI

Раннер-образ — самый привилегированный артефакт в CI. Внутри него исполняется ваш код: шаги сборки, тесты, зависимости из реестров, часто Docker-in-Docker. Если в образ что-то подсунули — скомпрометированный тулчейн, подменённый бинарник, лишний пакет, — это касается каждой сборки, которая на нём запускается. Поэтому «что именно внутри образа и кто его собрал» — не гигиена ради галочки, а часть модели угроз.

Ответ «доверьтесь нам» тут не годится. Доверие к образу должно быть проверяемым: посторонний человек должен уметь независимо убедиться, что образ собран из заявленного исходника, прошёл проверку на уязвимости и не подменён по пути. Разберём, из чего складывается такая цепочка — на примере наших runner-images (открывается в новой вкладке). Репозиторий публичный, чтобы было видно, что внутри.

Цепочка доверия к образу: сборка из закреплённого исходника → проверка trivy на CVE → подпись cosign keyless плюс attestations SBOM и SLSA-provenance → публикация в реестр по digest → admission пускает только подписанный образ → исполнение job.
Каждое звено привязано к одному digest; подпись ставится только после успешной проверки.

Что защищаем

Атака на цепочку поставки — это когда вредонос попадает не через уязвимость в вашем коде, а через то, чему вы доверяете: базовый образ, зависимость, сам инструмент сборки. Раннер-образ — привлекательная мишень: он исполняет чужой код и несёт тулчейн (компиляторы, рантаймы, CLI, браузеры). Скомпрометируй его один раз — и получишь доступ ко всем сборкам всех, кто на нём запускается.

Защита строится на трёх вопросах, на каждый из которых должен быть проверяемый ответ:

  • Целостность — образ собран из того, что заявлено, без плавающих версий?
  • Уязвимости — что известно об известных CVE внутри, и что с ними сделано?
  • Происхождение — кто, из какого исходника и в каком окружении собрал этот образ, и не подменён ли он?

Пины: целостность цепочки

Плавающий тег вроде :latest означает «что угодно, что сегодня под этим именем». Для образа, исполняющего чужой код, это неприемлемо: невозможно ни воспроизвести сборку, ни доказать, что завтра под тем же тегом не окажется другое содержимое. Поэтому в цепочке нет ни одного плавающего звена:

  • база — по sha256: digest, а не по тегу;
  • набор инструментов (раннер, рантаймы языков и toolcache, CLI, браузеры с драйверами) — по точным версиям, с проверкой загрузок по SHA256/512 или через apt-репозитории с проверкой ключа;
  • все GitHub Actions в пайплайне — по commit-SHA, а не по тегу вроде @v4;
  • публикуемые теги неизменяемыvYYYYMMDD и sha-<commit>, без :latest; потребитель (наш ARC scale-set) закрепляет образ по tag@sha256:.

Свежесть пинов держат две силы: Renovate предлагает PR с обновлением базового digest, SHA у GitHub Actions и версий тулчейна по мере появления исправлений, а еженедельная пересборка втягивает apt-обновления в слои. Пины — не «заморозить и забыть», а «менять осознанно и по одному».

CVE-проверка: trivy как условие публикации

Каждый push в main сканируется Сканер уязвимостей: проверяет образ на известные CVE в пакетах ОС и зависимостях. В CI используется как гейт, блокирующий публикацию при исправимых HIGH/CRITICAL. по опубликованному digest с --severity HIGH,CRITICAL. Правило жёсткое: исправимый HIGH/CRITICAL проваливает сборку — образ не подписывается, а неподписанный образ admission-политика потребителя не пускает в исполнение. То есть валидная подпись сама по себе означает, что образ прошёл проверку на CVE.

Несколько решений держат проверку строгой, а не косметической:

  • Проверка по слою ОС (--pkg-types os) — это apt/base-слой, который проект патчит напрямую (apt security updates плюс обновление базового digest на еженедельной пересборке). CVE во вложенных зависимостях сторонних инструментов закрываются обновлением версий этих инструментов (Renovate), а не неподдерживаемым списком исключений на каждую зависимость — тот же подход, что у образов GitHub-hosted.
  • --ignore-unfixed — CVE без доступного исправления не блокируют сборку (чинить нечего), но подхватываются автоматически, как только выйдет исправление. Для этого и нужна еженедельная пересборка.
  • Никакого тихого подавления. Исключения живут в .trivyignore.yaml (открывается в новой вкладке) и каждое несёт id (CVE), statement (обоснование) и expired_at — дату, после которой trivy снова начинает видеть этот CVE, что принуждает к повторному разбору. Расширять фильтр severity, чтобы «спрятать» проблему, нельзя.

Подпись и происхождение: Sigstore

После успешной проверки образ подписывается и получает свидетельства о происхождении. Всё это — Открытый стек для подписи артефактов без управления ключами: Fulcio выдаёт краткоживущий сертификат под OIDC-личность, Rekor — публичный неизменяемый журнал прозрачности подписей., открытый стек подписи без управления ключами.

  • Подпись — Инструмент проекта Sigstore для подписи и проверки контейнерных образов и артефактов. В keyless-режиме подпись привязана к OIDC-личности и не требует долгоживущих ключей. keyless. Fulcio выдаёт краткоживущий сертификат, привязанный к GitHub OIDC-личности сборочного workflow, а факт подписи попадает в публичный журнал прозрачности Rekor. Долгоживущих приватных ключей, которые можно украсть, нет.
  • Software Bill of Materials — машиночитаемый перечень всех компонентов и зависимостей внутри артефакта (образа): что именно внутри и каких версий. Форматы — SPDX или CycloneDX.: два формата. SPDX собирает buildkit (sbom: true) и встраивает в OCI-индекс образа как attestation-манифест — отдельной подписи у него нет, целостность покрыта подписью образа (cosign подписывает digest индекса, который на него ссылается). CycloneDX на уровне пакетов собирает syft и прикладывает к digest как cosign-Подписанное машиночитаемое утверждение об артефакте (например SBOM или provenance), привязанное к его digest и проверяемое независимо. с TSA-меткой (RFC 3161 (открывается в новой вкладке) — слишком велик для журнала прозрачности Rekor).
  • Supply-chain Levels for Software Artifacts — отраслевой стандарт уровней защиты цепочки поставки; уровни описывают, насколько надёжно доказано происхождение артефакта.-Криптографически подписанное свидетельство о происхождении артефакта: чем, из какого исходника и в каком окружении он собран. В SLSA — build provenance attestation. — свидетельство сборки (actions/attest-build-provenance): чем, из какого исходника и в каком окружении собран образ.

Подпись, provenance и SBOM привязаны к одному и тому же sha256:-digest: проверяется не «какой-то образ с таким тегом», а этот байт-в-байт артефакт.

Как проверить самому

Команды ниже может выполнить кто угодно, без доступа к нашей инфраструктуре. Работать нужно по неизменяемому @sha256:-digest.

Подпись (нужен cosign (открывается в новой вкладке)):

IMAGE=ghcr.io/tempusbuild/runner-ubuntu-24.04
cosign verify \
  --certificate-identity-regexp '^https://github\.com/tempusbuild/runner-images/\.github/workflows/build\.yml@refs/heads/main$' \
  --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
  "${IMAGE}@${DIGEST}"

--certificate-identity-regexp привязывает подпись к workflow build.yml на ветке main этого репозитория, а --certificate-oidc-issuer — к GitHub OIDC. Любая другая личность или issuer — это не наша сборка.

SLSA-provenance проверяется через gh CLI:

gh attestation verify "oci://${IMAGE}@${DIGEST}" --owner tempusbuild

SPDX-SBOM встроен в OCI-индекс образа — его читают buildkit-тулингом (docker buildx imagetools inspect "${IMAGE}@${DIGEST}" --format '{{json .SBOM}}'), а целостность гарантирует подпись образа выше; gh attestation verify относится к provenance, не к SPDX. CycloneDX-SBOM хранится как OCI-referrer (не legacy .att-тег) — наивный поиск .att вернёт пусто, поэтому нужна referrers-aware команда:

cosign verify-attestation \
  --type cyclonedx \
  --insecure-ignore-tlog \
  --use-signed-timestamps \
  --certificate-identity-regexp '^https://github\.com/tempusbuild/runner-images/\.github/workflows/build\.yml@refs/heads/main$' \
  --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
  "${IMAGE}@${DIGEST}"

--insecure-ignore-tlog снимает только поиск в Rekor — личность, подпись и метку времени TSA cosign всё равно проверяет. Как закрепить digest — в SECURITY.md (открывается в новой вкладке).

Чтобы это не устарело

Подписать образ один раз мало — цепочку надо поддерживать:

  • Еженедельная пересборка (weekly-rebuild) втягивает свежие apt-обновления, заново подписывает образ и обновляет свидетельства (attestations). Пин базы и версии те же — меняется только то, что пришло с обновлениями.
  • OpenSSF Scorecard непрерывно оценивает защищённость самого репозитория (защита ветки, пины, разрешения токенов), CodeQL проверяет сами workflow как код.
  • Отчёты и бейджи — публичные: статус проверок и Scorecard видно прямо в репозитории (открывается в новой вкладке).

Как это работает у нас

Образ раннера tempus.build — публичный (ghcr.io/tempusbuild/runner-ubuntu-24.04 (открывается в новой вкладке), Apache-2.0). Его можно скачать, изучить, собрать самому и — главное — независимо проверить подпись, SBOM и provenance по digest. На нашей стороне admission пускает в исполнение только подписанный образ, прошедший проверку; неподписанное не запускается.

Supply-chain — это слой, ортогональный рантайм-изоляции. Проверяемое происхождение отвечает на вопрос «что за образ мы запускаем», а аппаратная граница отвечает на «что этот образ может сделать хосту и соседям» — про неё в разборе изоляции Kata. Полная картина изоляции и ссылки на образ — на странице «Безопасность и изоляция».

Оговорки — честно

  • Подпись доказывает происхождение и целостность, а не отсутствие уязвимостей: «это собрали мы из заявленного исходника, и оно прошло проверку на CVE», но не «здесь нет ни одного изъяна».
  • Проверка по слою ОС не отменяет уязвимостей внутри самих инструментов — они закрываются обновлением версий, и это непрерывный процесс, а не разовая проверка.
  • Проверка требует инструментов (cosign, gh) и работы по digest. Зато она не требует доверия к нам — только к открытым Sigstore, GitHub OIDC и криптографии.

← в блог