SOMMAIRE
0 ce qu'est cette série
1 le système en un schéma
2 la stack — un langage par couche
3 les parties
4 trois décisions porteuses
5 par où commencer
──[ 0. Ce Qu'est Cette Série ]──
WazabiEDR est une implémentation fonctionnelle d'EDR (*EDR, Endpoint Detection
and Response* : classe de produit de sécurité host-based qui observe les
activités process, fichier, registre et réseau depuis le kernel, les remonte à
un backend, et expose des alertes à un opérateur) écrite de bout en bout. La
série suit l'ordre de construction : driver kernel d'abord, agent par-dessus,
serveur en aval, protocole de plugins en dernier.
Le code est réel et tourne. Il n'est pas feature-complet face à un EDR
commercial — il n'y a pas encore de moteur de règles de détection, pas de UI
console digne de ce nom, pas de driver signé WHQL. Ce qu'il a, c'est un pipeline
d'ingestion qui fonctionne depuis un driver KMDF, à travers un agent user-mode
en Rust, jusqu'à un serveur FastAPI avec trois stores backing, plus un protocole
de plugins avec un plugin de référence qui bridge Windows Defender. C'est assez
pour enseigner les décisions porteuses, et assez pour étendre.
À la fin de la série, le lecteur devrait pouvoir lire les sept dépôts sur
lululufr/WazabiEDR_* et comprendre pourquoi chaque pièce a la forme qu'elle a,
pas seulement ce qu'elle fait.
──[ 1. Le Système en un Schéma ]──
Sept dépôts composent le codebase. Cinq sont sur le schéma : driver, agent, SDK
plugin, un plugin d'exemple, serveur. Deux n'y sont pas — `WazabiEDR_Utils`
livre le CLI côté opérateur pour l'enrôlement de plugins (Partie 9), et
`WazabiEDR_Doc` porte la documentation transversale. La découpe en sept n'est
pas esthétique : chaque dépôt est une unité de déploiement avec sa propre
cadence de release, son pipeline de build, et son histoire d'ACL (*ACL, Access
Control List* : descripteur de permissions par objet qui gouverne quels
principaux peuvent agir sur un objet Windows).
──[ 2. La Stack — Un Langage Par Couche ]──
Le driver kernel mode, l'agent user-mode, le SDK plugin et le CLI opérateur sont
écrits en Rust. Le serveur est en Python.
Le driver tourne en ring 0 — chaque panic est un bug-check (l'écran bleu) — et
travaille contre les headers WDK via les bindings `wdk-sys`, avec KMDF
(*Kernel-Mode Driver Framework* : la couche d'abstraction Microsoft au-dessus du
modèle WDM brut, qui gère la durée de vie d'objets et la cancellation d'IRP pour
vous) par-dessus. L'agent tourne comme service Windows en user mode,
exclusivement contre `windows-sys` pour la surface Win32 dont il a besoin.
Le serveur est une application FastAPI sur Python 3.12 avec SQLAlchemy 2 async
(contre PostgreSQL), OpenSearch async (un fork d'Elasticsearch utilisé ici comme
store firehose pour les événements et alertes), et Redis async. Le chemin
d'ingestion est borné par l'I/O, pas par la disposition mémoire ; la
productivité de Python gagne cette couche proprement.
Rust n'est pas choisi par nouveauté ni par signal. Il est choisi parce que le
driver et l'agent ont tous les deux des exigences de correction qui mappent bien
aux garanties à la compilation de Rust : pas de pauses GC dans les chemins de
complétion d'IRP, propriété des allocations pool imposée par le système de
types, et pas d'unwind à travers les callbacks kernel.
──[ 3. Les Parties ]──
Partie 0 ── Intro (ce post)
Partie 1 ── Driver — DriverEntry, DriverUnload, table de dispatch
Partie 2 ── Driver — les cinq callbacks kernel
(process, image, registre, thread, accès aux handles)
Partie 3 ── Driver — wire format (repr(C, packed))
Partie 4 ── Driver — ring buffer + IOCTL en appel inversé
Partie 5 ── Agent — pump, spool, shipper
Partie 6 ── Serveur — FastAPI + Postgres + OpenSearch + Redis
Partie 7 ── Plugins — named pipe, handshake, framing
Partie 8 ── SDK plugin — trait Plugin, EventSink, Runner
Partie 9 ── Manifests — wedr-plugin, ACL, SHA-256 via BCrypt CNG
Partie 10 ── Defender Bridge — EvtSubscribe sur le channel Defender
Partie 11 ── Packaging — layout d'install, ce qui hot-reload
Chaque partie se lit seule. Elles sont séquencées pour que l'implémentation
puisse être construite incrémentalement — chaque partie ajoute du code qui
compile contre celui de la partie précédente sans retravail.
──[ 4. Trois Décisions Porteuses ]──
Trois décisions de design structurent l'ensemble du codebase et reviennent dans
chaque partie. À poser tout de suite :
**Observation seule au kernel.** Le driver enregistre les cinq callbacks comme
des callbacks *preoperation* (*preoperation callback* : la variante qui tourne
avant que le kernel ne commit l'opération, avec la possibilité de l'altérer ou
la refuser), mais il n'altère et ne bloque jamais. Chaque callback retourne
l'équivalent de "autoriser, sans modification". Un mode bloquant futur
s'insèrerait aux mêmes sites d'appel, mais le périmètre actuel est purement de
la télémétrie.
La justification est opérationnelle : un EDR bloquant peut faire tomber une
machine si sa politique est fausse. Un EDR en télémétrie seule peut se tromper
d'une manière qui produit du bruit mais pas une panne. On gagnera le droit de
bloquer en étant d'abord correct en observation.
**IPC en appel inversé.** Le driver ne pousse pas les événements vers l'agent.
L'agent émet un IOCTL `DeviceIoControl` (`IOCTL_WEDR_GET_EVENT`) et le driver le
complète avec un événement — synchroniquement si un événement est en file, en
parkant l'IRP sinon (*IRP, I/O Request Packet* : la structure utilisée pour
porter une opération I/O à travers la stack I/O Windows). Quand un producteur
(callback) génère un événement, il copie soit directement dans le buffer de
sortie de l'IRP parkée et la complète, soit pousse sur le ring buffer.
L'avantage, c'est que le driver ne possède aucun thread. La création de thread
dans `DriverEntry` (`PsCreateSystemThread`) fonctionne mais ajoute du signal de
shutdown explicite, des primitives d'attente respectant l'IRQL, et la propriété
de la queue producteur-consommateur. Le design en appel inversé replie tout ça
dans le cycle de vie d'IRP que possède déjà l'I/O manager.
**Ring buffer borné avec comptabilité des drops.** Les producteurs ne peuvent
pas bloquer — un callback kernel qui attend de la place dans la queue est un
deadlock kernel — donc le ring est borné (4096 slots) et "plein" veut dire
*éviction du plus vieux*. Un compteur (`DROP_COUNT`) enregistre les évictions ;
le prochain événement livré avec succès porte le compte dans son header, et
l'agent expose le gap.
Ces trois décisions sont la réponse à la plupart des questions "pourquoi X est
fait comme ça" dans les parties suivantes.
──[ 5. Par Où Commencer ]──
Pour construire en suivant :
- Une machine Windows 11 de labo ou une VM avec le WDK installé
et le test-signing activé (voir windows-internals/dev-setup
dans les anciens volumes de ce site pour les étapes exactes).
- Cloner les sept dépôts sous lululufr/WazabiEDR_*.
- Chaque dépôt a un ARCHITECTURE.md (en français) qui documente
le code correspondant en profondeur. Cette série est la
visite narrative à travers les sept ; la doc par dépôt est
la référence.
La Partie 1 démarre avec le squelette du driver : un driver KMDF en Rust qui
charge, expose le device, et ne fait rien d'autre. Les quatre sections qu'elle
couvre — configuration Cargo, plomberie `no_std` + allocateur + panic,
`DriverEntry`, `DriverUnload` — établissent la discipline d'unwind sur laquelle
s'appuieront tous les enregistrements ultérieurs.