SOMMAIRE
0 pour quoi existent les plugins
1 le named pipe et son acl
2 framing — u32 longueur, puis json
3 le handshake
4 régime établi — push only
5 raisons de rejet, goodbye, ctrl+c
──[ 0. Pour Quoi Existent les Plugins ]──
Les callbacks kernel de la Partie 2 nous donnent cinq sources de télémétrie.
C'est tout le menu de "ce qu'un driver peut observer par des callbacks
enregistrés". Un EDR s'intéresse à plus : entrées de l'Event Log Windows,
détections Defender, ce que Sysmon attraperait s'il était installé, traces
d'audit d'applications custom. Rien de tout ça n'est atteignable depuis un
callback kernel.
Deux designs résolvent ça. Soit chaque source est cuite dans l'agent, le faisant
grossir indéfiniment, soit une interface stable est définie et les sources
deviennent des programmes indépendants qui s'y connectent. On a choisi le second
: un **plugin** est un processus séparé qui se connecte à l'agent via un named
pipe et pousse des événements JSON-encodés. L'agent les ingère à travers le même
pipeline spool/shipper que les événements kernel. Les plugins sont versionnés
indépendamment, signés indépendamment, et démarrables indépendamment.
Ce post définit le protocole. La Partie 8 couvre le SDK qui l'abstrait. La
Partie 9 couvre le mécanisme de manifest qui autorise des plugins spécifiques.
La Partie 10 déroule un cas réel.
──[ 1. Le Named Pipe et Son ACL ]──
\\.\pipe\WazabiEDR_plugin
Les named pipes (*named pipe* : un mécanisme IPC de Windows qui expose un flux d
'octets duplex identifié par un nom dans le namespace kernel `DeviceNamedPipe`,
accédé depuis le user mode via des API fichier Win32 comme `CreateFileW`) sont l
e transport choisi. Un pipe, servi par l'agent. Les plugins se connectent via `C
reateFileW(L"\\.\pipe\WazabiEDR_plugin", …)`. Plusieurs plugins peuvent être con
nectés simultanément ; l'agent spawn un worker par connexion, chacun tenant son
propre état de session.
Le choix du named pipe sur une socket TCP loopback se résume à deux propriétés :
Le pipe vit entièrement en mémoire kernel. Aucune stack réseau n'est impliquée,
aucune allocation de port, aucune configuration de firewall. Un programme qui
peut atteindre le pipe est par définition local — on n'a pas besoin d'inventer
un check "est-ce que c'est localhost ?".
Le pipe porte une ACL (le descripteur de contrôle d'accès qu'on a couvert en
Partie 0). Le pipe est créé avec une ACL discrétionnaire qui restreint l'accès
de connexion aux processus tournant soit comme le même utilisateur que l'agent,
soit comme `SYSTEM`. Un utilisateur interactif normal ne peut pas se connecter ;
la frontière de sécurité est imposée au site de création du pipe, pas dans le
protocole.
──[ 2. Framing — u32 Longueur, Puis JSON ]──
Chaque frame dans n'importe quelle direction a la même forme :
┌──────────────────┬───────────────────────────────────┐
│ u32 LE longueur│ longueur octets de JSON UTF-8 │
└──────────────────┴───────────────────────────────────┘
Le framing length-prefixed est obligatoire parce qu'un named pipe en mode
byte-stream (`PIPE_TYPE_BYTE`) délivre un flux plutôt que des messages discrets
— le consommateur n'a pas de frontière implicite entre les writes du producteur.
Le header de longueur 4 octets little-endian dit au consommateur exactement
combien d'octets lire pour la prochaine frame.
Un cap de taille est imposé des deux côtés :
pub const MAX_FRAME_BYTES: usize = 1 * 1024 * 1024;
1 Mio. Le SDK l'impose sur l'outbound pour donner à l'auteur du plugin une
erreur typée plutôt qu'une déconnexion silencieuse quand une frame dépasserait
le cap. L'agent l'impose sur l'inbound pour la même raison de son côté, et en
plus comme défense contre un producteur corrompu ou hostile qui demanderait une
allocation de plusieurs Gio.
Du JSON sur un pipe binaire parfaitement utilisable mérite une phrase de
justification. Le wire format du driver (Partie 3) est binaire parce qu'il
s'assied sur un hot path kernel et que le kernel ne peut pas se payer un
sérialiseur. Le protocole plugin n'a pas ces contraintes — les plugins sont
écrits par des tiers dans des langages arbitraires, les volumes sont plus petits
que le firehose driver (les plugins émettent typiquement des événements
"intéressants", pas de l'activité brute), et "l'API prend du JSON" est le
contrat à plus faible friction pour le plus grand ensemble d'auteurs de plugins.
──[ 3. Le Handshake ]──
Une nouvelle connexion n'est pas encore une session. Les deux côtés doivent
parler une fois d'abord :
plugin agent
────── ─────
│── hello ───────────────────►│
│ │ lookup de manifest,
│ │ vérification expected_path,
│ │ vérification expected_sha256
│ │
│◄────────────── hello_ack ───│
│ │
│── event ───────────────────►│
│── event ───────────────────►│
│── heartbeat ───────────────►│
│── event ───────────────────►│
│── goodbye ─────────────────►│
│ (pipe fermé) │
La frame `Hello` du plugin s'identifie :
#[derive(Debug, Serialize)]
pub struct Hello<'a> {
pub plugin_id: &'a str, // UUID du manifest
pub plugin_version: &'a str, // semver, informatif
pub schema_version: u32, // 1 dans cette révision
}
L'agent cherche le manifest par `plugin_id`. S'il n'en existe pas, le plugin
reçoit un `Reject`. S'il en existe un, l'agent vérifie le processus qui se
connecte : son chemin d'image doit matcher `expected_path` et son SHA-256 doit
matcher `expected_sha256`. La cible de vérification est le *processus*
(identifié via `GetNamedPipeClientProcessId`, qui rend le PID du processus à
l'autre bout du pipe), pas le fichier exécutable sur disque — le check SHA-256
est effectué contre l'image en cours d'exécution pour détecter un tampering
entre disque et chargement.
Sur succès l'agent répond :
#[derive(Debug, Deserialize)]
pub struct HelloAck {
pub session_id: String,
pub server_version: String,
pub max_payload_bytes: u32,
pub heartbeat_sec: u32,
}
`session_id` corrèle les entrées dans le log stderr de l'agent.
`max_payload_bytes` est le cap de payload par événement, plus petit que
`MAX_FRAME_BYTES` parce que l'enveloppe JSON plus les headers consomment une
partie de la frame. `heartbeat_sec` est la cadence à laquelle l'agent attend un
heartbeat quand le plugin est par ailleurs au repos (défaut 30 s ; configurable
via le builder `Runner` en Partie 8).
──[ 4. Régime Établi — Push Only ]──
Après `HelloAck`, l'agent n'écrit plus jamais de frame. Le pipe devient
unidirectionnel : plugin → agent. Le SDK ne spawn même pas de thread lecteur.
Le plugin émet trois types de frames :
#[derive(Debug, Serialize)]
pub struct Event<'a> {
pub seq: u64,
pub ts_unix_ns: u64,
pub kind: &'a str,
pub payload: &'a serde_json::Value,
}
#[derive(Debug, Serialize)]
pub struct Heartbeat { pub seq: u64 }
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientFrame<'a> {
Hello(Hello<'a>),
Event(Event<'a>),
Heartbeat(Heartbeat),
Goodbye {},
}
`Event` est la frame de travail. `kind` est une clé de routage
(`"defender.threat_detected"`, `"app.user_login"`, …) utilisée par l'agent pour
tagger l'événement dans le spool et par les moteurs de règles en aval pour le
pattern matching. `payload` est du JSON opaque — le plugin choisit ce qu'il met
dedans. `seq` est monotone par session et permet à l'agent de détecter la perte
de frame dans une session (rare en pratique — le pipe est un byte-stream fiable
— mais pas cher à exposer).
`Heartbeat` est envoyé par le SDK sur un thread d'arrière-plan quand le plugin
n'a pas émis d'`Event` dans `heartbeat_sec`. Il distingue "plugin vivant mais au
repos" de "plugin crashé et l'OS ne l'a pas encore remarqué". Les auteurs de
plugins ne l'appellent pas directement.
`Goodbye` est émis par le SDK juste avant de fermer le pipe sur un shutdown
propre (Ctrl+C, arrêt du service parent). Fonctionnellement c'est cosmétique —
l'agent observe le même pipe cassé que Goodbye ait précédé ou non — mais ça
permet à l'agent de logger `goodbye received` plutôt que `EOF on pipe`, ce qui
améliore la lisibilité de stderr en debug.
──[ 5. Raisons de Rejet, Goodbye, Ctrl+C ]──
L'agent rejette un `Hello` pour un petit ensemble de raisons bien définies :
#[derive(Debug, Deserialize)]
pub struct RejectFrame {
pub reason: String,
}
Chaînes `reason` documentées :
unknown_plugin — plugin_id n'a pas de manifest dans le
répertoire
path_mismatch — le chemin d'image du processus qui se
connecte ne vaut pas manifest.expected_path
sha256_mismatch — le SHA-256 de l'image en cours ne vaut pas
manifest.expected_sha256
revoked — le manifest existe mais son champ revoked
vaut true (voir Partie 9)
schema_mismatch — schema_version du plugin != celui du
serveur
Les raisons sont des chaînes plates plutôt que des entiers codés parce que leurs
consommateurs sont des humains — l'opérateur qui lance `wedr-plugin doctor`
(Partie 9), le développeur qui lit le stderr de l'agent — et le protocole est
suffisamment étroit pour qu'une énumération plate de chaînes claires soit plus
découvrable qu'un dictionnaire de codes numériques.
Après `HelloAck`, la connexion est single-shot. Si quelque chose l'interrompt —
l'agent redémarre, le write de pipe échoue avec `ERROR_BROKEN_PIPE`, l'OS évince
le pipe sous pression mémoire extrême — le SDK ne tente pas de reconnexion. Le
`main` du plugin reçoit l'erreur et sort. Le redémarrage du processus plugin est
la responsabilité d'un superviseur de service : soit le contrôleur de services
OS pour les plugins installés comme services, soit l'agent lui-même pour les
plugins flaggés `auto_launch = true` dans leur manifest (Partie 9).
Prochain post : le SDK qui cache le protocole derrière une abstraction `trait
Plugin` / closure, laissant à l'auteur du plugin seulement la logique de
producteur à écrire.