
Ein deterministischer Netzwerk-Sandbox zum Testen von nftables-Regeln. Er verwendet ephemere Linux-Netzwerk-Namespaces (netns) und Scapy, um Firewall-Logik sicher zu validieren.
Warum NSE? • Oracle-Vertrag • Wire-Assertions • Funktionen • Voraussetzungen • Installation • Schnellstart • Funktionsweise • Projektstruktur
Das Testen von Firewall-Regelsätzen auf einem laufenden Linux-System birgt erhebliche Risiken: Fehlerhafte Regeln können SSH-Management-Sitzungen unterbrechen, während des Tests Klartextverkehr preisgeben oder verwaiste Firewall-Tabellen auf dem Host aktiv zurücklassen.
Network Sandbox Engine (NSE) bietet eine sichere, reproduzierbare Testumgebung. Sie erstellt flüchtige Linux-Netzwerk-Namespaces, verbindet virtuelle Ethernet-Paare, kompiliert nftables-Regelsätze und injiziert synthetische Pakete der Schicht 2 und Schicht 3 mit Scapy. Die gesamte Auswertung findet innerhalb des Sandbox-Namespace statt: Der Firewall-Zustand des Hosts wird niemals verändert.
Zentrale architektonische Eigenschaften:
nse_<uuid>) geladen und beim Abbau vollständig entfernt.nse/ umfasst ~1200 Anweisungen bei 98 % Testabdeckung.NSE erstellt Netzwerk-Namespaces, lädt nftables-Regelsätze und liest Kernel-Trace-Ereignisse, daher läuft es als Root. Es öffnet keinerlei Socket, Port oder RPC-Endpunkt — es ist eine Bibliothek und eine CLI, die Sie aufrufen, und es besitzt Privilegien nur für die Dauer eines Laufs.
Version 2.1.0 hat die FastAPI/Svelte-Weboberfläche entfernt, die frühere Releases mitbrachten. Diese Oberfläche lief ab 2.0.0 prozessintern als Root, was für ein Testwerkzeug eine große Angriffsfläche darstellte; der Code bleibt in der Git-Historie unter dem Tag v2.0.0 erhalten, falls Sie ihn benötigen.
Ein Firewall-Test ist eine negative Assertion — „dieses Paket kam nicht durch" — und eine negative Assertion ist wertlos, solange nicht bekannt ist, dass das Messinstrument funktioniert. Ein Trace-Monitor, der sich nie an den Kernel angehängt hat, und eine Firewall, die alles blockiert hat, erzeugen byte-identische Ausgaben.
NSE weigert sich daher, ein Urteil zu melden, dessen Messung es nicht nachweisen kann:
| Garantie | Mechanismus |
|---|---|
| Der Monitor war vor dem ersten Testpaket angehängt | Ein Bereitschafts-Canary wird injiziert und erneut injiziert, bis sein Kernel-Trace beobachtet wird. Keine Beobachtung, kein Lauf. |
| Der Monitor war noch nach dem letzten angehängt | Ein Liveness-Canary läuft nach der Injektion. Wird er verpasst, wird der Verdict-Stream als abgeschnitten erklärt. |
| Der Parser hat verstanden, was der Kernel gesagt hat | Trace-Zeilen, auf die kein Muster passt, werden gezählt, und jede Zählung über null ist ein Fehler statt eines Debug-Logs. |
| Der Monitor ist nicht still gestorben | Die Leseschleife zeichnet auf, warum sie endete — sauberer Stopp, unerwartetes EOF, Timeout oder Absturz — und nur ein sauberer Stopp ist akzeptabel. |
| Ein fehlendes Verdict ist kein Bestehen | Der CLI-Runner schlägt fehl, wenn die Anzahl der beobachteten Verdicts von der erwarteten abweicht, in beide Richtungen. |
Canary-Pakete werden anhand der Trace-ID von den Ergebnissen ausgeschlossen, sodass sie niemals in deinem Verdict-Stream erscheinen.
Die Suite beweist, dass dies gilt, statt es zu behaupten: make test-blind zwingt
den Parser, nichts zu verstehen, und der Build schlägt fehl, sofern der Runner nicht
mit einem Nicht-Null-Exitcode endet. Dieser Job läuft in der CI bei jedem Push.
nft monitor trace meldet, was das Ruleset entschieden hat. Es kann nicht melden, was
eine Schnittstelle verlassen hat, auf der das Ruleset nie gematcht hat — und genau das ist die Frage, die eine
Zero-Leak-Behauptung tatsächlich stellt. PCAPAsserter deckt das ab: ein AsyncSniffer-
Wrapper, der auf einer benannten Schnittstelle mitschneidet und die Frames zurückgibt, sodass ein Test
prüfen kann, dass ein Stimulus keine erzeugt hat.
from nse import PCAPAsserter
asserter = PCAPAsserter(iface="veth-host")
await asserter.start()
# ... send the stimulus ...
leaked = await asserter.stop()
assert not leaked, leaked
Es gilt dieselbe Regel wie beim Trace-Oracle, und es obliegt dem Aufrufer, sie durchzusetzen: Ein Sniffer, der nie angehängt wurde, und eine Firewall, die alles blockiert hat, liefern beide eine leere Liste. Erfasse den Stimulus einmal mit geleertem Ruleset und verlange, dass das Frame gesehen wird, bevor du seine Abwesenheit behauptest.
Der Standardfilter unterdrückt Link-Local-Rauschen und sonst nichts.
DEFAULT_FILTER schließt ARP und ICMPv6-Typen 133-137 aus – Neighbour Discovery, das ein Hop-Limit von 255 trägt und einen entfernten Beobachter nicht erreichen kann. Alles andere wird erfasst, einschließlich ICMPv6-Echo an eine global routbare Adresse. Ein filter-Argument wird mit diesem Standard kombiniert; replace_default_filter=True verwendet ausschließlich deinen eigenen. Die Notlösung existiert, weil der Filter in BPF kompiliert und im Kernel ausgewertet wird: Ein Paket, das er ausschließt, erreicht niemals den Userspace, sodass kein Consumer es nachgelagert wiederherstellen kann. Bis 2.1.2 lautete der Standard not arp and not icmp6, was auch routbares ICMPv6 verwarf – siehe
issue #14.