Cómo poner límites de tiempo a funciones síncronas con regex
6 de abril de 2023
0 minutos de lectura¿Qué tan difícil puede ser admitir tags personalizados para imágenes de contenedor? Resulta que… ¡bastante! Lo sé porque mi equipo ha estado trabajando arduamente en nuestra nueva compatibilidad con imágenes base personalizadas para Snyk Container, y nos asignaron el siguiente problema: dado un tag, analizar sus partes para poder compararlo con otros tags similares. Fue un problema divertido de resolver y nos encantaría compartir cómo llegamos a la solución final.
¿Expresiones regulares al rescate?
Nuestra idea fue usar regex, específicamente la función de agrupación con nombres. Por ejemplo, si queremos encontrar una coincidencia con esto:
Podríamos usar esta regex (disculpas de antemano a quienes de vez en cuando tienen pesadillas con regex):
A estas alturas, quizá ya se te encendieron algunas alertas 🚩. Todos sabemos que las regex pueden ser peligrosas, ¡y dejar que el usuario decida tanto la cadena de entrada como la regex es buscar problemas!
Un usuario podría crear fácilmente una imagen base llamada:
Y darnos la cadena:
¡Esto habría hecho que nuestro servicio dejara de procesar por completo cualquier otra solicitud! Ningún equipo de AppSec permitiría que alguien hiciera algo así.
¿Significa eso que debemos empezar de nuevo? ¡Por suerte, no! Existe una biblioteca (motor de análisis de regex) diseñada específicamente para prevenir este tipo de ataques. La biblioteca es RE2, desarrollada por Google. Es importante tener en cuenta que algunas funciones de regex no son compatibles, pero de todos modos no las usaríamos.
¡Ahora empieza lo divertido!
Preferimos que nuestra única protección no sea una dependencia externa, expuesta a errores fuera de nuestro control y a posibles ataques a la cadena de suministro de software. Por eso, queríamos una forma de poner límites de tiempo a las regex que tardan demasiado.
Como Node es de un solo hilo, no es fácil poner límites de tiempo al código que no puede ejecutarse de forma concurrente. Esta es la solución que se nos ocurrió:
¡Hilos 🧵!
Probablemente fue lo primero que se te ocurrió; a nosotros también. Además de poder detener un hilo, tenemos la ventaja adicional de no bloquear el bucle de eventos principal.
Muy bien, escribamos algo de código:
Hilo principal
Script del worker
Bastante sencillo, ¿verdad? ¿Qué tal si me aseguro de que el código se ejecute…

Mmm, nunca había visto este error. Veamos qué dice MDN:

Y: may not contain native (C++ backed) objects
Resulta que los objetos no se pasan por referencia, sino que se clonan. Y como RE2 es una función (clase), que no es más que una vinculación a un objeto respaldado por C++, ni siquiera podemos pasarla como argumento.
Podríamos crearla dentro del hilo y pasar la regex como una cadena. Aquí apareció otra alerta 🚩. Podría haber un impacto considerable en el rendimiento si tenemos que reconstruir el objeto RE2 en cada llamada a match.
No especulemos; podemos probarlo. Usamos benchmark.js.
Pruebas de rendimiento
⚠️ No es fácil ejecutar microbenchmarks como estos. V8 es muy inteligente a la hora de optimizar, así que toma estos resultados con cautela. Aquí tienes un excelente video de YouTube sobre el tema de los microbenchmarks
Como referencia, RE2.match puede ejecutarse 500 mil veces por segundo.
Como los objetos RegExp se pueden clonar, podemos enviarlos al hilo tal cual, en lugar de usar RE2, y ver cómo es el rendimiento. Resulta que ejecutar RegExp.match dentro de un hilo worker (esperando el resultado) permite 50 mil ejecuciones por segundo. ¡Una disminución del 90 %! 🤩
Crear el objeto RE2 en el hilo da un resultado aún peor: 17 mil ejecuciones por segundo. Una penalización del 97 % en el rendimiento. 😭
Quizá los hilos no sean la solución más óptima y, por suerte, ¡hay otra manera!
El módulo vm
Node tiene un módulo llamado vm que te permite ejecutar scripts en un contexto independiente. La ventaja es que incluye una opción de límite de tiempo.
Aquí tienes un ejemplo de código:
No necesitamos crear el script y el contexto cada vez que queremos ejecutar una coincidencia, así que podemos crear uncierre y devolverlo.
Código final
Volvamos a hacer la prueba de rendimiento
Ejecutemos eso en la prueba de rendimiento para ver los resultados. Ejecutar lo siguiente dentro de vm dio un resultado mucho más aceptable: 200 mil por segundo. 👍
¡Un momento! Olvidamos agregar un límite de tiempo, así que volvamos a ejecutar la prueba con un límite de 100 milisegundos:
No debería tener mucho impacto, ya que nunca llegamos a agotar el tiempo…
Mmmmmmmmmmm😭😭😭
¿Por qué agregar un límite de tiempo que nunca se alcanza dio como resultado un rendimiento PEOR que el enfoque con hilos?
Unas búsquedas en Google nos llevaron a un issue de GitHub, que nos llevó a un PR, que nos llevó a una nota oculta al final de la página de documentación, donde dice:
⚠️ Usar las opciones de límite de tiempo o
breakOnSigintinicia nuevos bucles de eventos y los hilos correspondientes, lo que tiene un costo de rendimiento distinto de cero.
¡Rayos!
Conclusiones
Como un hilo worker es más complejo (más difícil de leer, requiere más recursos y obliga a cambiar a async las firmas de todas las funciones que lo usan) que la solución con vm, y ambos enfoques tienen un rendimiento similar, elegimos la solución con vm. Si se convierte en un cuello de botella en el futuro, podremos optimizarla.
Ahora que expliqué cómo implementamos esta funcionalidad, nos encantaría que probaras nuestras nuevas recomendaciones de imágenes base personalizadas. Si te gustan, cuéntanos en Twitter: @snyksec.
Seguridad de contenedores centrada en los desarrolladores
Snyk encuentra y corrige automáticamente vulnerabilidades en imágenes de contenedores y cargas de trabajo de Kubernetes.



