Skip to main content

Temporiser des fonctions synchrones avec des regex

Écrit par
blog hero regex async

6 avril 2023

0 minutes de lecture

Est-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 :

1.82.5_2022_x64_final

Nous pourrions utiliser cette regex (toutes nos excuses à celles et ceux qui font parfois des cauchemars de regex) :

/(?<C0>\d+)\.(?<C1>1d+)\.(?<C2>1d+)_(?<C3>\d{4})_(?<M0>.*)(?<M1>.*)/

À 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 :

"docker-exploit:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!"

Et nous fournir la chaîne suivante :

/(a+)+$/

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

// Main thread
let worker = new Worker("./dist/regex-parsing/regex-worker.js");

async function runRegexSafely(tag: string, regex: RE2) {
 const threadedRegexMatch = new Promise((resolve) => {
   worker.once("message", resolve);
   worker.postMessage({ tag, regex }); // Send the string and regex to the worker
 });
 try {
   return await Promise.race([threadedRegexMatch, timeoutReject(timeoutMs)]);
 } catch (e) {
   await worker.terminate();
   worker = new Worker("./dist/regex-parsing/regex-worker.js");
   throw e;
 }
}

Script du worker

// Worker script
parentPort.on("message", ({ tag, regex }) => {
 parentPort?.postMessage(tag.match(regex));
});

Plutôt simple, non ? Voyons si le code s’exécute…

Sortie du terminal affichant un rejet de Promise non géré, causé par une erreur DataCloneError dans le code d’analyse asynchrone d’expressions régulières.

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

Diapositive intitulée « Ce qui ne fonctionne pas avec le clonage structuré », expliquant que les objets Function ne peuvent pas être dupliqués et provoquent une erreur DataCloneError.

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.

new RE2(/(a+)+$/)

É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 :

const vm = require('node:vm');

const context = {
  regex: '(?<C0>.*)\\.(?<C1>.*)',
  tag: 1.2
};

vm.createContext(context);
const script = new vm.Script("regex.exec(tag)");

return script.runInContext(context, {timeout: 1000});

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

function getClosure() {
	const context = {
	  regex: '(?<C0>.*)\\.(?<C1>.*)',
	  tag: 1.2
	};

	vm.createContext(context);
	const script = new vm.Script("regex.exec(tag)");

	return function(regex, tag, timeout=0) {
		context.regex = regex;
		context.tag = tag;

		return script.runInContext(context, {timeout});
	}
}

const finalMatchFunction = getClosure();

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. 👍

finalMatchFunction(RE2("(?<C0>.*)"), "1")

Oh, attendez ! Nous avons oublié d’ajouter un délai d’expiration. Relançons donc le benchmark avec un délai de 100 millisecondes :

finalMatchFunction(RE2("(?<C0>.*)"), "1", 100)

Cela ne devrait pas avoir beaucoup d’impact, puisque le délai n’est jamais dépassé…

finalMatchFunction#withtimeout × 10,798 ops/sec ±2.07% (86 runs sampled)

Process finished with exit code 0

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 breakOnSigint entraî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.


Publié dans: