SSD Nodes Learn 🎉 VPS desde $5.50/mes
Guías Matt ConnorPor Matt Connor

Ataques a la cadena de suministro de npm en su VPS

Descubra cómo llegan paquetes npm maliciosos a una app Node mediante versiones patch, scripts postinstall y typosquatting, y qué práctica de despliegue los bloquea.

Qué es un ataque a la cadena de suministro de npm en el servidor

Un ataque a la cadena de suministro de npm llega al servidor a través de un paquete que usted decidió instalar. No interviene ningún puerto abierto ni hay una fase de explotación. npm (node package manager) instala código, y al instalar código se ejecuta código. Por eso, una aplicación Node pequeña incorpora varios cientos de paquetes que usted nunca ha revisado, y cualquiera de ellos puede publicar una versión nueva dentro de una hora.

El despliegue obtiene una versión maliciosa porque el comando de instalación solicitó la versión más reciente que coincidiera con los requisitos. Ese código se ejecuta con los privilegios de la persona que realizó la instalación. Todo lo que sigue se deriva de esas dos afirmaciones.

Las variantes se ordenan según la frecuencia con la que afectan a una persona que despliega una aplicación Node en un VPS. Ese orden no es el que usaría una empresa grande, porque una empresa grande tiene un registro interno, un equipo de revisión y un mirror del registro público. Usted tiene un script de despliegue.

Forma 1: una cuenta de mantenedor es comprometida y publica un parche

El registro de npm no permite cambiar el contenido de una versión que ya existe. Por tanto, un atacante que suplanta a un mantenedor mediante phishing o roba un token de publicación no puede reescribir 4.18.2. Puede publicar 4.18.3.

Revise su package.json. Una línea como "express": "^4.18.2" no significa la versión 4.18.2. El signo de intercalación significa «cualquier versión 4.x igual o posterior a esta», y ~4.18.2 significa «cualquier versión 4.18.x». npm install resuelve ese rango en el momento de la ejecución, por lo que el mismo commit de git, implementado dos veces durante la misma tarde, puede instalar dos conjuntos de código diferentes. Esa diferencia es la superficie de ataque. No es necesario comprometer nada en su equipo para que se abra.

Las versiones maliciosas suelen notificarse y retirarse, pero la retirada ocurre después de que algunas personas las hayan instalado. Quien haya hecho un despliegue durante ese intervalo tendrá el código en el disco. Una canalización que resuelve rangos en cada ejecución entra automáticamente en ese intervalo varias veces por semana, sin que nadie lo decida.

Forma 2: un script de instalación se ejecuta con la cuenta del usuario que realiza el despliegue

El package.json de un paquete puede declarar preinstall, install, postinstall y prepare en su bloque scripts. npm los ejecuta durante la instalación. No están aislados y nadie los revisa. Son comandos de shell que se ejecutan con la cuenta del usuario que escribió el comando de instalación, en el directorio personal de ese usuario, con el acceso de red de esa cuenta y con todo el entorno de ese shell.

Por tanto, la pregunta útil no es qué puede hacer el paquete. Es qué puede leer ese usuario. En un equipo de despliegue normal, la respuesta incluye ~/.npmrc, que contiene un token del registro, ~/.ssh/id_ed25519, que se usa como clave de despliegue para SSH (secure shell), ~/.aws/credentials, ~/.docker/config.json y todas las variables exportadas en el shell. DATABASE_URL suele estar en ese entorno.

Una carga útil como esta no necesita persistencia ni escalada de privilegios. Lee algunos archivos, los envía a un host mediante HTTPS y termina con el estado 0. No se muestra nada porque npm oculta de forma predeterminada la salida de los scripts de instalación. Desactive esa ocultación y compruebe lo que se ejecuta realmente:

npm ci --foreground-scripts

foreground-scripts comparte la entrada, la salida y el error estándar con el proceso de npm. Por eso los scripts de compilación imprimen el resultado en el terminal en lugar de escribirlo en un búfer que npm descarta cuando la instalación termina correctamente.

Forma 3: typosquats y el nombre que no escribió exactamente

Un typosquat es un paquete publicado con un nombre parecido al de otro popular. Espera a que alguien ejecute un comando de instalación con un error tipográfico o con un nombre pegado incorrectamente. El mecanismo es el comando, no el código. Por eso, un lockfile no ayuda en este caso: añade el nombre incorrecto una vez y, a partir de entonces, el lockfile lo fija correctamente.

La variante que afecta a los equipos y no sólo a personas concretas es la confusión de dependencias. Su paquete interno se llama billing-utils y reside en un registro privado. Si no existe ningún paquete llamado billing-utils en el registro público, cualquiera puede publicar uno. npm resuelve los nombres sin ámbito contra el registro público predeterminado, por lo que puede imponerse la copia pública. La solución es usar un ámbito que controle y una asignación de registro para ese ámbito, en .npmrc:

@yourorg:registry=https://npm.yourorg.example/
//npm.yourorg.example/:_authToken=${NPM_TOKEN}

Ahora @yourorg/billing-utils sólo se obtiene de ese host, porque la asignación de ámbito a registro se consulta antes que el registro predeterminado. Un nombre interno sin ámbito no tiene ninguna asignación, por lo que no está protegido.

Antes de añadir una dependencia nueva, revísela en lugar de fijarse en su contador de descargas:

npm view some-lib repository.url maintainers time.created time.modified

Un paquete creado el mes pasado y publicado por una cuenta que no puede vincularse con un repositorio público implica un riesgo diferente al de otro con seis años de historial. Ninguno de estos datos constituye una prueba. Ambos son fáciles de comprobar.

Forma 4: la dependencia cuyo propietario cambió sin avisar

Los mantenedores transfieren los paquetes. Alguien deja de participar, otra persona se ofrece a ayudar, cambian los permisos de publicación y los proyectos que dependen del paquete no reciben ninguna notificación. No hay una intrusión. La confianza que concedió en 2021 ahora está en manos de otra persona.

Esta es la forma más lenta y difícil de detectar, y ningún comando responde directamente a ella. Hay dos medidas que ayudan a acotarla. Compruebe quién puede publicar antes de adoptar un paquete, mediante la línea npm view anterior. Después, revise el diff cuando cambie un paquete del que realmente dependa:

npm diff --diff-name-only --diff=some-lib@1.4.2 --diff=some-lib@1.4.3
npm diff --diff=some-lib@1.4.2 --diff=some-lib@1.4.3

La primera forma muestra sólo los nombres de los archivos modificados, por lo que es suficientemente rápida para ejecutarla con cada actualización de un paquete importante para usted. Conviene leer completo un lanzamiento de parche que modifique un script de compilación, añada un archivo en la raíz del paquete o edite el bloque scripts antes de que llegue al servidor.

Compilar a partir de un lockfile confirmado con npm ci

package-lock.json registra la versión exacta de cada paquete del árbol, la URL de origen, un hash de integridad sha512 de cada tarball y el paquete que lo requiere. Confírmelo en el repositorio. Es el único archivo que indica qué se probó realmente.

Después, instale con npm ci, nunca con npm install, en cualquier máquina que no sea el portátil de un desarrollador:

npm ci --omit=dev --ignore-scripts

npm ci se diferencia de npm install en varios aspectos importantes. Requiere que exista un lockfile. Elimina cualquier node_modules existente antes de empezar, por lo que los restos de un despliegue anterior no pueden sobrevivir en este. Nunca escribe en package.json ni en el lockfile, así que una instalación no puede avanzar silenciosamente a otra versión. Si el lockfile y package.json no coinciden, termina con un error en lugar de resolver la diferencia.

Ese error es una característica, no una molestia. Significa que un cambio de dependencia debe llegar como un commit revisado por alguien y no como un efecto secundario de un despliegue a las 02:00.

El hash de integridad se comprueba en cada descarga. Si los bytes de un tarball no coinciden con el hash registrado, la instalación falla con code EINTEGRITY en lugar de desempaquetarlo. Sea preciso sobre lo que esto garantiza: demuestra que el archivo recibido es el archivo fijado en el lockfile. Es la misma garantía que ofrece verificar descargas con sumas de comprobación, y tiene las mismas limitaciones. No indica si la versión fijada era maliciosa cuando se publicó.

Un detalle sobre --omit=dev: esos paquetes se siguen resolviendo y se siguen escribiendo en el lockfile. Simplemente no se colocan en el disco. Tener menos paquetes en el disco implica menos scripts de instalación y menos código cargado en tiempo de ejecución, por lo que conviene hacerlo. Esto no elimina una dependencia del árbol.

Trate los scripts de instalación como código y sepa cuándo rechazarlos

Puede desactivar los scripts de instalación. Añada esto al archivo .npmrc del proyecto y confirme el cambio junto al lockfile:

ignore-scripts=true
save-exact=true

ignore-scripts=true impide que npm ejecute los scripts declarados en las dependencias. save-exact=true hace que npm install some-lib escriba 1.4.2 en package.json en lugar de ^1.4.2, de modo que un rango de versiones no entre accidentalmente en el manifiesto.

Esto puede romper algunas cosas, y debe saber cómo resolverlas antes de activarlo. Los paquetes que compilan un complemento nativo o descargan un binario precompilado realizan ese trabajo en un script de instalación. Con los scripts desactivados, la instalación termina correctamente y el fallo aparece después, durante la ejecución, cuando un módulo no puede cargar su archivo de enlace. La solución es usar una lista de permitidos:

npm ci --ignore-scripts
npm rebuild better-sqlite3

npm rebuild <package> ejecuta los scripts de compilación de ese paquete. Así toma una decisión para cada paquete, en lugar de conceder permisos de ejecución generales a varios cientos de desconocidos con los que nunca tendrá contacto.

Para comprobar el alcance actual de esa concesión, consulte npm:

npm query ":attr(scripts, [postinstall])"

Esto muestra todos los paquetes del árbol instalado que contienen un script postinstall. En una aplicación habitual, la lista es más corta de lo que muchos esperan. Eso es precisamente lo que hace viable la lista de permitidos.

Separar la compilación del proceso que atiende el tráfico

El usuario de despliegue necesita escribir en node_modules. El proceso que responde a las solicitudes HTTP no necesita hacerlo. Si ambos usan la misma cuenta, el código que se ejecuta durante la instalación puede sobrescribir el código que atiende a los usuarios. El código que se ejecuta durante el tiempo de ejecución también puede hacerlo.

Sepárelos. Compile con un usuario, sirva con otro y haga que el directorio servido sea de sólo lectura para la cuenta que lo sirve:

sudo useradd --system --home-dir /srv/nodeapp --shell /usr/sbin/nologin nodeapp
sudo chown -R deploy:nodeapp /srv/nodeapp
sudo chmod -R o-rwx /srv/nodeapp

Después, deje que systemd lo aplique. Escriba /etc/systemd/system/nodeapp.service:

[Unit]
Description=Node application
After=network-online.target

[Service]
User=nodeapp
Group=nodeapp
WorkingDirectory=/srv/nodeapp/current
EnvironmentFile=/etc/nodeapp/env
ExecStart=/usr/bin/node server.js
Restart=on-failure

NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/srv/nodeapp/shared
NoExecPaths=/srv/nodeapp/shared
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX

[Install]
WantedBy=multi-user.target

ProtectSystem=strict monta todo el sistema de archivos como de sólo lectura para este servicio, excepto /dev, /proc, /sys y todo lo que incluya en ReadWritePaths. Por tanto, un intento de la aplicación de escribir en node_modules falla con EROFS: read-only file system. Puede comprobarlo y reproducirlo en sus propios registros en aproximadamente un minuto. NoExecPaths cubre el directorio de carga con permisos de escritura: el servicio puede escribir archivos allí, pero el kernel impide ejecutarlos. Esta opción requiere systemd 249 o posterior, y Ubuntu 24.04 incluye la versión 255.

Hay dos problemas que debe evitar en este archivo de unidad. Primero, no añada MemoryDenyWriteExecute=yes. Aparece en la mayoría de las listas de medidas de endurecimiento de systemd y evita que Node se inicie, porque V8 compila JavaScript en código máquina durante el tiempo de ejecución y necesita páginas que sean escribibles y ejecutables a la vez. Segundo, obtenga la ruta ExecStart de command -v node. Si Node se instaló con un gestor de versiones, se encuentra en el directorio personal del usuario de despliegue. ProtectHome=yes oculta después ese directorio al servicio y la unidad falla inmediatamente con status=203/EXEC y una línea de registro que indica que no se pudo localizar el ejecutable.

Compruebe el resultado en lugar de confiar en el archivo:

sudo systemctl daemon-reload
sudo systemctl enable --now nodeapp
sudo systemd-analyze security nodeapp.service
sudo -u nodeapp touch /srv/nodeapp/current/probe

systemd-analyze security muestra todos los ajustes de endurecimiento junto con su exposición, para que pueda ver cuáles siguen usando el valor predeterminado. touch debería fallar con Permission denied, porque nodeapp no es propietario de nada dentro de current. Si se ejecuta correctamente, la propiedad de los archivos es incorrecta y los ajustes de systemd la están ocultando de forma silenciosa.

Una observación sobre EnvironmentFile: systemd lo lee como root antes de cambiar a User=nodeapp, por lo que ese archivo puede tener root:root con permisos 600. La aplicación sigue recibiendo las variables. Cualquiera que tenga una shell como nodeapp todavía puede leerlas desde /proc/<pid>/environ. Por tanto, esto protege el secreto almacenado, pero no el proceso en ejecución.

Mantenga las credenciales de despliegue fuera del entorno de compilación

Los scripts de instalación heredan el entorno. Ese hecho debe determinar dónde compila.

La opción más segura es compilar en un equipo que no sea el servidor de producción y copiar allí el directorio terminado. El equipo de compilación sólo debe contener un token de registro de solo lectura. No debe contener una clave de despliegue SSH, una clave de acceso a la nube, una contraseña de base de datos ni credenciales de un registro de contenedores.

npm token create --read-only

Un token de solo lectura puede descargar paquetes, pero no puede publicarlos. Si se roba del entorno de compilación, la pérdida se limita a la capacidad de descargar paquetes públicos.

Si debe compilar en el servidor, hágalo como el usuario deploy con un entorno deliberadamente restringido. Mantenga los secretos de ejecución en /etc/nodeapp/env, que deploy no puede leer. El mismo razonamiento se aplica a la automatización de compilaciones que aloje usted mismo: un runner de GitHub Actions autohospedado contiene tokens y ejecuta código publicado arbitrario en cada trabajo, por lo que es la máquina de mayor valor en un despliegue pequeño. Cualquier programa que no haya escrito usted y que reciba todo su entorno pertenece a la misma categoría. Por eso, mantener los secretos fuera del entorno de un agente de IA plantea este mismo problema con otro programa en medio.

Fije versiones o incluya en el repositorio lo que no pueda auditar

Una dependencia fijada es aquella cuya versión no puede cambiar sin un commit. El lockfile incluido en el commit ya hace esto para todo el árbol. Hay dos casos que requieren medidas adicionales.

Las dependencias transitivas son el primero. Usted no controla de qué dependen sus dependencias. overrides en package.json fuerza una versión en cualquier punto del árbol:

{
  "overrides": {
    "some-transitive-lib": "1.4.2"
  }
}

Ejecute npm install una vez después de añadirlo para que el lockfile registre el resultado y, después, confirme ambos archivos.

El segundo caso es un paquete que no puede auditar ni eliminar. Inclúyalo en el repositorio. npm pack descarga el tarball exacto que serviría el registro, y una dependencia file: se instala desde su copia:

npm pack some-lib@1.4.2
mkdir -p vendor && mv some-lib-1.4.2.tgz vendor/
{
  "dependencies": {
    "some-lib": "file:vendor/some-lib-1.4.2.tgz"
  }
}

El tarball ahora está en su repositorio y no puede cambiar sin que usted lo modifique. También tendrá que encargarse de sus actualizaciones indefinidamente. Use esta opción para el paquete pequeño y abandonado del que no puede prescindir, no para su framework web.

También existe un periodo de espera que no cuesta nada:

npm install --before=2026-08-01

La opción before reconstruye el árbol usando sólo versiones publicadas en esa fecha o antes. Establezca una fecha de una o dos semanas atrás cuando actualice las dependencias. Así evita el intervalo en el que una versión defectuosa está disponible, pero todavía no se ha notificado. Es una medida poco precisa, porque también retrasa las correcciones de seguridad legítimas. Úsela para resolver los rangos, revise los cambios y, después, confirme el lockfile.

¿Cómo sé qué versión se publicó realmente?

El lockfile de git indica lo que debería haberse instalado. El disco indica lo que está instalado. Sólo lo segundo es una evidencia.

npm ls some-lib
node -e "console.log(require('./node_modules/some-lib/package.json').version)"

npm ls lee node_modules, por lo que informa de lo que está presente físicamente y no de lo que pretendía el lockfile. La línea node -e lee el manifiesto instalado mediante su ruta. Esto también funciona con paquetes cuyo campo exports impide las importaciones de subrutas. Además, imprime una sola versión sin dibujar el árbol alrededor.

Para la otra parte de la comparación, lea git:

git log --oneline -- package-lock.json
git show <commit>:package-lock.json | grep -A3 '"node_modules/some-lib"'

Haga permanente la relación entre ambos valores incorporando el commit a la estructura de despliegue. Publique en /srv/nodeapp/releases/<short commit sha> y apunte /srv/nodeapp/current hacia él mediante un enlace simbólico. La respuesta a «qué se está ejecutando ahora» pasa a ser readlink /srv/nodeapp/current. Además, queda disponible a las 03:00 para alguien que no fue quien hizo el despliegue.

Por último, compruebe qué puede confirmar el registro:

npm audit signatures

Esto verifica las firmas del registro de los paquetes del árbol instalado y las atestaciones de procedencia de los paquetes que las tienen. La procedencia vincula un tarball publicado con la compilación pública de integración continua (CI) que lo produjo. Por tanto, una atestación verificada permite rastrear el código hasta un commit y no hasta un portátil desconocido. La cobertura no es universal. Por eso, interprete una atestación ausente como «sin información», no como «paquete defectuoso».

Qué hacer después de que una versión maliciosa llegue al servidor

Parta de lo que se ejecutó y del usuario con el que se ejecutó.

Si el código se ejecutó durante la instalación, suponga que todo lo que el usuario de compilación podía leer está comprometido. Rote el token del registro, las claves SSH del directorio de inicio de ese usuario, las credenciales de la nube y cualquier secreto exportado en ese shell. La rotación es la única respuesta responsable, porque no puede demostrar que un archivo no se leyó.

Si el código se ejecutó durante el tiempo de ejecución con una cuenta de servicio restringida, el conjunto accesible es mucho menor: las variables de entorno propias de la aplicación y todo aquello a lo que pueda acceder mediante la red. Ese es el motivo para ejecutar servicios como usuarios sin privilegios en un VPS. Esto no evita la intrusión. Determina qué parte de la máquina obtiene la intrusión y si persiste después de un reinicio.

Después, vuelva a crear el entorno en lugar de limpiarlo. Elimine node_modules, fije el paquete afectado en una versión anterior a la versión maliciosa en package.json, ejecute npm install una vez para actualizar el archivo de bloqueo, haga commit de ese cambio y despliegue con npm ci. No repare un árbol en el mismo lugar. No puede enumerar todo lo que modificó un script de instalación.

Anote también el intervalo: el primer despliegue que podría haber descargado la versión y el despliegue que la eliminó. Ese intervalo indica qué registros propios debe revisar, y sólo puede determinarse si las versiones se identifican mediante commits.

Qué no soluciona nada de esto

Un archivo de bloqueo no hace segura una dependencia. Convierte el momento en que aceptó esa dependencia en una decisión fechada y revisada, en lugar de dejarlo como un efecto secundario de un despliegue. Todas las prácticas anteriores realizan la misma conversión: transforman un accidente en una elección.

npm audit no es una defensa en este caso. Compara su árbol de dependencias con una base de datos de vulnerabilidades notificadas, por lo que detecta problemas que ya se han publicado y asignado a un nombre. Un ataque a la cadena de suministro no tiene nombre durante todo el tiempo en que resulta útil para el atacante. Ejecute npm audit para detectar errores conocidos antiguos, pero no espere que le informe sobre una versión publicada hace cuatro horas.

Reducir el número de dependencias ayuda más que cualquier herramienta de esta guía, y es el consejo menos popular. Cada paquete que no añade es otro editor al que no pueden suplantar en su nombre mediante phishing, y otro script de instalación que nunca se ejecuta con su usuario de despliegue.

Nada de esto es específico de npm. Las mismas cuatro formas se aplican a PyPI, RubyGems, imágenes de contenedor y el gestor de paquetes de su distribución. El problema se manifiesta más en npm porque sus árboles de dependencias son más profundos y los scripts de instalación se ejecutan de forma predeterminada. La parte de la máquina circundante que debe proteger depende del entorno en el que se ejecuta. Esto forma parte de la cuestión más amplia de si el alojamiento VPS es seguro.

FAQ

¿npm ci me protege frente a un paquete de npm comprometido?

Te protege frente a cambios de versión que se produzcan sin tu conocimiento. npm ci instala exactamente lo que registra package-lock.json, comprueba cada tarball con su hash de integridad sha512 y termina con un error si package.json y el lockfile no coinciden, en lugar de resolver la diferencia. No indica si la versión fijada es segura. Si confirmas un lockfile que fija una versión maliciosa, npm ci instalará esa versión fielmente en todos tus servidores, cada vez.

¿Debo establecer ignore-scripts=true para todo?

Establécelo y después crea una lista de permitidos. ignore-scripts=true en el .npmrc del proyecto impide que se ejecuten los scripts de instalación de las dependencias. Esto elimina la vía más directa entre un paquete malicioso y las credenciales del usuario de despliegue. Los paquetes que compilan un complemento nativo o descargan un binario precompilado necesitan realmente sus scripts. Con los scripts desactivados, fallan más tarde en tiempo de ejecución por la ausencia del archivo de binding, en lugar de fallar durante la instalación. Ejecuta npm ci --ignore-scripts y después npm rebuild <package> para los pocos paquetes en los que hayas decidido confiar. npm query ":attr(scripts, [postinstall])" muestra cuántos hay realmente.

¿Cómo averiguo qué versión de un paquete instaló realmente mi servidor?

Lee el disco, no el lockfile. npm ls <package> informa de lo que está presente en node_modules, y node -e "console.log(require('./node_modules/<package>/package.json').version)" imprime sólo la cadena de versión. El lockfile de git responde a otra pregunta: qué debería haberse instalado. Comparar ambos valores es precisamente el objetivo. Desplegar en un directorio cuyo nombre corresponda al commit de git mantiene disponibles las dos respuestas meses después, cuando las necesites.

¿npm audit detecta ataques a la cadena de suministro?

No. npm audit compara tu árbol con una base de datos de vulnerabilidades notificadas. Por tanto, sólo detecta problemas que ya se hayan publicado y hayan recibido un identificador. Una versión maliciosa puede no estar notificada durante las horas o los días en que su instalación resulta relevante. npm audit signatures es el comando más útil: verifica las firmas del registro en todo el árbol instalado y comprueba las atestaciones de procedencia cuando el publicador las haya generado. Así indica si un tarball procede de una compilación pública y no de una máquina desconocida.

¿Por qué importa ejecutar la aplicación como un usuario sin privilegios si el ataque se produce durante la instalación?

Porque los dos fallos tienen un alcance diferente y debes protegerte frente a ambos. El código que se ejecuta durante la instalación lo hace como el usuario de despliegue y puede leer sus claves SSH, tokens del registro y credenciales de la nube. El código que se ejecuta en tiempo de ejecución lo hace como la cuenta de servicio. Con User=nodeapp, ProtectSystem=strict y sin credenciales en el disco, su alcance se limita al entorno de la propia aplicación y a su base de datos. Separar las cuentas también impide que el proceso que atiende el tráfico sobrescriba node_modules. Por tanto, un compromiso durante el tiempo de ejecución desaparece en el siguiente reinicio en lugar de hacerse permanente.