Skip to main content

Synchrone Funktionen mit Regex mit einem Timeout versehen

Artikel von
blog hero regex async

6. April 2023

0 Min. Lesezeit

Wie schwierig kann es schon sein, benutzerdefinierte Container-Image-Tags zu unterstützen? Wie sich herausstellt … ziemlich! Ich weiß das, weil mein Team intensiv an unserer neuen Unterstützung für benutzerdefinierte Basis-Images für Snyk Container gearbeitet hat und vor folgender Aufgabe stand: Ausgehend von einem Tag seine Bestandteile analysieren, um ihn mit ähnlichen Tags vergleichen zu können. Die Lösung dieses Problems hat Spaß gemacht – und wir möchten gern zeigen, wie wir zu unserer endgültigen Lösung gekommen sind!

Reguläre Ausdrücke als Rettung?

Unsere Idee war, Regex zu verwenden, insbesondere die Funktion für benannte Gruppen. Wenn wir zum Beispiel Folgendes abgleichen möchten:

1.82.5_2022_x64_final

Könnten wir diesen Regex verwenden (vorab eine Entschuldigung an alle, die gelegentlich von Regex-Albträumen geplagt werden):

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

An diesem Punkt sind bei Ihnen vielleicht einige Warnsignale 🚩 aufgeblinkt. Wir wissen alle, dass Regexe gefährlich sein können. Dem Benutzer sowohl die Eingabezeichenfolge als auch den Regex selbst zu überlassen, lädt geradezu zu Angriffen ein!

Ein Benutzer könnte ganz einfach ein Basis-Image mit folgendem Namen erstellen:

"docker-exploit:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!"

Und uns diese Zeichenfolge übergeben:

/(a+)+$/

Dadurch würde unser Service die Verarbeitung aller anderen Anfragen vollständig einstellen! Kein AppSec-Team würde so etwas jemals zulassen.

Heißt das, wir müssen wieder ganz von vorn anfangen? Zum Glück nicht! Es gibt eine Bibliothek (eine Regex-Parsing-Engine), die speziell zur Abwehr solcher Angriffe entwickelt wurde. Sie heißt RE2 und wurde von Google entwickelt. Einige Regex-Funktionen werden nicht unterstützt, sind aber ohnehin nicht erforderlich. 

Jetzt wird es spannend!

Wir wollten uns nicht ausschließlich auf eine externe Abhängigkeit verlassen, die anfällig für Bugs außerhalb unserer Kontrolle und mögliche Software-Supply-Chain-Angriffe ist. Deshalb wollten wir eine Möglichkeit finden, bei zu lange laufenden Regex einen Timeout auszulösen.

Da Node Single-Threaded ist, lässt sich Code, der nicht parallel ausgeführt werden kann, nicht ohne Weiteres mit einem Timeout versehen. Hier ist unsere Lösung:

Threads 🧵!

Das ist wahrscheinlich das Erste, was Ihnen in den Sinn gekommen ist – uns auch. Threads lassen sich nicht nur beenden, sondern blockieren außerdem nicht die Main Event Loop!

Also, schreiben wir etwas Code:

Main-Thread

// 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;
 }
}

Worker-Skript

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

Ziemlich einfach, oder? Sehen wir nach, ob der Code läuft …

Terminalausgabe mit einer nicht abgefangenen Promise-Zurückweisung, verursacht durch einen DataCloneError beim asynchronen Parsen eines regulären Ausdrucks.

Hmm, diesen Fehler habe ich noch nie gesehen. Schauen wir nach, was MDN dazu sagt:

Folie mit dem Titel „Dinge, die mit Structured Clone nicht funktionieren“. Sie erklärt, dass Function-Objekte nicht dupliziert werden können und einen DataCloneError auslösen.

Und: may not contain native (C++ backed) objects

Wie sich herausstellt, werden Objekte nicht als Referenz übergeben, sondern geklont. Und da RE2 eine Funktion (Klasse) ist, die lediglich an ein C++-gestütztes Objekt gebunden ist, können wir sie nicht einmal als Argument übergeben.

Wir könnten das Objekt einfach innerhalb des Threads erstellen und den Regex als Zeichenfolge übergeben. Doch auch hier blinkte ein Warnsignal 🚩 auf. Die Leistung könnte erheblich beeinträchtigt werden, wenn wir das RE2-Objekt für jeden match-Aufruf neu erstellen müssen.

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

Statt zu spekulieren, können wir das tatsächlich testen. Wir haben benchmark.js verwendet.

Leistung messen

⚠️ Solche Mikrobenchmarks sind nicht einfach durchzuführen. V8 ist bei Optimierungen sehr leistungsfähig. Nehmen Sie diese Tests also mit einer Prise Salz. Hier finden Sie ein großartiges YouTube-Video zum Thema Mikrobenchmarking

Als Referenz: RE2.match kann 500.000 Mal pro Sekunde ausgeführt werden.

Da sich RegExp-Objekte klonen lassen, können wir das Objekt unverändert an den Thread senden, statt RE2 zu verwenden, und die Leistung messen. Wie sich herausstellt, erreicht RegExp.match innerhalb eines Worker-Threads (mit Abwarten des Ergebnisses) 50.000 Aufrufe pro Sekunde. Ein Rückgang um 90 %! 🤩

Wird das RE2-Objekt im Thread erstellt, sinkt die Leistung sogar auf 17.000 Aufrufe pro Sekunde. Eine Leistungseinbuße von 97 %. 😭

Vielleicht sind Threads nicht die optimale Lösung. Zum Glück gibt es noch einen anderen Weg!

Das vm-Modul

Node bietet ein Modul namens vm, mit dem Sie Skripte in einem separaten Kontext ausführen können. Der Vorteil: Es bietet eine integrierte Timeout-Option.

Hier ein Beispiel:

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});

Wir müssen Skript und Kontext nicht jedes Mal neu erstellen, wenn wir einen Abgleich ausführen möchten. Deshalb können wir eine Closure erstellen und zurückgeben.

Endgültiger Code

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();

Noch einmal benchmarken

Sehen wir uns die Leistung im Benchmark an. Bei der Ausführung des folgenden Codes innerhalb der VM lag sie mit 200.000 Aufrufen pro Sekunde in einem deutlich akzeptableren Bereich. 👍

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

Moment! Wir haben vergessen, einen Timeout hinzuzufügen. Wiederholen wir den Benchmark also mit einem Timeout von 100 Millisekunden:

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

Das sollte sich kaum auswirken, da der Timeout nie tatsächlich ausgelöst wird …

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

Process finished with exit code 0

Hmmm … 😭😭😭

Warum führt ein Timeout, der nie ausgelöst wird, zu einer SCHLECHTEREN Leistung als der Thread-Ansatz!?

Einige Google-Suchen führten zu einem GitHub-Issue, das zu einem PR führte, der wiederum auf einen Hinweis verwies, der ganz unten auf der Dokumentationsseite versteckt war:

⚠️ Wenn Sie die Optionen „timeout“ oder breakOnSigint verwenden, werden neue Event-Loops und die zugehörigen Threads gestartet. Das verursacht zusätzlichen Leistungsaufwand.

Verdammt!

Fazit

Da Worker-Threads komplizierter sind (schwerer lesbar, ressourcenintensiver und Änderungen an den Signaturen aller Funktionen erfordern, die sie verwenden) als die vm-Lösung und beide eine ähnliche Leistung bieten, haben wir uns für die vm-Lösung entschieden. Sollte sie künftig zum Engpass werden, können wir sie optimieren.

Nachdem ich erklärt habe, wie wir diese Funktion implementiert haben, möchten wir Sie einladen, unsere neuen Empfehlungen für benutzerdefinierte Basis-Images auszuprobieren. Wenn sie Ihnen gefallen, sagen Sie uns auf Twitter unter @snyksec Bescheid.

Container-Sicherheit mit Fokus auf Entwickler

Snyk findet und behebt automatisch Schwachstellen in Container-Images und Kubernetes-Workloads.


Gepostet in: