Ataques a la cadena de suministro de npm en un VPS
Descubra cómo una versión maliciosa, un script postinstall o un typosquat llega a su app Node y qué práctica de despliegue evita instalar dependencias nuevas sin revisar.
Qué es un ataque a la cadena de suministro de npm en su servidor
Un ataque a la cadena de suministro de npm llega a su servidor a través de un paquete que usted decidió instalar. No interviene ningún puerto abierto ni 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. Cualquiera de ellos puede publicar una versión nueva dentro de una hora.
Su proceso de 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 cuenta que ejecutó la instalación. Todo lo que sigue se deriva de esas dos frases.
Los casos se ordenan según la frecuencia con la que afectan a una persona que despliega una aplicación Node en un VPS. No es el orden que usaría una empresa grande, porque una empresa grande dispone de un registro interno, un equipo de revisión y una réplica 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. Publica 4.18.3.
Revise su package.json. Una línea como "express": "^4.18.2" no significa la versión 4.18.2. El carácter 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, desplegado dos veces durante la misma tarde, puede instalar dos conjuntos de código diferentes. Esa diferencia es la superficie de ataque. Para que aparezca no es necesario comprometer nada en su máquina.
Las versiones maliciosas suelen notificarse y retirarse, pero la retirada ocurre después de que algunas personas las hayan instalado. Quien haya desplegado durante ese intervalo tiene 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 tenga que decidirlo.
Forma 2: un script de instalación se ejecuta con el 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 el usuario que escribió el comando de instalación, en el directorio personal de ese usuario, con su acceso de red 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, que es donde normalmente se encuentra DATABASE_URL.
Un payload como este 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 ve nada porque npm oculta la salida de los scripts de instalación de forma predeterminada. Desactive esa ocultación y observe lo que se ejecuta realmente:
npm ci --foreground-scriptsforeground-scripts comparte la entrada, la salida y el error estándar con el proceso de npm. Por eso los scripts de compilación imprimen en el terminal en lugar de hacerlo en un búfer que npm descarta cuando la instalación finaliza correctamente.
Forma 3: typosquatting 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 escriba mal o pegue incorrectamente un comando de instalación. El mecanismo es el comando, no el código. Por eso, un lockfile no ayuda en este caso: se 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 usuarios individuales es la confusión de dependencias. El paquete interno se llama billing-utils y se encuentra 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 ganar la copia pública. La solución consiste en usar un ámbito propio 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.modifiedUn paquete creado el mes pasado y publicado por una cuenta que no puede vincular a un repositorio público supone un riesgo distinto al de otro con seis años de historial. Ninguno de estos datos constituye una prueba. Ambos se pueden comprobar rápidamente.
Forma 4: la dependencia cuyo propietario cambió sin avisar
Los mantenedores transfieren los paquetes. Alguien se agota, otra persona se ofrece a ayudar, cambian los permisos de publicación y ningún aviso llega a los proyectos que dependen de ese paquete. No se ha producido ninguna intrusión. La confianza que depositó en 2021 ahora está en manos de otra persona.
Esta es la forma más lenta y difícil de detectar. Ningún comando responde directamente a esta cuestión. Hay dos medidas que ayudan a acotarla. Compruebe quién puede publicar antes de adoptar un paquete, con la línea npm view anterior. Después, lea 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.3La primera forma muestra sólo los nombres de los archivos modificados. Es suficientemente rápida para ejecutarla en cada actualización de un paquete importante. Conviene leer completo cualquier 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 a su 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 la que procede, 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-scriptsnpm ci difiere de npm install en aspectos relevantes. Requiere que exista un lockfile. Elimina cualquier node_modules existente antes de empezar, de modo que los restos de un despliegue anterior no puedan persistir en este despliegue. Nunca escribe en package.json ni en el lockfile, por lo que una instalación no puede avanzar de versión silenciosamente. 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 otra persona 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 termina con code EINTEGRITY en lugar de desempaquetarlo. Hay que precisar qué garantiza esto: demuestra que el archivo recibido es el archivo fijado por el lockfile, la misma garantía que ofrece verificar descargas con checksums, y tiene la misma limitación. No indica si la versión fijada era maliciosa cuando se publicó.
Un detalle sobre --omit=dev: esos paquetes siguen resolviéndose y siguen escribiéndose 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. No elimina una dependencia del árbol.
Trate los scripts de instalación como código y sepa cómo rechazarlos
Puede desactivar los scripts de instalación. Añada esto al .npmrc del proyecto y confírmelo junto al archivo de bloqueo:
ignore-scripts=true
save-exact=trueignore-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 por accidente en el manifiesto.
Esto rompe 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, como un módulo que no puede cargar su archivo de enlace. La solución es una lista de permitidos:
npm ci --ignore-scripts
npm rebuild better-sqlite3npm 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 incluyen un script postinstall. En una aplicación típica, la lista es más corta de lo que se espera, y precisamente por eso la lista de permitidos resulta práctica.
Separar la compilación del proceso que sirve el tráfico
El usuario de despliegue necesita escribir en node_modules. El proceso que responde a las peticiones HTTP no. Si son la misma cuenta, el código que se ejecuta durante la instalación puede sobrescribir el código que sirve a los usuarios. El código que se ejecuta en 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/nodeappDespué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.targetProtectSystem=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 escribible: el servicio puede escribir archivos allí, pero el kernel rechaza su ejecución. 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 endurecimiento de systemd y evita que Node se inicie, porque V8 compila JavaScript en código máquina durante la ejecución y necesita páginas que sean a la vez escribibles y ejecutables. Segundo, obtenga la ruta ExecStart de command -v node. Si Node se instaló con un gestor de versiones, se encuentra bajo el directorio de inicio del usuario de despliegue. ProtectHome=yes oculta después ese directorio al servicio y la unidad falla inmediatamente con status=203/EXEC, junto con una línea del 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/probesystemd-analyze security muestra todos los ajustes de endurecimiento con su nivel de 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 tiene éxito, la propiedad de los archivos es incorrecta y la configuración de systemd lo está ocultando silenciosamente.
Una observación sobre EnvironmentFile: systemd lo lee como root antes de cambiar a User=nodeapp, por lo que ese archivo puede tener permisos root:root con el modo 600. La aplicación sigue recibiendo las variables. Cualquiera que tenga un shell como nodeapp todavía puede leerlas desde /proc/<pid>/environ, por lo que 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 sistema que no sea el servidor de producción y copiar después el directorio terminado. La máquina de compilación sólo contiene un token de registro de solo lectura. No contiene una clave de despliegue SSH, una clave de acceso a la nube, una contraseña de base de datos ni credenciales para iniciar sesión en un registro de contenedores.
npm token create --read-onlyUn 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 limitado. 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 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 repositorio 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, incluya ambos archivos en el commit.
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 para siempre. Use esta opción para ese 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-01La opción before reconstruye el árbol usando sólo versiones publicadas en esa fecha o antes. Al actualizar dependencias, establezca una fecha de una o dos semanas atrás. Así evita el intervalo en el que una versión defectuosa está publicada pero todavía no se ha notificado. Es una medida poco precisa, porque también retrasa correcciones de seguridad legítimas. Úsela para resolver los rangos, revise los cambios y, después, incluya el lockfile en el commit. El mismo criterio se aplica a las herramientas de línea de comandos que instala desde npm en lugar de declarar como dependencias: una llamada npx sin versión fijada descarga lo que se haya publicado esa mañana, mientras que fijar una versión exacta de dsh hace que dos máquinas ejecuten el mismo código.
¿Cómo sé qué versión se publicó realmente?
El lockfile de git indica qué debería haberse instalado. El disco indica qué está instalado. Sólo el segundo aporta pruebas.
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, muestra una sola versión sin dibujar un á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 elementos incluyendo el commit en 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 mismo» pasa a ser readlink /srv/nodeapp/current. Además, queda disponible a las 03:00 para alguien que no fue quien lo desplegó.
Por último, compruebe qué puede garantizar el registro:
npm audit signaturesEsto verifica las firmas del registro en los paquetes del árbol instalado. También verifica 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. Interprete una atestación ausente como «sin información», no como «paquete defectuoso».
Qué hacer después de que una versión defectuosa llegue al servidor
Empiece por lo que se ejecutó y por el 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 ya está comprometido. Rote el token del registro, las claves SSH del directorio personal de ese usuario, las credenciales de la nube y cualquier secreto exportado en ese shell. La rotación es la única respuesta correcta, porque no puede demostrar que un archivo no se leyera.
Si el código se ejecutó en tiempo de ejecución con una cuenta de servicio restringida, el conjunto accesible es mucho menor: las variables de entorno de la aplicación y todo aquello al alcance de su acceso de red. Ese es el motivo para ejecutar servicios como usuarios sin privilegios en un VPS. Esto no impide la intrusión. Determina qué parte de la máquina queda expuesta y si la intrusión sobrevive a un reinicio.
Después, vuelva a crear el entorno en lugar de limpiarlo. Elimine node_modules, fije el paquete afectado a una versión anterior a la versión defectuosa en package.json, ejecute npm install una vez para actualizar el archivo de bloqueo, haga commit y despliegue con npm ci. No repare el árbol en el mismo lugar. No puede enumerar todo lo que modificó un script de instalación.
Anote también la ventana temporal: 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 determinarlo si las versiones se identifican mediante commits.
Lo que ninguna de estas medidas soluciona
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 sirve como defensa en este caso. Compara el árbol de dependencias con una base de datos de vulnerabilidades notificadas, por lo que detecta problemas que ya se han publicado y documentado. Un ataque a la cadena de suministro permanece sin identificar durante todo el tiempo en que resulta útil para el atacante. Ejecute npm audit para detectar errores conocidos antiguos, pero no espere que 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 un editor menos que puede sufrir phishing en su nombre y un script de instalación menos que se ejecuta con su usuario de despliegue.
Nada de esto es exclusivo de npm. Las mismas cuatro formas se aplican a PyPI, RubyGems, imágenes de contenedor y al gestor de paquetes de su distribución. El problema aparece con más frecuencia en npm porque los árboles de dependencias son más profundos y los scripts de instalación se ejecutan de forma predeterminada. Cualquier extensión de una herramienta que ya utiliza hereda el mismo problema. Por eso, determinar a qué puede acceder un plugin de dsh antes de instalarlo es el mismo proceso que leer un script de postinstall, pero con los permisos de su agente en lugar de los del usuario de despliegue. La parte de la máquina circundante que puede proteger depende del entorno donde se ejecute. Esta cuestión forma parte de la pregunta 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 los servidores que administres, 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. Si los desactivas, fallan más tarde durante la ejecución con un archivo de binding ausente, 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 decidiste 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)" muestra 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. Si realizas el despliegue en un directorio cuyo nombre corresponde al commit de git, mantienes 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 release maliciosa puede no estar notificada durante las horas o los días en los que instalarla 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. Esto indica si un tarball procede de una compilación pública y no de una máquina desconocida.
¿Por qué es importante ejecutar la aplicación con un usuario sin privilegios si el ataque ocurre 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 durante la ejecución lo hace como la cuenta de servicio y, con User=nodeapp, ProtectSystem=strict y sin credenciales en el disco, su alcance queda limitado al entorno de la aplicación y a su base de datos. Separar las cuentas también significa que el proceso que atiende el tráfico no puede sobrescribir node_modules. Por tanto, un servidor comprometido durante la ejecución queda corregido en el siguiente reinicio, en lugar de hacerse permanente.