← Voltar ao blog

Parseando o journal USN no navegador com Rust + WebAssembly

Como entregamos um parser completo de journal USN NTFS no seu navegador na forma de 105 KB de WebAssembly — e por que «parsear no cliente» é a única resposta aceitável para artefatos forenses.

3 min de leitura

Journals USN são sensíveis por natureza. Um $J de 100 MB de uma estação de trabalho corporativa contém, linha por linha, todo o histórico recente de arquivos daquela máquina: cada documento Word que o usuário tocou, cada executável que rodou, cada despejo de cache do navegador. Enviar isso para um SaaS para que «seja analisado para você» é algo que um profissional forense nunca deveria aceitar — e algo que nunca queremos oferecer.

Por isso construímos usnparser.com ao contrário: o parser roda no seu navegador. O arquivo é lido do disco para o JavaScript, entregue a um módulo WebAssembly, e os registros voltam sem que um único byte deixe sua máquina.

Este artigo é um passeio técnico por como isso funciona.

A crate Rust

Não reinventamos a roda: a lógica de parsing vem de usnrs, a implementação limpa de USN_RECORD_V2 da Airbus CERT. Já expunha uma interface Read + Seek — exatamente do que precisávamos. É precisamente o trait que std::io::Cursor<Vec<u8>> implementa, então podemos passar bytes crus de um Uint8Array de JS.

A crate wrapper tem cerca de 60 linhas 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)
}

Aceita os bytes do journal e, opcionalmente, os bytes do $MFT para resolução de caminhos completos. O todo compila para cerca de 105 KB de .wasm após wasm-opt.

Três pegadinhas do Cargo

Fazer usnrs e suas dependências transitivas compilarem limpinho para wasm32-unknown-unknown exigiu três pequenos workarounds:

  1. getrandom precisa da feature js no wasm32. A crate rand (puxada por mft) depende dela transitivamente, e sem o backend JS a build wasm falha com «no available getrandom backend». Forçamos no nosso Cargo.toml:
    [target.'cfg(target_arch = "wasm32")'.dependencies]
    getrandom = { version = "0.2", features = ["js"] }
    
  2. chrono precisa de wasmbind quando a feature clock está ativa, senão ele tenta chamar time(2). Habilitamos via nossa declaração direta.
  3. Desabilitar as default features do mft não é, na verdade, necessário aqui — a feature mft_dump só puxa deps opcionais de CLI que cross-compilam mesmo assim.

A cola do lado do navegador

Compilamos com wasm-pack build --target web --out-dir public/wasm, que produz um pequeno shim JS em módulo ES mais o binário .wasm. Ambos vivem sob /public/wasm/ e são servidos como assets estáticos em URLs estáveis.

O parser vive em um Web Worker para que a thread principal continue responsiva enquanto o parser tritura um milhão de registros:

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

É o único lugar onde wasm e worker se encontram. Nem o worker nem o wasm passam pelo bundler do Next.js, o que significa zero config webpack/Turbopack.

Números

Um $J representativo de 60 MB de uma estação Windows 11:

  • Tempo de parsing: ~1,4 s em um Macbook recente
  • Memória: transitória, liberada quando o worker termina
  • Registros produzidos: ~720 000
  • Bytes que saem da sua máquina: 0

A UI então virtualiza a tabela com TanStack Virtual, então mesmo um resultado de um milhão de linhas se sente instantâneo.

E os journals enormes?

Para journals acima de 500 MB, mudaríamos para uma API em streaming que entrega lotes de registros em vez de acumular tudo num Vec. A mudança é pequena — Usn já é um Iterator, basta expor next_batch(n) do lado wasm. Não enviamos isso porque ainda não temos feedback de que alguém precise. Se for o seu caso, abra uma issue.

Por que isso importa

O tooling forense foi historicamente dividido entre suítes desktop pesadas (X-Ways, EnCase) e scripts Python que você precisa instalar e nos quais precisa confiar dados sensíveis. Existe uma terceira via: aberta, inspecionável, roda inteiramente no cliente. WebAssembly hoje é rápido o suficiente para que não haja mais desculpa de desempenho para deixar essa via inexplorada.