<index> / <wazabiedr> / defender-bridge
[ en | fr ]
┌───────────────────────┐
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
└───────────────────────┘
Partie 10 — Defender Bridge
~ lululufr
SOMMAIRE
  0  pourquoi bridger defender
  1  deux fichiers, trois cents lignes
  2  evtsubscribe — la surface d'api
  3  le callback et le modèle de durée de vie
  4  rendre le xml de l'événement
  5  routage par event id
  6  limites connues

──[ 0. Pourquoi Bridger Defender ]──

Le moteur antimalware de Windows Defender vient avec l'OS, tourne par défaut sur 
chaque installation, et écrit sa sortie de détection dans un channel Event Log
stable. Réimplémenter la détection dans notre agent dupliquerait du travail que
Defender fait déjà bien ; bridger la sortie de Defender dans notre flux
d'événements est un petit morceau de travail bien scopé qui donne une baseline
de télémétrie de détection de malware gratuitement. Le plugin a un second but : c'est le **gabarit** canonique pour les plugins
pilotés par callback. La forme — ouvrir l'API de souscription de la source,
enregistrer un callback, forwarder les événements à travers `EventSink`, parker
le thread principal jusqu'au shutdown — c'est la même forme que prendra
n'importe quel plugin push-driven, des consommateurs ETW (Event Tracing for
Windows) aux hooks d'API Win32 aux sources d'applications custom. Lisez ce post
comme un exemple travaillé de ce pattern autant que comme un bridge spécifique à
Defender.
──[ 1. Deux Fichiers, Trois Cents Lignes ]──

Le plugin entier vit dans `WazabiEDR_Plugin_DefenderBridge/` :
   src/
   ├── main.rs        ~30 lignes — câblage SDK
   └── eventlog.rs    ~250 lignes — wrapper EvtSubscribe + rendu XML
`main.rs` est court parce que le SDK (Partie 8) absorbe le travail de protocole. 
Le voilà en entier :
mod eventlog;

use wedr_plugin_sdk::{Result, metadata, run_with};

const PLUGIN_NAME: &str = "wedr-defender-bridge";

fn main() -> Result<()> {
    let metadata = metadata!(PLUGIN_NAME)?;
    run_with(metadata, |sink, ctx| {
        let _subscription = eventlog::Subscription::start(sink.clone())?;
        eprintln!("[{PLUGIN_NAME}] subscribed to Defender/Operational");
        ctx.wait_for_shutdown();
        eprintln!("[{PLUGIN_NAME}] shutdown signalled, unsubscribing");
        Ok(())
    })
}
Le détail non évident, c'est l'ordering à l'intérieur de la closure.

L'abonnement est démarré en premier ; il rend un handle RAII (RAII : Resource 
Acquisition Is Initialisation, le pattern qui lie la durée de vie d'une
ressource à la portée d'une variable de pile pour que le destructeur libère la
ressource déterministiquement). Le handle est tenu sur la pile via `let
_subscription = …`. Puis `wait_for_shutdown` parke le thread. Au réveil, la
closure retourne, le handle drop (ce qui dit à l'Event Log d'arrêter de
dispatcher les callbacks), et *ensuite seulement* le SDK démonte le pipe. L'ordre inverse coursrait : si le SDK démontait le pipe avant que l'abonnement
ait arrêté de dispatcher, le `sink.emit` d'un callback en vol pourrait écrire
dans un pipe mort. Tenir l'abonnement sur la pile inverse l'ordre de drop —
abonnement d'abord, sink ensuite.
──[ 2. EvtSubscribe — La Surface D'API ]──

L'API Event Log de Windows (l'API exposée à travers `wevtapi.dll`, le 
remplacement moderne des APIs de logging de l'ère `eventvwr`) supporte plusieurs
modes d'abonnement. On utilise la saveur callback :
let handle = unsafe {
    EvtSubscribe(
        0,                          // session locale
        ptr::null_mut(),            // pas d'event de signal — on utilise un callback
        channel_w.as_ptr(),         // "Microsoft-Windows-Windows Defender/Operational"
        ptr::null(),                // pas de filtre XPath — livrer chaque événement
        0,                          // pas de bookmark
        ctx as *const c_void,
        Some(callback),
        SUBSCRIBE_FUTURE_EVENTS,
    )
};
`SUBSCRIBE_FUTURE_EVENTS` (la valeur du flag `EvtSubscribeToFutureEvents`) dit à 
l'API de ne livrer que les événements qui se produisent à partir de maintenant ;
elle ne rejoue pas l'histoire. Pour un plugin EDR c'est le bon défaut — on ne
veut pas qu'un démarrage frais de plugin inonde l'agent avec les événements
Defender de la semaine dernière. Le replay est supporté via des bookmarks (un
handle opaque rendu par `EvtCreateBookmark` qui enregistre "j'ai vu jusqu'à cet
événement") mais c'est hors périmètre pour la v1 de ce plugin. Le `ctx as *const c_void` est l'idiome standard pour faufiler de la donnée Rust
dans une API à callback C. Windows nous repasse le pointeur verbatim à chaque
dispatch :
struct CallbackCtx {
    sink: EventSink,
}
On `Box::into_raw` le contexte une fois avant de le passer à `EvtSubscribe`, et 
le callback le déréférence en arrière vers une référence Rust. La durée de vie
de cette allocation demande de l'attention — voir la section suivante.
──[ 3. Le Callback et le Modèle de Durée de Vie ]──

L'API Event Log dispatche les callbacks sur ses propres threads internes. La 
documentation indique que `EvtClose` draine les callbacks en vol avant de
retourner, mais le contrat est décrit assez vaguement pour que le code de
production prenne typiquement une posture défensive contre un callback errant
qui s'exécuterait après le close.
impl Drop for Subscription {
    fn drop(&mut self) {
        unsafe { EvtClose(self.handle); }
    }
}
L'allocation `CallbackCtx` est **intentionnellement leak**. Le processus plugin 
sort après que `Subscription` drop, et le coût de leak un `Arc<EventSink>` plus
une petite struct wrapper (quelques mots de mémoire) est négligeable comparé au
coût d'un use-after-free si `EvtClose` retourne avant de drainer un callback
encore en vol. L'alternative — refcounter le contexte avec `Arc` pour qu'un
callback errant du logger kernel puisse continuer à l'utiliser en sécurité —
c'est plus de code et ça ajoute une chose de plus qui peut être subtilement
fausse. Le callback lui-même est court :
unsafe extern "system" fn callback(
    action: i32,
    user_ctx: *const c_void,
    event: EVT_HANDLE,
) -> u32 {
    if action != ACTION_DELIVER { return 0; }
    if user_ctx.is_null() { return 0; }
    let ctx = unsafe { &*(user_ctx as *const CallbackCtx) };

    let xml = match render_event_xml(event) {
        Ok(s) => s,
        Err(_) => return 0,
    };

    let event_id = extract_event_id(&xml).unwrap_or(0);
    let kind = kind_for(event_id);

    let payload = json!({
        "event_id": event_id,
        "raw_xml":  xml,
    });

    let _ = ctx.sink.emit(&kind, payload);
    0
}
`action == 0` est la variante de livraison d'erreur de l'API — Windows appelle 
le même callback pour nous informer que l'abonnement lui-même a un problème (le
channel est parti, etc.). On les drop parce que les ré-émetter en plugin events
créerait une boucle de feedback : chaque erreur devient un autre événement
devient une autre erreur. Le callback retourne 0 inconditionnellement. L'API autorise du non-nul pour dire
"cet événement a eu un problème", mais un seul échec de rendu ne devrait pas
empoisonner l'abonnement entier — `0` suivi de logger-et-continuer, c'est la
posture plus pardonnante. L'erreur swallowée `let _ = ctx.sink.emit(...)` est
pour la même raison : si le pipe est cassé, le thread principal du plugin va le
découvrir et sortir ; le callback dans un thread Event Log ne peut pas réagir
productivement.
──[ 4. Rendre le XML de L'Événement ]──

`EvtRender` suit le pattern Win32 standard de deux appels probe-puis-render. 
Premier appel avec un buffer vide pour apprendre la taille requise, second appel
avec un buffer de cette taille :
fn render_event_xml(event: EVT_HANDLE) -> std::io::Result<String> {
    let mut buffer_used: u32 = 0;
    let mut property_count: u32 = 0;

    unsafe {
        let _ = EvtRender(
            0, event, RENDER_EVENT_XML, 0, ptr::null_mut(),
            &mut buffer_used, &mut property_count,
        );
        let err = GetLastError();
        if err != ERROR_INSUFFICIENT_BUFFER {
            return Err(std::io::Error::from_raw_os_error(err as i32));
        }
    }

    let u16_cap = (buffer_used as usize).div_ceil(2);
    let mut buf: Vec<u16> = vec![0; u16_cap];

    let ok = unsafe {
        EvtRender(
            0, event, RENDER_EVENT_XML, buffer_used,
            buf.as_mut_ptr() as *mut c_void,
            &mut buffer_used, &mut property_count,
        )
    };
    if ok == 0 {
        return Err(std::io::Error::last_os_error());
    }
    // … buf UTF-16 → String, trim le NUL final …
}
Une mine de conversion d'unité : `buffer_used` est rapporté en **octets** même 
si la sortie rendue est en UTF-16. La longueur du `Vec<u16>` est donc
`div_ceil(2)` du compte d'octets, pas le compte d'octets lui-même. La doc MSDN
dit "octets" mais c'est facile à louper. Les événements Defender sont petits — moins de 4 Kio chacun — donc le second
appel réussit toujours. Pour des channels avec des événements plus gros
(Security avec le détail audit-policy, ou n'importe quel channel portant des
logs de commandes PowerShell inline), un événement pourrait grandir entre la
probe et le render ; le code de production pour ces channels a besoin d'une
boucle de retry.
──[ 5. Routage Par Event ID ]──

Le champ `kind` sur le wire est la clé de routage primaire de l'agent pour les 
plugin events. Le plugin le sélectionne en parsant l'EventID hors du XML rendu :
fn kind_for(event_id: u32) -> String {
    match event_id {
        1116 | 1117 => "defender.threat_detected".into(),
        1118 | 1119 => "defender.threat_remediation_failed".into(),
        5001        => "defender.realtime_protection_disabled".into(),
        5004        => "defender.realtime_protection_config_change".into(),
        2000 | 2001 => "defender.engine_definition_update".into(),
        other       => format!("defender.event_{other}"),
    }
}
Une poignée de mappings explicites pour les événements qui pilotent l'alerting 
en aval, puis un fallback vers `defender.event_N` pour que rien ne soit droppé
silencieusement. Les consommateurs qui ne se soucient pas d'un EventID
spécifique voient un seul bucket ; ceux qui s'en soucient (une règle côté
serveur sur EventID 5001) matchent le `kind` dédié. Le parsing, c'est une recherche de sous-chaîne pour `EventID="N"` plutôt qu'un
parser XML complet. Le schéma XML de Defender est stable sur Windows 10 et
Windows 11, l'attribut `EventID` apparaît toujours au même niveau de nesting, et
une recherche de sous-chaîne est plus rapide qu'instancier un parser XML à
chaque invocation de callback. EventID 5001 (real-time protection disabled) reçoit un traitement dédié parce
que c'est le canari d'une attaque de tampering antimalware : un morceau de
malware qui désactive Defender comme précondition pour sa vraie charge produira
un 5001 avant qu'une détection 1116/1117 ne puisse se déclencher. Le `kind`
dédié permet à une règle côté serveur de réagir à "l'antivirus vient d'être
désactivé sur l'hôte X" avec une priorité plus haute que les détections
ordinaires.
──[ 6. Limites Connues ]──

Le README du plugin est explicite sur ce qui manque :
    - Pas de bookmark — pas de replay des événements qui se sont
      produits pendant que le plugin était down. L'agent est
      single-source d'événements pour cette fenêtre ; s'il
      collectait Defender via un autre mécanisme, des gaps
      apparaîtraient.
    - Pas de filtrage XPath. Le plugin pourrait passer une
      requête XPath à EvtSubscribe et laisser Defender dropper
      les événements inintéressants de son côté ; la v1 forwarde
      tout et laisse l'agent décider.
    - Pas de check WSC primary-AV. Si WazabiEDR était enregistré
      comme AV primaire via l'API Windows Security Center,
      Defender passerait en mode passif et arrêterait d'émettre
      les événements 11xx sans préavis. Le plugin devrait
      détecter cet état et le logger.

Aucun n'est architectural ; tous sont trackés. Le plugin gagne sa croûte en 
l'état pour une construction d'EDR de première version. Prochain post : le layout d'install, les parties du système qui hot-reload, et
les parties qui demandent un redémarrage de service.