Um journal USN é um dos ficheiros mais sensíveis que um analista alguma vez vai tocar. Um $J de 100 MB de uma workstation corporativa é o histórico recente completo dessa máquina: cada documento que o utilizador abriu, cada executável que correu, cada eviction de cache, cada save-by-rename do seu editor. Pedir a um profissional de forense que faça upload disso para um endpoint SaaS para que o servidor de outra pessoa o parseie é o tipo de pedido que devia tirar o SaaS do toolset, não levar a fazer upload do journal. Nunca quisemos ser esse SaaS.
Por isso, este site faz o inverso: o parser corre no seu navegador. O journal é lido do disco para JavaScript, entregue a um módulo WebAssembly, e os registos voltam sem um único byte sair da máquina. Este artigo percorre como isto funciona de facto, o crate Rust em que nos apoiámos, as três armadilhas do Cargo que custaram uma noite, e os números num ficheiro representativo.
O crate Rust
Sem reinventar a roda. A lógica de parse vem do usnrs, a implementação limpa de USN_RECORD_V2 da Airbus CERT. Já expõe uma interface Read + Seek, que coincide exactamente com o que std::io::Cursor<Vec<u8>> dá quando se tem bytes brutos em memória — exactamente o formato de um Uint8Array vindo do lado do navegador.
O crate envoltório tem cerca de 60 linhas de Rust. O ponto de entrada é:
#[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)
}
Recebe os bytes do journal e, opcionalmente, os bytes do $MFT para resolução de caminhos completos. Com wasm-opt, o artefacto .wasm final tem cerca de 105 KB.
Três armadilhas do Cargo
Compilar usnrs mais as suas dependências transitivas de forma limpa para wasm32-unknown-unknown não é gratuito. Três coisas morderam-nos:
getrandom precisa da feature js em wasm32. O crate rand (puxado por mft) depende dele de forma transitiva, e sem o backend JS o build wasm falha com «no available getrandom backend». Forçe-o no Cargo.toml:
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.2", features = ["js"] }
chrono precisa de wasmbind quando a feature clock está activa. Sem isso, o chrono tenta chamar time(2), que não existe em wasm32-unknown-unknown. Adicione features = ["wasmbind"] à declaração de dependência directa.
Default features do mft. A feature mft_dump puxa dependências de CLI que cross-compilam bem mas incham o artefacto wasm. Desactivámos as defaults e voltámos a activar apenas o necessário.
Nenhuma destas exigiu fork. Duas linhas de Cargo.toml resolvem tudo.
A cola do lado do navegador
O build é wasm-pack build --target web --out-dir public/wasm, que produz um pequeno shim JS em ES-module e o binário .wasm. Ambos ficam em /public/wasm/ e são servidos como assets estáticos em URLs conhecidos.
O parser corre num Web Worker para que o thread principal continue responsivo enquanto o módulo wasm mastiga um milhão de registos:
// 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 });
};
Este é o único sítio onde o módulo wasm e o worker se encontram. Nenhum passa pelo bundler do Next.js. Nenhuma configuração de webpack ou Turbopack foi maltratada.
Números de um ficheiro representativo
Um $J de 60 MB de uma workstation Windows 11, num Macbook recente:
- Tempo de parse: ~1,4 segundos.
- Memória: transitória, libertada quando o worker é terminado.
- Registos produzidos: ~720.000.
- Bytes a sair da máquina: 0. Confirme no separador Network.
A UI virtualiza depois a tabela de resultados com o TanStack Virtual, pelo que uma tabela de um milhão de linhas continua instantânea quando se rola ou filtra.
E journals realmente grandes?
Para journals acima de 500 MB passaríamos para uma API de streaming que yields batches de registos em vez de os acumular num Vec. A alteração é pequena — Usn já é um Iterator; expúnhamos next_batch(n) sobre wasm-bindgen. Ainda não enviámos isso porque ninguém bateu na parede. Se bater, abra um issue.
Uma segunda optimização deliberadamente deixada de fora: parsear directamente de um handle File via as stream APIs do navegador. O módulo wasm teria de aceitar uma interface tipo ReadableStream, o que significa perder o Cursor<Vec<u8>>. A complexidade ainda não compensa o ganho para journals abaixo de 500 MB.
Porque é que esta abordagem importa
A ferramenta forense tem-se historicamente dividido em dois campos. Suites desktop pesadas (X-Ways, EnCase, FTK) que se licencia, instala numa workstation e em que se confia. Scripts Python que se faz pip install e correm em qualquer endpoint que calhe ser conveniente, muitas vezes com dados sensíveis a atravessar as pastas temporárias do IDE pelo caminho.
O WebAssembly abre uma terceira via: aberto, inspeccionável, corre inteiramente no navegador, sem upload. As ferramentas do Eric Zimmerman ocupam o slot offline desktop de alta confiança. Plaso e a stack libyal ocupam o slot de pipeline scriptável. O slot do navegador esteve vazio demasiado tempo porque ninguém acreditava que pudesse igualar os outros em velocidade. Com wasm e um crate sensato por baixo, essa desculpa acabou — para USN, para EVTX, para Prefetch, para todos os artefactos Windows binários que um defensor realmente toca.
Leituras adicionais
- O repositório do usnrs — o crate Rust upstream. Ler
src/lib.rsé o caminho mais curto para perceber a interface do parserUSN_RECORD_V2. - O livro do wasm-bindgen — a referência para o FFI entre Rust e JavaScript.
- O Sysmon-modular do Olaf Hartong — não relacionado com wasm, mas é o tipo de artefacto que, combinado com um journal USN parseado, dá a imagem completa da actividade de ficheiros num host.