
Un bac à sable réseau déterministe pour tester les règles nftables. Il utilise des espaces de noms réseau Linux éphémères (netns) et Scapy pour valider la logique de pare-feu en toute sécurité.
Pourquoi NSE ? • Le contrat de l'oracle • Assertions sur le réseau • Fonctionnalités • Prérequis • Installation • Démarrage rapide • Fonctionnement • Structure du projet
Tester des jeux de règles de pare-feu sur un système Linux en production présente des risques importants : des règles mal formées peuvent couper les sessions SSH d'administration, laisser fuiter du trafic en clair pendant les tests, ou laisser des tables de pare-feu orphelines actives sur l'hôte.
Network Sandbox Engine (NSE) fournit un banc de test sûr et reproductible. Il construit des espaces de noms réseau Linux éphémères, câble des paires d'interfaces ethernet virtuelles, compile des jeux de règles nftables et injecte des paquets synthétiques de couche 2 et de couche 3 à l'aide de Scapy. Toute l'évaluation se déroule à l'intérieur de l'espace de noms du bac à sable : l'état du pare-feu de l'hôte n'est jamais modifié.
Propriétés architecturales clés :
nse_<uuid>) et sont entièrement supprimés lors du démontage.nse/ représente environ 1200 instructions avec 98 % de couverture de tests.NSE crée des espaces de noms réseau, charge des jeux de règles nftables et lit les événements de trace du noyau, il s'exécute donc en tant que root. Il n'ouvre ni socket, ni port, ni point de terminaison RPC d'aucune sorte — c'est une bibliothèque et une CLI que vous invoquez, et il ne détient des privilèges que pendant la durée d'une exécution.
La version 2.1.0 a supprimé l'interface web FastAPI/Svelte que les versions précédentes
fournissaient. Cette interface s'exécutait en processus en tant que root à partir de la version 2.0.0, ce qui constituait une
surface d'attaque importante pour un outil de test ; le code reste dans l'historique git au
tag v2.0.0 si vous en avez besoin.
Un test de pare-feu est une assertion négative — « ce paquet n'est pas passé » — et une assertion négative ne vaut rien tant que l'instrument n'est pas connu comme fonctionnel. Un moniteur de trace qui ne s'est jamais attaché au noyau et un pare-feu qui bloquait tout produisent une sortie identique octet pour octet.
NSE refuse donc de rapporter un verdict dont il ne peut pas démontrer qu'il l'a mesuré :
| Garantie | Mécanisme |
|---|---|
| Le moniteur a été attaché avant le premier paquet de test | Un canary de disponibilité est injecté et réinjecté jusqu'à ce que sa trace noyau soit observée. Pas d'observation, pas d'exécution. |
| Le moniteur était toujours attaché après le dernier | Un canary de vivacité s'exécute après l'injection. S'il est manqué, le flux de verdicts est déclaré tronqué. |
| Le parseur a compris ce que le noyau a dit | Les lignes de trace qu'aucun motif ne correspond sont comptées, et tout compte supérieur à zéro est une erreur plutôt qu'un journal de débogage. |
| Le moniteur n'est pas mort silencieusement | La boucle de lecture enregistre pourquoi elle s'est terminée — arrêt propre, EOF inattendu, timeout ou crash — et seul un arrêt propre est acceptable. |
| Un verdict manquant n'est pas une réussite | Le runner CLI échoue lorsque le nombre de verdicts observés diffère du nombre attendu, dans un sens comme dans l'autre. |
Les paquets canary sont exclus des résultats par trace id, de sorte qu'ils n'apparaissent jamais dans votre flux de verdicts.
La suite prouve que cela tient, plutôt que de l'affirmer : make test-blind force
le parseur à ne rien comprendre, et la compilation échoue sauf si le runner se termine
avec un code non nul. Cette tâche s'exécute en CI à chaque push.
nft monitor trace rapporte ce que le ruleset a décidé. Il ne peut pas rapporter ce qui
a quitté une interface que le ruleset n'a jamais matchée, ce qui est précisément la question que pose réellement
une affirmation de zéro fuite. PCAPAsserter couvre cela : un wrapper AsyncSniffer
qui capture sur une interface nommée et renvoie les trames, afin qu'un test
puisse affirmer qu'un stimulus n'a produit aucune.
from nse import PCAPAsserter
asserter = PCAPAsserter(iface="veth-host")
await asserter.start()
# ... send the stimulus ...
leaked = await asserter.stop()
assert not leaked, leaked
La même règle que pour l'oracle de trace s'applique, et il incombe à l'appelant de la faire respecter : un sniffer qui ne s'est jamais attaché et un pare-feu qui a tout bloqué renvoient tous deux une liste vide. Capturez le stimulus une fois avec le jeu de règles vidé et exigez que la trame soit vue avant de constater son absence.
Le filtre par défaut supprime le bruit link-local, et rien d'autre.
DEFAULT_FILTER exclut ARP et les types ICMPv6 133-137 - Neighbour Discovery, qui porte une limite de sauts de 255 et ne peut pas atteindre un observateur distant. Tout le reste est capturé, y compris l'écho ICMPv6 vers une adresse routable globalement. Un argument filter est combiné avec ce filtre par défaut ; replace_default_filter=True utilise le vôtre seul. La porte de sortie existe parce que le filtre est compilé dans BPF et évalué dans le noyau : un paquet qu'il exclut n'atteint jamais l'espace utilisateur, donc aucun consommateur ne peut le récupérer en aval. Jusqu'à la version 2.1.2, le filtre par défaut était not arp and not icmp6, ce qui écartait aussi l'ICMPv6 routable - voir
issue #14.