← Zurück zum Blog

Das USN-Journal im Browser parsen — mit Rust + WebAssembly

Wie wir einen vollständigen NTFS-USN-Journal-Parser in deinen Browser ausliefern — als 105 KB WebAssembly — und warum „im Client parsen" die einzig akzeptable Antwort für forensische Artefakte ist.

3 Min. Lesezeit

USN-Journals sind von Natur aus sensibel. Eine 100-MB-$J-Datei von einer Unternehmens-Workstation enthält Zeile für Zeile die komplette jüngere Dateihistorie der Maschine: jedes Word-Dokument, das der Nutzer angefasst hat, jede ausgeführte Executable, jede Browser-Cache-Verdrängung. Das in ein SaaS hochzuladen, damit es „für dich analysiert wird", ist etwas, dem ein forensischer Profi nie zustimmen sollte — und etwas, das wir nie anbieten wollen.

Also haben wir usnparser.com andersherum gebaut: der Parser läuft in deinem Browser. Die Datei wird von der Platte in JavaScript gelesen, an ein WebAssembly-Modul übergeben, und die Einträge kommen zurück, ohne dass ein einziges Byte deine Maschine verlässt.

Dieser Artikel ist ein technischer Durchgang, wie das funktioniert.

Die Rust-Crate

Wir haben das Rad nicht neu erfunden: Die eigentliche Parsing-Logik kommt aus usnrs, Airbus CERTs sauberer Implementierung von USN_RECORD_V2. Sie exponiert bereits ein Read + Seek-Interface — genau das, was wir brauchten. Es ist exakt der Trait, den std::io::Cursor<Vec<u8>> implementiert, also können wir rohe Bytes aus einem JS-Uint8Array reinreichen.

Die Wrapper-Crate sind rund 60 Zeilen 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)
}

Sie akzeptiert die Journal-Bytes und optional die $MFT-Bytes für die Pfad-Auflösung. Das Ganze kompiliert nach wasm-opt zu ca. 105 KB .wasm.

Drei Cargo-Stolpersteine

Damit usnrs und seine transitiven Abhängigkeiten sauber gegen wasm32-unknown-unknown bauen, waren drei kleine Workarounds nötig:

  1. getrandom braucht die js-Feature auf wasm32. Die rand-Crate (durch mft mitgezogen) hängt transitiv daran, und ohne JS-Backend scheitert der Wasm-Build mit „no available getrandom backend". Wir erzwingen es in unserer Cargo.toml:
    [target.'cfg(target_arch = "wasm32")'.dependencies]
    getrandom = { version = "0.2", features = ["js"] }
    
  2. chrono braucht wasmbind, wenn die clock-Feature aktiv ist, sonst versucht es time(2) aufzurufen. Wir aktivieren es über unsere direkte Deklaration.
  3. Default-Features von mft abschalten ist hier eigentlich nicht nötig — die mft_dump-Feature zieht nur optionale CLI-Deps, die trotzdem cross-kompilieren.

Der Browser-Glue

Wir bauen mit wasm-pack build --target web --out-dir public/wasm, was einen kleinen ES-Modul-JS-Shim plus die .wasm-Datei erzeugt. Beide leben unter /public/wasm/ und werden als statische Assets unter bekannten URLs ausgeliefert.

Der Parser lebt in einem Web Worker, damit der Hauptthread reaktiv bleibt, während der Parser sich durch eine Million Einträge frisst:

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

Das ist die einzige Stelle, wo Wasm und Worker sich treffen. Weder Worker noch Wasm laufen durch den Next.js-Bundler — was bedeutet: keine Webpack/Turbopack-Konfiguration.

Zahlen

Eine repräsentative 60-MB-$J von einer Windows-11-Workstation:

  • Parse-Zeit: ~1,4 s auf einem aktuellen Macbook
  • Speicher: transient, freigegeben, wenn der Worker beendet wird
  • Erzeugte Einträge: ~720 000
  • Bytes, die deine Maschine verlassen: 0

Die UI virtualisiert dann die Tabelle mit TanStack Virtual — so fühlt sich selbst ein Millionen-Zeilen-Ergebnis sofort an.

Was ist mit riesigen Journals?

Für Journals jenseits von 500 MB würden wir auf eine Streaming-API umschalten, die Batches von Einträgen liefert, statt alles in einem Vec zu sammeln. Die Änderung ist klein — Usn ist bereits ein Iterator, wir würden auf der Wasm-Seite einfach next_batch(n) exponieren. Wir haben es noch nicht ausgeliefert, weil wir noch kein Feedback haben, dass es jemand braucht. Falls du es brauchst: öffne ein Issue.

Warum das zählt

Forensisches Tooling war historisch zweigeteilt — zwischen schweren Desktop-Suiten (X-Ways, EnCase) und Python-Skripten, die man installieren und denen man sensible Daten anvertrauen muss. Es gibt eine dritte Spur: offen, inspizierbar, läuft komplett im Client. WebAssembly ist heute schnell genug, dass es keinen Performance-Vorwand mehr gibt, diese Spur ungenutzt zu lassen.