Supply-chain security: SBOM и подпись образов
В предыдущей главе ты закрыл периметр одного образа: чистый скан, подпись, non-root, capabilities drop. Но образ — это только кончик. Чтобы app:1.4.2 появился в реестре, сквозь твою инфраструктуру прошли тысячи чужих рук: авторы npm-пакетов и базовых образов, maintainer’ы GitHub Action’ов, сама CI-система, которая собирала бинарник, и сеть, по которой он ехал до registry. Supply-chain security — это защита всей цепочки, а не конечного звена.
Контекст стоит понимать всерьёз. Эпизод с пакетом event-stream в npm: maintainer передал пакет незнакомцу, тот добавил украденный код, который год собирал биткоин-кошельки у пользователей. Взлом SolarWinds: злоумышленники встроили бэкдор в билд-систему, и обновления с подписью компании стали доставлять вредоносный код в правительственные сети. Секреты, утёкшие через логи CI, — это третий типичный путь. Общий знаменатель: атакуют не твой код, а всё, от чего он зависит.
В краткой версии ты видел сканирование Trivy и подпись Cosign как два изолированных приёма. Здесь соберём полный контур доверия: манифест состава образа (SBOM), глубокое сканирование, криптографическую подпись с привязкой к идентичности CI, provenance-аттестации по модели SLSA и правила гигиены для самого пайплайна. Побочный эффект, который недооценивают: когда SBOM и подпись есть в каждом релизе, расследование инцидентов ускоряется на порядок — вместо «а что у нас вообще стоит на проде» у тебя машиночитаемый список всего.
Угрозы цепочки поставки
Заголовок раздела «Угрозы цепочки поставки»Прежде чем ставить инструменты, разложи поверхности атаки по этапам жизни артефакта:
| Этап | Атака | Пример |
|---|---|---|
| Зависимости | Захват пакета, typosquatting | event-stream, поддельный reqeusts |
| Базовый образ | Подмена тега в публичном registry | перезапись latest вредоносным слоем |
| CI-сборка | Вредоносный action, утечка секрета | скомпрометированный actions/checkout |
| Реестр | Пуш по украденному токену | подмена образа в ghcr.io |
| Деплой | Pull без верификации | неподписанный docker run на проде |
Заметь закономерность: ни одну из этих атак не остановит антивирус или firewall — они бьют по доверию, а не по эксплойтам. Поэтому ответ тоже криптографический: подписи, дайджесты, аттестации. Каждая строка таблицы закрывается конкретной практикой из этой главы: пиннинг зависимостей (две ниже), SBOM, keyless-подпись, верификация перед docker pull.
SBOM: из чего состоит образ
Заголовок раздела «SBOM: из чего состоит образ»SBOM (Software Bill of Materials) — машиночитаемый манифест всего, что вошло в артефакт: OS-пакеты, npm-зависимости, их версии и лицензии. Когда выходит критический CVE (типа Log4Shell), вопрос «уязвимы ли мы» превращается из недели раскопок в один запрос по SBOM-ам всех образов.
Два стандарта формата: SPDX (Linux Foundation, старше, богаче лицензионными полями) и CycloneDX (OWASP, компактнее, изначально для уязвимостей). Практически все сканеры едят оба; выбирай CycloneDX для контура безопасности, SPDX — если юристы требуют лицензионную отчётность.
Генерировать SBOM из образа умеет Syft — он смотрит в слои и распознаёт пакетные менеджеры:
# SBOM из образа в CycloneDX (json)syft scan myapp:1.4.2 -o cyclonedx-json > sbom.json
# SPDX — для лицензионной отчётностиsyft scan myapp:1.4.2 -o spdx-json > sbom-spdx.json
# Посмотреть сводку составаsyft scan myapp:1.4.2 | head -30SBOM — не бумажка для галочки, а рабочий артефакт: Trivy умеет сканировать его напрямую (trivy sbom sbom.json), dependency-track сервер накапливает SBOM всех релизов и шлёт алерты при выходе CVE для любого компонента. Артефакт кладётся рядом с образом в реестр или CI-хранилище и привязывается к дайджесту.
Под капотом: дайджест, манифест и подпись
Заголовок раздела «Под капотом: дайджест, манифест и подпись»Вся криптография главы опирается на три понятия из OCI-устройства образа, и важно различать их до конца.
Дайджест — SHA-256 по содержимому манифеста образа: sha256:9f3b.... Два образа с одинаковым дайджестом побитово идентичны. Дайджест неизменяем, тег — подвижный указатель на него.
Манифест — JSON со списком слоёв и их дайджестами. Подписывается именно он.
Подпись (Sigstore bundle) — в keyless-режиме это короткоживущий сертификат X.509 от Fulcio (CA Sigstore), утверждающий «GitHub Actions-раннер репозитория X на коммите Y в рамках workflow Z», плюс записи в прозрачных логах Rekor и, по желанию, timestamp-авторитет. При верификации cosign проверяет: подпись манифеста сходится, сертификат валиден и подписан корнем Sigstore, запись есть в Rekor, а идентичность (SAN сертификата) совпадает с ожидаемой (https://github.com/org/repo/.github/workflows/release.yml@refs/heads/main).
Именно это отличает keyless от статического ключа: вместо «кто-то, владеющий ключом» проверяется идентичность пайплайна. Украсть у CI нечего — сертификат живёт минуты.
# Статический ключ: подпись ключа, живущего в CI-секретахcosign sign --key env://COSIGN_PRIVATE_KEY myapp@sha256:9f3b...
# Keyless (в CI с OIDC): ключей нет вообщеcosign sign --yes myapp@sha256:9f3b...
# Верификация keyless: проверяем идентичность пайплайна, а не ключcosign verify myapp@sha256:9f3b \ --certificate-identity-regexp 'https://github.com/myorg/pet-app/.*' \ --certificate-oidc-issuer https://token.actions.githubusercontent.comУсловие keyless — OIDC-токен: в GitHub Actions он выдаётся job’е с разрешением id-token: write, временный, содержит имя репозитория, workflow, ref и SHA коммита. Cosign обменивает его на сертификат.
SLSA и provenance в GitHub Actions
Заголовок раздела «SLSA и provenance в GitHub Actions»SLSA (Supply-chain Levels for Software Artifacts) — фреймворк уровней зрелости сборки: от «SLSA 0 — всё как попало» до «SLSA 3 — выстроенный контур доверия». Практически для нас важно:
- SLSA 1 — provenance существует (кто, когда, из чего собрал).
- SLSA 2 — сборка на hosted-раннере (GitHub Actions), provenance подписан CI.
- SLSA 3 — изолированный, эфемерный, двусторонне аутентифицированный контур (GitHub + OIDC, без self-hosted раннеров с левым софтом).
Полный SLSA 3 вручную — работа на месяцы. Но базовый provenance GitHub Actions генерирует за одну строку: action actions/attest-build-provenance пишет аттестацию о том, что конкретный дайджест образа собран из конкретного коммита конкретным workflow.
# .github/workflows/release.yml — контур: build -> SBOM -> scan -> sign -> attestname: releaseon: push: tags: ["v*"]
permissions: contents: read id-token: write # нужен для keyless и attestations packages: write # push в ghcr.io
env: IMAGE: ghcr.io/${{ github.repository }}
jobs: build-and-publish: runs-on: ubuntu-latest outputs: digest: ${{ steps.push.outputs.digest }} steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4
- uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }}
- id: meta run: | TAG=${GITHUB_REF_NAME#v} echo "tag=$TAG" >> "$GITHUB_OUTPUT" docker build -t "$IMAGE:$TAG" .
- name: SBOM uses: anchore/sbom-action@f325429c0006ca79253593501c0785206100150a # v0.17 with: image: ${{ env.IMAGE }}:${{ steps.meta.outputs.tag }} format: cyclonedx-json output-file: sbom.json
- name: Scan (gate: критические блокируют) uses: aquasecurity/trivy-action@18f2510ee396bbf400402947b394f793ddc92915 # 0.28 with: image-ref: ${{ env.IMAGE }}:${{ steps.meta.outputs.tag }} severity: CRITICAL ignore-unfixed: true exit-code: "1"
- name: Push и подпись (дайджест!) id: push run: | docker push "$IMAGE:${{ steps.meta.outputs.tag }}" DIGEST=$(docker inspect "$IMAGE:${{ steps.meta.outputs.tag }}" \ --format='{{index .RepoDigests 0}}' | cut -d@ -f2) echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" cosign sign --yes "$IMAGE@$DIGEST"
- name: Provenance (SLSA) uses: actions/attest-build-provenance@1c608e817a0a3e61ed3b81d55a3f8f45cbd996a8 # v2 with: subject-name: ${{ env.IMAGE }} subject-digest: ${{ steps.push.outputs.digest }} push-to-registry: true
deploy: needs: build-and-publish runs-on: ubuntu-latest environment: production # protection rules: только после ревью steps: - name: Верификация подписи перед деплоем run: | cosign verify "$IMAGE@${{ needs.build-and-publish.outputs.digest }}" \ --certificate-identity-regexp 'https://github.com/${{ github.repository }}/.*' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com
- name: Деплой по дайджесту run: | ssh deploy@prod "docker pull $IMAGE@${{ needs.build-and-publish.outputs.digest }} && \ docker compose up -d --no-deps app"Три узла контура: сборка подписывает только по дайджесту (тег недоверен), деплой — отдельный job, который обязан пройти верификацию, иначе шаг упадёт и rollout не случится; environment production добавляет ручное одобрение поверх. Так deploy становится функцией от дайджеста, а не от тега.
Пиннинг: action’ы и базовые образы
Заголовок раздела «Пиннинг: action’ы и базовые образы»uses: actions/checkout@v4 — это подвижный тег. Владелец action’а (или взломавший его аккаунт) может перезаписать v4 вредоносным кодом, и твой CI его исполнит. Та же уязвимость, что и image: node:20 в Dockerfile: теги mutable.
Правило: пинь всё по SHA коммита (полный 40-символьный), комментарием с версией для читаемости. Это делает supply-chain атаку на action/образ невозможной без коммита в твой репозиторий — а коммит виден в ревью и в provenance.
# Плохо: тег mutable, завтра это может быть другой образFROM node:20 AS build
# Хорошо: дайджест — побитовая фиксация; обновление — осознанный PRFROM node:20.18.1-bookworm-slim@sha256:9e8fadf8f3c243e2f0f183b41d2c8c08af55282e88b12ea31b3d0d0a1abc42b2 AS buildSHA-обновления автоматизирует Dependabot (github/dependabot.yml с экосистемами github-actions и docker) — присылает PR на каждый новый дайджест, который проходит ревью и CI-гейт. Ты получаешь и иммутабельность, и регулярные обновления.
Защита самого пайплайна — три правила: GITHUB_TOKEN с минимальными permissions (дефолт read), environment protection rules для прода (обязательный ревью), CODEOWNERS на workflow-файлы (изменение пайплайна — отдельное ревью, не «попутно в фиче»). Внешний action от неизвестного автора — потенциальный curl | sh с доступом к твоим секретам: читай его код так же, как читал бы чужой скрипт в проде.
Защита CI-секретов
Заголовок раздела «Защита CI-секретов»Даже идеальный контур ломается через утёкший секрет. Практики, которые реально работают:
OIDC вместо долгоживущих ключей. Вместо AWS_ACCESS_KEY_ID в секретах на год — aws-actions/configure-aws-credentials с ролью, которой доверяет только конкретный репозиторий, ветка и workflow. Утечка OIDC-токена невозможна вне контекста запущенной job’ы; долгоживущий ключ утекает один раз — и крутится годами.
Секреты — в environment, не на весь репозиторий. Секрет уровня production-environment доступен только job’ам с environment: production, которые, в свою очередь, могут требовать ревью. Секрет на весь репозиторий доступен любому workflow, включая PR из форка (с pull_request_target — вообще любому коду из PR, классическая катастрофа).
Не печатай секреты и не скармливай их printf. run: echo ${{ secrets.MY_TOKEN }} положит значение в лог. То же с set -x. GitHub маскирует точное совпадение, но echo "${TOKEN:0:4}" маскировку обходит.
Ротация как ритуал. Каждый секрет должен иметь владельца и календарную ротацию. Если секрет попал в лог — считай скомпрометированным сразу, не «ну мы его замаскировали».
# Антипаттерн: секрет доступен любому workflow репозиторияenv: DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} # плохо — на весь репозиторий
# Правильно: environment + OIDC, без долгоживущих ключейdeploy: environment: production steps: - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343501 # v4 with: role-to-assume: arn:aws:iam::123456789012:role/pet-app-deploy aws-region: eu-central-1Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»- Подпись по тегу.
cosign sign app:1.4.2подтверждает лишь, что кто-то подписал нечто под этим именем; завтра тег перезапушат, и подпись будет «валидна» для другого содержимого. Правильно: резолвишь тег в дайджест (docker inspect --format '{{index .RepoDigests 0}}') и подписываешьimage@sha256:.... - Безусловный
cosign verifyбез проверки идентичности.cosign verify image@digestв keyless-режиме проверяет любую валидную Sigstore-подпись. Атакующий, подписавший свой образ своим пайплайном, пройдёт. Обязательны--certificate-identity-regexpи--certificate-oidc-issuer— верификация это проверка чьей подписи. - SBOM генерируется не из того артефакта. SBOM из
npm lsили из исходников до сборки отражает lockfile, а не реальное содержимое слоёв (мультистейдж может отбросить половину зависимостей, alpine-дистрибутив добавить свои). Правильно:syft scanфинального образа по дайджесту, после push. - Self-hosted раннеры без изоляции. Постоянный раннер, где деплой-скрипты оставляют мусор, а job’ы PR и main идут под одним пользователем, — это SLSA 0 независимо от подписей. Правильно: эфемерные раннеры (VM на job), изоляция деплой-job’ов, никаких
pull_request_targetс секретами. - Гейт сканера с
--severity HIGHбез--ignore-unfixed. На первой же сборке пайплайн красный от пятисот unfixed-CVE в debian, команда добавляетcontinue-on-error: true, и гейт мёртв. Правильно: начать сCRITICAL+--ignore-unfixed, завести базовые образы (см. предыдущую главу) и постепенно поднимать планку. - Секреты в env для всех job.
env: TOKEN: ${{ secrets.X }}на уровне workflow отдаёт токен каждому шагу, включая внешние action’ы. Правильно: минимальныеpermissions, секреты в environment, OIDC-роли вместо статических ключей. - «Подпись есть — значит безопасно». Подпись доказывает происхождение и неизменность, но не качество: подписанный образ с критической уязвимостью — валидно подписанный опасный образ. SBOM + скан + подпись + provenance — это один контур, элементы не заменяют друг друга.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»Что такое SBOM и зачем он, если есть сканер образов? Машиночитаемый манифест состава артефакта (пакеты, версии, лицензии) в форматах SPDX или CycloneDX. Сканер отвечает «есть ли известные CVE сейчас», SBOM позволяет ответить на вопрос задним числом — «были ли мы уязвимы в марте, когда работал релиз X» — и обеспечивает алертинг при выходе новых CVE без пересборки. Сканы эфемерны, SBOM — актив инвентаризации.
Объясни keyless-подпись Cosign: где ключи?
Ключей нет. CI-раннер с id-token: write получает OIDC-токен (репозиторий, workflow, ref, commit), Cosign обменивает его в Fulcio на короткоживущий X.509-сертификат, подпись и сертификат публикуются в Rekor. Верификация — это проверка цепочки до корня Sigstore плюс совпадение идентичности (issuer + SAN). Утечка невозможна: сертификат живёт минуты и не вынимается из пайплайна.
Почему верификацию перед деплоем делают отдельным job’ом? Разделение полномочий: если job, который подписывает, тот же, что деплоит, скомпрометированный раннер подпишет подменённый образ и «проверит» его сам. Отдельный job получает только дайджест и не может влиять на содержимое; идеал — отдельный раннер с другой identity.
Что такое SLSA и какие уровни реалистично достижимы в GitHub Actions?
Фреймворк зрелости сборки: SLSA 1 — provenance есть, SLSA 2 — hosted-раннер и подписанный provenance, SLSA 3 — изолированный эфемерный контур без self-hosted раннеров. В GitHub Actions SLSA 2 достигается практически из коробки (attest-build-provenance + OIDC), SLSA 3 — с эфемерными раннерами и жёстким разделением job’ов.
Как правильно пиннить GitHub Action’ы и почему не хватает версионного тега?
Полным SHA коммита с комментарием-версией: actions/checkout@08c6903cd... # v4. Тег mutable — владелец action’а может перезаписать v4, и вредоносный код попадёт в твой CI без единого изменения в твоём репозитории. SHA фиксирует код; обновления присылает Dependabot ревьюируемыми PR.
Твой OIDC-токен GitHub Actions утёк в логах. Что делать? Ничего страшного, если настроено правильно: токен короткоживущий (минуты), привязан к конкретному запуску job, и доверие на стороне облака ограничено репозиторием, веткой и workflow. Действия: пересмотреть, почему он вообще попал в лог (устранить печать/маскировку), убедиться, что cloud-роль требует точных условий доверия. С долгоживущим ключом ситуация противоположная — ротация и аудит доступов.
Сканер пропустил образ, он уехал в прод, через неделю вышел критический CVE. Виноват сканер? Нет — сканер ищет известные CVE на момент скана, zero-day’и не ловит никто. Виноват процесс, если нет: SBOM для быстрых запросов по всем релизам, dependency-track для алертинга, регулярного пересканирования уже выкаченных дайджестов (CVE-база растёт, образ — нет) и плана реагирования. Ссылка на /10-data/postgres-backups/ уместна тут же: откат на предыдущий подписанный дайджест — часть плана.
Практика
Заголовок раздела «Практика»Мини-проект: доверенный пайплайн поставки для pet-проекта. Собери в своём репозитории контур build → SBOM → scan → sign → verify → deploy, где деплой физически невозможен без валидной подписи.
- Контур сборки. Перепиши release-workflow: checkout по SHA, base-образ по дайджесту, push в GHCR, резолв тега в дайджест. Критерий: на выходе job’а — переменная
digest, передаваемая дальше, и ни одного упоминания тега в деплой-шагах. - SBOM. Подключи
anchore/sbom-action(илиsyftвручную) сcyclonedx-json, артефакт прикрепи к релизу. Критерий:trivy sbom sbom.jsonнаходит те же пакеты, что иtrivy imageпо дайджесту. - Гейт сканирования. Trivy с
CRITICAL,--ignore-unfixed,exit-code: 1между сборкой и push. Проверь: собери образ со старым openssl — CI красный; обнови базу — зелёный. Критерий: вредоносный образ не доходит до registry. - Keyless-подпись и provenance.
cosign sign --yesпо дайджесту сid-token: write, плюсattest-build-provenance. Критерий:cosign verifyлокально проходит с правильными--certificate-identity-regexpи issuer; с подменённым образом падает. - Отказ при невалидной подписи. В отдельном job’е верификация + условный деплой. Испытание: в отдельной ветке убери/сломай проверку и попробуй задеплоить подписанный другим репозиторием образ — job должен упасть на верификации и не выполнить ни одной деплой-команды. Критерий: в логах видно, что rollout не состоялся.
- Аудит пайплайна. Прогони все action’и и базовые образы через пиннинг по SHA + Dependabot. Критерий:
grep -r '@v[0-9]' .github/workflows/пустой, dependabot.yml покрываетgithub-actionsиdocker.
Что почитать
Заголовок раздела «Что почитать»- SLSA Framework — уровни, требования к provenance и сборочному контуру
- Sigstore Cosign — keyless-подпись, верификация по идентичности, Rekor/Fulcio
- Syft и CycloneDX спецификация — генерация и формат SBOM
- GitHub: использование OIDC в Actions — keyless-доступ к облакам вместо статических ключей
- GitHub Security Hardening — секреты,
pull_request_target, permissions токена - Trivy — сканирование образов, файловых систем и SBOM