Parser le USN Journal dans le navigateur avec Rust + WebAssembly

Comment nous livrons un parser complet de USN Journal NTFS dans ton navigateur sous forme de 105 Ko de WebAssembly, et pourquoi « le parser côté client » est la seule réponse acceptable pour un artefact forensique.

Par 5 min de lecture

Un USN journal est l'un des fichiers les plus sensibles qu'un analyste touchera jamais. Un $J de 100 Mo d'une workstation d'entreprise est un historique récent complet de cette machine : chaque document que l'utilisateur a ouvert, chaque exécutable qui a tourné, chaque éviction de cache, chaque save-by-rename dans son éditeur. Demander à un professionnel de la forensique d'uploader ça vers un endpoint SaaS pour que le serveur de quelqu'un d'autre le parse, c'est le genre de demande qui devrait sortir le SaaS du toolset, pas uploader le journal. On n'a jamais voulu être ce SaaS.

Donc ce site fait l'inverse : le parser tourne dans ton navigateur. Le journal est lu depuis le disque vers JavaScript, passé à un module WebAssembly, et les enregistrements reviennent sans qu'un seul octet quitte la machine. Ce billet passe en revue comment ça marche réellement, la crate Rust sur laquelle on s'est appuyé, les trois pièges Cargo qui ont coûté une soirée, et les chiffres sur un fichier représentatif.

La crate Rust

Pas de réinvention. La logique de parsing vient de usnrs, l'implémentation propre de USN_RECORD_V2 d'Airbus CERT. Elle expose déjà une interface Read + Seek, ce qui correspond exactement à ce que std::io::Cursor<Vec<u8>> te donne quand tu as des octets bruts en mémoire — exactement la forme d'un Uint8Array arrivant côté navigateur.

La crate enveloppe fait environ 60 lignes de Rust. Le point d'entrée est :

#[wasm_bindgen(js_name = parseUsn)]
pub fn parse_usn(
  usn_bytes: &[u8],
  mft_bytes: Option<Box<[u8]>>,
) -> Result<JsValue, JsValue> {
  let usn = Cursor::new(usn_bytes.to_vec());
  let mft = mft_bytes
    .map(|b| MftParser::from_buffer(b.into_vec()))
    .transpose()?;
  let iter = Usn::new(mft, usn, None)?;
  let records: Vec<UsnRecord> = iter.map(into_record).collect();
  serde_wasm_bindgen::to_value(&records)
}

Elle prend les octets du journal et, en option, les octets $MFT pour la résolution des chemins complets. Avec wasm-opt l'artefact .wasm final fait environ 105 Ko.

Trois pièges Cargo

Compiler usnrs plus ses dépendances transitives proprement pour wasm32-unknown-unknown n'est pas gratuit. Trois choses nous ont mordus :

getrandom a besoin de la feature js sur wasm32. La crate rand (tirée par mft transitivement) en dépend, et sans le backend JS le build wasm échoue avec « no available getrandom backend ». Force-la dans Cargo.toml :

[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.2", features = ["js"] }

chrono a besoin de wasmbind quand la feature clock est activée. Sans, chrono essaie d'appeler time(2), qui n'existe pas dans wasm32-unknown-unknown. Ajoute features = ["wasmbind"] à la déclaration directe de dépendance.

Features par défaut de mft. La feature mft_dump tire des dépendances CLI qui finalement cross-compilent bien, mais elles gonflent l'artefact wasm. On désactive les défauts et on réactive uniquement ce dont on a besoin.

Aucune de ces trois n'a exigé de fork quoi que ce soit. Deux lignes de Cargo.toml règlent l'affaire.

La glu côté navigateur

Le build est wasm-pack build --target web --out-dir public/wasm, qui produit un petit shim JS de module ES et le binaire .wasm. Les deux vivent sous /public/wasm/ et sont servis comme assets statiques à des URL connues.

Le parser tourne dans un Web Worker pour que le main thread reste réactif pendant que le module wasm mâche un million d'enregistrements :

// public/workers/parse.js
import init, { parseUsn } from "/wasm/usn_wasm.js";

await init();
self.onmessage = (event) => {
  const { usnBytes, mftBytes } = event.data;
  const records = parseUsn(
    new Uint8Array(usnBytes),
    mftBytes ? new Uint8Array(mftBytes) : null,
  );
  self.postMessage({ type: "result", records });
};

C'est le seul endroit où le module wasm et le worker se rencontrent. Aucun ne passe par le bundler Next.js. Aucune configuration webpack ou Turbopack n'a été abîmée.

Chiffres sur un fichier représentatif

Un $J de 60 Mo issu d'une workstation Windows 11, sur un Macbook récent :

  • Temps de parsing : ~1,4 seconde.
  • Mémoire : transitoire, libérée quand le worker est terminé.
  • Enregistrements produits : ~720 000.
  • Octets quittant la machine sur le réseau : 0. À confirmer dans l'onglet Network.

L'UI virtualise ensuite la table de résultats avec TanStack Virtual, donc une table à un million de lignes reste instantanée quand tu scrolles ou filtres.

Et les journals vraiment gros ?

Pour les journals au-delà de 500 Mo on basculerait sur une API streaming qui produit des batchs d'enregistrements plutôt que de les accumuler dans un Vec. Le changement est petit : Usn est déjà un Iterator ; on exposerait next_batch(n) via wasm-bindgen. On n'a pas livré ça parce que personne n'a touché ce mur. Si tu le fais, ouvre un issue.

Une seconde optimisation qu'on a délibérément laissée de côté : parser directement depuis un handle File via les API stream du navigateur. Le module wasm devrait accepter une interface façon ReadableStream, ce qui veut dire perdre Cursor<Vec<u8>>. La complexité ne vaut pas encore le gain pour des journals sous 500 Mo.

Pourquoi cette approche compte

L'outillage forensique s'est historiquement divisé en deux camps. Les suites desktop lourdes (X-Ways, EnCase, FTK) que tu licenes, installes sur une workstation et auxquelles tu fais confiance. Les scripts Python que tu fais pip install et tournes sur n'importe quel endpoint qui se présente, souvent avec des données sensibles qui traversent les répertoires temp d'IDE en chemin.

WebAssembly ouvre une troisième voie : ouvert, inspectable, tourne entièrement dans le navigateur, pas d'upload. Les outils d'Eric Zimmerman occupent la case desktop hors-ligne à haute confiance. Plaso et la stack libyal occupent la case pipeline scriptable. La case navigateur est restée vide trop longtemps parce que personne ne croyait qu'elle pouvait égaler les autres en vitesse. Avec wasm et une crate sensée en dessous, cette excuse est tombée — pour USN, pour EVTX, pour Prefetch, pour chaque artefact binaire Windows qu'un défenseur touche vraiment.

Lectures complémentaires

  • Le repo usnrs — la crate Rust en amont. Lire src/lib.rs est le chemin le plus court pour comprendre l'interface du parser USN_RECORD_V2.
  • Le livre wasm-bindgen — la référence pour le FFI entre Rust et JavaScript.
  • Sysmon-modular d'Olaf Hartong — sans lien avec wasm, mais le type d'artefact qui, couplé à un USN journal parsé, te donne l'image complète de l'activité fichier sur un hôte.