USN ジャーナルは、アナリストが触れる中でも最もセンシティブなファイルの一つです。企業ワークステーションから取った 100 MB の $J は、そのマシンの最近の履歴を丸ごと収めています。ユーザが開いた文書、実行したすべての実行ファイル、すべてのキャッシュ排出、エディタでの保存リネームのすべてです。それを別の誰かのサーバにパースしてもらうために SaaS エンドポイントへアップロードするよう求めるのは、ジャーナルをアップロードさせるのではなく、その SaaS をツールセットから外させるべき類の依頼です。私たちはそんな SaaS にはなりたくありませんでした。
ですので、本サイトは逆を行きます。パーサはあなたのブラウザ内で動きます。ジャーナルはディスクから JavaScript に読み込まれ、WebAssembly モジュールに渡され、レコードが返ってきます。一バイトもマシンから出ません。本記事は、それが実際にどう動作するか、私たちが寄りかかった Rust crate、一晩を奪った 3 つの Cargo の落とし穴、そして代表的なファイルにおける数値を順に紹介します。
Rust crate
再発明はしていません。パースロジックは usnrs — Airbus CERT のクリーンな USN_RECORD_V2 実装 — に由来します。すでに Read + Seek インタフェースを公開しており、これはブラウザ側から到着する Uint8Array を std::io::Cursor<Vec<u8>> でメモリ上の生バイトとして扱う際の形状と完全に一致します。
ラッパー crate は約 60 行の 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)
}
ジャーナルのバイト列と、オプションでフルパス解決のための $MFT のバイト列を受け取ります。wasm-opt を通すと、最終的な .wasm アーティファクトは約 105 KB です。
三つの Cargo の落とし穴
usnrs とその推移的依存を wasm32-unknown-unknown 向けにクリーンにコンパイルすることは無料ではありません。三つに噛まれました:
getrandom は wasm32 で js フィーチャが必要です。 rand crate (mft 経由でプルされる) が推移的に依存しており、JS バックエンドがないと wasm ビルドは "no available getrandom backend" で失敗します。Cargo.toml で強制します:
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.2", features = ["js"] }
chrono は clock フィーチャを有効にしたとき wasmbind が必要です。 これがないと、chrono は time(2) を呼び出そうとしますが、これは wasm32-unknown-unknown には存在しません。直接の依存宣言に features = ["wasmbind"] を追加します。
mft のデフォルトフィーチャ。 mft_dump フィーチャは CLI 依存をプルしますが、クロスコンパイル自体は問題ないものの、wasm アーティファクトを肥大化させます。デフォルトを無効にし、必要なものだけを再有効化します。
これらにフォークは不要でした。Cargo.toml の 2 行で全部解決します。
ブラウザ側のグルー
ビルドは wasm-pack build --target web --out-dir public/wasm で、小さな ES モジュールの JS シムと .wasm バイナリが生成されます。両方とも /public/wasm/ に置かれ、既知の URL で静的アセットとして提供されます。
パーサは Web Worker で動作するので、wasm モジュールが 100 万件のレコードを噛みつぶしている間もメインスレッドは応答可能なまま保たれます:
// 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 });
};
これが wasm モジュールと worker が出会う唯一の場所です。どちらも Next.js のバンドラを通りません。webpack や Turbopack の設定をいじる必要もありませんでした。
代表的なファイルでの数値
Windows 11 ワークステーションからの 60 MB の $J を、最近の Macbook で:
- パース時間: 約 1.4 秒。
- メモリ: 一時的、Worker が終了すると解放されます。
- 生成されたレコード: 約 72 万件。
- マシンから出るワイヤバイト: 0。Network タブで確認できます。
UI は TanStack Virtual で結果テーブルを仮想化するので、100 万行のテーブルでもスクロールやフィルタが瞬時に感じられます。
本当に大きなジャーナルはどうするか
500 MB を超えるジャーナルに対しては、レコードを Vec に蓄積するのではなくバッチで yield するストリーミング API に切り替えるつもりです。変更は小さく、Usn はすでに Iterator です。wasm-bindgen 経由で next_batch(n) を公開します。誰もまだその壁にぶつかっていないので、まだ出荷していません。ぶつかったら issue を開いてください。
意図的に検討から外した第二の最適化があります。ブラウザのストリーム API 経由で File ハンドルから直接パースすることです。wasm モジュールは ReadableStream ライクなインタフェースを受け取る必要があり、Cursor<Vec<u8>> を失います。500 MB 以下のジャーナルでは、複雑さに見合うほどの利得はまだありません。
このアプローチが重要な理由
フォレンジックツールは歴史的に二つの陣営に分かれてきました。ライセンスを取得し、ワークステーションにインストールし、信頼する重い デスクトップスイート (X-Ways、EnCase、FTK) と、pip install してその時都合のいいエンドポイントで実行する Python スクリプト (途中でセンシティブなデータが IDE の temp ディレクトリを通過したりする) です。
WebAssembly は第三の道を開きます。オープンで、検査可能で、完全にブラウザ内で動作し、アップロードもなし。Eric Zimmerman のツール群は高信頼のオフラインデスクトップ枠を占めています。Plaso と libyal スタックはスクリプタブルなパイプラインの枠を占めています。ブラウザの枠は、速度で他に肩を並べられないと誰も信じなかったために、あまりに長く空いていました。wasm とその下に適切な crate があれば、その言い訳はもうありません — USN、EVTX、Prefetch、そして防御者が実際に触れるあらゆる Windows のバイナリアーティファクトについて。
参考資料
- usnrs リポジトリ — 上流の Rust crate。
USN_RECORD_V2パーサのインタフェースを理解する最短経路はsrc/lib.rsを読むことです。 - wasm-bindgen book — Rust と JavaScript の FFI に関するリファレンスです。
- Olaf Hartong の Sysmon-modular — wasm とは無関係ですが、パース済みの USN ジャーナルと組み合わせるとホスト上のファイル活動の全体像を提供する類のアーティファクトです。