Секрет попадает в кэш не из-за ошибки в workflow, а из-за того, как устроен инструмент: Miri сохраняет переменные окружения в target/, а target/ кэшируется. Разбираем, почему это происходит, как устроены фазыЭтапы работы cargo-miri, различаемые по способу вызова: подготовка окружения и подмена инструментов сборки, вызов как компилятора, запуск собранного бинарника и обработка вызовов rustdoc. запуска Miri, как actions/cache сопоставляет ключи и что из этого следует для защиты.
Почему кэш CI — это канал утечки
При запуске cargo miri Miri сохраняет все переменные окружения в каталог target/, чтобы иметь доступ к переменным, влияющим на сборку, между запусками. Именно поэтому содержимое кэша может включать секреты: если шаг, запускающий cargo miri, имеет доступ к секретам как переменным окружения, эти секреты попадают в target/, а затем в кэш. Кэш target/ обычно создаётся через actions/cache или swatinem/rust-cache. Кэш, созданный для ветки в одном запуске workflow, может быть восстановлен из другого запуска workflow для того же репозитория и ветки, поэтому запуск по pull request из форка читает кэш, записанный доверенным запуском, и получает доступ к секретам, которые в нём оказались.
Секрет оказывается в окружении процесса cargo miri тремя способами: он передаётся самому шагу как переменная окружения; он задан в env для workflow; он передан в предыдущий шаг, который каким-то образом сохраняет его в окружении. Краткосрочное исправление заставляет Miri сохранять только переменные CARGO_* (кроме CARGO_*_TOKEN) и OUT_DIR.
Как устроен Miri: фазы запуска
cargo miri run/test реализован как набор фаз, различаемых по тому, как именно cargo-miri был вызван. Фазы идут по порядку: сначала cargo-miri готовит окружение и подменяет инструменты сборки, затем его вызывают как компилятор, потом запускают уже собранный бинарник, а в конце обрабатываются вызовы rustdoc.
На этапе подготовки разбирается подкоманда (run, test, nextest, clean, setup) и выполняется подготовка: определяется версия rustc, целевые архитектуры, при необходимости очищается target-каталог и sysroot. Именно здесь Miri подменяет RUSTC_WRAPPERпеременная окружения, задающая обёртку вокруг вызова rustc на самого себя, чтобы cargo вызывал cargo-miri вместо rustc:
cmd.env("RUSTC_WRAPPER", &cargo_miri_path);Дополнительно там же удаляется RUSTC_WORKSPACE_WRAPPER, а RUSTDOC тоже указывает на cargo-miri, чтобы можно было запускать doctests. Далее cargo вызывает cargo-miri как rustc: для target-крейтакрейт, собираемый под целевую архитектуру, а не под host аргументы передаются в Miri, а для host-крейтов — обычному rustc. Затем запускается уже собранный бинарник через Miri, а в конце обрабатываются вызовы rustdoc.
Target-крейты, runnable-крейты и что попадает в кэш
cargo-miri определяет target-крейт по наличию флага --target: host-крейты (build-скрипты и proc-macro) этот флаг никогда не получают. runnable-крейткрейт, который можно запустить: бинарник или тест определяется так: сначала отбрасываются информационные запросы (--print или -vV), затем проверяется, что crate-type равен bin (по умолчанию тоже bin) или присутствует флаг --test.
Имена выходных файлов вычисляются так: если задан -o, берётся только он (он имеет приоритет над --out-dir), иначе берётся --out-dir и к нему присоединяются имена, полученные запуском rustc --print file-names с проброшенными --crate-name, --crate-type, --target и extra-filename. Когда крейт одновременно runnable и target, вместо компиляции в выходные файлы пишется JSON с информацией для запуска. Для target-крейтов Miri запускается с --sysroot из MIRI_SYSROOT и MIRI_BE_RUSTC="target", для host-крейтов — с MIRI_BE_RUSTC="host"; в обоих случаях артефакты попадают в каталог, который получается из --target-dir (или meta.target_directory) с добавленным подкаталогом miri. Именно этот каталог очищается при необходимости.
Зачем нужен stub .d-файл
Miri создаёт пустой stub .d-файл, чтобы Cargo не считал крейт подлежащим пересборке. Это делается, когда среди значений флага --emit встречается dep-info; имя файла собирается из --out-dir, --crate-name и необязательного extra-filename. Причина в том, что вместо настоящей компиляции крейта Miri записывает JSON-файл с информацией для запуска, и для этого достаточно пустого .d-файла. Таким образом, закэшированный результат может быть переиспользован без перезапуска Miri: Cargo видит актуальный dep-info и не пересобирает крейт, а сохранённый JSON затем используется, когда Cargo снова вызывает Miri в фазе CARGO_TARGET_RUNNER.
Как устроен Miri: работа с окружением
Miri хранит переменные окружения интерпретируемой программы в поле env_vars машины. Вид хранилища зависит от целевой платформы: для целей без операционной системы переменные окружения не поддерживаются, для Unix-целей Miri держит указатели на строки name=value в памяти интерпретируемой программы, а для Windows — только карту строк без указателей, поэтому обход provenanceпроисхождение указателя: откуда он появился и к какой аллокации относится для Windows ничего не делает.
Для Unix-целей чтение переменной находит указатель на строку name=value в памяти интерпретатора, а затем читает её как C-строку. При сохранении переменной Miri выделяет новую C-строку name=value с видом памяти MiriMemoryKind::Machine и кладёт полученный указатель в карту. Если для того же имени уже был указатель, старый освобождается, после чего update_environ перестраивает блок environ. При удалении переменной указатель также освобождается, а update_environ обновляет environ.
setenv, unsetenv и другие вызовы
setenv читает указатель на имя и значение; если указатель на имя не нулевой, имя не пустое и не содержит =, создаётся новая переменная окружения, вставляется в карту, старая переменная освобождается, вызывается update_environ, возвращается 0; иначе errno=EINVAL и -1. unsetenv аналогично проверяет указатель, непустоту и отсутствие =, удаляет переменную из карты, освобождает её и обновляет environ, иначе EINVAL/-1.
chdir читает путь из C-строки, при изоляциирежим Miri, в котором запрещены обращения к хостовой файловой системе и окружению возвращает PermissionDenied, иначе вызывает смену текущего каталога. getcwd читает буфер и размер, при изоляции возвращает PermissionDenied и null, иначе получает текущий каталог и пишет путь; при неудаче — ERANGE и null.
gethostname требует ненулевой длины (иначе UB), проверяет доступ к буферу на указанное число байт; при длине больше длины HOSTNAME пишет HOSTNAME и возвращает 0; при коротком буфере поведение зависит от платформы: Android возвращает ENAMETOOLONG без записи, Linux/Gnu и FreeBSD пишут без завершающего нуля и возвращают ENAMETOOLONG, Linux (не Gnu), macOS, Solaris и Illumos пишут с завершающим нулём и возвращают 0.
getpid возвращает идентификатор процесса: при включённой коммуникации — идентификатор реального процесса, иначе 1000. Идентификатор потока равен идентификатору процесса плюс номер потока.
update_environ и память интерпретируемой программы
update_environ обновляет статическую переменную environ: освобождает старый список указателей на переменные окружения и записывает новый. Сначала читается указатель на старый блок environ и освобождается, затем собираются значения из карты переменных окружения, строится новый блок (с завершающим нулевым указателем) и его указатель записывается обратно в environ. Вызывается из setenv и unsetenv после изменения карты, чтобы environ отражал актуальный набор.
Как устроен Miri: машина и аллокации
MiriMachine хранит переменные окружения в поле env_vars, которое при создании машины инициализируется значением по умолчанию. Комментарий к полю прямо говорит, что env_vars зависит от полного интерпретатора, поэтому его нельзя корректно инициализировать на этапе создания машины. Настоящая инициализация выполняется позже, в late_init, где первым шагом переменные окружения заполняются уже при наличии интерпретатора. Аналогично устроены и другие поля, зависящие от интерпретатора: аргументы командной строки инициализируются только после создания интерпретатора.
provenance и сохранение спанов
Provenance описывает происхождение указателя: вариант Concrete хранит идентификатор аллокации и метку borrow trackerмеханизм отслеживания заимствований в Miri, а вариант Wildcard используется для указателей, созданных приведением целого числа к указателю, и ведёт себя как любой ранее раскрытый указатель. ProvenanceExtra — это дополнительная информация указателя поверх обычного идентификатора аллокации.
Функция should_save_allocation_span решает, сохранять ли информацию о спане для аллокации, в зависимости от её вида: true для Rust, Miri, C, WinHeap, WinLocal и Mmap, false для Machine, Global, ExternStatic, Tls, Runtime и SocketAddress. Функция get_global_alloc_salt вычисляет соль для глобальной аллокации: если аллокация уникальна, используется фиксированная константа, иначе — случайное число. Эти функции не связаны с сохранением содержимого env-переменных в артефактах: утечка происходит потому, что Miri сохраняет все переменные окружения в target/, а не из-за правил сохранения спанов.
Механика утечки через кэш GitHub Actions
Действие cache сначала ищет попадание по key и версию кэшаСпособ пометить кэш метаданными о path и инструменте сжатия, использованном при создании кэша, чтобы потребляющий запуск workflow однозначно совпал с кэшем, который он может распаковать и использовать. в ветке запуска workflow; если попадания нет, ищет совпадения по префиксу key, а если и их нет — по restore-keys и версии; при отсутствии попаданий в текущей ветке те же шаги повторяются в ветке по умолчанию.
restore-keys задаёт список альтернативных ключей восстановления на случай промаха по key; действие перебирает их последовательно, и когда ключ не совпадает напрямую, ищет ключи с этим префиксом, а при нескольких частичных совпадениях возвращает самый недавно созданный кэш.
Именно поэтому restore-keys с префиксом может вернуть кэш, созданный в контексте с секретами, в контекст без них: кэш, созданный для ветки, доступен и может быть восстановлен из другого запуска для того же репозитория и ветки. Содержимое кэша не подписывается и не проверяется, и любой запуск workflow, способный читать кэш, может извлечь его содержимое; извлечённые кэши могут изменять файлы, которые затем исполняются в запуске workflow, что ведёт к выполнению вредоносного кода. Поэтому не следует хранить в кэше секреты, токены или учётные данные, а если кэшируется target/, нужно следить, чтобы у процессов, создающих target/, не было доступа к секретам, поскольку Cargo/Miri/Rust не гарантирует, что переменные окружения не будут скопированы в target/.
Доступ к кэшу между ветками и триггерами
Для защиты от отравления кэшаcache poisoning — запись вредоносного кэша, который затем восстанавливает более привилегированный workflow только определённые триггеры могут создавать или перезаписывать кэши в области ветки по умолчанию: push, workflow_dispatch, repository_dispatch, delete, registry_package, page_build, schedule. Запуски, вызванные любыми другими событиями, разрешающимися в ветку по умолчанию, получают доступ только для чтения. Это включает pull_request_target, issue_comment и workflow_run. Событие pull_request не затронуто, так как его кэши уже привязаны к merge ref и не могут быть записаны в область ветки по умолчанию.
Ключ cache-mode управляет доступом к кэшу для токена задания и принимает значения read (восстановление да, сохранение нет), write (и то и другое), write-only (только сохранение) и none (ничего); при пропуске используется значение по умолчанию в зависимости от типа триггера: для доверенного — write, для низкодоверенного — read. Явное объявление cache-mode: write или cache-mode: write-only переопределяет безопасный read-only по умолчанию для низкодоверенных триггеров, но возвращает риск отравления кэша.
Типичные настройки позволяют CI на main и других ветках писать в кэш, а PR могут только читать из кэша. Именно это сочетание — доверенный workflow записал секреты в target/, а PR читает кэш — и делает утечку возможной.
Лимиты и вытеснение кэша
вытесненияудаление кэшей, чтобы освободить место: GitHub удаляет записи кэша, к которым не обращались более 7 дней, а при заполнении хранилища — в порядке даты последнего доступа, от самых старых к самым новым Число кэшей не ограничено, но общий размер всех кэшей в репозитории по умолчанию ограничен 10 ГБ, и этот лимит может быть увеличен владельцами enterprise, организаций или администраторами репозитория. При достижении максимума политика вытеснения освобождает место, удаляя кэши в порядке даты последнего доступа — от самых старых к самым новым; при превышении лимита GitHub сохраняет новый кэш, но начинает вытеснять кэши, пока общий размер не станет меньше лимита репозитория.
Скорость создания кэшей — до 200 загрузок в минуту на репозиторий, скачивания — 1500 в минуту на репозиторий; при превышении последующие попытки загрузки или скачивания завершаются ошибкой до сброса лимита, а время до сброса возвращается в заголовке Retry-After. Репозитории, принадлежащие пользователям, могут настроить до 10 ТБ на репозиторий; для организаций максимум определяется настройками организации, для организаций под enterprise — настройками enterprise. Дополнительное хранилище контролируется бюджетами: при превышении бюджета кэш становится доступным только для чтения, пока статус биллинга не разрешится или использование не опустится ниже бесплатного лимита 10 ГБ за счёт истечения или явного удаления кэшей.
На окно доступности утёкшего кэша влияют эти правила: запись, к которой не обращались более 7 дней, удаляется, а при заполнении хранилища вытеснение по дате последнего доступа может удалить её раньше.
Что видит клиент и наблюдатель
В логах шага cargo miri пользователь видит секреты, потому что этот шаг имеет доступ к секретам как переменной окружения, а workflow кэширует каталог target, причём кэш доступен для PR. Cargo/Miri/Rust не гарантирует, что переменные окружения не будут скопированы в target/, и это может происходить не только через официальный инструментарий Rust, но и через build-скрипты, сохраняющие окружение в артефакты компиляции.
Редактирование секретов в логах GitHub не срабатывает для структурированных данных, потому что редактирование в значительной степени полагается на поиск точного совпадения конкретного значения секрета. Например, не следует использовать блок JSON, XML или YAML для инкапсуляции значения секрета, так как это значительно снижает вероятность корректного редактирования. Вместо этого нужно создавать отдельные секреты для каждого чувствительного значения. Регистрация секретов применяется и к преобразованиям: если секрет преобразован (например, Base64 или URL-encoded), новое значение тоже нужно зарегистрировать как секрет.
Как выглядит cache-hit
Успешный cache-hit — это булево значение, которое показывает, что для указанного ключа найдено точное совпадение. Когда точное совпадение найдено, вывод устанавливается в true. При точном совпадении действия восстанавливают файлы из кэша в каталог path. Этот признак можно использовать в условии шага, например чтобы выполнить действие только при промахе:
if: ${{ steps.cache-npm.outputs.cache-hit != 'true' }}Отличить переиспользование заражённого кэша от честной сборки по одному лишь cache-hit нельзя: при точном совпадении файлы восстанавливаются из кэша, а при промахе создаётся новый кэш, если задание завершилось успешно. То есть cache-hit говорит только о том, что файлы взяты из кэша, но не о том, что в кэше лежит.
Митигации и компромиссы
GitHub рекомендует для защиты от инъекций использовать промежуточную переменную окружения: значение выражения сохраняется в памяти и используется как переменная, не участвуя в генерации скрипта. Также рекомендуется вместо inline-скрипта создавать JavaScript-действие, которое принимает значение контекста как аргумент, что не подвержено инъекции.
Для защиты от утечек через недоверенный код следует избегать триггеров pull_request_target и workflow_run с недоверенными pull request или кодом; если они используются, workflow не должен явно извлекать недоверенный код, а артефакты из других workflow нужно обрабатывать с осторожностью. Для защиты секретов окружения можно использовать required reviewers: задание workflow не может получить доступ к секретам окружения до одобрения ревьюером. Для снижения риска утечки токена рекомендуется ограничивать назначенные разрешения токена.
Почему «просто не кэшировать target» — компромисс
Быстрые меры включают отключение кэша для задачи, ограничение секретов шагами, которые не вызывают Miri, и временное отключение Miri. Для контроля доступа к кэшу GitHub Actions описан ключ cache-mode, который управляет правами токена задачи на восстановление и сохранение кэша и принимает значения read, write, write-only, none. Разделение по уровню доверия реализовано через триггеры: только push, workflow_dispatch, repository_dispatch, delete, registry_package, page_build, schedule могут создавать или перезаписывать кэши в области ветки по умолчанию, а остальные триггеры получают доступ только для чтения.
Что из этого следует на практике
- Кэш
target/— это не просто ускоритель сборки, а канал передачи данных между запусками workflow. Если доверенный запуск пишет в него секреты, а PR читает, секреты утекают. - Miri сохраняет в
target/все переменные окружения, а не только те, что нужны для сборки. Краткосрочное исправление сужает набор доCARGO_*(кромеCARGO_*_TOKEN) иOUT_DIR, но до его применения любой запускcargo miriс секретами в окружении загрязняетtarget/. - Утечка не ограничена официальным инструментарием: build-скрипты тоже могут сохранять окружение в артефакты компиляции.
restore-keysс префиксом возвращает самый недавно созданный кэш с этим префиксом, поэтому заражённый кэш может быть восстановлен в контексте, где секретов нет.- Редактирование секретов в логах не спасёт, если секрет попал в структурированные данные: редактирование ищет точное совпадение значения, а JSON, XML или YAML его ломают.
cache-hitговорит только о том, что файлы взяты из кэша, и не отличает заражённый кэш от честной сборки.- Окно доступности утёкшего кэша ограничено: запись удаляется через 7 дней без обращений, а при заполнении хранилища вытеснение по дате последнего доступа может удалить её раньше.
Где смотреть в коде
- env.rs: unsetenv
- env.rs: chdir
- env.rs: update_environ
- phases.rs: phase_rustc
- machine.rs: should_save_allocation_span
- phases.rs: env
- env.rs: alloc_os_str_as_c_str
- env.rs: get_env_var