Cuando pensamos en ataques a la cadena de suministro de software, casi siempre imaginamos lo mismo: alguien roba la cuenta de quien mantiene un paquete popular y publica una versión con código malicioso. Trail of Bits ha publicado Package managers have an integrity problem, donde Keegan Ryan plantea el siguiente paso: ¿qué pasa si quien queda comprometido es el propio repositorio de paquetes?
La idea es razonable: si se consigue proteger las cuentas de quienes mantienen los paquetes, los atacantes irán a por el siguiente eslabón débil, que son los servidores que distribuyen el código. Para comprobar cuánto aguanta cada ecosistema, han probado 25 de los gestores de paquetes más populares contra un repositorio malicioso que mete código en paquetes legítimos. El resultado no es bueno.
Cómo lo han probado
El montaje me parece muy elegante. Cada gestor corre en un contenedor Docker con un proyecto de ejemplo, con y sin lockfile. El tráfico pasa por WireGuard hasta un mitmproxy con un complemento propio, y como el certificado de mitmproxy está instalado en el contenedor, puede modificar todo lo que devuelve el repositorio. El proxy inyecta código malicioso en los paquetes descargados, y si el proyecto de ejemplo lo ejecuta, el ataque ha funcionado.
Se asume que cualquier servidor expuesto a internet puede estar comprometido, pero no las claves privadas de firma. Y se usan los comandos por defecto de cada gestor, siguiendo sus guías de inicio, para ver si hace falta algún paso extra para estar protegido.
Primera defensa: hashes en el lockfile
La forma más natural de comprobar que un paquete no ha cambiado es guardar su hash en el lockfile y compararlo al descargar. Así de bien lo hace cada gestor:
| Comportamiento | Gestores |
|---|---|
| Verifica siempre la integridad | Go, vcpkg |
| Verifica si hay lockfile | Bun, Bundler, Cargo, npm, pnpm, Poetry, SwiftPM, uv, Yarn |
| Verifica con lockfile y pasos extra | Conda, Gradle, Pip, NuGet, Pub |
| No verifica | CocoaPods, Composer, Conan, cpanminus, Gem, Maven, PowerShellGet, PSResourceGet, R |
Hay casos que sorprenden:
- cpanminus, el gestor recomendado para Perl, sigue usando HTTP sin cifrar por defecto. Lo saben desde hace años, pero cambiarlo rompería el comportamiento actual.
- Pub, el de Dart y Flutter, guarda un SHA-256 en el lockfile y detecta cuando no coincide… pero instala el paquete igual y actualiza el lockfile. Para bloquearlo hay que usar
--enforce-lockfile. pnpm hacía lo mismo hasta la versión 11.4, de mayo de 2026. - Conan guarda un MD5 que sirve como identificador, no como protección, y ni siquiera lo vuelve a calcular al instalar.
- CocoaPods guarda el SHA-1 de la especificación del paquete, pero el código lo descarga de un repositorio git por su etiqueta. Si el servidor está comprometido, puede servir otro código con la misma etiqueta.
El caso de Composer
Como PHP ha estado siempre muy presente en este blog, el caso de Composer es el que más me ha llamado la atención. El composer.lock guarda el hash del commit de cada dependencia:
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/b094ded77ee97a6027ad6cf0e8c7b9f88381814c",
"reference": "b094ded77ee97a6027ad6cf0e8c7b9f88381814c",
"shasum": ""
}
Parece que está protegido, pero no: el hash del commit identifica el código, pero el ZIP que se descarga no incluye el árbol de git, así que no hay forma de comprobar que corresponde a ese commit. Y el campo shasum está vacío. Si GitHub sirviera un ZIP manipulado, Composer no lo detectaría. El artículo reconoce, eso sí, que el equipo de Composer está añadiendo protecciones contra este tipo de ataques y tiene planes para verificar la integridad de los paquetes.
Segunda defensa: firmas
Los hashes en el lockfile tienen otro problema: si fijas el hash exacto, no recibes las actualizaciones de seguridad hasta que actualizas el lockfile. Las firmas resuelven eso, porque lo que compruebas es quién publicó el paquete, no su contenido exacto. Pero aquí el panorama es peor:
| Comportamiento | Gestores |
|---|---|
| Verifica firmas por defecto | Go |
| Verifica si se configura | Bundler, Gem, Gradle, Maven, npm, NuGet |
| Verificación incompleta o inexistente | Bun, cpanminus, Pip, pnpm, Poetry, PowerShellGet, PSResourceGet, uv, Yarn |
| No hay firmas que verificar | Cargo, CocoaPods, Composer, Conan, Conda, Pub, R, SwiftPM, vcpkg |
Lo curioso es que las firmas existen en muchos ecosistemas, pero casi nadie las comprueba:
- npm firma los paquetes en el registro, pero
npm installno lo comprueba. Hay que ejecutar apartenpm audit signatures. El equivalente de pnpm descarga las claves públicas del propio registro, que es justo lo que podría estar comprometido. - NuGet acepta por defecto paquetes con firmas inválidas.
- Maven Central obliga a firmar con PGP, pero ni Maven ni Gradle lo comprueban por defecto.
- PyPI, npm, RubyGems y Maven Central ya admiten firmas con Sigstore (sin claves privadas que gestionar, usando la identidad de GitHub Actions o GitLab CI), pero Pip, uv y Poetry aún no las verifican.
Por qué Go sale tan bien parado
Go es el único que verifica todo por defecto, y lo consigue con una idea distinta: la base de datos de checksums (sum.golang.org). Cada vez que go get descarga un módulo, calcula su hash y lo compara con el que hay en esa base de datos, que está firmada con una clave pública incluida en el propio código de Go.
Es un registro de transparencia: un registro global e inmutable en el que todo el mundo ve lo mismo. Si un atacante quisiera dar a una víctima concreta una versión manipulada de un paquete, no coincidiría con la del registro. Es la misma idea que los registros de Certificate Transparency de los certificados TLS, de los que hablé hace poco con los certificados falsos tras el secuestro de tres dominios. La pega es que mucha confianza depende de una sola clave privada.
Hace unas semanas escribí sobre usar un dominio propio para los imports de Go, y esto lo complementa bien: aunque cambies de dónde se descarga el código, la base de datos de checksums garantiza que es el mismo.
La propuesta
Trail of Bits propone combinar todo lo anterior: que el lockfile no guarde el hash de cada paquete, sino la identidad de quien lo publica (su clave pública o su identidad en Sigstore), y añadir un registro de transparencia para evitar ataques dirigidos. Así se podría fijar una versión exacta o sólo una versión mínima, y en los dos casos se comprobaría que el paquete viene de quien debe.
No es una idea nueva: ya aparece en el formato pylock.toml de Python, en la verificación de dependencias de Gradle y en un plugin de Maven. Y el artículo cierra con una comparación que me parece acertada: la firma de paquetes está hoy donde estaba HTTPS hace diez años. Todo el mundo sabe que es un problema, pero la protección real casi no existe. HTTPS se generalizó cuando Let’s Encrypt lo hizo gratis y sencillo, y la firma de paquetes necesita algo parecido.
Lo que me llevo
Para el día a día, unas cuantas cosas prácticas:
- Sube el lockfile al repositorio en las aplicaciones y usa los modos estrictos:
npm ci,pnpm install --frozen-lockfile,flutter pub get --enforce-lockfile, etc. - Ejecuta
npm audit signaturesen CI si usas npm. Es un comando y comprueba las firmas quenpm installignora. - Si usas Composer, ten claro que el lockfile fija versiones, pero no protege contra un servidor comprometido.
- Si publicas paquetes, firma con Sigstore desde tu CI. Cuantos más paquetes firmados haya, antes merecerá la pena que los gestores lo verifiquen por defecto.
Y me quedo con la idea de fondo: confiamos en que lo que descargamos es lo que publicó quien lo mantiene, pero en la mayoría de ecosistemas esa confianza se apoya sólo en que el servidor no esté comprometido.
Fuente: Package managers have an integrity problem, Keegan Ryan, Trail of Bits.




