El pull request parecía una actualización rutinaria de dependencias. Enterrada en el diff había una pequeña edición a la configuración de CI que habría añadido un paso extra a cada build. El mantenedor que contó esta historia en Medium ejecutaba sus jobs en un runner autoalojado, un VPS barato que conservaba porque los minutos de CI alojado se acumulan, y esa máquina aceptaba jobs de forks mientras guardaba credenciales de nube en un archivo de configuración. Leyó el diff con atención, cerró el PR y no pasó nada. Mi argumento de hoy es que el resto deberíamos comportarnos como si ese mismo PR ya estuviera esperando en nuestra cola de revisión.

Si mantienes un paquete público de PHP, piensa en lo que hace tu pipeline cuando un desconocido abre un PR. composer install puede ejecutar plugins y scripts antes de que arranque tu primer test. Tu matriz probablemente cubre varias versiones de PHP en un runner que cachea dependencias entre jobs. Disparar el CI desde un fork significa ejecutar código que nunca has leído en hardware del que eres responsable. Es un buen servicio para ofrecer a quienes contribuyen. Se vuelve peligroso en el momento en que la máquina puede alcanzar algo que te importa, y un archivo de credenciales que dejó ahí tu yo del pasado cuenta sin duda.

El arreglo reflejo es envolver el runner en un contenedor, y no voy a fingir que eso no sirve de nada. Pero un contenedor toma prestado el kernel del host, la mayoría de las configuraciones de CI le montan el socket de Docker para que los builds puedan construir imágenes, y el directorio de caché que hace rápido tu pipeline es escribible por quien ejecutó el job anterior. Un build malicioso puede dejarle un regalo al siguiente, y reiniciar el contenedor entre medias no lo va a limpiar. Un aislamiento que comparte estado entre jobs es aislamiento con asterisco.

La respuesta del autor fue una microVM por job: cada build recibe una VM Firecracker recién creada, la misma capa de aislamiento sobre la que AWS construyó Lambda, que arranca en menos de un segundo, se destruye en cuanto el build termina y vive en una red privada sin puertos expuestos. Él lo hace a través de un servicio llamado Krova Cloud, y sí, el post se lee en parte como un anuncio del mismo. Quítale la marca y la propiedad sigue en pie: cuando la vida entera de una máquina es un solo job, la persistencia deja de ser algo que un atacante pueda lograr. Una caché manipulada muere con la máquina, y con ella todo lo que un build escribió en disco esperando que un job futuro tropezara con ello.

Antes de que salgas a comprar hosting de microVMs, eso sí, el contraargumento aburrido merece su espacio. Los runners alojados de GitHub ya son efímeros, y para la librería PHP mediana los minutos gratuitos cubren toda la matriz de tests. También puedes exigir aprobación manual antes de que corran los workflows de contribuidores externos. Si autoalojas porque tus tests de integración necesitan una máquina potente, me parece bien. Si autoalojas solo para ahorrar minutos en un repo público, el cambio más seguro es una página de ajustes y toma cinco minutos. La efimeridad que no tienes que construir tú mismo vale mucho.

Sea lo que sea sobre lo que ejecutes tus jobs, la parte de esto que yo clavaría en la wiki del equipo tiene que ver con las credenciales, y aquí el autor es refrescantemente honesto sobre los límites de su propia configuración: una VM impoluta no hace nada por una clave de nube de larga vida que inyectas en cada build. La exfiltración simplemente ocurre desde hardware más bonito. Genera tokens por job mediante federación OIDC, limítalos al único bucket o registro que el job realmente toca, y deja que expiren en minutos. Si tu job de tests todavía puede listar todos los recursos de la cuenta, la elegante arquitectura del runner es pura decoración.

Lo que se me queda grabado es lo ordinario que fue el ataque. Sin zero-day, sin escape del kernel. Un fragmento en un archivo de workflow y un mantenedor revisando tarde un martes, ese era todo el plan, y casi funciona. La contramedida es igual de ordinaria: revisa cualquier cambio bajo .github/workflows con la misma sospecha que le darías a un cambio en tu middleware de autenticación, y trata la máquina que ejecuta PRs de forks como terreno hostil por defecto. Así que cuéntame cómo lo manejas en la práctica. ¿Autoalojas runners para un repo público y, si es así, qué se interpone concretamente entre un job de un fork y tu entorno? Me encantaría conocer las configuraciones que sobrevivieron al contacto con el PR de un desconocido, y también, en la misma medida, las que no.