Пакетным менеджерам пора ввести период охлаждения

от автора

Привет, Хабр!

Проблема вредоносных пакетов, которые могут что-нибудь у вас украсть или зашифровать, с каждым месяцем стоит всё острее. К сожалению, не все знают, что во многие пакетные менеджеры уже встроены способы временного карантина открытых пакетов, которые позволят спать и собираться чуть спокойнее и увереннее.

Чтобы эта информация была доступнее (и популярнее), команда CodeScoring перевела статью Эндрю Несбитта «Package Managers Need to Cool Down«, в которой рассмотрены актуальные возможности пакетных менеджеров как неотъемлемой части инфраструктуры разработки.


Этот пост появился по просьбе Сета Ларсона: он спросил, могу ли я разобрать, как разные пакетные менеджеры реализуют задержку установки новых версий зависимостей, или cooldown. Его идея в том, что всем таким инструментам нужен глобальный параметр вроде exclude-newer-than, куда можно передать относительный срок, например 7d, то есть 7 дней. Такая задержка замедлит автоматизированную эксплуатацию и даст специалистам время обнаружить проблему и вмешаться.

Когда злоумышленник компрометирует учетные данные мейнтейнера или получает контроль над заброшенным пакетом, он публикует вредоносную версию и ждет, пока автоматика незаметно внедрит ее в тысячи проектов. Уильям Вудрафф обосновал необходимость cooldown для зависимостей в ноябре 2025 года, а месяц спустя уточнил концепцию: не устанавливать версию пакета, пока она не проведет в реестре минимально заданный срок. За это время сообщество и ИБ-вендоры успеют выявить проблему, и ваша сборка не подтянет зараженный пакет. Из десяти атак на цепочку поставки, которые он рассмотрел, у восьми окно возможностей было меньше недели, поэтому даже умеренный cooldown в семь дней заблокировал бы попадание большинства из них к конечным пользователям.

В разных инструментах эта идея называется по-разному (cooldown, minimumReleaseAge, stabilityDays, exclude-newer), а реализации отличаются деталями: где-то используется скользящий интервал, где-то абсолютная временная метка*; одни инструменты проверяют транзитивные зависимости, другие только прямые; в одних случаях обновления безопасности обходят это правило, в других нет.

*Прим. ред.: здесь важно не смешивать два режима. Скользящий интервал нужен для защиты от свежих публикаций, а абсолютная дата нужна скорее для воспроизводимости сборки на конкретный момент времени.

JavaScript

Экосистема JavaScript отреагировала быстрее всех: pnpm выпустил minimumReleaseAge в версии 10.16 в сентябре 2025 года. Настройка работает и для прямых, и для транзитивных зависимостей, а для пакетов, которым вы доверяете достаточно, чтобы пропустить проверку, есть список minimumReleaseAgeExclude. Yarn в том же месяце выпустил npmMinimalAgeGate в версии 4.10.0* (тоже в минутах, с npmPreapprovedPackages для исключений), затем Bun добавил minimumReleaseAge в версии 1.3 в октябре 2025 года через bunfig.toml. npm подключился позже, но выпустил min-release-age в версии 11.10.0 в феврале 2026 года. У Deno есть --minimum-dependency-age для deno update и deno outdated. Пять пакетных менеджеров за полгода. Не припомню другого случая, чтобы конкуренты так слаженно внедряли одну и ту же функцию.

*Прим. ред: актуальная документация Yarn сейчас говорит, что npmMinimalAgeGate введен в 4.12.

Python

В uv с ранних версий был флаг --exclude-newer для абсолютных временных меток. В версии 0.9.17, выпущенной в декабре 2025 года, к нему добавили относительные интервалы вроде 1 week и 30 days, а также переопределения для отдельных пакетов через exclude-newer-package. pip выпустил --uploaded-prior-to в версии 26.0 в январе 2026 года: сначала только с абсолютными временными метками, а затем с поддержкой относительных интервалов в версии 26.1. Poetry добавил solver.min-release-age в версии 2.4.0. Теперь такая возможность есть у трех основных инструментов установки и разрешения зависимостей в Python.

Ruby

У RubyGems и Bundler теперь есть предложение по реализации от мейнтейнера Хироси Сибаты: параметр cooldown в днях. Его можно передать как CLI-флаг, задать через bundle config или переменную окружения BUNDLE_COOLDOWN, либо указать для конкретного источника в Gemfile: source "https://rubygems.org", cooldown: 7. Если настройка задается на уровне источника, а не отдельных gem-пакетов, на приватные реестры задержка просто не распространяется. Поэтому не нужно поддерживать отдельный список исключений, а --cooldown 0 остается аварийным выходом на случай, когда исправление CVE нужно получить немедленно.

Отдельно gem.coop, gem-сервер под управлением сообщества, тестирует такую задержку на своей стороне: недавно опубликованные gem-пакеты становятся доступны через отдельную точку доступа только через 48 часов. Такой перенос cooldown на уровень индекса, а не клиента, интересен тем, что любой пользователь Bundler, настроенный на gem.coop, получает защиту без изменений в инструментах и привычном рабочем процессе.

Другие экосистемы

У Cargo есть RFC, а в Cargo 1.94 стабилизировали инфраструктуру реестра, необходимую для cooldown. Их подход полностью обходит проблему списков исключений. Вместо того чтобы заранее освобождать отдельные пакеты от задержки, вы явно выбираете нужную новую версию командой cargo update foo --precise 1.5.10, и этот выбор записывается в lock-файл. В результате нет списка исключений, о котором потом нужно помнить и который придется чистить. Пока так же существует cargo-cooldown — сторонняя обертка, которая в качестве прототипа принудительно применяет настраиваемое окно ожидания на машинах разработчиков.

В других экосистемах поддержка пока движется медленнее. У Go есть открытое предложение для go get и go mod tidy, у Composer — две открытые задачи, у NuGet — одна. При этом проекты .NET, использующие Dependabot, уже получают cooldown на стороне бота обновлений: такая поддержка появилась после расширения NuGet-интеграции Dependabot в июле 2025 года.

У Dart pub есть открытая задача с предложением добавить minimum_release_age в pubspec.yaml и поддержать список исключений. Там обсуждение уже ушло дальше: нужно ли пропускать версию, если вскоре после нее вышел новый релиз? Логика такая: быстрый последующий релиз мог исправлять проблему, которую лучше не тащить в проект. У Elixir Hex добавили опции cooldown в mix hex.outdated, а у conda, который управляет пакетами не только для Python, но и для R и других языков, есть открытая задача с предложением --exclude-newer.

Инструменты обновления зависимостей

В Renovate уже много лет есть minimumReleaseAge: раньше эта настройка называлась stabilityDays. Она добавляет к веткам обновлений status check в состоянии pending и держит его там, пока не пройдет заданное время. Depfu в июне 2019 года выпустил стратегию “reasonably up-to-date”: новые релизы удерживаются от нескольких дней до месяца перед открытием PR. Тогда это подавалось скорее как способ сократить поток PR, а не как защита цепочки поставки ПО. Mend Renovate 42 сделал минимальный возраст релиза в 3 дня значением по умолчанию для npm-пакетов в конфигурации “best practices” через предустановку security:minimumReleaseAgeNpm. Так cooldown стал для пользователей этой конфигурации не opt-in, а opt-out.

Dependabot выпустил cooldown в июле 2025 года: в dependabot.yml появился блок cooldown с поддержкой default-days и переопределений по уровням semver (semver-major-days, semver-minor-days, semver-patch-days). Обновления безопасности при этом обходят cooldown. Snyk занимает самую жесткую позицию: встроенный, ненастраиваемый 21-дневный cooldown для автоматических PR с обновлениями. npm-check-updates добавил параметр --cooldown, который принимает значения вроде 7d или 12h*.

Прим. ред.: В CodeScoring мы считаем 14 дней разумным стартовым ориентиром для cooldown, но не универсальным правилом для всех проектов. Такой порог снижает риск быстро распространяющихся атак на цепочку поставки ПО, однако его нужно внедрять осознанно: транзитивные зависимости даже у старых компонентов могут подтягиваться без жестко зафиксированных версий. Поэтому важно не только задать минимальный возраст пакета, но и уметь инструментально выявлять такие ситуации, обрабатывать исключения и контролировать фактический состав сборки.

Проверка конфигурации

zizmor добавил правило аудита dependabot-cooldown в версии 1.15.0. Оно отмечает конфигурации Dependabot, где нет настроек cooldown или задан слишком короткий период ожидания. Порог по умолчанию – 7 дней. Правило также поддерживает автоматическое исправление. StepSecurity предлагает проверку PR в GitHub, которая не пропускает изменения, если они добавляют npm-пакеты, опубликованные внутри заданного периода ожидания. У OpenRewrite есть готовый рецепт AddDependabotCooldown для автоматического добавления секций cooldown в конфигурации Dependabot. Специально для GitHub Actions pinact добавил флаг --min-age, а prek, реализация pre-commit на Rust, добавил --cooldown-days.

Все еще ждем

Для Go, Bundler, Composer, Dart, Hex и conda поддержка cooldown все еще обсуждается или реализована только частично. Поэтому задержку часто приходится применять через Dependabot или Renovate. Для автоматических обновлений этого достаточно, но ничто не мешает разработчику локально запустить bundle update или go get и подтянуть версию, опубликованную десять минут назад. Я вообще не нашел обсуждений cooldown для Maven, Gradle или Swift Package Manager. Если вы знаете о таком обсуждении, дайте мне знать, и я обновлю этот пост.

У этой функции также есть как минимум десять разных имен параметров в инструментах, которые ее поддерживают (cooldown, minimumReleaseAge, min-release-age, npmMinimalAgeGate, exclude-newer, stabilityDays, uploaded-prior-to, min-age, cooldown-days, minimum-dependency-age), из-за чего писать о ней почти так же сложно, как настраивать ее в проекте, где используются несколько языков программирования и пакетных менеджеров.

Языковые и системные пакетные менеджеры

В npm, PyPI и RubyGems запуск npm publish или gem push делает пакет доступным для установки по всему миру за считанные секунды. Если в этот момент срабатывает Dependabot или Renovate, вредоносный код может попасть в проект до того, как его увидит человек. Все атаки на цепочку поставки ПО, которые рассматривал Уильям, используют именно это свойство: публикация и распространение здесь почти одно и то же действие. Между скомпрометированной учетной записью мейнтейнера и тысячами зависимых проектов нет промежуточного барьера.

Системные пакетные менеджеры работают иначе, потому что разделяют публикацию исходного проекта и попадание пакета к пользователям. Когда кто-то выпускает новую версию библиотеки, она не появляется в apt install или brew install автоматически. Сначала мейнтейнер дистрибутива должен просмотреть изменение, обновить описание пакета и провести его через сборочный пайплайн. Пакеты Fedora проходят проверку и сборки Koji, а Homebrew требует pull request, который проходит CI и принимается мейнтейнером. Даже скомпрометированный архив исходного проекта должен пройти этот процесс, прежде чем попадет на чью-то машину. А люди, которые проводят проверку, обычно замечают, если патч добавляет обфусцированный postinstall-скрипт, скачивающий вредоносную нагрузку через curl.

В Debian даже загрузка пакета от скомпрометированного мейнтейнера сначала попадает в unstable, затем автоматически мигрирует в testing через 2-10 дней в зависимости от срочности и доступности тестов пакета, а stable получает обновления только через отдельный процесс релиза.

Механизм cooldown пытается добавить в языковые пакетные менеджеры окно для проверок, которого в этих экосистемах исторически не было. Это дает исследователям безопасности несколько дней, чтобы заметить вредоносную публикацию до того, как автоматизированные инструменты зафиксируют ее в lock-файле проекта. Просить Homebrew или apt добавить такую же задержку означало бы откладывать патчи безопасности в процессе, где уже есть человеческая проверка. В таком случае издержки могут оказаться выше пользы.

Проблема временных меток

Флаг --uploaded-prior-to в pip и более старый флаг --before в npm изначально принимали только абсолютные временные метки. Обсуждение относительных интервалов в pip хорошо показывает, что это два разных сценария, хотя технически они похожи. Абсолютная временная метка фиксирует результат разрешения зависимостей на конкретный момент: если повторить установку через шесть месяцев, инструмент выберет тот же набор версий. Относительный интервал вроде 7 дней решает задачу безопасности. Это скользящее окно, которое движется вместе со временем и всегда исключает свежие публикации, независимо от даты запуска сборки. pip добавил относительные интервалы в 26.1, --exclude-newer в uv принимает обе формы, а в npm есть --before для абсолютных дат и min-release-age для относительных интервалов. pnpm, Yarn, Bun и Deno принимают только относительные интервалы.

В обсуждении pip отдельно разобрали неприятные детали работы со строками длительности. Формат ISO 8601, например P7D, однозначен, но его неудобно вводить руками. Человекочитаемые строки вроде 7 days удобнее, но требуют отдельного разбора. А календарные единицы переменной длины, например, месяцы и годы, нельзя просто перевести в фиксированное число дней без учета конкретной даты. uv выбрал ISO 8601 и удобные строки, но полностью исключил месяцы и годы. pip в версии 26.1 тоже остановился на относительных интервалах. Этого хватает почти для всех реальных сценариев и не требует арифметики с високосными годами.

Даже вопрос о том, что значит “семь дней назад”, быстро становится нетривиальным. CI-сервер может работать в UTC, ноутбук разработчика — в тихоокеанском времени США, а реестр может сохранять время публикации в своей временной зоне. Несколько часов расхождения достаточно, чтобы решить судьбу пакета: пройдет ли он проверку cooldown, если был опубликован шесть дней и двадцать два часа назад, или будет заблокирован до истечения полного срока.

Различается и то, какая временная метка считается датой публикации. Например, проверка cooldown в Dependabot для GitHub Actions использует дату коммита тега, а не дату публикации релиза. Поэтому тег, созданный 100 дней назад, но опубликованный только вчера, не блокируется 7-дневным cooldown. У большинства языковых реестров есть единая временная метка загрузки, поэтому такой проблемы обычно нет. Но у всего, что берется из git-тегов, включая GitHub Actions, Go modules и git submodules, есть как минимум две возможные даты, и они могут отличаться на месяцы.

Наличие временных меток в реестре хорошо предсказывает скорость внедрения cooldown. Быстрее всего двигались экосистемы, где время публикации уже было доступно через API индекса. Если механизм разрешения зависимостей уже видит, когда появилась каждая версия, cooldown становится обычным фильтром на стороне клиента. В npm полный ответ реестра с метаданными пакета, который называют packument, уже много лет содержит поле time для каждой версии. PyPI добавил upload-time в simple index через PEP 700 в 2022 году. Поэтому pnpm, uv и pip могли выпустить эту функцию без изменений на стороне сервера.

Но у этого поля npm есть обратная сторона: time доступно только в полном ответе реестра с метаданными пакета, то есть в packument. Обычно пакетные менеджеры запрашивают более короткий ответ, поэтому включение cooldown может заставить их загружать многомегабайтные JSON-файлы для популярных пакетов. У RubyGems похожая проблема: compact index вообще не содержит временных меток. Поэтому для Bundler сначала нужно добавить created_at в эндпоинт /info индекса, а изменение формата ответа API требует более широкого согласования, чем обычный клиентский флаг.


Подписывайтесь на Codescoring в Telegram, YouTube или VK.

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