Защита CI-CD для open source-проекта: запираем зависимости

Команда VK Cloud перевела второй пост из серии трёх частей о том, как Cilium укрепляет свой CI/CD-конвейер. Первая часть рассказывала про управление доступом: кто может запускать сборки и какой CI-код разрешено исполнять. Этот пост про уровень зависимостей: какой код эти сборки подтягивают и как мы убеждаемся, что его не подделали.
Запираем зависимости
Если вы контролируете, кто запускает сборки, следующий вопрос: какой код эти сборки подтягивают. Зафиксированный workflow, который тянет скомпрометированную зависимость, всё равно скомпрометирован.
Пиннинг GitHub Actions по SHA-дайджесту
Самое полезное, что здесь может сделать любой проект, перестать доверять изменяемым тегам. Каждая директива uses: в файлах workflow ссылается на action’ы по полному 40-символьному коммит-SHA, а человекочитаемую версию мы указываем комментарием:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Если кто-то скомпрометирует тег v6 и сделает force-push вредоносного кода, workflow его не подтянут: они запиннованы к конкретному коммиту. Это работает для каждого стороннего action’а: docker/build-push-action, sigstore/cosign-installer, golangci/golangci-lint-action и десятков других. Контейнерные образы, которые шаги workflow используют напрямую, мы пиннуем так же по @sha256:-дайджесту.
У пиннинга есть одно слепое пятно — транзитивные зависимости. Когда вы пиннуете actions/checkout@de0fac2e…, код этого action’а известен точно. Но если сам actions/checkout ссылается на другой action по тегу (uses: some-org/some-helper@v1), это разрешение происходит во время выполнения и остаётся невидимым. Атакующий, который скомпрометирует вложенную зависимость, всё ещё может добраться до конвейера.
Исправление на подходе: блокировку зависимостей на уровне workflow анонсировали в дорожной карте безопасности GitHub Actions на 2026 год. Она добавит в YAML workflow секцию dependencies, которая запирает все прямые и транзитивные зависимости-action’ы по коммит-SHA аналогично паре go.mod + go.sum для Go. Мы возьмём её на вооружение, когда она выйдет.
Автоматизированные обновления с границей доверия
Поддерживать SHA-пины руками непрактично, поэтому мы этого не делаем. Конфигурация Renovate расширяет пресет helpers: pinGitHubActionDigests и глобально выставляет pinDigests: true. Когда выходит новая версия action’а, Renovate открывает PR с поднятым SHA — это позволяет проекту оставаться актуальным и не возвращаться к изменяемым ссылкам.
Renovate работает как self-hosted бот по часовому расписанию и использует выделенное GitHub App с тонко настроенными разрешениями вместо персонального access token’а. Включён vulnerabilityAlerts, так что известные CVE в дереве зависимостей сразу превращаются в PR’ы.
Недавно мы добавили Renovate cooldown (выдержку), чтобы не подхватывать только что вышедшие релизы немедленно. При нынешних темпах атак на цепочку поставок этих нескольких дней обычно хватает, чтобы скомпрометированный пакет успели заметить и удалить:
.github/renovate.json5
{
// Выдержка зависимостей: пропускать версии, опубликованные менее 5 дней назад
"matchUpdateTypes": ["major", "minor", "patch"],
"minimumReleaseAge": "5 days"
},
{
"matchPackageNames": [
"actions/{/,}**", // Официальные action'ы GitHub
"docker/{/,}**", // Официальные action'ы Docker
"cilium/{/,}**", // Наша собственная экосистема
"k8s.io/{/,}**", // Официальные Kubernetes
"sigs.k8s.io/{/,}**", // Kubernetes SIGs
"golang.org/x/{/,}**", // Экспериментальные Go
"github.com/golang/{/,}**", // Официальная организация Go
"github.com/prometheus/{/,}**",
"github.com/hashicorp/{/,}**",
"go.etcd.io/etcd/{/,}**",
// ...сокращено
],
"automerge": true,
"automergeType": "pr",
"groupName": "auto-merge-trusted-deps",
"reviewers": ["ciliumbot"]
}
Обновления из этого allow-list (списка разрешённых) мёржатся автоматически после прохождения CI. Всё остальное требует ревью человеком.
Workflow авто-апрува добавляет ещё одну проверку: он убеждается, что PR создал cilium-renovate[bot] и что запрос на ревью инициировал сам бот, а не кто-то под него маскирующийся:
if: ${{
github.event.pull_request.user.login == 'cilium-renovate[bot]' &&
(github.triggering_actor == 'cilium-renovate[bot]' ||
github.triggering_actor == 'auto-committer[bot]')
}}
Если эти условия не выполнены, авто-апрува не происходит.
Вендоринг Go-модулей
Все Go-зависимости вендорятся и коммитятся в репозиторий. CI проверяет, что между go.mod, go.sum, и vendor/ нет расхождений. Сборки воспроизводимы и не обращаются к внешним module-прокси, поэтому подделанный модуль на прокси до системы не доходит. Заодно мы запускаем проверки лицензий (go run ./tools/licensecheck), чтобы держать зависимости с нежелательными лицензиями вне дерева.
Было бы ещё безопаснее форкать action’ы в собственную организацию?
В теории да. Если форкать каждый сторонний action в организацию cilium/ и пиннить к SHA форка, компрометация upstream не попадёт в workflow. Некоторые проекты с высокими требованиями к безопасности так и делают.
Мы решили не форкать в основном потому, что операционные издержки реальны, а выгода для безопасности меньше, чем кажется на первый взгляд:
-
Бремя сопровождения. Мы используем десятки сторонних action’ов. Держать форки в синхроне с upstream-патчами безопасности — это серьёзная работа, а устаревший форк с незакрытыми уязвимостями сам по себе становится проблемой безопасности.
-
Упущенные улучшения. Upstream-action’ы регулярно исправляют баги и выпускают функции безопасности. Форки добавляют трения и мешают их подхватывать.
-
Сложность Renovate. Конвейеру обновлений пришлось бы отслеживать upstream-релизы, открывать PR против каждого форка и обновлять потребляющие workflow. Цепочка вдвое удлиняется.
SHA-пиннинг даёт ту гарантию неизменяемости, которая важна: конкретный коммит — это конкретный коммит, независимо от того, какая организация его хостит. Вместе с Renovate, который предлагает обновления по мере выхода новых версий, выгода для безопасности приходит без операционных накладных расходов. Если бы крупного провайдера action’ов компрометировали раз за разом, форкнуть самые рискованные было бы разумной эскалацией, но до этого порога мы не дошли.
Тот же компромисс для Go-зависимостей
Вопрос «а не стоит ли это форкнуть?» в той же мере относится к дереву Go-зависимостей. Cilium подтягивает сотни Go-модулей: клиентские библиотеки Kubernetes, gRPC, etcd, Prometheus и другие. Форкать и сопровождать их все нереалистично.
Go стартует с чуть лучшей позиции, чем npm или PyPI, потому что пути импорта явно включают источник (github.com/stretchr/testify) — это убирает целый класс атак Dependency Confusion (путаница зависимостей). А вот тайпсквоттинг (опечаточный сквоттинг) остаётся реальной угрозой. Исследование Michael Henriksen нашло тайпсквоттинговые Go-пакеты в реальных условиях, например, форк urfave/cli, зарегистрированный как utfave (одна переставленная буква), который слал данные домой: имя хоста, ОС и архитектуру. Заменить этот колбэк на reverse shell (обратную оболочку) можно было бы правкой в одну строку.
Тайпсквоттинг — не худший случай. SolarWinds показал, что у легитимного, широко доверяемого вендора могут скомпрометировать конвейер сборки, и он начнёт проталкивать вредоносное ПО через обычные обновления. То же может случиться с любым Go-модулем: атакующий получает доступ к аккаунту сопровождающего, публикует вредоносный релиз, прокси его кеширует, и любой, кто запускает go get, его подтягивает. Поэтому вендоринг и важен: он переносит решение о доверии со времени сборки (где оно невидимо) на время ревью (где человек видит diff).
Вендоринг — основная защита. Тайпсквоттинговый путь импорта проявляется как diff в vendor/ во время код-ревью, вместо того чтобы молча разрешиться через module-прокси. Опечатку в момент её внесения он не ловит (тут всё держится на том, что ревьюер заметит незнакомый путь в PR), но вместе с гейтингом по CODEOWNERS работает хорошо.
Ещё мы взвешенно подходим к тому, какие зависимости брать на себя. В конфигурации Renovate есть явный список отключённых зависимостей, которыми управляем вручную, либо потому, что им нужны координированные обновления (как sigs.k8s.io/gateway-api вместе с conformance-тестами), либо потому, что мы сопровождаем форк с проектно-специфичными патчами (как github.com/cilium/dns), либо потому, что зависимость разрабатываем внутри и поднимать её надо взвешенно (как github.com/cilium/ebpf). Изменения в vendor/ ревьюит выделенная команда @cilium/vendor через CODEOWNERS.
Go-пословица гласит: «A little copying is better than a little dependency» («Немного копирования лучше, чем немного зависимости»). Проект периодически проводит аудит сторонних библиотек и активно ужимает дерево. Если зависимость нужна только ради небольшой утилитарной функции, мы заменяем её несколькими строками, скопированными inline. Каждая удалённая зависимость — это та, которую уже никогда не скомпрометируют, а ревьюить будущие изменения зависимостей становится проще.
Ловим ошибки статическим анализом
Даже когда политики настроены правильно, ошибки случаются. Доброжелательный контрибьютор может добавить workflow без permissions: или взять ubuntu-latest вместо запиннованного раннера. Статический анализ ловит это до ревью.
Там, где workflow нужен доступ на запись (подписание релизов, OIDC для Cosign), он объявляет только конкретно необходимую область, например, id-token: write или contents: write. Там, где не нужен, объявляет permissions: read-all или permissions: {}, чтобы отказаться от более широких значений по умолчанию. На память мы тут не полагаемся. CodeQL запускается на каждый push и PR с включённым правилом actions/missing-workflow-permissions, и сборка падает на любом изменённом файле workflow, который не выставляет permissions явно.
Вдобавок actionlint статически проверяет каждый файл workflow на синтаксические ошибки, небезопасные паттерны и неправильные конфигурации. Тот же lint-конвейер заодно следит за соблюдением проектных конвенций: у каждого job и step есть имя, ни один job не использует плавающий тег раннера ubuntu-latest (он запиннован к ubuntu-24.04), а в файлах workflow нет хвостовых пробелов.
Один класс уязвимостей стоит упомянуть отдельно — инъекция выражений GitHub Actions. Синтаксис ${{ }} в YAML workflow представляет собой текстовую подстановку, которая происходит до того, как bash увидит строку. Если атакующий контролирует подставляемое значение (заголовок PR, имя ветки), он может через ;, $(…) или backtick’и инъецировать произвольные shell-команды. Bash не знает, откуда пришло значение. Решение сначала присвоить значение переменной окружения и ссылаться на неё как "$MY_VAR" в блоке run:, чтобы bash трактовал её как единую переменную независимо от содержимого. Команда безопасности GitHub сообщала об этом ранее, и каждый случай мы исправили. Это тонкий баг: его легко внести и трудно заметить в ревью, именно поэтому статический анализ так важен: и actionlint, и CodeQL флагируют использование ${{ }} в блоках run:, куда втекает недоверенный ввод.
Часть 3 охватит финальный уровень: изоляцию учётных данных CI и продакшена, подписание и аттестацию каждого релиза, а также пробелы, над которыми ещё ведётся работа.
Автор: levashove

