← Retour au blog

Parser le journal USN dans le navigateur avec Rust + WebAssembly

Comment nous livrons un parseur complet de journal USN NTFS dans votre 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.

3 min de lecture

Les journaux USN sont par nature sensibles. Un $J de 100 Mo issu d'un poste d'entreprise contient, ligne à ligne, l'historique fichier récent complet de la machine : chaque document Word ouvert, chaque exécutable lancé, chaque éviction de cache navigateur. Le téléverser vers un SaaS pour « se le faire analyser » est quelque chose qu'un professionnel forensique ne devrait jamais accepter, et quelque chose que nous ne voulons jamais proposer.

Nous avons donc bâti usnparser.com à l'envers : le parseur tourne dans votre navigateur. Le fichier est lu depuis le disque vers JavaScript, transmis à un module WebAssembly, et les enregistrements reviennent sans qu'un seul octet ne quitte votre machine.

Cet article est une plongée technique dans comment ça fonctionne.

La crate Rust

Nous n'avons pas réinventé la roue : la logique de parsing vient de usnrs, l'implémentation propre d'Airbus CERT de USN_RECORD_V2. Elle expose une interface Read + Seek, exactement ce dont nous avions besoin — c'est précisément le trait qu'implémente std::io::Cursor<Vec<u8>>, donc nous pouvons lui passer les octets bruts d'un Uint8Array JS.

La crate wrapper fait une soixantaine de lignes de Rust :

#[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 accepte les octets du journal et, en option, les octets du $MFT pour la résolution des chemins complets. Le tout compile à environ 105 Ko de .wasm après wasm-opt.

Trois gotchas Cargo

Faire compiler usnrs et ses dépendances transitives proprement vers wasm32-unknown-unknown a demandé trois petits contournements :

  1. getrandom a besoin de la feature js sur wasm32. La crate rand (tirée par mft) en dépend transitivement, et sans le backend JS la build wasm échoue avec « no available getrandom backend ». On la force dans notre Cargo.toml :
    [target.'cfg(target_arch = "wasm32")'.dependencies]
    getrandom = { version = "0.2", features = ["js"] }
    
  2. chrono a besoin de wasmbind quand la feature clock est activée, sinon il essaie d'appeler time(2). On l'ajoute via notre déclaration de dépendance directe.
  3. Désactiver les features par défaut de mft n'est en fait pas nécessaire ici — la feature mft_dump ne tire que des dépendances CLI optionnelles qui se cross-compilent quand même.

La glue côté navigateur

On compile avec wasm-pack build --target web --out-dir public/wasm, ce qui produit un petit shim JS module ES plus le binaire .wasm. Les deux vivent sous /public/wasm/ et sont servis comme assets statiques à des URLs stables.

Le parseur tourne dans un Web Worker pour que le thread principal reste réactif pendant que le parseur 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ù wasm et worker se rencontrent. Ni le worker ni le wasm ne passent par le bundler de Next.js, ce qui veut dire aucune config webpack/Turbopack.

Chiffres

Un $J représentatif de 60 Mo issu d'un poste Windows 11 :

  • Temps de parsing : ~1,4 s sur un Macbook récent
  • Mémoire : transitoire, libérée à la fin du worker
  • Enregistrements produits : ~720 000
  • Octets sortant du poste : 0

L'UI virtualise ensuite la table avec TanStack Virtual, donc même un résultat d'un million de lignes reste instantané.

Et les journaux gigantesques ?

Pour des journaux au-delà de 500 Mo, on passerait à une API en streaming qui livre des lots d'enregistrements plutôt que de tout accumuler dans un Vec. Le changement est petit — Usn est déjà un Iterator, il suffit d'exposer next_batch(n) côté wasm. Nous n'avons pas livré ça parce que personne ne nous a encore signalé en avoir besoin. Si c'est votre cas, ouvrez une issue.

Pourquoi ça compte

L'outillage forensique a historiquement été coupé en deux : suites desktop massives (X-Ways, EnCase) et scripts Python qu'il faut installer et auxquels il faut confier des données sensibles. Il y a une troisième voie : ouverte, inspectable, entièrement côté client. WebAssembly est aujourd'hui assez rapide pour qu'il n'y ait plus d'excuse de performance à laisser cette voie inexploitée.