Temporiser des fonctions synchrones avec des regex
6 avril 2023
0 minutes de lectureEst-ce si difficile de prendre en charge des tags personnalisés pour les images de conteneurs ? Il semblerait que oui… et même beaucoup ! Je le sais parce que mon équipe travaille d’arrache-pied sur notre nouvelle fonctionnalité de prise en charge des images de base personnalisées pour Snyk Container, et nous avons dû résoudre le problème suivant : à partir d’un tag, en analyser les différentes parties pour pouvoir le comparer à d’autres tags similaires. C’était un problème amusant à résoudre, et nous avons hâte de vous expliquer comment nous sommes arrivés à notre solution finale !
Les regex à la rescousse ?
Nous avons pensé utiliser des regex, et plus précisément la fonctionnalité de groupes nommés. Par exemple, pour faire correspondre ceci :
Nous pourrions utiliser cette regex (toutes nos excuses à celles et ceux qui font parfois des cauchemars de regex) :
À ce stade, vous avez peut-être vu quelques signaux d’alerte 🚩. Nous savons tous que les regex peuvent être dangereuses, et laisser l’utilisateur choisir à la fois la chaîne d’entrée et la regex, c’est s’exposer à une attaque !
Un utilisateur pourrait facilement créer une image de base appelée :
Et nous fournir la chaîne suivante :
Cela aurait complètement empêché notre service de traiter toute autre requête ! Aucune équipe AppSec ne laisserait jamais quelqu’un faire ça.
Cela signifie-t-il que nous devons tout reprendre depuis le début ? Heureusement, non ! Il existe une bibliothèque (un moteur d’analyse de regex) spécialement conçue pour empêcher ce type d’attaque. Il s’agit de RE2, développée par Google. Il est important de noter que certaines fonctionnalités des regex ne sont pas prises en charge, mais nous ne les utiliserions pas de toute façon.
Et maintenant, place au fun !
Nous préférons ne pas dépendre uniquement d’une dépendance externe, susceptible de contenir des bugs indépendants de notre volonté ou d’être la cible d’attaques de la chaîne d’approvisionnement logicielle. Nous voulions donc pouvoir interrompre les regex qui prennent trop de temps.
Node étant monothread, il n’est pas facile d’interrompre du code qui ne peut pas s’exécuter simultanément. Voici la solution que nous avons trouvée :
Les threads 🧵 !
C’est probablement la première idée qui vous est venue à l’esprit — c’est aussi la nôtre. En plus de pouvoir arrêter un thread, nous avons l’avantage de ne pas bloquer la boucle d’événements principale !
Bon, écrivons du code :
Thread principal
Script du worker
Plutôt simple, non ? Voyons si le code s’exécute…

Hmm, je n’avais jamais vu cette erreur. Voyons ce qu’en dit MDN :

Et : may not contain native (C++ backed) objects
Il s’avère que les objets ne sont pas transmis par référence, mais clonés. Et comme RE2 est une fonction (classe), qui n’est qu’une liaison vers un objet C++, nous ne pouvons même pas le transmettre en argument.
Nous pourrions simplement le créer dans le thread et transmettre la regex sous forme de chaîne. Mais là encore, un signal d’alerte 🚩 s’est allumé. La reconstitution de l’objet RE2 à chaque appel de match pourrait avoir un impact non négligeable sur les performances.
Évitons les suppositions : nous pouvons le tester. Nous avons utilisé benchmark.js.
Évaluer les performances
⚠️ Il n’est pas facile d’exécuter des microbenchmarks comme ceux-ci. V8 est très efficace pour optimiser le code : prenez donc ces résultats avec des pincettes. Voici une excellente vidéo YouTube sur les microbenchmarks
Comme référence, RE2.match peut s’exécuter 500k fois/s.
Comme les objets RegExp peuvent être clonés, nous pouvons les envoyer tels quels au thread au lieu d’utiliser RE2, puis comparer les performances. Il s’avère que l’exécution de RegExp.match dans un worker thread (en attendant le résultat) atteint 50k exécutions/s. Soit une baisse de 90 % ! 🤩
La création de l’objet RE2 dans le thread donne un résultat encore pire : 17k exécutions/s. Soit une perte de performances de 97 %. 😭
Les threads ne sont peut-être pas la solution la plus optimale, et heureusement, il existe une autre approche !
Le module vm
Node possède un module appelé vm qui permet d’exécuter des scripts dans un contexte distinct. Son avantage : il intègre une option de délai d’expiration.
Voici un exemple de code :
Nous n’avons pas besoin de créer le script et le contexte à chaque fois que nous voulons effectuer une correspondance. Nous pouvons donc créer une closure et la renvoyer.
Code final
Reprenons les benchmarks
Testons cela avec le benchmark pour voir les performances. L’exécution du code suivant dans vm a donné un résultat beaucoup plus acceptable : 200k/s. 👍
Oh, attendez ! Nous avons oublié d’ajouter un délai d’expiration. Relançons donc le benchmark avec un délai de 100 millisecondes :
Cela ne devrait pas avoir beaucoup d’impact, puisque le délai n’est jamais dépassé…
Hummmmmmmm 😭😭😭
Pourquoi l’ajout d’un délai d’expiration qui n’est jamais atteint a-t-il entraîné des performances INFÉRIEURES à celles de l’approche avec des threads !?
Quelques recherches sur Google nous ont menés à un problème sur GitHub, puis à une PR, puis à une note cachée tout en bas de la page de documentation :
⚠️ L’utilisation des options timeout ou
breakOnSigintentraîne le démarrage de nouvelles boucles d’événements et des threads correspondants, ce qui implique un surcoût non nul en termes de performances.
Raaah !
Conclusion
Comme un worker thread est plus complexe (plus difficile à lire, nécessite davantage de ressources et oblige à modifier les signatures en async de toutes les fonctions qui l’utilisent) que la solution vm, et que leurs performances sont similaires, nous avons choisi la solution vm. Nous pourrons l’optimiser si elle devient un goulot d’étranglement à l’avenir.
Maintenant que je vous ai expliqué comment nous avons mis en œuvre cette fonctionnalité, nous aimerions que vous essayiez nos nouvelles recommandations d’images de base personnalisées. Si elles vous plaisent, dites-le-nous sur Twitter à @snyksec.
La sécurité des conteneurs, pensée pour les développeurs
Snyk détecte et corrige automatiquement les vulnérabilités dans les images de conteneurs et les workloads Kubernetes.
