Dependencias: el código ajeno que ejecuta el tuyo
En resumen: Las dependencias son código ajeno que ejecuta tu programa; el archivo lock y el hash del paquete protegen contra la manipulación, y un escáner encuentra las vulnerabilidades conocidas.
Problema
El paquete que nadie eligió
El package.json de la tienda lista 14 paquetes. node_modules tiene 900. Cada uno de los 14 trae sus propias dependencias, y esas traen las suyas.
Una noche alguien entra en la cuenta del autor de un paquete pequeño para dar formato a fechas. El paquete está en lo profundo del árbol: la tienda lo usa a través de la biblioteca de facturas en PDF, y nadie en el equipo sabe que está ahí. El atacante sube una versión nueva con una línea de más: una línea que lee el entorno del proceso y envía PAYMENT_API_KEY hacia fuera. Por la mañana la compilación de la tienda trae la versión nueva, porque el rango ^2.1.3 lo permite.
La clave que sacaste del código y pasaste al entorno en la lección anterior se filtra desde ahí. La tienda no hizo nada mal en su propio código.
Esto no es inventado. Pasó con event-stream (2018) y con ua-parser-js (2021), y en 2024 el cambio en xz utils se descubrió poco antes de llegar a servidores de todo el mundo.
La idea
Tu proceso, código ajeno
Todo paquete que carga el programa se ejecuta en tu proceso y con tus derechos. Lee el mismo entorno y los mismos archivos, y habla con la misma red. Algunos paquetes ejecutan código ya al instalarse (los scripts postinstall de npm, setup.py en paquetes viejos de Python).
Los paquetes que pediste son dependencias directas. Los que estos traen son transitivas. Las transitivas suelen ser la mayoría, y nadie las eligió.
Esta es la cadena de suministro (supply chain): del autor del paquete, pasando por el registro (npm, Maven Central, NuGet, PyPI), hasta tu compilación. Un atacante puede entrar por cualquier eslabón:
- una cuenta secuestrada de un autor: una versión nueva con código dañino;
- typosquatting: un paquete con el nombre de uno conocido, pero una letra distinta (
reqeusts,lodahs), a la espera de un error de tecleo; - un cambio por el camino: un espejo, una caché o un proxy que entrega bytes distintos bajo el mismo nombre y versión;
- un paquete abandonado que alguien se queda y «actualiza» meses después.
Contra todo esto hay dos reglas simples. Saber exactamente qué versiones entran. Y comprobar que los bytes son los mismos que comprobaste.
Míralo
El árbol y el paquete cambiado
function verify(name, bytes, lockFile) { const expected = lockFile[name]; const actual = integrity(bytes); if (actual !== expected) { console.log(`${name}: MANIPULADO`); console.log(` lock: ${brief(expected)}`); console.log(` descargado: ${brief(actual)}`); return false; } console.log(`${name}: OK`); return true;}Paso a paso, en palabras
- La tienda pide dos paquetes. Estos traen otros tres propios. Los cinco se ejecutan dentro de la tienda.
- Alguien cambia date-fmt por el camino. Sin comprobación su código se ejecuta en la tienda, con su entorno y sus archivos.
- El archivo lock guarda la versión exacta y el hash de cada paquete, también de los profundos.
- El hash de lo descargado: SHA-512, escrito en Base64.
- El mismo hash que en el archivo lock: son los bytes que se comprobaron. Instalado.
- date-fmt tiene una línea de más, y todo el hash es distinto. No se instala.
- Instalados: 4 de 5. La compilación se detiene, y nadie ejecuta el código cambiado.
El hash de aquí es un pariente del código hash de la lección sobre códigos hash, pero es criptográfico. SHA-512 da 64 bytes, y nadie puede hacer otro paquete con la misma huella. El código hash de las colecciones tiene 4 bytes y se repite con facilidad: sirve para las cubetas de una tabla, pero no para comprobar código ajeno.
La idea
El archivo lock: la versión exacta y su huella
El manifiesto dice lo que quieres: "pdf-invoice": "^5.0.0" significa «5.0.0 o un 5.x más nuevo». El archivo lock dice exactamente lo que está instalado: la versión de cada paquete del árbol, también de los transitivos, y un hash de su contenido. Se guarda en git, y la compilación instala exactamente eso.
En JavaScript es package-lock.json (o pnpm-lock.yaml, yarn.lock). Cada entrada tiene un integrity:
"node_modules/date-fmt": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/date-fmt/-/date-fmt-2.1.3.tgz", "integrity": "sha512-Jeb+bpiC7IqY/wWOY2a0umsIM6ce0XcMc8Xl…"}CI ejecuta npm ci, no npm install: instala exactamente el archivo lock y se detiene si no coincide con package.json o si un hash no coincide. npm install puede cambiarlo.
La comprobación es simple: un hash de los bytes descargados y una comparación con la entrada. Aquí está como programa, con un hash en la forma de npm, sha512- y Base64:
function integrity(bytes) { const digest = createHash("sha512").update(bytes).digest("base64"); return "sha512-" + digest;}
function brief(hash) { return hash.slice(0, 19) + "…";}function verify(name, bytes, lockFile) { const expected = lockFile[name]; const actual = integrity(bytes); if (actual !== expected) { console.log(`${name}: MANIPULADO`); console.log(` lock: ${brief(expected)}`); console.log(` descargado: ${brief(actual)}`); return false; } console.log(`${name}: OK`); return true;}const lockFile = { "pdf-invoice@5.0.2": "sha512-IN0lH/YKriHbj87FMea7idgW4qdE8vgdeYTdEVVqz/GwAnMAzmU8X7TOO2TJ20CSQLaDtD9hKB7J5bw8JLos0A==", "font-kit@3.1.0": "sha512-19+g7wktnKQh+JGfMRNeNdrG3//S+BsOUeuBcAp8p10kuHCpM3HFgbQzKnzTQ+DRLcA2b1MmnHFfHYvjFrjm9g==", "date-fmt@2.1.3": "sha512-Jeb+bpiC7IqY/wWOY2a0umsIM6ce0XcMc8XlRr9udQktm8kZCNIV+qcJtad0Uip8hhhdn9P+8yE6p56qsZkZSw==", "pay-sdk@4.2.0": "sha512-qyQmG1zNAk4HmkwNd+yxZ0GkyOsQoLn9CGHMW2LsBWmkhwpMo7rXHYT2keUIu6n50HuYBjv411Twk69Wqc0nig==", "http-lite@1.8.0": "sha512-TI5SSzlpujK57oie/hoakeRXqVh5YuW22qwmU7I6moo75Gl3qXY5ZqRuEsy0sKdy6dz7uqhlJzDDliKF4tUQQA==",};const downloaded = [ ["pdf-invoice@5.0.2", Buffer.from("pdf-invoice 5.0.2: renders invoices to PDF")], ["font-kit@3.1.0", Buffer.from("font-kit 3.1.0: loads and measures fonts")], ["date-fmt@2.1.3", Buffer.from("date-fmt 2.1.3: formats dates and times\n+ one line added by the attacker")], ["pay-sdk@4.2.0", Buffer.from("pay-sdk 4.2.0: talks to the payment provider")], ["http-lite@1.8.0", Buffer.from("http-lite 1.8.0: a small HTTP client")],];const installed = downloaded.filter(([name, bytes]) => verify(name, bytes, lockFile)).length;console.log(`Instalados: ${installed} de ${downloaded.length}`);pdf-invoice@5.0.2: OKfont-kit@3.1.0: OKdate-fmt@2.1.3: MANIPULADO lock: sha512-Jeb+bpiC7IqY… descargado: sha512-sMR370edweyw…pay-sdk@4.2.0: OKhttp-lite@1.8.0: OKInstalados: 4 de 5Los paquetes de aquí son un texto corto, pero para el hash no hay diferencia: son bytes. Una línea de más en date-fmt y el hash es completamente distinto, desde el primer carácter.
Pruébalo
Predice la salida
Los dos textos se diferencian solo por un espacio al final. ¿Qué imprimirá?
const a = integrity(Buffer.from("total 49.90"));const b = integrity(Buffer.from("total 49.90 "));console.log(a.length);console.log(b.length);console.log(a === b);SHA-512 da siempre 64 bytes, y Base64 los escribe como 88 caracteres; con sha512- son 95, sea cual sea la longitud de la entrada. Un espacio de más cambia todo el hash, así que la comparación es false.
Pruébalo
Completa
¿Qué algoritmo da el hash del archivo lock?
const digest = createHash("").update(bytes).digest("base64");SHA-512: es el algoritmo del sha512- de npm y del contentHash de NuGet. Gradle y pip usan SHA-256, que también es criptográfico.
Pruébalo
Encuentra el error
Un compañero vuelve a escribir la comprobación. El servidor del registro devuelve el paquete junto con su hash, así que el compañero compara con ese. ¿Qué línea es el error?
Elige la línea con el error.
El hash esperado viene del mismo lugar que el paquete. Quien puede cambiar el paquete puede cambiar el hash que va al lado, y la comprobación siempre pasa. El valor esperado tiene que venir del archivo lock: está en tu git y se escribió cuando se comprobó el paquete. lockFile ni siquiera se usa.
La idea
Vulnerabilidades conocidas y actualizar
El archivo lock protege contra los cambios, pero también congela los errores viejos. Un paquete sin código dañino puede tener una vulnerabilidad: un error que deja entrar a un atacante. Un ejemplo es Log4Shell (2021): una línea en el registro de un servidor con log4j-core anterior a 2.17.1 bastaba para que el atacante ejecutara su propio código.
Las vulnerabilidades se publican en bases de datos (CVE, GitHub Advisory Database, OSV), y hay herramientas que comparan tu árbol con ellas:
- JavaScript:
npm audit; - Python:
pip-audit; - .NET:
dotnet list package --vulnerable --include-transitive; - Java: OWASP Dependency-Check (un plugin para Maven y Gradle);
- para todos: Dependabot y Renovate abren un pull request con la actualización.
La higiene de actualizar:
- Poco y a menudo. Actualizar una versión con pruebas detrás es seguro. Cinco años de golpe, no. Aquí ayudan Las pruebas como red de seguridad.
- Las críticas de inmediato, sobre todo cuando la corrección ya es pública.
- Lee qué cambia antes de aceptar una versión mayor.
- Quita lo que no necesitas. Cada paquete es código que ejecutas. Seis líneas de código propio a veces cuestan menos que un paquete con 40 dependencias.
- Una dependencia nueva es una decisión. Quién la mantiene, cuánta gente, cuántas dependencias trae, cuándo salió la última versión.
Más precisamente, para curiosos
Rangos. En npm ^2.1.3 permite cualquier 2.x.y desde 2.1.3, ~2.1.3 solo 2.1.y. Por eso, sin archivo lock, dos compilaciones del mismo código dan árboles distintos. Las bibliotecas escriben rangos para no obligar a todos a una sola versión; las aplicaciones fijan el árbol exacto.
Scripts de instalación. npm ci --ignore-scripts no ejecuta postinstall y similares. Muchos proyectos lo hacen por defecto en CI y permiten scripts solo para paquetes listados.
Un registro propio. Las empresas suelen traer los paquetes a través de su propio registro proxy (Artifactory, Nexus, Azure Artifacts). Guarda una copia, deja pasar solo paquetes aprobados y sigue qué se usa dónde.
SBOM. Un Software Bill of Materials es una lista de cada componente del producto, en formato CycloneDX o SPDX. Cuando salga el próximo Log4Shell, la respuesta a «¿nos afecta?» es una búsqueda en él, no una semana de rebuscar.
Firmas. Un hash dice «los mismos bytes que cuando se comprobó». Una firma dice «publicado por este autor»: npm provenance, Sigstore, paquetes firmados en NuGet y Maven Central.
Cómo ver el árbol: npm ls --all, mvn dependency:tree, gradle dependencies, dotnet list package --include-transitive, pipdeptree.
Ejercicio
Una pequeña auditoría
Resuélvelo en tu propio editor.
Están instalados log-kit 2.14.1, http-lite 1.8.0, pay-sdk 4.2.0 y qr-code 2.9.0. La base de datos de vulnerabilidades dice (paquete, corregido en la versión, gravedad): log-kit 2.17.1 critical, http-lite 1.8.2 high, pay-sdk 3.9.0 moderate, qr-code 2.10.0 low. Imprime para cada uno si es vulnerable y a qué versión actualizar, y cuántos son vulnerables:
log-kit 2.14.1: vulnerable (critical), actualizar a 2.17.1http-lite 1.8.0: vulnerable (high), actualizar a 1.8.2pay-sdk 4.2.0: bienqr-code 2.9.0: vulnerable (low), actualizar a 2.10.0Vulnerables: 3Cuidado con qr-code: como texto, "2.9.0" va después de "2.10.0", porque 9 va después de 1. Las versiones se comparan parte por parte, como números.
Más tareas
- Gravedad. Ordena los vulnerables por gravedad: critical, high, moderate, low.
- Un lock desde texto. Lee el archivo lock como líneas
name@version hashy úsalo para comprobar los paquetes descargados de la lección. - Typosquatting. Compara el nombre de un paquete nuevo con una lista de populares y avisa si se diferencia en una letra (un cambio, una inserción o un borrado).
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Datos, switch y desestructuración · Las pruebas como red de seguridad · Entrada no confiable · Secretos en el código y en git
- Ejercicios sobre el tema: Seguridad para programadores