Un USN journal è uno dei file più sensibili che un analista toccherà mai. Un $J da 100 MB di una workstation aziendale è una storia recente completa di quella macchina: ogni documento che l'utente ha aperto, ogni eseguibile che è girato, ogni eviction di cache, ogni save-by-rename nel suo editor. Chiedere a un professionista della forense di caricare quella roba su un endpoint SaaS perché il server di qualcun altro lo parsi è il tipo di richiesta che dovrebbe togliere il SaaS dal toolset, non caricare il journal. Non abbiamo mai voluto essere quel SaaS.
Quindi questo sito fa l'inverso: il parser gira nel tuo browser. Il journal viene letto da disco in JavaScript, passato a un modulo WebAssembly, e i record tornano senza che un singolo byte lasci la macchina. Questo post percorre come funziona davvero, la crate Rust su cui ci siamo appoggiati, i tre tranelli di Cargo che sono costati una serata, e i numeri su un file rappresentativo.
La crate Rust
Nessuna reinvenzione. La logica di parsing arriva da usnrs, l'implementazione pulita di USN_RECORD_V2 di Airbus CERT. Espone già un'interfaccia Read + Seek, che è esattamente ciò che std::io::Cursor<Vec<u8>> ti dà quando hai byte grezzi in memoria — esattamente la forma di un Uint8Array che arriva dal lato browser.
La crate wrapper è circa 60 righe di Rust. L'entry point è:
#[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)
}
Prende i byte del journal e, opzionalmente, i byte di $MFT per la risoluzione del percorso completo. Con wasm-opt l'artefatto .wasm finale è circa 105 KB.
Tre tranelli di Cargo
Compilare usnrs più le sue dipendenze transitive pulitamente per wasm32-unknown-unknown non è gratis. Tre cose ci hanno morso:
getrandom necessita la feature js su wasm32. La crate rand (tirata in da mft transitivamente) dipende da essa, e senza il backend JS la build wasm fallisce con "no available getrandom backend". Forzala in Cargo.toml:
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.2", features = ["js"] }
chrono necessita wasmbind quando la feature clock è abilitata. Senza, chrono prova a chiamare time(2), che non esiste in wasm32-unknown-unknown. Aggiungi features = ["wasmbind"] alla dichiarazione diretta della dipendenza.
Feature di default di mft. La feature mft_dump tira in dipendenze CLI che alla fine cross-compilano bene, ma gonfiano l'artefatto wasm. Disabilitiamo i default e riabilitiamo solo ciò che ci serve.
Nessuna di queste ha richiesto di fare fork di niente. Due righe di Cargo.toml risolvono il tutto.
La colla lato browser
La build è wasm-pack build --target web --out-dir public/wasm, che produce un piccolo shim JS modulo ES e il binario .wasm. Entrambi vivono sotto /public/wasm/ e sono serviti come asset statici a URL noti.
Il parser gira in un Web Worker così il main thread resta reattivo mentre il modulo wasm 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 });
};
Questo è l'unico posto in cui il modulo wasm e il worker si incontrano. Nessuno dei due passa per il bundler Next.js. Nessuna configurazione webpack o Turbopack è stata danneggiata.
Numeri su un file rappresentativo
Un $J da 60 MB di una workstation Windows 11, su un Macbook recente:
- Tempo di parsing: ~1,4 secondi.
- Memoria: transitoria, liberata quando il worker viene terminato.
- Record prodotti: ~720.000.
- Byte che lasciano la macchina sulla rete: 0. Conferma nella tab Network.
La UI poi virtualizza la tabella dei risultati con TanStack Virtual, quindi una tabella da un milione di righe rimane istantanea quando scrolli o filtri.
E i journal davvero grossi?
Per journal oltre i 500 MB passeremmo a un'API streaming che produce batch di record invece di accumularli in un Vec. Il cambio è piccolo: Usn è già un Iterator; esporremmo next_batch(n) su wasm-bindgen. Non l'abbiamo spedito perché nessuno ha colpito quel muro. Se lo fai, apri una issue.
Una seconda ottimizzazione che abbiamo deliberatamente lasciato fuori: parsare direttamente da un handle File via le API stream del browser. Il modulo wasm dovrebbe accettare un'interfaccia tipo ReadableStream, il che significa perdere Cursor<Vec<u8>>. La complessità non vale ancora il guadagno per journal sotto i 500 MB.
Perché questo approccio conta
Il tooling forense si è storicamente diviso in due campi. Suite desktop pesanti (X-Ways, EnCase, FTK) che licenzi, installi su una workstation e di cui ti fidi. Script Python che fai pip install e fai girare su qualsiasi endpoint capiti a tiro, spesso con dati sensibili che attraversano le directory temp dell'IDE per strada.
WebAssembly apre una terza corsia: aperto, ispezionabile, gira interamente nel browser, niente upload. Gli strumenti di Eric Zimmerman occupano lo slot desktop offline ad alta fiducia. Plaso e lo stack libyal occupano lo slot pipeline scriptabile. Lo slot browser è rimasto vuoto troppo a lungo perché nessuno credeva potesse pareggiare gli altri in velocità. Con wasm e una crate sensata sotto, quella scusa è andata — per USN, per EVTX, per Prefetch, per ogni artefatto binario Windows che un difensore tocchi davvero.
Letture aggiuntive
- Il repo usnrs — la crate Rust upstream. Leggere
src/lib.rsè il modo più breve per capire l'interfaccia del parserUSN_RECORD_V2. - Il libro wasm-bindgen — la reference per l'FFI tra Rust e JavaScript.
- Sysmon-modular di Olaf Hartong — non correlato a wasm, ma il tipo di artefatto che, abbinato a un USN journal parsato, ti dà il quadro completo dell'attività sui file su un host.