ReuseLessSoftware - reutilizar 'menos' software
(wiki.alopex.li)- Los ataques a la cadena de suministro se han convertido en un problema mayor a medida que el costo de distribuir software cayó muchísimo y se generalizó la automatización de compilación y despliegue
- En la década de 1970 existía una crisis del software porque era difícil crear software reutilizable, pero hoy los repositorios y gestores de paquetes traen y compilan código solo con un nombre y una versión
- Las actualizaciones automáticas de dependencias hacen que los cambios maliciosos se propaguen rápido a través de CI, y un buen ataque a la cadena de suministro se expande a la velocidad a la que se ejecutan los runners de CI
- El vendoring, que consiste en incluir todas las dependencias dentro del repositorio del proyecto, hace crecer el repositorio, pero bloquea los cambios automáticos y hace más visible la escala y el costo de las dependencias
- No es una solución para todo el software, pero mucho software pequeño puede beneficiarse al reducir a 2 o 3 dependencias las dependencias que podrían cambiar repentinamente desde afuera
Problema
- Los ataques a la cadena de suministro se han vuelto un problema cada vez mayor no porque haya cambiado la esencia del software o del mantenimiento, sino porque el modelo de costos para compartir y distribuir software se volvió extremadamente barato
- El costo de distribución bajó tanto que, aunque haya desperdicio, se usa mucha automatización, y la automatización en sí es útil
- Cada pocos meses aparece un nuevo ataque a la cadena de suministro que termina dañando una gran parte del código del mundo
Cómo llegamos hasta aquí
- A fines de los años 60 y comienzos de los 70, la gente no sabía bien cómo crear software reutilizable, y a eso se le llamó crisis del software
- La demanda de software crecía de forma exponencial, pero la capacidad de crear nuevo software con la complejidad requerida crecía más lento
- Ese período llevó a investigaciones sobre modularidad, programación estructurada y temas similares, y casi todos los sistemas de módulos de los lenguajes de programación creados después de 1990 pueden rastrear su linaje hasta Modula-2
- En los años 90 y 2000, Internet permitió soluciones más potentes, la compilación y distribución de software se abarataron, y gran parte del software que realmente se quería usar era de código abierto
- A partir de CPAN, CTAN y las distribuciones Linux surgieron muchos repositorios de paquetes y gestores de paquetes, y estas herramientas encuentran, traen y compilan software usando solo un archivo de manifiesto, un nombre y, por lo general, un número de versión bastante arbitrario
-
De la integración manual a las dependencias automáticas
- Antes, una buena forma de construir sistemas de software complejos era ensamblar manualmente y con cuidado piezas que funcionaban, y las distribuciones Linux básicamente hacen eso
- En 2003, compilar SDL con toda su funcionalidad podía ser tan doloroso que tomaba días, y no hace falta extrañar esa época
- Cuando una distribución Linux provee un entorno base conocido, mucho software personalizado puede funcionar dentro de su propio mundo sin preocuparse demasiado por otras partes del sistema
- Cuando se comunica con otro software, muchas veces lo hace a través de archivos o sockets de red usando protocolos bien conocidos
- Hoy hay mucho buen software construido desde cero con Rust o Go, o desplegado en contenedores Docker, y este tipo de software casi no interactúa con las bibliotecas del sistema
- En vez de ajustarse al conjunto de software que ofrece la distribución del SO, se volvió común que el propio sistema de build traiga directamente las bibliotecas necesarias
-
La crisis en dirección contraria
- Hoy, al revés de los años 70, hay una crisis donde la gente reutiliza demasiado software y eso empeora los programas
- Distribuir software sigue siendo muy barato, pero usar software sigue teniendo un costo
- Durante mucho tiempo, el mayor costo fue la complejidad de compilar software y lograr que corriera en una computadora, pero ese problema desapareció en gran parte gracias a la automatización
- Ahora compilamos, distribuimos y usamos muchísimo más software, y ese costo aparece como infierno de dependencias, hinchazón, tiempos de build largos y desaparición de paquetes o de gestores de paquetes
- El mayor problema son los ataques a la cadena de suministro
-
Cómo se propagan los ataques a la cadena de suministro
- Los ataques a la cadena de suministro existen desde hace tanto como el software de código abierto
- En el pasado, un intento de parche malicioso para el kernel de Linux que buscaba poner
uid = 0en lugar deuid == 0fue el primer parche malicioso para el kernel observado en la práctica, y cuenta como un intento de ataque a la cadena de suministro - La razón por la que en los últimos 10 años los ataques a la cadena de suministro se volvieron más grandes y problemáticos es que los sistemas de build se automatizaron para traer código fuente y distribuirlo
- Los sistemas de CI normalmente se ejecutan ante cada cambio de código o ante cambios importantes, y esos cambios quedan disponibles automáticamente para todos los que dependen de ese código
- Los sistemas de CI de quienes dependen de él también traen el cambio e incluyen el nuevo código malicioso, y un buen ataque a la cadena de suministro se propaga como incendio forestal a la velocidad a la que corren los runners de CI
- Hay maneras de ralentizar los ataques a la cadena de suministro, como un cooldown de dependencias, pero eso genera discusiones sobre políticas y responsabilidades
Solución
- La clave es no dejar que sistemas de build como
npmocargotraigan automáticamente dependencias desde ubicaciones de red cada vez, sino poner todas las dependencias junto con el software - Hay que hacer vendoring de todas las dependencias del proyecto, copiando el contenido del control de versiones upstream dentro del repositorio git y haciendo commit
- Cuando haya actualizaciones upstream, se descargan y se vuelven a copiar, y si el trabajo manual se vuelve tedioso, se puede dejar que una herramienta de build lo automatice
- Si ya existe un lockfile, basta con hacer que apunte al árbol completo de fuentes dentro del control de versiones
- Se posee todo el código fuente con un control fuerte sobre cada línea
-
Costos y trade-offs
- El repositorio crece, pero el espacio en disco es barato
- El costo de transferencia es menos barato que el disco, pero en esta discusión sigue siendo un factor que hay que aceptar
- Puede parecer que el tiempo de build aumentará, pero no necesariamente crece, porque de todos modos ya se estaban recompilando esas dependencias
- Reutilizar código puede volverse más difícil, y en programas como clientes y servidores que usan bibliotecas de protocolos compartidos eso puede ser un problema real
- Esos programas ya tienen problemas de incompatibilidad de versiones y de todos modos deben lidiar con ellos, así que a largo plazo obligar a prestar atención no necesariamente es peor
-
Un cortafuegos contra los ataques a la cadena de suministro
- Si no se actualizan dependencias automáticamente, todos los paquetes del ecosistema se convierten en un cortafuegos frente a los ataques a la cadena de suministro
- El mismo enfoque también frena la propagación de correcciones de bugs y parches, pero si una corrección es importante, de todos modos alguien la va a revisar manualmente
- Las correcciones que nadie revisa suelen no ser tan importantes
- También se podría lograr un efecto parecido descartando en el sistema de build semver o la idea de que “dos fragmentos distintos de código deberían comportarse igual”, y tratando todos los números de versión como identificadores únicos sin relación entre sí
- El problema de semver es que expresa intención humana, no la realidad, y aun así solo funciona cuando se usa de manera más o menos correcta
- Tratar los números de versión como únicos no resuelve problemas como dependencias que desaparecen, son alteradas o cuyo contenido se corrompe de otras maneras
-
Visibilidad de las dependencias
- Hacer vendoring de todas las dependencias no solo ralentiza los cambios automáticos, también aumenta un poco el costo de usar dependencias
- Ese aumento de costo no es irrecuperable; más bien obliga a pensar un poco más antes de usar código upstream
- Funciona como un mecanismo suave para volver a preguntar “¿de verdad hace falta?” al agregar una dependencia nueva
- Aumenta la visibilidad de las dependencias y deja menos escondida la hinchazón que se oculta detrás de ellas
- Si agregaste una biblioteca simple que parecía tener unas 200 líneas y resultó tener 50,000, queda mucho más claro que hay que detenerse y preguntarse por qué
- El carácter casi mágico de las dependencias disminuye, y se vuelve más fácil rastrear dentro de la base de código el camino por el que un bug termina llevándote al código de otra persona
-
Árboles de dependencias y problemas de compartición
- Hacer vendoring de todo por defecto puede empujar a árboles de dependencias más planos y más anchos
- No es deseable llegar al nivel de bibliotecas gigantes como Boost o Qt en C++
- Esas bibliotecas gigantes existen porque hacer y usar pequeñas bibliotecas de C/C++ es demasiado difícil
- La idea implícita es que, en vez de que cada quien entienda por su cuenta incluso cómo compilar algo como Boost o Qt, es mejor que un integrador de sistemas como una distribución Linux lo haga una sola vez
- La desventaja real es que las dependencias transitivas no se comparten
- Si lib A y lib B dependen de Z, eliminar la duplicación no es imposible, pero sí más difícil, y requiere trabajo manual o herramientas más sofisticadas
- Incluso cuando las dependencias transitivas sí se comparten, aparecen problemas, y tener dependencias transitivas ya es parte del problema
- Permitir que una biblioteca especifique dependencias transitivas equivale a ceder a otros el control sobre tu programa
Análisis
- No todo el software puede usar este enfoque
- Hacer vendoring y compilar todo Redis como parte del despliegue de un backend web no suena especialmente razonable
- Aun así, si el despliegue ya está automatizado con Ansible o imágenes Docker, es posible que en la práctica ya se esté haciendo algo parecido
- Hay un límite a la complejidad que este enfoque puede soportar, pero empresas gigantes de monorepo como Google y Facebook muestran que ese límite puede ser más alto de lo que parece
- En algún punto las dependencias se encuentran con el sistema operativo, y el sistema operativo es una gran dependencia con muchos problemas propios
- La idea de un unikernel para backends web es atractiva, pero en la práctica hay problemas de herramientas y todavía no hemos llegado a ese punto
-
Distribuciones Linux y entornos de build
- Este enfoque no es una forma de construir sistemas completamente interactivos como una distribución Linux o BSD
- Esos sistemas tienen muchos programas y bibliotecas que deben funcionar juntos, así que se trata de un problema distinto
- Llevar este principio hasta el extremo termina acercándolo a enfoques como Nix o Guix
- La idea de tener que ensamblar correctamente un “entorno de build” se parece más a una forma perezosa e insuficiente de resolver el problema de “cómo compilar software”
- Ese concepto es un residuo de la época en que el software se compilaba una vez en alguna minicomputadora y luego se compartía ampliamente en binario
- Hoy se compila muchísimo más software sobre la marcha que en los años 70
-
Alcance aplicable
- Este enfoque no es una solución universal, pero puede aplicarse a mucho software y aportar beneficios
- La mayor parte del software es pequeño, y los proyectos grandes ya tienen que resolver muchos de estos problemas
- Hay muchas bibliotecas que solo hacen cómputo puro o que solo interactúan con el exterior mediante I/O básico y portátil, como archivos y sockets de red
- Ejemplos como bibliotecas de compresión, libcurl, bibliotecas TUI o Django pueden tratarse como candidatos a vendoring
- Hacer vendoring permite evitar en gran medida que algo se rompa sin explicación al desplegar o compilar en un sistema nuevo por conflictos de versión o bugs introducidos por parches repentinos
- El objetivo es reducir las dependencias que pueden cambiar sin aviso desde afuera no a 200 o 300, sino como mucho a 2 o 3
Conclusión
- Reducir las actualizaciones automáticas de dependencias y hacer que el proyecto posea directamente hasta el código fuente de sus dependencias puede frenar la propagación automática de ataques a la cadena de suministro
- Si se eleva un poco el costo de usar dependencias y se mejora su visibilidad, resulta más fácil detectar reutilización innecesaria e hinchazón oculta
- Este enfoque no encaja con todos los sistemas, pero tiene ventajas prácticas para software pequeño y muchas bibliotecas
1 comentarios
Opiniones en Lobste.rs
El gestor de paquetes de Zig me parece una solución de compromiso bastante buena
Todos los paquetes quedan fijados por hash de contenido, así que en la práctica es como tener un lockfile por defecto, y evita el problema de que “el repositorio upstream de repente se vuelva malicioso”, aunque sigue existiendo el problema de que “el repositorio upstream desaparezca”
Aun así, como hay caché global y local y todo está basado en hashes de contenido, si el repositorio upstream desaparece basta con poner el tarball de una copia local donde haga falta
Parece un buen punto medio entre “vendorizar el código fuente” y “software simple y reutilizable”
Poner todo el código fuente en un almacenamiento direccionado por contenido, y luego hacer hash de cada programa a partir del hash de sus entradas
Supongo que habría que modificar el lockfile o encontrar una colisión de hash, y ninguna de las dos parece fácil
Aun así, como estoy acostumbrado al ecosistema de
cargo, no termina de convencerme del todo. Cuando subes una dependencia, sus dependencias transitivas también tienden a subir sin mucho aviso, y también cambian otras que coinciden con el rango de versionado semánticoPara llamarlo “ataque a la cadena de suministro”, yo diría que no, porque no hay un contrato firmado con una propuesta y una contraprestación, así que no es una cadena de suministro
Aparte de eso, desde la perspectiva de garantizar que una dependencia no cambie por debajo, un lockfile con hashes o el esquema de selección de versión mínima de Go son equivalentes a vendorizar dependencias
Entiendo que la diferencia es que vendorizar mete fricción, pero si lo llevas al extremo terminas reimplementando todo o, peor aún, convirtiendo dependencias en código generado al vuelo, así que me parece mejor usar software escrito por expertos del dominio y bien validado
Trabajé en esto en Facebook, y no le recomendaría a nadie cómo manejan allí las dependencias de terceros. Para una dependencia directa de cierto crate de Rust, en todo fbsource solo se permiten como máximo dos versiones simultáneas incompatibles a nivel de versionado semántico. Si quieres actualizar una dependencia, te toca cargar con el costo de actualizar todo fbsource
Puede que sea una forma adecuada para Facebook, pero no me parece especialmente brillante ni sostenible
Sospecho que lo de “no especialmente brillante ni sostenible” es más una función de la escala que de la política en sí. Permitir varias versiones trae otros problemas, porque la mayoría de los lenguajes modernos, excepto TypeScript, usan tipos nominales principalmente o por completo, así que con cada cambio rompedor se bloquea la reutilización de tipos entre versiones a menos que apliques el “semver trick”
Recuerdo claramente que durante Log4Shell, las empresas con muchas versiones repartidas por todos lados sufrieron más para actualizar que las que tenían pocas versiones o las tenían fijadas
Según The Third Networking Truth, “con suficiente empuje, los cerdos vuelan bastante bien. Sin embargo, eso no implica necesariamente que sea una buena idea”
Muchas prácticas citadas en lugares como Google o Facebook solo funcionan porque esas empresas pueden meterle suficiente empuje
Por ejemplo, sé que algunos de esos lugares asignan equipos más grandes que toda la plantilla de la empresa donde trabajo para sostener sus monorepos y las decisiones relacionadas con dependencias. Ellos pueden costearlo, pero a la mayoría de nosotros nos costaría mucho
Buena perspectiva. Coincido mucho con que “si vendorizar todas las dependencias, sube el costo de usar dependencias”
Pero no deberías copiar y pegar libcurl. Para la mayoría de las bibliotecas es una estrategia razonable, pero no es un buen consejo para programas en C que manejan entradas hostiles. No vas a hacerlo mejor que el sistema operativo manteniendo libcurl seguro
Algo en lo que nunca había pensado es que es al menos un poco raro que los gestores de paquetes para usuarios finales, como apt, aparecieran primero y los gestores de paquetes a nivel de lenguaje vinieran después
Creo que eso de verdad causó muchos problemas. Si ves rubygems a inicios de los 2000, queda bastante claro que intentaba ser una especie de “apt para Ruby”, con instalación global al sistema como comportamiento por defecto, no gestión por proyecto. Hicieron falta décadas y agregar bundler para deshacer el daño de ese error, pero si desde el principio se hubiera reconocido la necesidad de aislamiento por proyecto, bundler no habría hecho falta
Python todavía sigue arreglando ese caos, y Perl probablemente también, aunque no lo sé en detalle
Históricamente, los gestores de paquetes eran originalmente la forma de construir un sistema, y esos sistemas tenían varios usuarios, entornos de escritorio y mucho software funcionando junto
Construir software costaba mucho tiempo y memoria, y había muchísimo software en comparación con el disco y la RAM, así que reutilizar bibliotecas era importante
Con el auge de las webapps, la mayoría de las computadoras importantes pasaron a ser servidores que ejecutan solo unos pocos programas durante toda su vida, y el disco y la RAM se abarataron lo suficiente como para que el tamaño del código binario importara menos
Las herramientas para construir sistemas no siguieron ese cambio de época al mismo ritmo, y por eso la mayoría de la gente que hace software terminó necesitando herramientas para construir bien un solo programa, no un enorme sistema interconectado con muchas bibliotecas compartidas
En paralelo a esta historia también está la línea de “C no tiene un sistema de módulos decente”, pero aquí importa menos
Puede que esté equivocado, pero parecería haber una desventaja en que los escáneres no detecten errores en dependencias copiadas
En ese caso, problemas potenciales sobre los que normalmente habrías recibido una alerta podrían quedarse ahí en silencio
Los escáneres son muy útiles para mostrar cosas que podrían ser un problema, pero se vuelven muy fastidiosos cuando te hacen posponer de golpe trabajo planeado para arreglar algo que el escáner creyó problemático pero en realidad no lo era
Si, como propone el artículo, incluyes todas las dependencias dentro del software, copias la gestión del código upstream dentro de tu repositorio git y la dejas automatizada por la herramienta de build cuando te canses de hacerlo a mano, ¿no terminas dando toda la vuelta para volver a incluir software de terceros sin revisarlo?
Pero ese enfoque no resuelve el problema de que una dependencia desaparezca o sea alterada, ni el de que alguien manipule el contenido del paquete de otra manera. Es más bien una optimización y, en mi opinión, una optimización prematura. Tal vez algún día lleguemos ahí, pero no debería ser el punto de partida