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

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

В марте 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-запроса:

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) → артефакт → реестр → ВЫ

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

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

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

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

Тут живёт самая коварная атака последних лет — бэкдор в 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»:

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

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

Полезную нагрузку несли два файла, замаскированных под тестовые данные декодера: tests/files/bad-3-corrupt_lzma2.xz (нулевая стадия) и tests/files/good-large_compressed.lzma (объектник + зашифрованный код). Для библиотеки-распаковщика бинарный мусор в tests/ — норма, на ревью не цепляет.

Полезную нагрузку несли два файла, замаскированных под тестовые данные декодера: tests/files/bad-3-corrupt_lzma2.xz (нулевая стадия) и tests/files/good-large_compressed.lzma (объектник + зашифрованный код). Для библиотеки-распаковщика бинарный мусор в tests/ — норма, на ревью не цепляет.

Первая стадия извлекалась байтовой подстановкой и распаковкой — и результат уходил прямо в /bin/sh : cat tests/files/bad-3-corrupt_lzma2.xz | tr "\t \-_" " \t_\-" | xz -d

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

На стадии make скрипт доставал из второго файла ~88 КБ объектного кода и вмерживал его в liblzma , подменив функцию is arch_extension_supported() так, чтобы та вызывала get cpuid() из вредоносного объектника.

На стадии make скрипт доставал из второго файла ~88 КБ объектного кода и вмерживал его в liblzma , подменив функцию is arch_extension_supported() так, чтобы та вызывала get cpuid() из вредоносного объектника.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Сразу честно, без иллюзий. Если вашу библиотеку ставят обычным 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 (а это самый массовый сценарий):

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Вот это и есть «ключ» новой модели: не «Вася с отпечатком 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-кода. К концу вы получите вот это:

Чистый 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.

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

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

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

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

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

in-toto / SLSA provenance

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

trusted root

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

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

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

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

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

Триггер только на релизные теги. 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 и по бандлу-файлу из ассетов релиза — оба пути доставки проверены. Это и проверка, что мы ничего не сломали, и постоянный смоук-тест подписи на каждом релизе.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Стоит задержаться на строчке про 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:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Ваш случай

Нужно ли

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

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

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

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

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

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

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

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

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

Что делает

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

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

in-toto Statement v1 и v0.1

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

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

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

Куда дальше

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

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

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

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

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

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

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

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

← Cybersecurity