Ir al contenido
wannacode

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

static bool Verify(Tarball pkg,
Dictionary<string, string> lockFile)
{
var expected = lockFile[pkg.Name];
var actual = Integrity(pkg.Bytes);
if (actual != expected)
{
Console.WriteLine($"{pkg.Name}: MANIPULADO");
Console.WriteLine($" lock: {Brief(expected)}");
Console.WriteLine($" descargado: {Brief(actual)}");
return false;
}
Console.WriteLine($"{pkg.Name}: OK");
return true;
}
Paso a paso, en palabras
  1. La tienda pide dos paquetes. Estos traen otros tres propios. Los cinco se ejecutan dentro de la tienda.
  2. Alguien cambia date-fmt por el camino. Sin comprobación su código se ejecuta en la tienda, con su entorno y sus archivos.
  3. El archivo lock guarda la versión exacta y el hash de cada paquete, también de los profundos.
  4. El hash de lo descargado: SHA-512, escrito en Base64.
  5. El mismo hash que en el archivo lock: son los bytes que se comprobaron. Instalado.
  6. date-fmt tiene una línea de más, y todo el hash es distinto. No se instala.
  7. Instalados: 4 de 5. La compilación se detiene, y nadie ejecuta el código cambiado.

El hash de aquí es un pariente de GetHashCode 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 .NET el archivo lock es packages.lock.json. Lo activas en el proyecto:

<PropertyGroup>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>

Cada entrada tiene resolved (la versión exacta) y contentHash (el SHA-512 del paquete). CI ejecuta dotnet restore --locked-mode: se detiene si tuviera que cambiar el archivo lock.

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:

static string Integrity(byte[] bytes) =>
"sha512-" + Convert.ToBase64String(SHA512.HashData(bytes));
static string Brief(string hash) => hash[..19] + "…";
static bool Verify(Tarball pkg,
Dictionary<string, string> lockFile)
{
var expected = lockFile[pkg.Name];
var actual = Integrity(pkg.Bytes);
if (actual != expected)
{
Console.WriteLine($"{pkg.Name}: MANIPULADO");
Console.WriteLine($" lock: {Brief(expected)}");
Console.WriteLine($" descargado: {Brief(actual)}");
return false;
}
Console.WriteLine($"{pkg.Name}: OK");
return true;
}
var lockFile = new Dictionary<string, string>
{
["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==",
};
Tarball[] downloaded =
[
new("pdf-invoice@5.0.2", Encoding.UTF8.GetBytes("pdf-invoice 5.0.2: renders invoices to PDF")),
new("font-kit@3.1.0", Encoding.UTF8.GetBytes("font-kit 3.1.0: loads and measures fonts")),
new("date-fmt@2.1.3", Encoding.UTF8.GetBytes("date-fmt 2.1.3: formats dates and times\n+ one line added by the attacker")),
new("pay-sdk@4.2.0", Encoding.UTF8.GetBytes("pay-sdk 4.2.0: talks to the payment provider")),
new("http-lite@1.8.0", Encoding.UTF8.GetBytes("http-lite 1.8.0: a small HTTP client")),
];
int installed = downloaded.Count(pkg => Verify(pkg, lockFile));
Console.WriteLine($"Instalados: {installed} de {downloaded.Length}");
Salida
pdf-invoice@5.0.2: OK
font-kit@3.1.0: OK
date-fmt@2.1.3: MANIPULADO
lock: sha512-Jeb+bpiC7IqY…
descargado: sha512-sMR370edweyw…
pay-sdk@4.2.0: OK
http-lite@1.8.0: OK
Instalados: 4 de 5

Los 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á?

var a = Integrity(Encoding.UTF8.GetBytes("total 49.90"));
var b = Integrity(Encoding.UTF8.GetBytes("total 49.90 "));
Console.WriteLine(a.Length);
Console.WriteLine(b.Length);
Console.WriteLine(a == b);
Respuesta

Pruébalo

Completa

¿Qué algoritmo da el hash del archivo lock?

static string Integrity(byte[] bytes) =>
    "sha512-" + Convert.ToBase64String(.HashData(bytes));

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.

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:

Salida
log-kit 2.14.1: vulnerable (critical), actualizar a 2.17.1
http-lite 1.8.0: vulnerable (high), actualizar a 1.8.2
pay-sdk 4.2.0: bien
qr-code 2.9.0: vulnerable (low), actualizar a 2.10.0
Vulnerables: 3

Cuidado 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. En .NET Version.Parse lo hace por ti.

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 hash y ú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

1. La tienda depende directamente de pdf-invoice. Este depende de date-fmt. ¿El código de quién se ejecuta cuando la tienda imprime una factura?
2. El archivo lock de npm dice date-fmt versión 2.1.3 con el hash sha512-Jeb+…. El servidor entrega un paquete cuyo hash es sha512-sMR3…. ¿Qué hace npm ci?
3. ¿Qué es el typosquatting?
4. npm audit dice que log-kit 2.14.1 tiene una vulnerabilidad crítica, corregida en 2.17.1. ¿Qué es lo correcto?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·