Подробные статьи о технологиях, определяющих будущее.

Атака через цепочку поставок PyPI: урок LiteLLM

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

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

Три файла. Вот и весь след одной из самых мерзких атак на цепочку поставок Python, которые мы видели: 12-байтовый cook.pth, 22-килобайтный cook.py и 25-килобайтный sitecustomize.py. Когда LiteLLM 1.107.0 появился на PyPI, эти три файла лежали внутри каждого из семи его wheel-пакетов, и из-за того, как устроен механизм запуска Python, они выполнялись в каждом процессе Python на зараженном хосте: в вашем приложении, в CI-раннере, в самом pip и даже в языковом сервере вашего редактора. Импортировать litellm не требовалось. Запускать его тоже не требовалось. Достаточно было запустить любой интерпретатор Python.

Команда LiteLLM подтвердила компрометацию, а PyPI удалил релиз в течение нескольких часов, но окна хватило. Зараженные Docker-образы, включая тег latest, были доступны примерно два с половиной часа. Если ваш CI что-то подтягивал в этот период, стилер уже успел собрать SSH-ключи, облачные учётные данные, пароли из браузера, секреты CI/CD и токены AI-инструментов, зашифровать их и отправить на домен, контролируемый атакующими. Это был не typosquat и не похожий пакет. Это был настоящий пакет, собранный настоящим пайплайном и подписанный настоящим процессом релиза. И это должно изменить то, как вы думаете о доверии к зависимостям.

Как три файла взломали официальный пайплайн релиза

Атакующие не взламывали PyPI и не протаскивали коммит в публичный репозиторий: исходники на GitHub чистые. Согласно отчёту об инциденте от LiteLLM, у атакующих оказались учётные данные GitHub CLI трёх разработчиков, судя по всему, вытащенные из логов внутреннего развёртывания LiteLLM, которое было открыто в интернет. Имея токен владельца организации, они обошли защиту ветки и направили приватный релизный workflow на похожий репозиторий Berri-AI/litellm вместо BerriAI/litellm. Учётные данные второго разработчика закрыли шаг одобрения. Официальный пайплайн собрал, подписал и опубликовал артефакты атакующих.

Сама инъекция была элегантно мелкой. Одна дополнительная строка в pyproject.toml sdist:

[tool.maturin]
python-source = "."
data = "cook"            # <-- injected, copies cook/ into the wheel
python-packages = ["litellm_cli"]   # legitimate, present upstream

Директива data = "cook" говорит maturin упаковать каталог cook/ в путь purelib wheel-пакета, а pip послушно устанавливает его в site-packages. В самом sdist этой папки не было, так что человек, бегло просматривающий релиз, увидел бы одну загадочную строку и больше ничего. Полезная нагрузка пряталась в артефактах сборки, а не в исходниках. Просмотр истории git, того, что большинство из нас считает источником истины, не показал бы вам вообще ничего.

Почему .pth-файлы и sitecustomize.py — мечта для вредоносного ПО

Две особенности процесса запуска Python сделали эту атаку необычно мощной, и, честно говоря, большинство Python-разработчиков о них никогда не задумывались. Первое: любой .pth-файл в site-packages обрабатывается при запуске интерпретатора, и строки, начинающиеся с import, выполняются. Двенадцатибайтовый cook.pth содержал ровно одну строку, import cook, и этого хватало, чтобы запустить стилер в каждом процессе Python на машине. Второе: Python автоматически импортирует модуль sitecustomize при старте, если находит его в пути. Атакующие подложили свой, который запускал отсоединённый демон. Тот раз в час обращался к текстовому файлу на GitHub, скачивал оттуда код, на который указывал атакующий, и выполнял его через exec().

Это куда серьёзнее, чем типичный вредоносный пакет с отравленным setup.py или плохим импортом в __init__.py. Те срабатывают при установке или импорте пакета. Этот срабатывал, когда вы запускали что угодно на Python. Ваш запуск pytest в CI. Команда pip list, которую вы набрали, чтобы проверить, что произошло. Форматтер black в вашей IDE. Каждый такой запуск снова взводил ловушку.

Стоит остановиться на том, что стилер на самом деле собирал, потому что список читается как карта современной жизни разработчика: учётные данные AWS, Azure и gcloud, kubeconfig, токены GitHub CLI, SSH- и GPG-ключи, хранилища паролей браузеров, история shell, CI-секреты из GitLab runner и Jenkins, учётные данные Terraform, переменные окружения других процессов, считанные через /proc/<pid>/environ, учётные данные из метаданных экземпляра AWS и, с мрачной иронией, токены Claude Code, Cursor, Copilot и десятка других AI-инструментов для программирования, а также ключи LLM-провайдеров, хранящиеся в собственной базе прокси LiteLLM. Один pip install, и у атакующего оказывается фактически вся ваша личность как инженера.

Импортировать litellm не требовалось. Для запуска стилера на хосте достаточно было запустить любой интерпретатор Python.

Хеш-пиннинг и lockfile: необходимо, но недостаточно

Каждый чеклист безопасности цепочки поставок начинается с lockfile и хеш-пиннинга, и так и должно быть. Если вы фиксируете хеши через pip install --require-hashes или используете инструмент вроде uv или Poetry с зафиксированными хешами, wheel, который тихо изменился после проверки, будет отклонён. Это реальная защита от целого класса атак: подмены в реестре, отравления зеркал, замены артефактов.

Но давайте честно о том, что это даёт здесь: ничего, по крайней мере тем, кто обновился первым. Хеш-пиннинг гарантирует, что артефакт, который вы установите завтра, побайтово совпадает с тем, что установили сегодня. В этой атаке вредоносные wheel-пакеты и были официальными артефактами: собранными первой стороной, пайплайном и с последовательными хешами. Как только кто-то выполнил очередное плановое обновление, зафиксировал новую версию и закоммитил обновлённый lockfile, вредоносные хеши попали в репозиторий с зелёной проверкой CI. Пиннинг защищает от изменений, но ничего не говорит о намерениях.

Там, где пиннинг действительно проявляет себя в такой истории, это радиус поражения. Команды с зафиксированными хешами и осознанным графиком обновлений не подхватывают каждый релиз автоматически в ту же минуту, когда он выходит. Если ваша политика звучит как «обновляем зависимости в еженедельном PR с периодом охлаждения в несколько дней для всего, что не является критическим исправлением», то удаление с PyPI в 13:23 UTC случилось раньше, чем открылось ваше окно обновления. Скучный процесс побеждает хитроумные инструменты чаще, чем нам хотелось бы признавать.

Так что lockfile оставляйте, и генерируйте их с хешами. Но перестаньте говорить себе, что это защита от скомпрометированного конвейера мейнтейнера: это защита от дрейфа и подмены, а также лежачий полицейский против плохих релизов. Вот и всё.

Массивная дверь хранилища с огромной дырой, пробитой в слабой стене рядом с ней.
Хеш-пиннинг запирает парадную дверь; эта атака прошла сквозь стену, то есть сам пайплайн сборки.

Аттестация происхождения поймала бы эту атаку

Вот неприятная обратная сторона: у этой конкретной атаки была вопиющая, обнаруживаемая аномалия, и инструменты экосистемы, которые её ловят, существуют прямо сейчас. Вредоносные wheel-пакеты были собраны не из публичного репозитория, а из похожего репозитория, который создали атакующие. Любой шаг проверки, спрашивающий «собран ли этот артефакт из того коммита и репозитория, которые он заявляет?», упал бы с громким треском.

Именно это и даёт аттестация происхождения. PyPI поддерживает PEP 740: когда пакет публикуется через Trusted Publishers из GitHub Actions, workflow может создать подписанное заявление, привязывающее артефакт к точному репозиторию, workflow и коммиту, которые его произвели. Тогда проверка на стороне потребителя становится механической: проверить аттестацию, убедиться, что исходный репозиторий, BerriAI/litellm, а не Berri-AI/litellm. Bundle-ы Sigstore, провенанс SLSA и проверки в духе pip-audit относятся к этому же семейству.

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

Есть и более тонкий урок. Атакующим понадобились три учётные записи разработчиков: одна, чтобы перенаправить workflow, одна, чтобы одобрить, и одна, чтобы не вызывать подозрений. Это системный провал гигиены учётных данных, а не одна ошибка. Короткоживущие токены Trusted Publisher на основе OIDC в PyPI значительно сократили бы поверхность атаки: нет долгоживущего токена, лежащего в файле логов и ждущего, чтобы его украли. Если ваш проект до сих пор публикуется с API-токенами, хранящимися в CI-секретах, это ваш сигнал.

Изолированные установки и аргументы в пользу периодов охлаждения

Представьте худший случай: вредоносный релиз проскальзывает мимо вашей гигиены lockfile и проверок происхождения, потому что однажды такое случится. Что остаётся? Локализация. Вопрос перестаёт быть «как предотвратить плохие пакеты» и становится «что достанет плохой пакет, когда запустится?»

Для CI ответ действительно достижим. Сборки должны выполняться в эфемерных окружениях без долгоживущих учётных данных, с ограниченными токенами и фильтрацией исходящего трафика. Поведение стилера LiteLLM заметно в сети, если вы её слушаете: POST на незнакомый домен с нестандартным заголовком авторизации, загрузка с raw-URL GitHub в ежечасном цикле и, самый явный признак, Python-процесс, обращающийся к 169.254.169.254, сервису метаданных экземпляра AWS. Прикладному коду почти никогда не нужна законная причина ходить на IMDS напрямую: этим занимается SDK. Настройте оповещения о Python-процессах, обращающихся к этому адресу, и вы поймаете целое семейство похитителей учётных данных, а не только этого. Ранее мы писали о слепых пятнах в логировании аутентификации в облаке. Это одно из них, и закрыть его дёшево.

С машинами разработчиков сложнее, и не буду притворяться иначе. Ноутбук, где один и тот же пользователь запускает редактор, браузер, SSH-агент и произвольные pip install, это шведский стол для стилера. Практичные шаги: выполнять экспериментальные установки в контейнерах или devcontainer, а не в окружении хоста; держать установку пакетов внутри devcontainer проекта, чтобы вредоносный sitecustomize.py никогда не попал в site-packages вашего хоста; и считать ~/.aws/credentials обузой. SSO с короткоживущими токенами (aws sso login, gcloud auth) означает, что на диске лежит гораздо меньше того, что можно украсть. Ничего экзотического в этом нет. Это просто трение, а трение и есть цель.

  • Фиксируйте версии и хеши в lockfile, но относитесь к обновлениям как к осознанным событиям, а не фоновому шуму.
  • Вводите период охлаждения, 48–72 часа, перед тем как обновления некритичных зависимостей попадут в продуктивные сборки. Большинство вредоносных релизов удаляют в течение нескольких часов.
  • Проверяйте аттестации происхождения там, где они есть, и отдавайте предпочтение пакетам, опубликованным через Trusted Publishers с OIDC, а не долгоживущим API-токенам.
  • Запускайте CI-сборки в эфемерных средах без учётных данных с фильтрацией исходящего трафика; оповещайте о любом Python-процессе, обращающемся к эндпоинтам метаданных экземпляра.
  • Устанавливайте и запускайте сторонний код в контейнерах на машинах разработчиков, а статические файлы с учётными данными облака заменяйте короткоживущими сессиями SSO.
  • Проверьте окружение на IoC, если вы что-либо подтягивали в этом окне: cook.pth, cook.py или лишний sitecustomize.py в site-packages, а также /tmp/.pc_once_* или /tmp/remote_code_loader.lock на диске.

Что эта атака говорит о том, где на самом деле живёт риск

Обратите внимание, чем атакующие не стали заниматься. Они не эксплуатировали уязвимость в коде. Им не нужен был zero-day. Они пошли по самой мягкой мишени современного софта: людям и машинам, которые держат постоянные учётные данные. Трёх токенов GitHub CLI, судя по всему, вытащенных из логов открытого в интернет внутреннего развёртывания, хватило, чтобы захватить релизный пайплайн, обслуживающий миллионы загрузок. Это тот же паттерн, который мы наблюдаем в индустрии снова и снова: секреты расползаются по логам, агентам и конфигам, и каждый из них становится отмычкой. Если вы запускаете внутренний экземпляр чего-либо и открываете его в интернет, его логи теперь часть вашей модели угроз. Эта фраза должна неприятно кольнуть нескольких человек.

Есть и проблема скорости, о которой стоит сказать. LiteLLM быстро развивающийся проект: десятки тысяч коммитов, частые релизы, и он стоит в центре стека AI-инструментов, подтягиваясь фреймворками и SDK, о зависимости от которых многие команды даже не знают. Транзитивный охват означает, что выбором прямых зависимостей вы не выбираете свою уязвимость. В таком мире SBOM перестаёт быть бумажкой для compliance; это способ за минуты, а не дни ответить на вопрос «мы затронуты?». Инструменты, которые строят список компонентов по реальным данным сборки, а не по скрейпингу манифестов, поймают зависимости, о которых ваш requirements.txt не упоминает.

Одинокая фигура с фонарём рассматривает одну коробку среди бесконечных стеллажей склада.
Современные деревья зависимостей это склады; большинство команд никогда не инвентаризировали, что лежит на их полках.

Моё мнение: отклик индустрии на атаки на цепочку поставок слишком сильно смещён в сторону профилактического театра, то есть сканеров, которые помечают заведомо плохие версии задним числом, и недостаточно внимания уделяет неброским мерам, которые могли бы изменить эту историю. Короткоживущие учётные данные для публикации. Проверка происхождения на уровне прокси. Периоды охлаждения. Оповещения о исходящем трафике к эндпоинтам метаданных. Изолированные установки. Ни одна из этих задач не является исследовательской. Это решения по конфигурации, доступные уже сегодня, и большинство команд не внедрили ни одной. Атакующим LiteLLM понадобилась одна плохая строка в конфиге сборки и три украденных токена. Перечисленные защиты заставили бы их преодолеть четыре независимых слоя вместо одного. Вот что на самом деле означает эшелонированная защита: не больше сканеров, а больше независимых способов не провалиться. Это та же философия, что стоит за инженерией систем, устойчивых к катастрофам, а не систем, которые просто надеются на лучшее.

Следующий скомпрометированный релиз уже кто-то планирует. Вы не сможете помешать его публикации. Но вы точно можете решить, будет ли он работать, имея доступ к вашим ключам AWS, с разблокированным SSH-агентом и открытым сетевым путём к домену атакующих, или он умрёт в эфемерном контейнере без учётных данных за файрволом. Это решение принимается сейчас, в вашей CI-конфигурации и настройке devcontainer, а не в день, когда выйдет отчёт об инциденте.