Un ticket de hardening que hubo que re-derivar antes de poder implementarlo
Este era un issue de hardening de seguridad para CI/CD con tres tareas escritas semanas atrás, salidas de un spike que auditó todo el repositorio. Para cuando llegué a él, el terreno ya había cambiado debajo. Dos de las tres tareas seguían siendo correctas. La tercera me enseñó más de lo esperado, a partir de un diff de dos líneas en un workflow.
La parte que hubo que re-derivar
La primera tarea decía: dejar de correr los builds de pull requests en el runner autoalojado, porque una actualización de dependencia comprometida podría ejecutar código arbitrario ahí. Razonable, en el momento en que se escribió. Salvo que, en el medio, migré todo el pipeline de CI a ese mismo runner autoalojado, específicamente para que los pull requests volvieran a tener feedback real de pruebas después de que GitHub cortara la facturación de runners alojados. Hacer lo que decía el ticket viejo habría deshecho eso en silencio.
Entonces, en vez de implementarla, fui a verificar qué es cierto hoy en la práctica. GitHub ya trata los pull requests generados por Dependabot como si vinieran de un fork, token de solo lectura, cero acceso a secretos, de forma automática y sin necesidad de configuración. Eso está documentado, y leí la documentación en vez de asumir. El riesgo residual (el código de una dependencia maliciosa igual corre en el host del runner durante la instalación y las pruebas, incluso sin secretos) es real, pero no hay arreglo posible en el archivo de workflow mientras se mantenga la situación de facturación, enrutar esos PRs a runners alojados solo significa que vuelven a fallar al iniciar, que es justo el problema que ya había resuelto. Expliqué esto y pregunté antes de descartar la tarea, en vez de decidir en silencio por mi cuenta que un ticket viejo ya no aplicaba.
Lo que construí
Las otras dos tareas se sostuvieron bien: permisos explícitos de solo lectura en los workflows, y dejar de tener una master key de Rails desencriptada tirada en el disco del runner después de un deploy.
La segunda es donde se puso interesante. El primer intento escribía la key en un archivo, corría el deploy, y después borraba el archivo. Directo, y funcionaba. La revisión de código señaló que esto solo reduce la ventana de exposición, no la cierra. Si el proceso del runner moría entre la escritura y el borrado, algo que puede pasar en una máquina persistente que no se destruye después de cada job, la key se quedaría ahí tirada. Y después vino una pregunta más difícil: ¿por qué escribirla en un archivo, para empezar, si el workflow ya la tenía disponible como variable de entorno?
Buena pregunta. Fui a investigar, y resultó que la escritura del archivo existía únicamente porque la configuración de secretos de la herramienta de deploy tenía hardcodeada la lectura desde una ruta de archivo en vez de desde el entorno. Nada en Rails ni en la herramienta de deploy requería realmente un archivo, y eso se podía comprobar, porque un job completamente distinto en el mismo pipeline ya pasaba ese mismo secreto como una simple variable de entorno, sin ningún archivo de por medio.
Lo que me sorprendió
Arreglar el archivo de secretos no fue tan simple como poner una expresión de fallback al estilo bash, “usar la variable de entorno si está definida, si no, recurrir al archivo.” Escribí eso, y parecía razonable. Después leí de verdad el código fuente de la herramienta que parsea ese archivo, en vez de asumir que se comporta como un script de shell solo porque se parece a uno. No es así. Lo parsea una librería estilo dotenv con una pequeña extensión personalizada para sustitución de comandos, y esa librería no tiene ningún concepto de sintaxis de fallback. Si se le da lo que escribí, se queda en silencio con solo la primera mitad, descarta el resto como texto basura, y devuelve un valor corrupto sin ningún error. Ese es el tipo de bug que se ve bien en un diff y solo se manifiesta cuando un deploy se rompe en silencio en producción, exactamente el tipo de cosa que siempre quiero evitar. (El comportamiento de secretos dotenv de Kamal ya me había mordido antes.)
El arreglo que sobrevivió al contacto con el parser real: la herramienta permite apilar un archivo de secretos específico de destino encima de uno compartido, y los valores más recientes sobrescriben a los anteriores para la misma clave. Entonces le di al destino de staging su propio archivito que lee la key directo del entorno, mientras dejaba el archivo compartido, el que producción todavía usa para deploys manuales locales, completamente intacto. Probé esto directamente antes de confiar en ello: instancié el propio resolvedor de secretos de la herramienta en un script descartable, y confirmé que staging tomaba un valor de entorno falso mientras producción seguía leyendo la key real desde disco exactamente como antes. Después mergeé, vi correr el deploy real en CI, y entré por SSH al runner como root para confirmar, no suponer, que no existe ningún archivo de key en ningún lugar de su workspace.
Qué sigue
Nada de seguimiento por acá. Pero vale la pena recordar la forma de este caso: un ticket de hardening viejo, un arreglo que “debería ser simple” con una respuesta incorrecta que parecía plausible, y un comentario de revisión que resultó tener razón sobre el fondo, no solo sobre el estilo. Vale la pena revisar el parser real antes de confiar en una sintaxis que solo se parece a algo escrito cien veces antes.
Lecturas relacionadas
Desplegando Rails 8 a staging automáticamente con Kamal y un runner autoalojado de GitHub Actions
Hacer que cada merge despliegue staging automáticamente: un runner autoalojado, cuatro obstáculos seguidos, y la trampa de los secretos de Kamal que más costó resolver.
Retirando el entorno de staging
Un segundo contenedor, un monitor aparte, una tasa de fallos del 11% en el workflow, y cero evidencia de que alguna vez detectara algo que los deploys de producción no detectaran. La auditoría que terminó en un borrado.
Turbo Frames, un sanitizador de defensa en profundidad, y cómo enseñarle eso a Brakeman
El primer Turbo Frame del editor se comió su propio target de Stimulus al recargar, la vista previa obtuvo dos capas independientes de sanitización, y un falso positivo obtuvo una huella documentada en vez de un encogimiento de hombros.