Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,22 @@ fvol serve -f memory.raw # prints a local URL with an access token

<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/screenshots/web-ui-overview-dark.png">
<img alt="The web UI's overview of a Windows 11 image: evidence details, a process tree with lifelines, suggested first plugins and triage hints." src="docs/assets/screenshots/web-ui-overview-light.png" width="840">
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/screenshots/web-ui-workspace-dark.png">
<img alt="The web UI's workspace for a Windows image: the file overview printed line by line, the plugin list with three malware plugins ticked and their options open, two runs with the per-plugin row counts, and triage hints." src="docs/assets/screenshots/web-ui-workspace-light.png" width="840">
</picture>
</p>

Plugin palette, million-row tables, process tree, hex viewer, run comparison, exports. Localhost
only, token-protected. [docs/web-ui.md](docs/web-ui.md)
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/screenshots/web-ui-results-dark.png">
<img alt="The results of a Windows Triage run: a tab per plugin with its row count, and the netscan table with its connections and listening sockets." src="docs/assets/screenshots/web-ui-results-light.png" width="840">
</picture>
</p>

Pick plugins or a preset, run them together, and read the results a tab per plugin: million-row
tables, one filter across all plugins, process trees, exports, every `fvol` option and your own
triage rules. Everything is saved in `~/.fvol` and comes back when the dump is reopened.
Localhost only, token-protected. [docs/web-ui.md](docs/web-ui.md)

## Supported images

Expand Down Expand Up @@ -159,8 +168,7 @@ originals. [docs/architecture.md](docs/architecture.md#performance-techniques)

## License

Volatility Software License 1.0, as a port of Volatility 3 ([LICENSE.txt](LICENSE.txt)). The web UI
embeds JetBrains Mono (SIL OFL 1.1).
Volatility Software License 1.0, as a port of Volatility 3 ([LICENSE.txt](LICENSE.txt)).

fastvol is built on the work of the Volatility Foundation and the volatility3 contributors. It is
an independent project, not affiliated with or endorsed by the Volatility Foundation.
49 changes: 31 additions & 18 deletions bench/web/perf.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,8 @@ def ms(xs):

print("== 42M-row plugin: windows.memmap (no --pid)")
t = time.perf_counter()
r = req("POST", "/api/runs", {"plugin": "windows.memmap.Memmap"})
rid = r["id"]
big = req("POST", "/api/batches", {"name": "memmap", "entries": [{"plugin": "windows.memmap.Memmap"}]})
rid = big["runs"][0]
first = None
while True:
s = req("GET", f"/api/runs/{rid}")
Expand Down Expand Up @@ -115,22 +115,34 @@ def ms(xs):

print("== browser (headless chromium)")
from cdp import Chrome # noqa: E402
small = req("POST", "/api/batches", {"name": "pslist", "entries": [{"plugin": "windows.pslist.PsList"}]})
req("GET", f"/api/runs/{small['runs'][0]}/stream")
TABLE = "document.querySelector('#rs-body .vt')"


def open_run(bid):
"""Open a run's results from the Runs panel and wait for its first rows."""
c.eval(f"document.querySelector('.rn-head[data-key=\"b{bid}\"]').click()")
c.wait("document.querySelector('#rs-body .vt-row:not(.loading)')", 30)
c.pump(0.5)


c = Chrome(1440, 900)
try:
c.goto(f"http://127.0.0.1:{PORT}/#token={TOKEN}", 1.5)
c.wait("document.querySelectorAll('.tnode').length > 0", 30)
c.wait("document.querySelector('.qs') || document.querySelector('.wb-panel')", 30)
nav = c.eval("JSON.stringify(performance.getEntriesByType('navigation').map(e => [e.domContentLoadedEventEnd, e.loadEventEnd]))")
print(f"app shell: DOMContentLoaded / load at {nav} ms after navigation start (after the login redirect)")
c.eval(f"location.hash = 'run/{rid}'")
c.wait("document.querySelector('#panes > .pane:not([hidden]) .vt-row:not(.loading)')", 30)
c.pump(0.5)
c.key("Escape")
c.wait("document.querySelector('.rn-head')", 10)
open_run(big["id"])
res = c.eval("""(async () => {
const vt = document.querySelector('#panes > .pane:not([hidden]) .vt')._vt;
const vt = %s._vt;
const sc = vt.scroll;
const costs = [];
const H = sc.scrollHeight;
for (let i = 0; i < 120; i++) {
sc.scrollTop = (H * ((i * 37) % 120)) / 120;
sc.scrollTop = (H * ((i * 37) %% 120)) / 120;
const t0 = performance.now();
vt.render();
document.body.getBoundingClientRect();
Expand All @@ -145,29 +157,30 @@ def ms(xs):
const warm = performance.now() - t1;
costs.sort((a, b) => a - b);
return { median: costs[60], p95: costs[114], max: costs[119], warm, rows: vt.total };
})()""")
})()""" % TABLE)
print(f"scroll-jump render over {res['rows']:,} rows (JS + layout per frame): median {res['median']:.2f} ms, p95 {res['p95']:.2f} ms, max {res['max']:.2f} ms")
res = c.eval("""(async () => {
const pane = document.querySelector('#panes > .pane:not([hidden])');
const vt = pane.querySelector('.vt')._vt;
const q = pane.querySelector('input.q');
const vt = %s._vt;
const q = document.querySelector('.rs-q');
const t0 = performance.now();
q.value = '0x7ff6';
q.dispatchEvent(new Event('input'));
while (!(vt.view && vt.rowAt(0))) await new Promise(r => setTimeout(r, 5));
vt.render();
return { ms: performance.now() - t0, rows: vt.total, view: vt.viewMs };
})()""")
const out = { ms: performance.now() - t0, rows: vt.total, view: vt.viewMs };
q.value = '';
q.dispatchEvent(new Event('input'));
return out;
})()""" % TABLE)
print(f"type a filter -> filtered rows on screen (incl. 180 ms debounce): {res['ms']:.0f} ms ({res['rows']:,} matching rows, server view {res['view']} ms)")
c.eval(f"location.hash = 'plugin/windows.pslist.PsList'")
c.wait("document.querySelector('#panes > .pane:not([hidden]) .vt-row:not(.loading)')", 30)
open_run(small["id"])
res = c.eval("""(() => {
const vt = document.querySelector('#panes > .pane:not([hidden]) .vt')._vt;
const vt = %s._vt;
const costs = [];
for (let i = 0; i < 50; i++) { const t0 = performance.now(); vt.moveTo(i); void vt.scroll.offsetHeight; costs.push(performance.now() - t0); }
costs.sort((a, b) => a - b);
return { median: costs[25], max: costs[49] };
})()""")
})()""" % TABLE)
print(f"keyboard row move in a table (render + layout): median {res['median']:.2f} ms, max {res['max']:.2f} ms")
if c.errors:
print("JS errors:", c.errors)
Expand Down
4 changes: 4 additions & 0 deletions bench/web/serve.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ case $cmd in
start)
port=$3; img=$4; shift 4
stop
# a HOME of its own: test servers save their analyses there, never in the real ~/.fvol
# (the caches stay shared: XDG_CACHE_HOME points at the real ~/.cache)
mkdir -p "$dir/home"
XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" HOME="$dir/home" \
nohup "$ROOT/bench/scripts/limit.sh" -m 6G "$BIN" serve -f "$img" --port "$port" \
--token "testtoken-$name-0123456789" -o "$dir/out" "$@" > "$dir/serve.log" 2>&1 &
for _ in $(seq 1 6000); do # limit.sh may wait for a free slot
Expand Down
Loading
Loading