← Torna al blog

Parsare il journal USN nel browser con Rust + WebAssembly

Come consegniamo un parser completo del journal USN NTFS nel tuo browser sotto forma di 105 KB di WebAssembly — e perché «parsare lato client» è l'unica risposta accettabile per gli artefatti forensi.

3 min di lettura

I journal USN sono per natura sensibili. Un $J da 100 MB di una workstation aziendale contiene, riga per riga, la storia recente completa dei file di quella macchina: ogni documento Word toccato dall'utente, ogni eseguibile lanciato, ogni evizione di cache del browser. Caricarlo su un SaaS perché «te lo analizzi» è qualcosa che un professionista forense non dovrebbe mai accettare, e qualcosa che noi non vogliamo mai offrire.

Quindi abbiamo costruito usnparser.com al contrario: il parser gira nel tuo browser. Il file viene letto dal disco in JavaScript, consegnato a un modulo WebAssembly, e i record tornano senza che un singolo byte abbandoni la tua macchina.

Questo articolo è una passeggiata tecnica su come funziona.

La crate Rust

Non abbiamo reinventato la ruota: la logica di parsing viene da usnrs, l'implementazione pulita di USN_RECORD_V2 di Airbus CERT. Esponeva già un'interfaccia Read + Seek — esattamente ciò che ci serviva — proprio il trait implementato da std::io::Cursor<Vec<u8>>, quindi possiamo passarle byte grezzi da un Uint8Array JS.

La crate wrapper è di circa 60 righe di 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)
}

Accetta i byte del journal e, opzionalmente, i byte del $MFT per la risoluzione dei percorsi completi. Il tutto compila a circa 105 KB di .wasm dopo wasm-opt.

Tre intoppi di Cargo

Far compilare usnrs e le sue dipendenze transitive in modo pulito verso wasm32-unknown-unknown ha richiesto tre piccoli workaround:

  1. getrandom ha bisogno della feature js su wasm32. La crate rand (tirata da mft) ne dipende transitivamente, e senza il backend JS la build wasm fallisce con «no available getrandom backend». La forziamo nel nostro Cargo.toml:
    [target.'cfg(target_arch = "wasm32")'.dependencies]
    getrandom = { version = "0.2", features = ["js"] }
    
  2. chrono ha bisogno di wasmbind quando la feature clock è attiva, altrimenti tenta di chiamare time(2). La aggiungiamo via la nostra dichiarazione diretta.
  3. Disabilitare le feature di default di mft in realtà non è necessario qui — la feature mft_dump tira solo dipendenze CLI opzionali che comunque cross-compilano.

La colla lato browser

Compiliamo con wasm-pack build --target web --out-dir public/wasm, che produce un piccolo shim JS modulo ES più il binario .wasm. Entrambi vivono sotto /public/wasm/ e sono serviti come asset statici a URL stabili.

Il parser vive in un Web Worker così il thread principale resta reattivo mentre il parser mastica un milione di record:

// 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 });
};

È l'unico posto dove wasm e worker si incontrano. Né il worker né il wasm passano dal bundler di Next.js, il che significa zero config webpack/Turbopack.

Numeri

Un $J rappresentativo da 60 MB di una workstation Windows 11:

  • Tempo di parsing: ~1,4 s su un Macbook recente
  • Memoria: transitoria, liberata alla terminazione del worker
  • Record prodotti: ~720 000
  • Byte che lasciano la tua macchina: 0

L'UI virtualizza poi la tabella con TanStack Virtual, così anche un risultato da un milione di righe si sente istantaneo.

E i journal enormi?

Per journal oltre 500 MB passeremmo a una API in streaming che restituisce batch di record invece di accumularli in un Vec. Il cambiamento è piccolo — Usn è già un Iterator, esporremmo semplicemente next_batch(n) lato wasm. Non l'abbiamo spedito perché non abbiamo ancora feedback che serva a qualcuno. Se è il tuo caso, apri un issue.

Perché conta

Il tooling forense è stato storicamente diviso tra suite desktop pesanti (X-Ways, EnCase) e script Python da installare e a cui affidare dati sensibili. C'è una terza via: aperta, ispezionabile, gira interamente lato client. WebAssembly è oggi abbastanza veloce da non lasciare più scuse di performance per non sfruttarla.