Безопасность
Безопасность цепочки поставок: твоя реальная поверхность атаки — это зависимости
Инженер Microsoft бенчмаркил PostgreSQL на тестовой машине в марте 2024, когда SSH-логины стали занимать 500ms вместо обычных ~100ms. Эти полсекунды были единственным видимым симптомом CVE-2024-3094: бэкдора, посаженного в xz-utils — библиотеку сжатия, лежащую под sshd на большинстве дистрибутивов Linux. Код был чистым. Никто не написал уязвимой строки. Атакующий потратил примерно два года, чтобы стать доверенным мейнтейнером малоизвестной зависимости, а затем протащил бэкдор через сборку. Поймали это по регрессии latency, а не на код-ревью.
Ты отгружаешь в основном чужой код
Современное веб-приложение — тонкий слой собственной логики поверх глубокого стека зависимостей. Свежий create-next-app или проект на NestJS тянет сотни пакетов, подавляющее большинство — транзитивные: их ставят твои зависимости, а не ты, и они не названы в твоём package.json. Честная формулировка для сеньора: большинство байтов, что ты отгружаешь в прод, написаны незнакомцами, не проаудированы никем в твоей команде и обновляются автоматически.
Это меняет, куда бьют атаки. Cross-site scripting и SQL-инъекции целятся в твой код. Атаки на цепочку поставок пропускают твой код целиком и целятся в шаг установки — момент, когда твоя машина или CI забирает пакет и запускает его install-скрипты с твоими кредами, токенами реестра и доступом к сети. Можно написать безупречный прикладной код и всё равно быть взломанным на npm install.
Как сработали реальные инциденты
Четыре задокументированные атаки размечают поверхность. Каждая обосновывает конкретную защиту.
Бэкдор xz-utils (CVE-2024-3094, 2024) был многолетней социально-инженерной кампанией. Контрибьютор под именем «Jia Tan» потратил ~2021–2024 на построение доверия в проекте xz — патчи, помощь, аккаунты-куклы, давившие на выгоревшего оригинального мейнтейнера, — пока не стал со-мейнтейнером. Затем он спрятал вредоносный код в обфусцированных бинарных тестовых файлах и подправленном скрипте сборки, так что бэкдор существовал только в выпущенном tarball, а не в читаемом исходнике в Git. Он целился в авторизацию sshd и дошёл до Fedora, Debian unstable и Kali, прежде чем 500ms-подсказка по latency его выдала. Урок: доверие к мейнтейнеру — не доверие к сборке.
Dependency confusion (Alex Birsan, 2021) был проще и страшнее. Многие пакетные менеджеры, когда ты просишь пакет, проверяют публичный реестр наравне с приватным — и предпочитают более высокий номер версии. Birsan собрал имена внутренних пакетов, утёкшие в публичных JS-бандлах, опубликовал пакеты с этими точными именами в npm/PyPI/RubyGems с версией 9.9.9 и стал ждать. Сборки в Apple, Microsoft, Tesla, Uber и 30+ других молча резолвили его публичный пакет вместо внутреннего и запускали его код. За доказательство он получил более $130 000 в bug bounty.
Typosquatting ставит на промах пальцем: пакет с именем lodahs или crossenv рядом с настоящими lodash или cross-env. Одна опечатка в Dockerfile — и ты установил пакет атакующего.
Скомпрометированные легитимные пакеты — event-stream (2018) и ua-parser-js (2021) — показывают третий путь: реальный, широко используемый пакет получает вредоносную версию. У event-stream появился новый «мейнтейнер», добавивший пейлоад под конкретный криптокошелёк; ua-parser-js с десятками миллионов загрузок в неделю был угнан на несколько часов, чтобы отгружать криптомайнер и кражу кредов.
| Атака | Механизм | Главная защита |
|---|---|---|
| Бэкдор xz-utils | Доверенный мейнтейнер отгружает бэкдор в артефакте сборки, не в исходнике | Provenance сборки (SLSA), воспроизводимые сборки, подписанные артефакты |
| Dependency confusion | Публичный пакет затеняет внутреннее имя более высокой версией | Scoped-имена + приоритет реестра; резервируй внутренние имена публично |
| Typosquatting | Похожее имя рядом с популярным | Lockfile + ревью новых зависимостей; —ignore-scripts |
| Скомпрометированный пакет | Реальный популярный пакет получает вредоносную версию | Пины версий + integrity-хэши; отложенное принятие; audit |
Базовый слой: lockfile, integrity и npm ci
Lockfile (package-lock.json, yarn.lock, pnpm-lock.yaml) пинит каждую зависимость — прямую и транзитивную — к точной версии и криптографическому integrity-хэшу (sha512-...). Версия останавливает тихий скачок на вредоносный релиз; хэш означает, что пакет, чьи байты изменились под тем же номером версии, провалит установку. Это твоя первая и самая дешёвая линия обороны.
Но lockfile защищает, только если ты его реально форсишь. npm install с радостью мутирует lockfile, чтобы удовлетворить ослабленный диапазон; npm ci делает обратное — ставит ровно lockfile и падает с ошибкой, если package.json и lock расходятся. Правило сеньора: npm ci в CI, никогда npm install. Добавь --ignore-scripts, чтобы остановить запуск произвольного postinstall-кода во время установки — именно так выполняется большинство пейлоадов на этапе установки. Пины точных версий (без ^-диапазонов) плюс короткая задержка принятия — не бери релиз в день его выхода — притупляют окно в стиле ua-parser-js, когда вредоносная версия живёт несколько часов, прежде чем её снимут.
Почему это работает
Почему хэши, а не только пины версий? Номер версии — это метка, которой управляет публикатор; байты — нет. Если атакующий перепубликует ту же версию с новым содержимым (или реестр скомпрометирован), пин только по версии её спокойно поставит. Integrity-хэш вычисляется из реального tarball, поэтому любое изменение — вредоносное или случайное — ломает установку громко, а не выполняется тихо.
Слой целостности: SBOM, provenance и подпись
Lockfile говорит, от чего ты зависишь; он ничего не говорит, как эти артефакты собирали и не подменили ли их после публикации. Это и есть брешь, которую использовал xz — исходник был чист, отгруженный артефакт — нет.
SBOM (Software Bill of Materials) — это список ингредиентов: машиночитаемая опись каждого компонента и версии в сборке, в стандартном формате вроде CycloneDX или SPDX. Когда выйдет следующий CVE, SBOM ответит «затронуты ли мы?» за секунды, а не паническим grep по репозиториям. SLSA (Supply-chain Levels for Software Artifacts) — дополняющая половина: она сертифицирует, как собирали артефакт. Её прогрессивные уровни идут от «provenance существует» (L1) до укреплённой, изолированной, нефальсифицируемой сборки (L3); большинство команд целятся в L2–L3 для прода. Provenance — подписанная, проверяемая запись, связывающая артефакт с точным коммитом исходника и сборкой, что его произвела, чтобы подменённый бинарь не выдал себя за официальный. Подписанные коммиты и подписанные артефакты (через Sigstore / in-toto attestations) замыкают петлю: ты проверяешь подпись, прежде чем доверять байтам, а не доверяешь имени.
| Слой | На какой вопрос отвечает | Инструменты |
|---|---|---|
| Lockfile + хэш | Получил ли я ровно те байты, что ожидал? | npm ci, поле integrity |
| SBOM | Какие компоненты в этой сборке? | CycloneDX, SPDX, Syft |
| Provenance (SLSA) | Как и из какого исходника собрано? | SLSA attestations, in-toto |
| Подпись | Артефакт подлинный и неподменённый? | Sigstore, подписанные коммиты |
Остановить dependency confusion: приоритет, а не везение
Confusion-атаки эксплуатируют порядок резолва. Фикс — сделать так, чтобы сборка никогда не лезла в публичный реестр за внутренним именем. Используй scoped-неймспейс (@acme/billing), которым ты владеешь в публичном реестре, чтобы под ним никто не смог опубликоваться. Настрой пакетный менеджер так, чтобы у внутреннего/приватного реестра был приоритет для твоего scope, и либо проксируй публичные пакеты через один прокси, либо вовсе блокируй публичный реестр для внутренних scope. На всякий случай публикуй пакеты-заглушки под своими внутренними именами в публичный реестр сам — владеть именем проще всего, чтобы гарантировать: атакующий его не займёт. Автоматическое ревью зависимостей (Dependabot, npm audit, Renovate) затем следит за известными уязвимыми версиями и помечает рискованные новые зависимости прямо в PR, прежде чем они попадут в lockfile.
Твой CI-пайплайн запускает `npm install` и использует caret-диапазоны в package.json. Коллега предлагает укрепить шаг установки. Выбери самое сильное изменение.
Какое единственное наблюдение привело к обнаружению бэкдора xz-utils (CVE-2024-3094)?
Почему атака dependency confusion срабатывает, даже когда у твоего внутреннего пакета валидная версия?
Расставь защиты от самой дешёвой/немедленной к самой организационной:
- 1 Закоммитить lockfile и перевести CI на `npm ci` с integrity-хэшами
- 2 Запинить точные версии и добавить `--ignore-scripts` к установкам
- 3 Включить автоматическое ревью зависимостей (Dependabot / audit) на каждый PR
- 4 Scoped-имена + приоритет приватного реестра; резервировать внутренние имена публично
- 5 Генерировать SBOM и требовать SLSA provenance + подписанные артефакты для релизов
- 01Коллега говорит: «мы пиним версии в package.json, значит, мы защищены от атак на цепочку поставок». Объясни, от чего это защищает, а от чего — нет.
- 02Пройди по тому, как работает атака dependency confusion, и по многослойной защите от неё.
Код, который ты пишешь сам, — тонкий слой на глубоком стеке зависимостей, что ты не писал, поэтому современная поверхность атаки — это шаг установки, а не твоя прикладная логика. Задокументированные инциденты размечают поверхность: xz-utils показал, что доверенный мейнтейнер может отгрузить бэкдор в артефакте сборки, чей исходник выглядит чисто; dependency confusion показал, что публичные реестры могут затенять внутренние имена более высокой версией; typosquatting и компромиссы event-stream / ua-parser-js показали, как похожий или угнанный-но-реальный пакет проскальзывает. Защиты выстраиваются слоями. Базовый слой — закоммиченный lockfile с integrity-хэшами, ставящийся через npm ci (никогда голый npm install), с точными пинами и --ignore-scripts. Выше — автоматическое ревью зависимостей ловит известно-плохие версии, scoped-имена плюс приоритет приватного реестра убивают confusion, а SBOM плюс SLSA provenance и подписанные артефакты отвечают на вопросы, которые хэшам не под силу: что внутри сборки и не подменили ли её после написания исходника. Ничего экзотического — в основном это конфигурация, которую ты включаешь до того, как тебя найдёт следующий сюрприз на 500ms.