Supply chain через npm и PyPI: пинируем зависимости по хешу и подключаем Snyk в CI
Атаки через публичные пакетные репозитории учащаются. Вводим pin dependencies по хешу, npm shrinkwrap, Dependabot alerts и Snyk для транзитивных зависимостей в CI.
Учащаются атаки на цепочку поставок через npm и PyPI: вредоносные пакеты с typosquatting и подменой легитимных
В этом году npm и PyPI несколько раз оказывались в заголовках по одному и тому же поводу: кто-то публикует пакет с именем, похожим на популярный, или взламывает аккаунт мейнтейнера, и тысячи проектов тянут к себе код, который никто не проверял. Имена пакетов-двойников порой отличаются одним символом - классический typosquatting, на который в момент npm install никто не смотрит.
Мы дошли до этого не абстрактно. У одного из клиентов в ходе аудита обнаружили, что несколько внутренних Node.js-проектов тянут зависимости без lock-файлов в репозитории. CI скачивал последние совместимые версии на каждый билд. То есть сборка в понедельник и сборка в среду могли принести разный код - и никто не заметил бы разницы без специального инструмента.
Что конкретно происходило в 2020-м
Инцидентов несколько, и они разного характера:
- Typosquatting - пакеты с именами вроде
crossenv(вместоcross-env) илиmongose(вместоmongoose). Публикуются в расчёте на опечатку при вводе или случайныйinstallс ошибкой. - Подмена через публичный реестр - атака на среды, где есть внутренние пакетные реестры. Публичный пакет с тем же именем и более высокой версией «выигрывает» при разрешении зависимостей и подтягивается вместо внутреннего.
- Account takeover - взлом аккаунта мейнтейнера и публикация новой «патч-версии» с payload. Сюда же - передача заброшенного пакета новому владельцу, который оказывается злоумышленником.
По последнему типу атак показательна история с event-stream в 2018-м - тогда npm-экосистема впервые поняла масштаб проблемы. В 2020-м она не исчезла, а стала более системной.
Что мы изменили у себя и у клиентов
Политику сформулировали коротко: ни одна зависимость не должна разрешаться в момент сборки без явной фиксации. Под этим - несколько конкретных шагов.
npm shrinkwrap и lock-файлы в репозитории. Звучит банально, но lock-файлы часто добавлены в .gitignore по привычке или по умолчанию из старых .gitignore-шаблонов. Первым делом проверили, что package-lock.json или npm-shrinkwrap.json коммитится и не игнорируется. npm shrinkwrap фиксирует дерево зависимостей включая транзитивные - это отдельно важно, потому что прямые зависимости обычно на виду, а транзитивные - нет.
Фиксация по integrity hash. В package-lock.json версии 2 каждый пакет имеет поле integrity с SHA-512 хешем tarballa. При npm ci (в отличие от npm install) npm проверяет этот хеш и падает, если он не совпадает. Именно npm ci нужен в CI-пайплайне - он не обновляет lock-файл молча, а валидирует его.
# В GitHub Actions - принципиальная разница
- run: npm ci # проверяет integrity, не трогает lock
# вместо
- run: npm install # может обновить lock тихо
Dependabot alerts на GitHub. Включили для всех репозиториев, где есть package-lock.json или requirements.txt. Это базовый уровень: Dependabot смотрит на прямые зависимости и сигнализирует о CVE из GitHub Advisory Database. Alerts - это не PR, это уведомление. PR на автоматическое обновление мы решили не включать повсеместно - слишком много шума и риск сломать тестированную конфигурацию.
Snyk в CI для транзитивных зависимостей. Dependabot смотрит на прямые - Snyk смотрит глубже. Интегрировали в GitLab CI одной строкой через официальный образ:
snyk-scan:
image: snyk/snyk:node
script:
- snyk test --severity-threshold=high --json > snyk-report.json || true
- snyk test --severity-threshold=critical
artifacts:
paths:
- snyk-report.json
allow_failure: false # critical - блокируем
Логика такая же, как мы делали со SAST: High - в отчёт и видимость, Critical - блокируем merge. Высокий уровень при первом прогоне на легаси-проектах дал неожиданное количество транзитивных зависимостей с известными CVE - половина из них приходилась на пакеты, которые в package.json вообще не упоминаются. Их тянули чужие зависимости.
Про PyPI: там похожая история
Всё то же самое, только инструменты другие. Для Python фиксируем через pip freeze > requirements.txt в зафиксированном виртуальном окружении и коммитим. В CI используем pip install --require-hashes, который требует явного указания хеша для каждого пакета в requirements.txt:
cryptography==3.1.1 \
--hash=sha256:7b2d54e787a884f4...
Генерировать такие файлы удобнее через pip-compile из pip-tools - он сам считает хеши и расписывает транзитивные зависимости. Snyk для Python тоже работает, хотя покрытие базы данных по сравнению с npm-экосистемой немного уже.
Что не решили
Честно: подмену через публичный реестр для внутренних пакетов мы закрыли не везде. Там нужен настроенный приватный реестр с явным скопом для внутренних пакетов и правильная конфигурация .npmrc. Это отдельная работа, которая у части клиентов впереди.
Плюс подписи пакетов - npm сейчас не требует подписи от мейнтейнеров, и интегрированной проверки подписи в стандартном flow нет. Здесь пока полагаемся на хеши в lock-файлах и Snyk-сигнатуры по базам уязвимостей.
Пинирование по хешу и Snyk в CI - это не серебряная пуля, но это конкретная планка, которую мы подняли. Атака через typosquatting на зафиксированную сборку с проверкой integrity уже не пройдёт незамеченной.