
Когда я закончил работать над проектом ecosyste-ms/package-manager-resolvers, мне стало интересно, какой алгоритм разрешения зависимостей использует сервис GitHub Actions. Когда вы записываете в файл рабочего потока uses: actions/checkout@v4, то объявляете зависимость. GitHub её разрешает, скачивает и исполняет. Так работает управление пакетами. Я же решил заглянуть в кодовую базу runner, чтобы увидеть весь этот процесс изнутри. И открытия, скажу я вам, оказались весьма тревожными.
Менеджеры пакетов являются важнейшей частью обеспечения безопасности цепочки поставок. Индустрия многие годы занимается их укреплением после инцидентов с left-pad, event-stream и множества других. Lockfile, контроль целостности по хэшам (integrity hashes) и прозрачность зависимостей (dependency visibility) — это не опциональные возможности. Это основа, и GitHub Actions игнорирует её полностью.
Сравним зрелые экосистемы управления пакетами:
Функция |
npm |
Cargo |
NuGet |
Bundler |
Go |
Actions |
Lockfile |
✓ |
✓ |
✓ |
✓ |
✓ |
✗ |
Закрепление транзитивных зависимостей |
✓ |
✓ |
✓ |
✓ |
✓ |
✗ |
Подтверждения целостности по хэшам |
✓ |
✓ |
✓ |
✓ |
✓ |
✗ |
Видимость дерева зависимостей |
✓ |
✓ |
✓ |
✓ |
✓ |
✗ |
Спецификация по разрешению зависимостей |
✓ |
✓ |
✓ |
✓ |
✓ |
✗ |
Основная проблема в отсутствии lockfile. Во всех других пакетных менеджерах с этим разобрались десятки лет назад — вы объявляете в манифесте слабые ограничения, резолвер выбирает конкретную версию зависимости, и этот выбор регистрируется в lockfile. В GitHub Actions ничего подобного нет. При каждом запуске зависимости из вашего файла workflow разрешаются заново, и результаты этого процесса могут изменяться даже без изменения кода.
В ходе исследования USENIX Security 2022 было проанализировано более 200 000 репозиториев. Результаты показали, что 99,7% используют сторонние экшены, в 97% включены экшены от непроверенных создателей, а в 18% они применяются без обновлений безопасности. Последующее исследование с использованием статичного анализа источников проблем выявило уязвимости к атакам внедрением кода в 4 300 проектах из 2,7 миллионов изученных. Почти в каждом экшене GitHub пользователь привлекает сторонний код без должной проверки. Никакого lockfile и никакой видимости того, от чего этот код зависит.
Мутабельные версии. После того, как вы привязываете код к actions/checkout@v4, этот тег может перемещаться. Мейнтейнер может запушить новый коммит и изменить тег. В итоге ваш рабочий поток меняется незаметно. По идее, SHA, в который разрешается этот @v4, должен регистрироваться в lockfile, обеспечивая воспроизводимость с сохранением понятных тегов версий. Но вместо этого вам нужно выбирать — читаемые теги без стабильности или нечитаемые SHA без пути для автоматического обновления.
На GitHub добавили костыли — иммутабельные релизы, в которых после публикации тег git фиксируется. Организации могут установить обязательную фиксацию по SHA в виде политики. Также можно ограничить рабочий поток экшенами от проверенных авторов. Всё это помогает, но решает вопрос только с зависимостями верхнего уровня, никак не касаясь транзитивных зависимостей, которые и являются основным вектором атаки.
Невидимые транзитивные зависимости. Привязка по SHA эту проблему не решает. Composite Actions разрешают собственные зависимости, но видеть или контролировать то, что они подтягивают, нельзя. Когда вы привязываете экшен к SHA, то только фиксируете внешний файл. Если же он внутренне подтянет some-helper@v1 с изменяемым тегом, то ваш рабочий поток всё равно окажется уязвим. И видимость здесь нулевая. Lockfile должен регистрировать всё разрешённое дерево зависимостей, делая их транзитивную часть видимой и фиксируемой. В результате исследования JavaScript Actions было выявлено, что 54% экшенов содержат как минимум одну уязвимость безопасности, большинство из которых кроются в косвенных зависимостях. Инцидент с tj-actions/changed-files показал, во что это выливается на практике — скомпрометированный экшен позволил атакующим обновить транзитивные зависимости и получить доступ к связанным с ними секретам. При использовании lockfile такое неожиданное изменение транзитивной зависимости отразилось бы в выводе diff.
Отсутствие проверки целостности. Npm регистрирует хэши Integrity в lockfile. Cargo записывает контрольные суммы в Cargo.lock. Когда вы производите установку, пакетный менеджер проверяет, чтобы скачанная версия соответствовала той, которая зафиксирована в файле. В Actions такого нет. Вы просто полагаетесь на то, что GitHub предоставит вам правильный код для указанного SHA. Lockfile с контрольными хэшами позволил бы гарантировать соответствие выполняемого кода тому, который был определён при разрешении зависимостей.
Невоспроизводимость кода при перезапуске. Разработчики GitHub открыто это подтвердили: «Если в рабочем потоке используются какая-то версия экшена, то после её принудительного пуша (force-push) или обновления мы подтягиваем именно последнюю версию». То есть перезапущенная после сбоя задача может молча получить не тот код, с которым она запускалась изначально. И использование кэша здесь всё дополнительно усугубляет: кэши сохраняются только в успешно завершившихся задачах, поэтому при перезапуске после force-push подтягивается другой код, и кэш приходится обновлять. Здесь у нас целых два источника неопределённости. Наличие lockfile сделало бы перезапуски детерминированными — один и тот же lockfile, один и тот же код, каждый раз.
Отсутствие видимости дерева зависимостей. В npm есть npm ls. В Cargo есть cargo tree. Это позволяет проинспектировать весь граф зависимостей, найти повторы, отследить подтягивание транзитивных пакетов. В Actions для этого снова ничего нет. Нельзя увидеть, от чего реально зависит ваш рабочий поток, кроме как прочитав исходный код каждого составного экшена. Опять же, lockfile здесь стал бы полноценным отражением дерева зависимостей вашей программы.
Незадокументированный механизм разрешения зависимостей. Все пакетные менеджеры документируют механизм работы разрешения зависимостей. Спецификация есть в npm, она есть в Cargo. Но её нет в Actions. Исходники runner открыты, и весь «алгоритм разрешения» находится в ActionManager.cs. Вот его упрощённая версия того, что он делает:
// Упрощённая версия ActionManager.cs из репозитория actions/runner
async Task PrepareActionsAsync(steps) {
// Каждый раз запуск с нуля — никакого кэширования.
DeleteDirectory("_work/_actions");
await PrepareActionsRecursiveAsync(steps, depth: 0);
}
async Task PrepareActionsRecursiveAsync(actions, depth) {
if (depth > 10)
throw new Exception("Composite action depth exceeded max depth 10");
foreach (var action in actions) {
// Разрешение происходит на сервере GitHub, то есть для нас невидимо.
var downloadInfo = await GetDownloadInfoFromGitHub(action.Reference);
// Скачивание и извлечение — целостность не проверяется.
var tarball = await Download(downloadInfo.TarballUrl);
Extract(tarball, $"_actions/{action.Owner}/{action.Repo}/{downloadInfo.Sha}");
// Если экшен составной, рекурсивно углубляется в его зависимости.
var actionYml = Parse($"_actions/{action.Owner}/{action.Repo}/{downloadInfo.Sha}/action.yml");
if (actionYml.Type == "composite") {
// Эти вложенные экшены могут использовать мутабельные теги — мы на это повлиять никак не можем.
await PrepareActionsRecursiveAsync(actionYml.Steps, depth + 1);
}
}
}
Вот так. Никаких ограничений версий, никакой дедубликации (экшен, который упоминается дважды, скачивается тоже дважды), никаких проверок целостности. Tarball URL указывает на GitHub API, и приходится полагаться на то, что их сервер вернёт правильное содержимое для указанного SHA. Отсутствие спецификации с помощью lockfile не исправить, но он хотя бы зарегистрирует, что конкретно было получено в результате разрешения.
И даже если не брать lockfile, в Actions есть и другие проблемы, которые в правильных пакетных менеджерах давно решены.
Отсутствие реестра. Экшены живут в репозиториях git, где нет ни центрального индекса, ни сканирования на уязвимости, ни обнаружения вредоносов, ни защиты от тайпсквоттинга. Реальный же реестр имеет возможность маркировки заражённых пакетов, позволяет сохранять иммутабельные независимые копии кода и обеспечивает единый центр реагирования на угрозы. Существует Marketplace, но это лишь тонкий слой поверх поиска по репозиториям. Без реестра негде хранить иммутабельные метаданные. Если исходный репозиторий экшена исчезнет или окажется взломан, запасного варианта не будет.
Общая мутабельная среда. Экшены не изолированы друг от друга. Два экшена, вызывающих setup-node с разными версиями, изменяют одну и ту же $PATH. И результат будет зависеть от порядка выполнения, а не от детерминированной схемы разрешения.
Отсутствие поддержки офлайн. Экшены подтягиваются с GitHub каждый раз. Возможности установить их офлайн — нет, встроить зависимости в проект нельзя, как и выполнить код без доступа в интернет. Другие менеджеры пакетов позволяют встраивать зависимости или настраивать приватные зеркала. Если же мы используем Actions, то при падении GitHub падает и наш конвейер CI.
В качестве пространств имён выступают имена пользователей GitHub. Любой, кто создаёт аккаунт GitHub, получает пространство имён для Actions. При этом существует опасность захвата аккаунта и тайпсквоттинга. Когда учётную запись мейнтейнера популярного экшена взламывают, атакующие могут запушить вредоносный код и изменить теги. Наличие lockfile и хэшей целостности не предотвратит захват аккаунтов, но позволит обнаруживать неожиданные изменения кода. Несоответствие хэшей будет приводить к провалу сборки, исключая незаметное выполнение загруженного злоумышленником кода. Ещё одно решение — это использовать что-то вроде базы данных контрольных сумм Go, то есть прозрачный журнал проверенных хэшей, который будет отлавливать ситуации, когда у одной и той же версии вдруг внезапно изменится содержимое.
Как мы до такого дошли?
Actions runner — это форк Azure DevOps, инструмента, созданного для корпораций с управляемыми внутренними библиотеками задач, которым пользователи доверяют. Разработчики GitHub прикрутили к нему публичный маркетплейс без переосмысления фундаментальной структуры. Добавление Composite Actions и переиспользуемых workflow привело к созданию системы зависимостей. Вот только в этой реализации были проигнорированы уроки из сферы управления пакетами в виде lockfile, проверки целостности, фиксации транзитивных зависимостей и видимости дерева зависимостей.
Причём всё это выходит за рамки CI/CD. В реестрах пакетов запустили механизм доверенных публикаций (trusted publishing): PyPI, npm, RubyGems и другие теперь позволяют публиковать пакеты напрямую из GitHub Actions, используя вместо длительно хранящихся секретов токены OIDC. OIDC исключает один класс атак (кражу учётных данных), но способствует другому, так как теперь безопасность цепочек поставок этих реестров зависит полностью от GitHub Actions, системы, в которой нет требуемых ими механизмов lockfile и контроля целостности. Взлом одной из зависимостей экшена вашего рабочего потока может привести к внедрению заражённых пакетов в реестры, где реализованы более эффективные меры защиты, чем в системе, которой они доверяют публикацию.
В других системах CI дела обстоят лучше. В GitLab CI версии 17.9 добавили ключевое слово integrity, которое позволяет указывать хэш-SHA256 для внедрения удалённого кода. Если хэш не совпадёт, в пайплайне произойдёт сбой. В документации платформы приводится открытое предупреждение, что включение удалённых конфигураций «равнозначно подтягиванию сторонней зависимости», и рекомендуется привязываться к полным SHA коммитов. В GitLab осознают проблему и реализовали механизм проверки целостности. В GitHub же отклонили запрос на добавление этой функции.
И проектные решения GitHub влияют не только на пользователей этой платформы. Forgejo Actions обеспечивает совместимость с GitHub Actions, а значит, проекты, мигрирующие на Codeberg по этическим причинам, наследуют ту же кривую архитектуру CI. Мейнтейнеры Forgejo открыто признают эту проблему, а контрибьюторы описывают экосистему GitHub Actions как систему с ужасным дизайном и управлением. Но они вынуждены поддерживать совместимость с ней. В попытке снизить зависимость от GitHub разработчики Codeberg создают зеркала на распространённые экшены, но фундаментальная проблема лежит в самой модели платформы. Недочёты дизайна GitHub распространяются и на похожие альтернативные платформы.
В GitHub issue #2195 пользователи требовали поддержку lockfile. Но разработчики GitHub это обращение в 2022 году закрыли, объяснив своё решение тем, что добавление такой поддержки «не планируется». В исследовании «Unpinnable Actions», проведённом в Пало-Альто, отмечается, что даже те экшены, которые закрепляются по SHA, могут иметь незакрепляемые транзитивные зависимости.
Dependabot может обновлять версии экшенов, и это помогает. Некоторые команды встраивают их прямо в собственные репозитории. Инструмент zizmor прекрасно справляется со сканированием рабочих потоков и поиском проблем безопасности. Но всё это костыли для системы, которой не хватает простых основ.
Исправит это добавление lockfile. Записывайте полученные в результате разрешения хэши для каждой ссылки на экшен, включая транзитивные. Добавьте контроль целостности с помощью хэшей. Сделайте так, чтобы дерево зависимостей было наглядным. Разработчики GitHub закрыли этот запрос три года назад и с тех пор к нему не возвращались.
YegorP
Фигня какая-то. Вот так у нас залочено по хешу коммита. Легко ли его подделать?
Не уверен что там с actions/checkout, с телефона сложно посмотреть.