Пакет из npm полез в сеть при сборке: как заметить это на CI
Команда npm install или pip install выполняет чужой код на вашей машине ещё до того, как вы запустили хоть строчку из установленного пакета — postinstall-скрипты и sdist-хуки запускаются автоматически, с теми же правами, что и сам процесс сборки. Если в зависимости (или в зависимости зависимости) оказался вредоносный код, он может тихо прочитать переменные окружения, токены CI и ключи из ~/.npmrc или ~/.aws/credentials и отправить их на сторонний сервер за секунды, пока вы смотрите на зелёный чек сборки. Разберём, как поймать такую активность на уровне сети — без анализа кода каждого пакета, просто наблюдая, кто и куда стучится во время npm install.
Содержание
Почему это вообще работает и почему код не спасает
Экосистемы npm и PyPI устроены так, что установка пакета — это не копирование файлов, а выполнение произвольного кода издателя пакета на вашей машине. В npm это scripts.preinstall, scripts.install, scripts.postinstall в package.json; в Python — это setup.py при сборке из исходников (sdist) или, реже, скрипты, зашитые в сам код пакета и выполняемые при первом импорте. Хуки существуют для легитимных задач — собрать нативный биндинг, скачать бинарник под вашу платформу (классика — node-sass, puppeteer, sharp), но с точки зрения безопасности это означает: любая транзитивная зависимость — а их в среднем проекте на Node.js могут быть сотни — потенциально имеет полный доступ к файловой системе и сети процесса сборки.
Ревью кода тут помогает слабо по трём причинам. Во-первых, вы физически не читаете диффы всех транзитивных зависимостий при каждом обновлении лок-файла — это сотни пакетов. Во-вторых, вредоносный код в install-скриптах часто обфусцирован или спрятан в бинарном виде (base64-строка, которая декодируется и выполняется через eval). В-третьих, атака типа dependency confusion или захват заброшенного пакета (когда мейнтейнер теряет доступ к своей npm-учётке, а её захватывает злоумышленник) добавляет вредоносный код во внешне легитимный, давно используемый пакет — статический анализ на этапе npm audit его не поймает, потому что это не известная CVE, а свежая публикация.
Сетевой мониторинг работает иначе: он не пытается понять, что делает код, а фиксирует факт — процесс установки открыл исходящее соединение туда, куда его никто не просил. Это не заменяет аудит зависимостей и lock-файлы, но ловит именно тот класс атак, который проходит мимо всех остальных проверок — потому что смотрит на поведение, а не на код.
Что считается нормальным поведением сборки
Прежде чем настраивать алерты, нужно чётко определить базовую линию — что легитимная сборка обычно делает в сети, а что уже аномалия.
Обычной сборке нужно исходящее соединение к очень ограниченному набору адресов:
- Реестр пакетов:
registry.npmjs.orgдля npm,pypi.orgиfiles.pythonhosted.orgдля pip, аналогично для приватного реестра (Verdaccio, Nexus, GitLab Package Registry), если он у вас есть. - Git-хостинг, если зависимости тянутся напрямую по git-ссылке (
github.com, ваш self-hosted GitLab). - CDN сборочных инструментов — например, Playwright или Puppeteer скачивают бинарники браузеров с
playwright.azureedge.netилиstorage.googleapis.com, это тоже легитимно, но стоит явно занести в allowlist, а не разрешать вслепую. - Внутренние сервисы — кеш-прокси зависимостей, artifact-репозиторий, если сборка публикует туда результат.
Всё, что выходит за этот список, — повод для вопроса. Обращение к произвольному IP без DNS-имени, к домену, зарегистрированному неделю назад, к порту, отличному от 443/80, DNS-запрос к домену с длинным набором случайных символов (частый паттерн у C2-инфраструктуры и у DNS-эксфильтрации, когда данные кодируются прямо в поддомене) — всё это не должно происходить во время обычной установки зависимостей. Ключевая мысль: сборка — это не рантайм приложения, ей не нужен доступ к продовым API, к базам данных, к произвольным внешним сервисам. Чем уже вы определите легитимный периметр, тем проще будет заметить выход за его границы.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверПростой вариант: логирование через iptables
Самый низкоуровневый и надёжный способ — залогировать все исходящие соединения на уровне ядра через iptables (или nftables на более новых системах), не блокируя ничего, просто фиксируя факт попытки соединения. Это работает на любом self-hosted раннере — GitLab Runner, Woodpecker, Drone, Jenkins-агенте — без изменений в самой сборке.
Базовое правило логирования всех новых исходящих TCP-соединений:
# Логируем каждое новое исходящее соединение с раннера
iptables -A OUTPUT -m state --state NEW -j LOG \
--log-prefix "CI-OUTBOUND: " --log-level 4
# То же самое для UDP (DNS-запросы, в частности)
iptables -A OUTPUT -p udp -m state --state NEW -j LOG \
--log-prefix "CI-OUTBOUND-UDP: " --log-level 4
Записи уходят в dmesg / journalctl -k (через kern.log на системах с rsyslog) с префиксом, по которому легко фильтровать:
journalctl -k --since "10 min ago" | grep "CI-OUTBOUND"
Строка лога содержит SRC, DST, DPT (порт назначения) и интерфейс — этого достаточно, чтобы восстановить, куда именно стучался процесс сборки. Если вы запускаете сборки в изолированном network namespace или в отдельном Docker-контейнере на своей bridge-сети, удобнее вешать правило не на OUTPUT хоста, а на цепочку FORWARD для конкретного диапазона IP контейнеров — тогда лог не смешивается с трафиком остальной системы.
Более практичный вариант — логировать только то, что НЕ попадает в allowlist, а не вообще всё (иначе лог раздувается легитимными запросами к реестру):
# Разрешённые адреса реестра пропускаем молча
iptables -A OUTPUT -p tcp -d registry.npmjs.org --dport 443 -j ACCEPT
iptables -A OUTPUT -p tcp -d pypi.org --dport 443 -j ACCEPT
iptables -A OUTPUT -p tcp -d files.pythonhosted.org --dport 443 -j ACCEPT
# Всё остальное — логируем и (опционально) блокируем
iptables -A OUTPUT -m state --state NEW -j LOG --log-prefix "CI-UNEXPECTED: "
iptables -A OUTPUT -j REJECT --reject-with icmp-port-unreachable
Обратите внимание: iptables резолвит доменное имя в IP один раз при добавлении правила, а не динамически — если у реестра несколько IP или он стоит за CDN с ротацией адресов, такое правило будет ловить не все легитимные IP и генерировать ложные срабатывания. Для доменов за CDN (а registry.npmjs.org именно такой) практичнее либо обновлять правила по крону, либо использовать ipset с периодическим resolve, либо перейти сразу к DNS-уровню фильтрации, о котором ниже.
DNS-уровень: часто информативнее, чем IP
Эксфильтрация через DNS — отдельный частый паттерн: если исходящий TCP запрещён файрволом, вредоносный скрипт может попробовать закодировать украденные данные в поддомене DNS-запроса (<base64-кусок>.attacker-domain.com) — сам DNS-трафик обычно разрешён везде, потому что без него ничего не резолвится.
Логировать DNS-запросы раннера проще всего через dnsmasq в режиме локального резолвера с логированием, или через tcpdump на порту 53:
# Слушаем DNS-запросы с интерфейса раннера в реальном времени
tcpdump -i any -n port 53 -w /var/log/ci-dns-$(date +%s).pcap
# Или сразу в текстовом виде для быстрого просмотра
tcpdump -i any -n port 53 -l | tee /var/log/ci-dns.log
Признаки аномалии в DNS-логе: необычно длинные поддомены (эксфильтрация кодирует данные прямо в имени), большое количество запросов к одному и тому же необычному домену за короткое время, запросы к доменам, зарегистрированным недавно (это можно проверить точечно через whois, но не в потоковом режиме — для потока достаточно самого факта обращения к домену вне allowlist).
Если у вас уже есть Pi-hole, AdGuard Home или любой другой DNS-фильтр внутри инфраструктуры — направьте DNS раннера через него и используйте его лог-панель вместо ручного tcpdump. Настройка Pi-hole или AdGuard Home на VPS — тема отдельная, если у вас его ещё нет, проще всего поднять AdGuard Home в Docker и завернуть DNS раннера туда через --dns в конфиге контейнера сборки.
Изоляция сети сборочного окружения: allowlist вместо доверия по умолчанию
Логирование само по себе только фиксирует факт — оно не мешает утечке произойти, оно лишь позволяет узнать о ней постфактум (иногда через часы, если вы не смотрите логи в реальном времени). Следующий шаг — не «разрешить всё и смотреть», а «запретить всё и явно разрешить нужное». Это меняет модель угрозы: даже если вредоносный install-скрипт попытается достучаться до C2-сервера, соединение просто не установится.
Практическая реализация зависит от того, как у вас организованы раннеры:
Отдельная сеть для сборочных контейнеров. Если сборки идут в Docker, создайте отдельную bridge-сеть с explicit egress-правилами вместо docker0 по умолчанию:
# docker-compose.yml для CI-раннера
networks:
ci-restricted:
driver: bridge
internal: false # true полностью отрежет интернет — не подходит, если нужен реестр
и ограничьте выход через iptables-правила, привязанные к подсети этой сети (docker network inspect ci-restricted покажет диапазон), а не к хосту целиком — так вы не заденете остальной трафик сервера.
Egress-прокси вместо прямого выхода в интернет. Более контролируемый вариант — завернуть весь исходящий HTTP(S)-трафик раннера через прокси (например, Squid) с allowlist по домену:
# squid.conf — фрагмент
acl allowed_registries dstdomain .npmjs.org .pypi.org .pythonhosted.org
http_access allow allowed_registries
http_access deny all
Раннер настраивается на использование прокси через переменные окружения HTTP_PROXY/HTTPS_PROXY/NO_PROXY, а npm/pip уважают их из коробки. Плюс такого подхода — централизованный лог всех запросов с доменами в явном виде (не нужно резолвить IP обратно в домен), плюс блокировка происходит до установления TLS-сессии, а не после. Минус — прокси нужно поддерживать отдельно, и если сборка использует что-то мимо HTTP(S) (например, git+ssh://), потребуется отдельное правило.
Приватный зеркалирующий реестр как единственная точка выхода. Самый строгий вариант — раннер вообще не ходит напрямую в npmjs.org или PyPI, а только в приватный кеш-прокси (Verdaccio для npm, devpi или простой pip index-mirror для Python), который сам стоит на отдельном хосте с доступом наружу. Тогда правило файрвола для раннера предельно простое: разрешён только один внутренний адрес, всё остальное — запрещено. Это заодно решает проблему воспроизводимости сборок (пакет не пропадёт из публичного реестра посреди недели) и немного смягчает риск атак на сам реестр — компрометация происходит на уровне зеркала, где вы контролируете, что туда попадает.
Для настройки CI-раннера с нуля на выделенном сервере это стоит закладывать сразу в конфигурацию сети, а не добавлять постфактум — гораздо проще спроектировать изолированную подсеть на старте, чем вырезать доступ у уже работающих раннеров, ломая параллельно легитимные сборки.
На что реагировать в первую очередь
Не все аномалии одинаково срочные. Разумная приоритизация при разборе алертов:
| Признак | Уровень риска | Что делать |
|---|---|---|
| Соединение к IP без обратного DNS, порт не 80/443 | Высокий | Немедленно остановить раннер, ротировать секреты, доступные этой сборке |
| Множественные DNS-запросы с длинными случайными поддоменами | Высокий | То же самое — похоже на эксфильтрацию через DNS |
| HTTPS-соединение к новому домену на 443 | Средний | Проверить, легитимный ли это CDN/бинарник (Playwright, node-gyp), если да — добавить в allowlist осознанно |
| Повторное соединение к уже известному, но не внесённому в allowlist домену | Низкий-средний | Разобраться разово, чаще всего — забытая легитимная зависимость сборки |
| Запрос к самому реестру пакетов на нестандартный порт | Средний | Проверить конфиг .npmrc/pip.conf на предмет подмены registry |
Отдельно стоит проверять переменные окружения, которые получает процесс сборки. Если раннер сборки по умолчанию получает продовые секреты (ключи S3, токены деплоя, доступ к базе) просто потому что «так исторически сложилось» — это отдельная проблема конфигурации, независимая от сетевого мониторинга: даже идеальный мониторинг сети не отменит того, что скомпрометированный install-скрипт нашёл и прочитал AWS_SECRET_ACCESS_KEY до того, как соединение было заблокировано файрволом. Секреты сборке нужны ровно те, без которых она не может завершиться, и не дольше, чем длится конкретный шаг, которому они нужны.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Это заменяет npm audit и сканирование зависимостей?
Нет, это дополняющий слой. npm audit и подобные инструменты ловят пакеты с уже известными и опубликованными уязвимостями (CVE), а сетевой мониторинг ловит поведенческую аномалию независимо от того, знает ли о ней база данных уязвимостей — в том числе свежие, ещё не описанные случаи.
Не будет ли слишком много ложных срабатываний из-за легитимных бинарников (Playwright, sharp, esbuild)?
Будет, если не составить allowlist заранее. Решение — один раз прогнать типичную сборку с полным логированием, собрать список легитимных адресов и явно занести их в разрешённый список, а не реагировать на каждое новое соединение как на инцидент.
Достаточно ли просто заблокировать сеть при сборке полностью?
Для части проектов — да, если все зависимости кешируются локально и сборка офлайн-совместима. Но большинству нужен как минимум доступ к реестру пакетов, поэтому чаще практичнее allowlist, чем полная блокировка.
Нужно ли это на каждой сборке или можно выборочно?
Логирование дешёвое по ресурсам и имеет смысл включать постоянно на всех раннерах, а не только при подозрении — инцидент обычно замечают уже постфактум, и без непрерывного лога разбираться не с чем.
А если раннер эфемерный (поднимается и убивается под каждую сборку)?
Тогда правила файрвола и логирование нужно закладывать в образ раннера или в bootstrap-скрипт, который выполняется при старте контейнера/VM, — вручную настраивать каждый раз бессмысленно.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →