Cómo proteger tu proyecto de paquetes maliciosos en npm

Cómo proteger tu proyecto de paquetes maliciosos en npm
Photo by Nick Fewings / Unsplash

Haces npm install, confías en que todo está bien, y sin saberlo un script malicioso ya se ejecutó en tu máquina. Así funcionan los ataques de supply chain en npm: infectan paquetes populares (o sus dependencias) y usan los lifecycle scripts para correr código antes de que te des cuenta.

Este tipo de ataque se viene hablando desde el 2022 pero los atacantes lo han utilizado con mayor frecuencia debido al alcance que logran tener en el ecosistema de npm. Un ejemplo reciente: Shai Hulud.

En este post te explico cómo funcionan estos ataques y qué configuraciones puedes aplicar hoy para proteger tus proyectos.

¿Qué es un "supply chain attack" o ataque a la cadena de suministro?

Este tipo de ataque se caracteriza por infectar alguna parte del ciclo de entrega del software.
Muchos proyectos tienen este ciclo de vida:

  1. Escribir el código
  2. Instalar paquetes
  3. Mandar a un ambiente de CI/CD
  4. Enviar proyecto a producción

Los ataques de supply chain se insertan en uno de estos pasos comprometiendo toda la cadena de pasos siguientes.

¿Cómo se infecta una librería?

Los atacantes no siempre comprometen el paquete popular directamente. Muchas veces infectan una dependencia interna de ese paquete.

Tu al instalar el paquete popular, sin saberlo estás instalando también el código de la dependencia infectada.

¿Cómo funcionan los lifecycle scripts en npm?

Los ataques recientes han utilizado una funcionalidad del gestor de paquetes de npm: la capacidad de ejecutar scripts antes o después de la instalación de un paquete.

A esto se le llaman "lifecycle scripts". Muchos paquetes los usan de manera legítima para preparar pasos previos a la instalación, como la compilación de paquetes nativos. Aunque la documentación de npm recomienda evitarlos, algunos paquetes los necesitan, y es precisamente esa característica la que los paquetes infectados utilizan.

Como esto es un proceso que sucede antes de que se instale el paquete, nosotros no nos percatamos de lo que se ejecuta. Los paquetes con malware ejecutan todos los pasos que necesitan para infectar tu equipo, robar información y después dejar todo limpio como si nada hubiera pasado. Eso es lo más crítico.

¿Cómo proteger tu proyecto de paquetes maliciosos en npm?

Hay varias medidas que podemos tomar. Vamos una por una.

No actualices versiones a lo loco

Es muy común utilizar herramientas como npm-check-updates para actualizar dependencias automáticamente. Siempre queremos tener lo último y pensamos que eso es lo correcto, pero realmente introducimos un vector de ataque.

La última versión puede traer malware. Mejor revisa qué se publicó, qué cambió en el changelog y si hay reportes antes de actualizar.

Bloquea los lifecycle scripts

Como vimos, es por estos scripts que muchos de los ataques suceden. Podemos desactivarlos al instalar una dependencia.

De manera individual:

npm install --ignore-scripts <pkg>

De manera global:

npm config set -g ignore-scripts true

O por proyecto con el archivo .npmrc:

ignore-scripts=true

Esto se agregó en la versión v11 de npm.

Bloquea instalaciones desde Git

Adicional a esto necesitas configurar el flag de allow-git para evitar que se instalen paquetes desde algún repositorio de Git en vez del registro de npm.

npm install --ignore-scripts --allow-git=none <pkg>

# global
npm config set -g allow-git none

# .npmrc
ignore-scripts=true
allow-git=none

Fija la versión de tus dependencias

Algo que yo particularmente no sabía es que fijar las versiones es una buena práctica. Estamos acostumbrados a instalar con npm install y asumir que el símbolo ^ al inicio de la versión en package.json está bien.

Pero ese ^ significa que al actualizar puedes descargar cualquier versión del paquete que no haga un salto de major version (el primer número). Es decir, si tienes la versión 1.x.x, npm puede instalar cualquier 1.y.z sin preguntarte.

Para fijar la versión exacta:

npm install --save-exact <pkg>

# global
npm config set -g save-exact true

# .npmrc
save-exact=true

No instales la versión más reciente

Otra precaución: evita instalar el paquete más reciente que existe. Esto puede pasar cuando haces un npm update o instalas el paquete por primera vez.

Muchos de los infectados en los ataques fue porque se publicó una versión nueva y muchas personas la instalaron de inmediato. Al paso de unas horas los contribuidores del paquete se dieron cuenta de que el paquete había sido comprometido.

Para evitar esto existe min-release-age: configuras cuánto tiempo debe haber pasado desde que se publicó un paquete para que npm lo instale.

npm install --min-release-age 3 <pkg>

# .npmrc
min-release-age=3 # 3 dias (npm usa días)

Esta configuración está disponible a partir de la versión v11 de npm.

Tu .npmrc defensivo mínimo

Así queda el .npmrc mínimo para tus proyectos:

save-exact=true
ignore-scripts=true
min-release-age=3

Y en CI, siempre npm ci --ignore-scripts, nunca npm install.

Considerar pnpm

pnpm es una alternativa a npm que bloquea los lifecycle scripts por defecto. Personalmente lo estoy usando para mis proyectos porque reduce el consumo de espacio (optimiza los node_modules) y porque trae estas protecciones incluidas.

Bloqueo de dependencias y builds

Por defecto, pnpm bloquea los scripts de pre/post instalación. Si algún paquete legítimo los necesita (como esbuild o next), puedes permitirlo explícitamente en el pnpm-workspace.yml:

# Permitir dependencias que sí pueden usar los lifecycle scripts (pnpm 10+)
allowBuilds:
  esbuild: true
  next: true

strictDepBuilds: true # Muestra error si alguna dependencia no listada hace uso de lifecycle scripts

Tiempo de antigüedad

Como en npm, puedes configurar el tiempo mínimo que debe haber pasado para instalar un paquete:

allowBuilds:
  esbuild: true
  next: true

strictDepBuilds: true

minimumReleaseAge: 1440 # mínimo 24 hrs (pnpm usa minutos)

Snyk recomienda que usemos al menos 21 días como periodo para instalar una nueva dependencia.

¿Cómo funciona trustPolicy?

pnpm asigna un nivel de confianza a cada versión publicada de un paquete, basado en cómo fue publicada. Los niveles de mayor a menor son:

  1. Trusted Publisher: publicado vía GitHub Actions con OIDC (el CI firma criptográficamente que el paquete viene de un workflow específico en un repo específico)
  2. Provenance: tiene una prueba verificable de npm que confirma dónde y cómo se construyó el paquete
  3. Signatures: tiene firma del registry de npm
  4. Sin evidencia: publicado sin ninguna señal de confianza

Si configuras trustPolicy: no-downgrade, pnpm compara el nivel de confianza de la versión que vas a instalar con el de versiones anteriores del mismo paquete. Si la confianza baja (por ejemplo, un paquete que siempre se publicó desde CI de repente aparece publicado sin ninguna evidencia), pnpm aborta la instalación.

Revisar antes de instalar

No instales paquetes de manera ciega. Existen herramientas que te ayudan a revisarlos antes.

npq

npq te ayuda a revisar lo que piensas instalar antes de siquiera instalarlo. Te advierte de posibles vulnerabilidades.

npx npq install express --dry-run

A partir de lo que veas puedes decidir si instalas la última versión o te esperas.

Socket Firewall

Socket Firewall es similar a npq pero además te permite usarlo con el manejador de paquetes de Python (uv). Personalmente no lo he utilizado, pero lo mencionan en las buenas prácticas.

Medidas adicionales

Evitar el uso de .env con información sensible

No tengas tus claves de producción, tokens o lo que sea importante en tu .env de manera plana como solíamos hacerlo. Si bien no subimos esta información a GitHub, en los ataques de supply chain los paquetes maliciosos tienen acceso al entorno donde se ejecutan. Esto quiere decir: tus accesos están expuestos.

Una alternativa es usar secretos. En su blog, Liran te explica cómo hacerlo.

Usa devcontainers

Personalmente ya sabía que existían pero no suelo trabajar con ellos. En las buenas prácticas de seguridad de Node.js mencionan la opción de usarlos para aislar los ataques y reducir el área de ataque a únicamente ese contenedor.

Si te gustaría un tutorial, déjame un comentario.

Verifica tu ambiente

Para verificar si tu ambiente ya bloquea los pre/post scripts puedes probar este paquete creado específicamente para eso. Si la instalación falla, significa que tus protecciones están funcionando.

  npm install @lavamoat/preinstall-always-fail

No estamos 100% seguros

Con estas prácticas reducimos el área de ataque, pero lo importante es mantenernos atentos a las noticias de seguridad y reducir el número de paquetes instalados si es posible.

Entre menos paquetes tengas, menos área de ataque y menos dependencias que vigilar. JavaScript ha evolucionado tanto que muchas cosas que antes necesitaban una librería hoy son triviales de hacer de forma nativa, o la librería estándar de Node.js ya las incluye.

Con un .npmrc bien configurado ya reduces la mayoría de los vectores de ataque. Recuerda revisar tus paquetes con npq o algún otra herramienta antes de instalarlos.

¿Ya tenías alguna de estas configuraciones? ¿Usas alguna otra medida? ¿Hay algo que no sea correcto? Deja tus comentarios.

Disclaimer: no soy experto en seguridad. Esto es información recopilada para mi propio uso y para ayudar a los demás.

Comentarios