diff --git a/README.md b/README.md
index 857a270..449cd2c 100644
--- a/README.md
+++ b/README.md
@@ -76,13 +76,22 @@ fvol serve -f memory.raw # prints a local URL with an access token
-
-
+
+
-Plugin palette, million-row tables, process tree, hex viewer, run comparison, exports. Localhost
-only, token-protected. [docs/web-ui.md](docs/web-ui.md)
+
+
+
+
+
+
+
+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
@@ -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.
diff --git a/bench/web/perf.py b/bench/web/perf.py
index ad26a63..8b1e402 100644
--- a/bench/web/perf.py
+++ b/bench/web/perf.py
@@ -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}")
@@ -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();
@@ -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)
diff --git a/bench/web/serve.sh b/bench/web/serve.sh
index f6f1103..3e92bf3 100755
--- a/bench/web/serve.sh
+++ b/bench/web/serve.sh
@@ -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
diff --git a/bench/web/shots.py b/bench/web/shots.py
index 586c062..e2e79de 100644
--- a/bench/web/shots.py
+++ b/bench/web/shots.py
@@ -2,7 +2,9 @@
bench/scripts/limit.sh -m 4G python3 bench/web/shots.py [--port 18765] [--name win] [steps...]
-Needs a server started with bench/web/serve.sh (token testtoken-NAME-0123456789)."""
+Needs a server started with bench/web/serve.sh (token testtoken-NAME-0123456789). The `readme`
+step writes the README's pictures (docs/assets/screenshots/web-ui-*.png); `--scrub DIR=NEW`
+replaces a local path in the page before each picture, e.g. --scrub /home/me/images=/cases."""
import argparse
import json
@@ -18,6 +20,7 @@
ap.add_argument("--name", default="win")
ap.add_argument("--out", default=os.path.join(DATA, "testdata/scratch/webui/shots"))
ap.add_argument("--size", default="1440x900")
+ap.add_argument("--scrub", action="append", default=[], metavar="DIR=NEW", help="show NEW instead of DIR in the pictures")
ap.add_argument("steps", nargs="*")
a = ap.parse_args()
os.makedirs(a.out, exist_ok=True)
@@ -30,6 +33,7 @@
def shot(name):
+ c.eval("document.getElementById('toasts').replaceChildren()")
p = os.path.join(a.out, f"{a.name}-{name}.png")
c.shot(p)
print("shot", p)
@@ -45,223 +49,192 @@ def deco(fn):
STEPS = []
-def login(hash_=""):
+def login():
c.goto(f"{BASE}/?r={time.time()}#token={TOKEN}", 1.0)
- if hash_:
- c.eval(f"location.hash = {json.dumps(hash_.lstrip('#'))}")
- c.wait("document.querySelector('.tab')", 20)
+ c.wait("document.querySelector('.qs') || document.querySelector('.wb-panel')", 20)
+ c.pump(0.4)
-def ready():
- c.wait("document.querySelectorAll('.tnode').length > 0 || document.querySelector('.tree-empty')", 30)
- c.pump(0.4)
+def workspace():
+ """Past the Quick Start, results closed, nothing ticked."""
+ if c.eval("!!document.querySelector('.qs')"):
+ c.key("Escape")
+ if c.eval("document.body.classList.contains('results-open')"):
+ c.key("Escape")
+ c.eval("document.getElementById('pl-tab-select').click()")
+ c.wait("document.querySelector('.wb-plugins .pl-row')", 20)
+ c.pump(0.3)
+
+
+def theme(t):
+ c.eval(f"localStorage.setItem('fastvol.theme', '{t}'); document.documentElement.setAttribute('data-theme', '{t}')")
+ c.pump(0.3)
-@step("overview")
-def overview():
+def scrub():
+ """Local paths (and notifications) out of a picture (the page shows real paths)."""
+ c.eval("document.getElementById('toasts').replaceChildren()")
+ pairs = json.dumps([x.split("=", 1) for x in a.scrub])
+ c.eval(f"""(() => {{ const map = {pairs};
+ const fix = s => {{ for (const [f, t] of map) s = s.split(f).join(t); return s; }};
+ const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
+ let n; while ((n = w.nextNode())) {{ const v = fix(n.nodeValue); if (v !== n.nodeValue) n.nodeValue = v; }}
+ for (const e of document.querySelectorAll('[title]')) e.title = fix(e.title); }})()""")
+
+
+def start_run(name):
+ """Run the built-in triage preset for the image's OS (else the first one); returns its name."""
+ c.eval("document.getElementById('pl-tab-presets').click()")
+ c.wait("document.querySelector('.ps-row')", 10)
+ pick = "([...document.querySelectorAll('.ps-row')].find(r => /Triage/.test(r.textContent)) || document.querySelector('.ps-row'))"
+ preset = c.eval(f"{pick}.querySelector('.ps-name').textContent")
+ c.eval(f"{pick}.click()")
+ c.wait("!document.getElementById('pl-run').hidden", 10)
+ c.eval("document.getElementById('pl-run').click()")
+ c.wait("document.body.classList.contains('results-open')", 20)
+ # every plugin of the run finished
+ c.wait("document.querySelectorAll('.rs-tab').length > 0 && ![...document.querySelectorAll('.rs-tab .rs-dot')].some(d => d.classList.contains('running') || d.classList.contains('queued'))", 300)
+ c.pump(0.6)
+ return preset
+
+
+@step("quickstart")
+def quickstart():
login()
- ready()
- c.pump(0.8)
- shot("01-overview")
-
-
-@step("process")
-def process():
- # the process with the most children that is still running: usually explorer / services
- pid = c.eval("import('/assets/core.js').then(m => { const ps = (m.store.procs||[]).filter(p => p.exit === null && p.name); ps.sort((a,b) => b.kids.length - a.kids.length); const e = ps.find(p => /explorer|systemd|launchd|bash|sshd/i.test(p.name)) || ps[0]; return e ? e.pid : null; })")
- c.eval(f"location.hash = 'proc/{pid}'")
- c.wait("document.querySelector('.pv-head h2')", 10)
- c.wait("document.querySelector('.subpanes .vt-row:not(.loading)') || document.querySelector('.subpanes .errcard') || document.querySelector('.subpanes .vt-empty:not([hidden])')", 30)
- c.pump(1.0)
- shot("02-process")
-
-
-@step("network")
-def network():
- pid = c.eval("import('/assets/core.js').then(m => { const p = (m.store.procs||[]).find(p => /svchost|explorer|sshd|systemd-resolve/i.test(p.name) && p.exit === null); return p ? p.pid : 4; })")
- c.eval(f"location.hash = 'proc/{pid}'")
- c.wait("document.querySelector('#panes > .pane:not([hidden]) .subtab')", 10)
- ok = c.eval("(() => { const b = [...document.querySelectorAll('#panes > .pane:not([hidden]) .subtab')].find(b => /Network|Sockets/.test(b.textContent) && !b.disabled); if (b) b.click(); return !!b; })()")
- if ok:
- c.wait("document.querySelector('#panes > .pane:not([hidden]) .subpanes > .pane:not([hidden]) .vt-row:not(.loading)') || document.querySelector('#panes > .pane:not([hidden]) .subpanes > .pane:not([hidden]) .vt-empty:not([hidden])')", 60)
+ shot("01-quickstart")
+ c.key("2")
+ c.wait("document.querySelector('.qs-file')", 10)
+ c.pump(0.4)
+ shot("02-quickstart-open")
+ c.key("3")
c.pump(0.8)
- shot("02b-process-network")
+ shot("03-quickstart-previous")
-@step("palette")
-def palette():
- c.key("k", mods=2)
- c.wait("document.querySelector('.palette')", 5)
- c.type("mal")
- c.pump(0.4)
- shot("03-palette")
- c.key("Tab")
- c.pump(0.3)
- shot("03b-palette-form")
- c.key("Escape")
- c.pump(0.2)
+@step("workspace")
+def workspace_step():
+ login()
+ workspace()
+ shot("04-workspace")
-@step("results")
-def results():
- c.eval("import('/assets/core.js').then(m => { location.hash = 'plugin/' + m.store.session.os + '.pslist.PsList'; })")
- c.wait("document.querySelector('#panes > .pane:not([hidden]) .vt-row:not(.loading)')", 20)
+@step("select")
+def select():
+ workspace()
+ c.eval("document.getElementById('pl-tab-select').click()")
+ c.eval("(q => { q.value = 'ps'; q.dispatchEvent(new Event('input')); })(document.querySelector('#plugins input[type=search]'))")
c.pump(0.3)
- # sort by CreateTime descending via keyboard: focus table, move to column, press s twice
- c.eval("document.querySelector('#panes > .pane:not([hidden]) .vt').focus()")
- for _ in range(8):
- c.key("ArrowRight")
- c.key("s")
- c.pump(0.2)
- c.key("s")
- c.pump(0.5)
- c.key("ArrowDown")
- c.key("ArrowDown")
- c.key("Enter")
- c.pump(0.5)
- shot("04-results-drawer")
+ c.eval("document.querySelectorAll('.pl-row')[0].click()")
+ c.eval("document.querySelectorAll('.pl-row')[1].click()")
+ c.wait("!document.getElementById('pl-run').hidden", 5)
+ c.pump(0.3)
+ shot("05-select")
+ c.eval("(q => { q.value = ''; q.dispatchEvent(new Event('input')); })(document.querySelector('#plugins input[type=search]'))")
-@step("filescan")
-def filescan():
- c.eval("location.hash = 'plugin/windows.filescan.FileScan'")
- c.wait("document.querySelector('#panes > .pane:not([hidden]) .vt-row:not(.loading)')", 60)
- c.pump(0.5)
- c.key("/")
- c.type("\\users\\")
- c.pump(0.8)
- c.eval("document.querySelector('#panes > .pane:not([hidden]) .vt').focus()")
- c.key("f")
+@step("presets")
+def presets():
+ workspace()
+ c.eval("document.getElementById('pl-tab-presets').click()")
+ c.wait("document.querySelector('.ps-row')", 10)
c.pump(0.3)
- shot("05-filescan-filter")
-
-
-@step("hex")
-def hexview():
- off = c.eval("import('/assets/core.js').then(m => { const p = (m.store.procs||[]).find(p => typeof p.offset === 'string'); return p ? p.offset : '0x0'; })")
- c.eval(f"location.hash = 'hex/kernel/{off}'")
- c.wait("document.querySelector('#panes > .pane:not([hidden]) .hx-row b')", 10)
- c.pump(1.0)
- c.eval("document.querySelector('#panes > .pane:not([hidden]) .hx-grid').focus()")
- for k in ["ArrowRight"] * 8:
- c.key(k)
- c.key("d")
- c.pump(1.0)
- shot("06-hex")
-
-
-@step("compare")
-def compare():
- ids = c.eval("import('/assets/core.js').then(m => [...m.store.runs.values()].filter(r => r.status === 'done').map(r => [r.id, r.plugin]))")
- by = {p: i for i, p in ids}
- a_id = by.get("windows.pslist.PsList")
- run = c.eval("import('/assets/core.js').then(m => m.runPlugin('windows.psscan.PsScan', {}, {reuse: true}).then(r => r.id))")
- c.eval(f"import('/assets/core.js').then(m => m.whenDone({run}))")
- c.eval(f"location.hash = 'compare/{a_id}/{run}'")
- c.wait("document.querySelectorAll('.cmp-side .vt-row:not(.loading)').length > 2", 20)
- c.pump(0.8)
- shot("07-compare")
+ shot("06-presets")
-@step("timeline")
-def timeline():
- c.eval("import('/assets/core.js').then(m => { location.hash = 'plugin/' + m.store.session.os + '.pslist.PsList'; })")
- c.wait("document.querySelector('#panes > .pane:not([hidden]) .vt-row:not(.loading)')", 20)
- c.eval("[...document.querySelectorAll('#panes > .pane:not([hidden]) .rv-bar button')].find(b => b.textContent === 'Timeline').click()")
- c.wait("document.querySelector('#panes > .pane:not([hidden]) .hist svg')", 10)
+@step("results")
+def results():
+ workspace()
+ start_run("results")
+ shot("07-results")
+ # a tree (pstree, or the first plugin with nested rows)
+ tree = c.eval("[...document.querySelectorAll('.rs-tab')].findIndex(t => /pstree/.test(t.textContent))")
+ if tree >= 0:
+ c.eval(f"document.querySelectorAll('.rs-tab')[{tree}].click()")
+ c.wait("document.querySelector('.vt-row:not(.loading)')", 20)
+ c.pump(0.6)
+ shot("08-results-tree")
+ c.eval("(q => { q.value = 'svchost'; q.dispatchEvent(new Event('input')); })(document.querySelector('.rs-q'))")
+ c.pump(1.2)
+ shot("09-results-filter")
+ c.eval("(q => { q.value = ''; q.dispatchEvent(new Event('input')); })(document.querySelector('.rs-q'))")
c.pump(0.5)
- shot("08-timeline")
-@step("timeliner")
-def timeliner():
- c.eval("location.hash = 'plugin/timeliner.Timeliner'")
- c.wait("document.querySelector('#panes > .pane:not([hidden]) .hist svg rect')", 120)
- c.pump(0.8)
- shot("15-timeliner")
- # brush the busiest stretch: drag across the middle fifth of the chart
- # brush around the busiest bar: the burst an analyst would zoom into
- box = c.eval("""(() => { const svg = document.querySelector('#panes > .pane:not([hidden]) .hist svg'); const r = svg.getBoundingClientRect();
- let best = null; for (const b of svg.querySelectorAll('rect.b')) { const q = b.getBoundingClientRect(); if (!best || q.height > best.height) best = q; }
- return [r.left, r.top, r.width, r.height, best.left + best.width / 2]; })()""")
- y = box[1] + box[3] / 2
- x0, x1 = max(box[0] + 1, box[4] - 12), box[4] + 12 # may run past the edge: the brush clamps
- c.send("Input.dispatchMouseEvent", {"type": "mousePressed", "x": x0, "y": y, "button": "left", "clickCount": 1})
- for k in range(1, 11):
- c.send("Input.dispatchMouseEvent", {"type": "mouseMoved", "x": x0 + (x1 - x0) * k / 10, "y": y, "button": "left"})
- c.send("Input.dispatchMouseEvent", {"type": "mouseReleased", "x": x1, "y": y, "button": "left", "clickCount": 1})
- c.pump(1.5)
- c.send("Input.dispatchMouseEvent", {"type": "mouseMoved", "x": box[0] + box[2] * 0.5, "y": box[1] + box[3] - 4})
- c.pump(0.5)
- shot("16-timeliner-range")
+@step("options")
+def options():
+ workspace()
+ c.eval("document.getElementById('wb-options').click()")
+ c.wait("document.querySelectorAll('.op-row').length > 10", 10)
+ c.pump(0.3)
+ shot("10-options")
+ c.key("Escape")
@step("light")
def light():
- c.eval("localStorage.setItem('fastvol.theme', 'light')")
- login()
- ready()
- c.pump(0.8)
- shot("09-overview-light")
- pid = c.eval("import('/assets/core.js').then(m => { const ps = (m.store.procs||[]).filter(p => p.exit === null && p.name); ps.sort((a,b) => b.kids.length - a.kids.length); return ps[0] ? ps[0].pid : null; })")
- c.eval(f"location.hash = 'proc/{pid}'")
- c.wait("document.querySelector('.subpanes .vt-row:not(.loading)') || document.querySelector('.subpanes .errcard')", 30)
- c.pump(0.8)
- shot("10-process-light")
- c.eval("localStorage.setItem('fastvol.theme', 'dark')")
+ workspace()
+ theme("light")
+ shot("11-workspace-light")
+ theme("dark")
@step("laptop")
def laptop():
- c.resize(1280, 720)
- login()
- ready()
- c.eval("import('/assets/core.js').then(m => { location.hash = 'plugin/' + m.store.session.os + '.pslist.PsList'; })")
- c.wait("document.querySelector('#panes > .pane:not([hidden]) .vt-row:not(.loading)')", 20)
- c.pump(0.6)
- shot("11-laptop-1280")
+ workspace()
+ c.resize(1280, 800)
+ c.pump(0.5)
+ shot("12-laptop-1280")
+ c.eval("document.getElementById('wb-ovbtn').click()")
+ c.pump(0.4)
+ shot("13-laptop-overview")
+ c.eval("document.getElementById('wb-ovbtn').click()")
c.resize(W, H)
-
-
-@step("error")
-def error():
- other = c.eval("import('/assets/core.js').then(m => m.store.session.os === 'windows' ? 'linux.pslist.PsList' : 'windows.info.Info')")
- c.eval(f"location.hash = 'plugin/{other}'")
- c.wait("document.querySelector('#panes > .pane:not([hidden]) .errcard')", 20)
c.pump(0.4)
- shot("13-error")
-@step("nosession")
-def nosession():
- login()
- c.wait("document.querySelector('.evcard') || document.querySelector('.openbox')", 10)
- c.pump(1.0)
- shot("14-session")
-
-
-@step("openimage")
-def openimage():
- c.eval("document.getElementById('evidence').click()")
- c.wait("document.querySelector('.dialog .fslist button')", 10)
- c.eval("(() => { const i = document.querySelector('.dialog input'); i.value = %s; i.dispatchEvent(new Event('input')); })()"
- % json.dumps(DATA + "/testdata/images/"))
- c.pump(1.0)
- shot("17-open-image")
- c.key("Escape")
-
-
-@step("help")
-def helpstep():
- c.key("?")
- c.pump(0.3)
- shot("12-help")
- c.key("Escape")
+@step("readme")
+def readme():
+ """The README's pictures: 1440x780, workspace with plugins ticked and a run open, results."""
+ c.resize(1440, 780)
+ for t in ("dark", "light"):
+ theme(t)
+ c.eval("for (const k of Object.keys(localStorage)) if (k.startsWith('fastvol.cols:') || k === 'fastvol.layout') localStorage.removeItem(k)")
+ login()
+ workspace()
+ if not c.eval("document.querySelector('.rn-head')"):
+ start_run("readme")
+ workspace()
+ head = "document.querySelector('.rn-head')"
+ if not c.eval(f"{head}.parentElement.classList.contains('open')"):
+ c.eval(f"{head}.querySelector('.rn-caret').click()")
+ c.eval("document.getElementById('pl-tab-select').click()")
+ c.eval("(q => { q.value = 'malware'; q.dispatchEvent(new Event('input')); })(document.querySelector('#plugins input[type=search]'))")
+ c.pump(0.3)
+ c.eval("[...document.querySelectorAll('.pl-row')].filter(r => /malfind|hollowprocesses|ldrmodules/.test(r.title)).forEach(r => r.click())")
+ c.eval("document.querySelector('.pl-list').scrollTop = 0; document.activeElement.blur()")
+ c.pump(0.6)
+ scrub()
+ c.shot(os.path.join(a.out, f"web-ui-workspace-{t}.png"))
+ c.eval(f"{head}.click()")
+ c.pump(0.2)
+ if not c.eval("document.body.classList.contains('results-open')"):
+ c.eval(f"{head}.click()")
+ c.wait("document.body.classList.contains('results-open')", 10)
+ # the tab with the most rows makes the fullest table
+ c.eval("(ts => ts.sort((x, y) => (+y.querySelector('.rs-badge').textContent.replace(/,/g, '') || 0) - (+x.querySelector('.rs-badge').textContent.replace(/,/g, '') || 0))[0].click())([...document.querySelectorAll('.rs-tab')])")
+ c.pump(1.5)
+ c.eval("document.activeElement.blur()")
+ scrub()
+ c.shot(os.path.join(a.out, f"web-ui-results-{t}.png"))
+ c.eval("(q => { q.value = ''; q.dispatchEvent(new Event('input')); })(document.querySelector('#plugins input[type=search]'))")
+ c.key("Escape")
+ print("readme pictures", t)
+ theme("dark")
+ c.resize(W, H)
try:
login()
- ready()
for name, fn in STEPS:
if a.steps and name not in a.steps:
continue
diff --git a/docs/assets/screenshots/web-ui-memory.png b/docs/assets/screenshots/web-ui-memory.png
deleted file mode 100644
index 4438118..0000000
Binary files a/docs/assets/screenshots/web-ui-memory.png and /dev/null differ
diff --git a/docs/assets/screenshots/web-ui-overview-dark.png b/docs/assets/screenshots/web-ui-overview-dark.png
deleted file mode 100644
index 6aab556..0000000
Binary files a/docs/assets/screenshots/web-ui-overview-dark.png and /dev/null differ
diff --git a/docs/assets/screenshots/web-ui-overview-light.png b/docs/assets/screenshots/web-ui-overview-light.png
deleted file mode 100644
index 650be29..0000000
Binary files a/docs/assets/screenshots/web-ui-overview-light.png and /dev/null differ
diff --git a/docs/assets/screenshots/web-ui-process.png b/docs/assets/screenshots/web-ui-process.png
deleted file mode 100644
index 2cec76c..0000000
Binary files a/docs/assets/screenshots/web-ui-process.png and /dev/null differ
diff --git a/docs/assets/screenshots/web-ui-results-dark.png b/docs/assets/screenshots/web-ui-results-dark.png
new file mode 100644
index 0000000..e614755
Binary files /dev/null and b/docs/assets/screenshots/web-ui-results-dark.png differ
diff --git a/docs/assets/screenshots/web-ui-results-light.png b/docs/assets/screenshots/web-ui-results-light.png
new file mode 100644
index 0000000..910a329
Binary files /dev/null and b/docs/assets/screenshots/web-ui-results-light.png differ
diff --git a/docs/assets/screenshots/web-ui-workspace-dark.png b/docs/assets/screenshots/web-ui-workspace-dark.png
new file mode 100644
index 0000000..ff9be39
Binary files /dev/null and b/docs/assets/screenshots/web-ui-workspace-dark.png differ
diff --git a/docs/assets/screenshots/web-ui-workspace-light.png b/docs/assets/screenshots/web-ui-workspace-light.png
new file mode 100644
index 0000000..2645f6a
Binary files /dev/null and b/docs/assets/screenshots/web-ui-workspace-light.png differ
diff --git a/docs/web-ui.md b/docs/web-ui.md
index 656eb14..072ad3d 100644
--- a/docs/web-ui.md
+++ b/docs/web-ui.md
@@ -1,94 +1,105 @@
# The web UI: `fvol serve`
-`fvol serve` runs a small web server inside the `fvol` binary and serves an analysis workspace for
-one memory image. It is part of fastvol only; python volatility3 has no equivalent command.
+`fvol serve` runs a small web server inside the `fvol` binary and serves an analysis workspace in
+the browser: open a memory dump, run sets of plugins, read their results and keep all of it for
+next time. It is part of fastvol only; python volatility3 has no equivalent command.
This page has two parts. The [tutorial](#tutorial-a-first-session) walks through a first
-session. The [reference](#reference) lists the options, keyboard shortcuts, HTTP API and the
-security model.
+session. The [reference](#reference) lists the options, the saved files, the keyboard
+shortcuts, the HTTP API and the security model.
Applies to fastvol 0.1.0.
## Tutorial: a first session
-In this tutorial you open a Windows image in the browser, look at its processes, inspect one
-process, read memory at an address and export a result. You need a built `fvol` binary, a Windows
-memory image and a browser on the same machine. Linux and macOS images work the same way once you
-add their symbol directory with `-s`.
+In this tutorial you open a Windows image, run a preset, read the results, save your own preset
+and come back to the analysis later. You need a built `fvol` binary, a memory image and a browser
+on the same machine. Linux and macOS images work the same way once their symbol directory is set
+in **Options** (or given with `-s`).
### 1. Start the server
```bash
-fvol serve -f
+fvol serve
```
```text
fastvol web UI · Volatility 3 Framework 2.28.2
- image /cases/memory-dirty.raw (5.0 GiB)
+ image (none yet: open one from the UI)
output /cases/vol-serve-output
open http://127.0.0.1:8765/#token=1c5fa1176bd21e349985b1160bfc6ac6
Anyone with this URL can read the image. Press Ctrl+C to stop.
```
-The server starts analysing the image right away: it maps the file, finds the kernel and loads
-its symbols in the background.
-
-### 2. Open the workspace
-
Copy the `open` URL into your browser. The page stores the token and removes it from the address
bar.
-The overview tab shows what fastvol found: the operating system, kernel base, DTB, the symbol file
-and the layers of the image. Next to it are quick actions for the plugins most analysts run first.
-On the left is the process tree.
+### 2. Open a memory image
+
+The page starts with the **Quick Start**:
+
+- **Continue** takes the image `fvol serve -f` was given.
+- **New analysis** browses the server's folders. Memory images are tagged *image*; double-click
+ one, or press **Open File…** to pick it in your desktop's own file dialog.
+- **Previous** lists the dumps you analysed before (see step 7).
-### 3. Explore the process tree
+Press `Esc` to go straight to the workspace.
-Each process in the tree has a lifeline on the time axis of the capture, from its creation to its
-exit or to the capture time. Processes that look out of place carry a hint, such as an unexpected
-parent, a second instance of a process that is normally unique, or a process started shortly
-before the capture.
+### 3. Read the workspace
-Press `T` and type `svchost` to filter the tree. Use the arrow keys to move and press `Enter` on
-a process to open its process view.
+The workspace has four panels, each resizable by dragging the lines between them
+(double-click a line to reset it):
-### 4. Look at one process
+- **File Overview** on the left prints what fastvol found about the image, line by line: path,
+ size, operating system, capture time, symbols, and the kernel facts of `windows.info`.
+- **Plugins** in the middle is where plugins are chosen.
+- **Runs** at the top right lists the runs of this image.
+- **Triage Hints** at the bottom right flags processes worth a look: unexpected parents,
+ duplicated singletons, processes started shortly before capture. **Load Rules…** adds your
+ own checks from a JSON file ([rules](#triage-rules)).
-The process view shows the process's parent, children, lifetime and command line, and has a tab
-for each plugin that can be limited to one PID: handles, DLLs, memory regions, environment,
-network, threads, injected code and more. Open a tab to run its plugin for this process only.
+In a narrow window the File Overview hides first; the **File Overview** button in the top bar
+shows it again.
-In any table, move with the arrow keys, press `Enter` for the details of a row, `S` to sort by
-the current column and `F` to filter it. Filters accept text, `=exact`, `!not`, comparisons such
-as `>0x10` and `/regex/`.
+### 4. Run a preset
-### 5. Run any plugin
+Click **Presets** in the Plugins header and choose **Windows Triage**. Its plugins are ticked
+under **Select**, where you can untick some or tick more. A ticked plugin with options unfolds
+them below it: checkboxes for flags such as `--dump`, boxes for values such as `--pid`.
-Press `Ctrl+K` to open the plugin palette, type `netscan` and choose `windows.netscan.NetScan`.
-Plugins with options show a form first; integer fields accept python syntax such as `0x1000`, and
-PID fields offer the process list.
+Press **Run**. All the ticked plugins start together as one run, named after the preset, a few
+at a time. The run appears under **Runs** with its progress, and its results replace the File
+Overview and Plugins panels.
-The run appears in the history rail. Rows stream in while the plugin runs, and a large result
-stays fast because the browser holds only the rows on screen.
+### 5. Read the results
-### 6. Read memory at an address
+The results panel, *Windows Triage: Results*, has a tab per plugin with its status and row count.
+Step through them with `]` and `[`, or `Ctrl+→` and `Ctrl+←`.
-In a result with addresses, put the cursor on an address column and press `H`. The memory viewer
-opens at that address. It shows a hex dump with a data inspector. Press `Enter` on a pointer to
-follow it, `Backspace` to go back and `D` to disassemble from the cursor.
+- **Filter rows** filters the rows of every plugin of the run: type `svchost` once, then step
+ through the tabs. Each run keeps its own filter.
+- **Filters** adds a filter box per column: `text`, `=exact`, `!not`, `>0x10`, `/regex/`.
+- Click a column header to sort, drag it sideways to move the column, drag its right edge to
+ resize it and double-click that edge to fit the column to its values.
+- Plugins that nest processes, such as `pstree`, draw their tree in the first column.
+- **Export** downloads the rows shown as CSV, TSV, JSON, JSON Lines or Markdown, or as
+ **fvol output**: exactly what `fvol` prints for the plugin.
-### 7. Export a result
+Press `Esc` or **×** to go back to the File Overview and Plugins. Click a run under **Runs** to
+open its results again; click it once more to fold it.
-Use the export menu of a result tab to download the visible columns of the current view as CSV,
-TSV, JSON, JSON Lines or Markdown. The `fvol -r ...` entries download what the command line prints
-for the same plugin and options instead. Files that a plugin wrote, such as dumped executables,
-are listed with the run and can be downloaded one by one or as a zip.
+### 6. Save a preset
-Stop the server with `Ctrl+C`. The results of this session are gone, but the files plugins wrote
-stay in `vol-serve-output/`, and the fastvol caches make the next session on the same image start
-immediately.
+Under **Select**, tick the plugins you want and press **Save Preset**. Name it; it is saved as
+`~/.fvol/presets/.json` and listed under **Presets** for every image from then on.
-For more, read the [reference](#reference) below, and [usage.md](usage.md) for symbol setup.
+### 7. Come back later
+
+Stop the server with `Ctrl+C`. Everything you did is already saved: the runs, their results and
+filters, the options and the rules. Start `fvol serve` again, choose **Previous** in the Quick
+Start and pick the dump. The fastvol caches make the image open immediately, and the results
+come back without running any plugin again. To delete an analysis, press **Delete** next to it
+in **Previous**.
## Reference
@@ -102,11 +113,11 @@ fvol serve [-h] [-f FILE] [--host HOST] [--port PORT] [-s SYMBOL_DIRS] [-o OUTPU
| Option | Default | Meaning |
| ------------------------ | -------------------- | ------------------------------------------------------------------------ |
-| `-f, --file FILE` | none | Image to open. Without it, open one from the UI. |
+| `-f, --file FILE` | none | Image to open. Without it, open one from the Quick Start. |
| `--host HOST` | `127.0.0.1` | IP address to listen on. `localhost` means `127.0.0.1`. |
| `--port PORT` | 8765 | Port. Without the option, the first free port from 8765 to 8784 is used, then any free port. `0` means any free port. |
-| `-s, --symbol-dirs DIRS` | none | Semicolon-separated symbol directories, as for `fvol`. |
-| `-o, --output-dir DIR` | `./vol-serve-output` | Root of the per-run output directories. |
+| `-s, --symbol-dirs DIRS` | none | Semicolon-separated symbol directories, as for `fvol`. Like `--offline`, `-u` and `--cache-path`, it wins over the options saved with an analysis. |
+| `-o, --output-dir DIR` | `./vol-serve-output` | Root of the per-run output directories. Given, it wins over an output folder saved with an analysis. |
| `--offline` | off | Never download symbols. |
| `-u, --remote-isf-url URL` | none | Remote symbol file list, as for `fvol`. |
| `--cache-path PATH` | python's default | python volatility3 cache path, as for `fvol`. |
@@ -116,44 +127,102 @@ fvol serve [-h] [-f FILE] [--host HOST] [--port PORT] [-s SYMBOL_DIRS] [-o OUTPU
| `--parallel N` | 3 | Plugins that may run at the same time, between 1 and 64. |
| `--max-memory SIZE` | `3G` | Memory for stored result rows across all runs, such as `2G` or `512M`. Rows past the budget are counted but not kept; exports through the `fvol` renderer stay complete. |
+### Options
+
+**Options** in the top bar sets the global options of `fvol`. Tick an option to use it; each does
+what it does on the command line, applied where the web UI has the same step. The options are
+saved with the analysis.
+
+| Options | Applied |
+| ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
+| `-s`, `--offline`, `-u`, `--cache-path`, `--single-location`, `--stackers`, `--single-swap-locations`, `-v`, `-o` | When the image is opened. A change reopens the image; its runs stay. |
+| `--clear-cache` | Once, when the image reopens. It empties `~/.cache/fastvol` only, never `~/.fvol`. |
+| `-c`, `-e` | To each plugin started afterwards: values for options the plugin leaves unset. `-e automagic.*` keys act when the image is opened. |
+| `--write-config`, `--save-config NAME` | Each run writes its configuration, as `fvol` writes it, into its output folder (`config.json`, or `NAME`). |
+| `-l FILE` | A line per run started, finished or failed. `FILE` must end in `.log`. |
+| `--parallelism off` | One plugin at a time instead of `--parallel N`. |
+| `-r`, `--filters`, `--hide-columns` | The **fvol output** export. |
+| `-q`, `-p` | No effect: the web UI has no console progress, and fastvol does not load python plugins. |
+
+### Saved files
+
+```text
+~/.fvol/-metadata.json one analysis: the dump's path, size and modification time,
+ the options, the rules file, every run and its plugins
+~/.fvol///.jsonl the rows of the n-th plugin of a run
+~/.fvol/presets/.json your presets, for every image
+```
+
+`dump_id` is the key fastvol's cache (`~/.cache/fastvol`) uses for an image: a hash of its
+canonical path, size and modification time. A moved or changed dump is therefore a new
+analysis; **Previous** marks the old one *missing* or *changed*. A metadata file this fastvol
+cannot read (damaged, or written by another version) is listed as *unreadable* and left as it is:
+the dump opens without its analysis, and nothing is saved over the file until it is deleted.
+Opening another dump saves the current one and closes its runs. The dump itself is never
+copied. **Delete** next to an analysis in **Previous** removes its metadata and result rows (for
+the dump that is open, its runs too). Results that did not fit the memory budget (`--max-memory`) are not saved; their run
+lists them, and running the plugin again gets them back.
+
+A preset file:
+
+```json
+{"name": "Ransomware sweep", "os": "windows", "created": 1790000000,
+ "plugins": [{"plugin": "windows.pslist.PsList", "args": {"pid": "4 628"}},
+ {"plugin": "windows.malware.malfind.Malfind", "args": {"dump": true}}]}
+```
+
+Presets can be copied between machines and edited by hand; a file that cannot be read is listed
+with the reason under **Presets**.
+
+### Triage rules
+
+**Load Rules…** reads a JSON file of checks against the process list. A rule flags the processes
+for which every field of its `match` matches:
+
+```json
+{"name": "Office and script abuse",
+ "rules": [{"title": "Office app spawning a shell", "severity": "high",
+ "description": "Macros often launch cmd or PowerShell.",
+ "match": {"parent": "/^(winword|excel|outlook)\\.exe$/i",
+ "name": "/^(cmd|powershell|wscript)\\.exe$/i"}}]}
+```
+
+| Field | Value |
+| ----- | ----- |
+| `name`, `parent`, `path`, `cmdline` | text: `"text"` (contains), `"=exact"`, `"!not"`, `"/regex/flags"` |
+| `pid`, `ppid`, `threads`, `handles`, `session` | numbers: `">10"`, `"<=0x40"`, `"=4"` |
+| `age` | seconds between the process start and the capture, such as `"<600"` |
+| `exited`, `wow64` | `true` or `false` |
+
+`severity` is `high`, `medium` (the default) or `low`. `path` and `cmdline` are known on Windows
+once `pstree` ran in the background. The rules file is saved with the analysis.
+
### Output files
Every run of a plugin that writes files gets its own directory below the output root, named
-`run--`. Exports through the `fvol` renderer use `export--`. Downloads are
-served only from these directories.
+`run--`; a run's configuration file (`--write-config`) goes there too. Exports
+through the `fvol` renderer use `export--`. Downloads are served only from these
+directories.
### Keyboard shortcuts
-Press `?` in the UI for this list.
-
| Where | Keys | Action |
| -------------- | ---------------------------- | -------------------------------------------------------- |
-| Anywhere | `Ctrl+K` | Run a plugin |
-| | `/` | Filter the current table |
-| | `T` | Filter the process tree |
-| | `O` | Overview |
-| | `M` | Memory viewer at an address |
-| | `Alt+1` to `Alt+9` | Go to tab N |
-| | `[` and `]` | Previous and next tab |
-| | `Alt+W` | Close the tab |
-| | `Shift+T` | Light or dark theme |
-| | `?` | Shortcut help |
+| Quick Start | `1` to `3` | Choose |
+| | `Esc` | Go to the workspace |
+| Anywhere | `Shift+T` | Light or dark theme |
+| Plugins | `↑`, `↓`, `Space`, `Enter` | Move, tick |
+| Runs | `Enter` | Open the run's results (again: fold it) |
+| | `Space`, `←`, `→` | Fold or unfold |
+| | `F2` | Rename |
+| Results | `]`, `[`, `Ctrl+→`, `Ctrl+←` | Next and previous plugin |
+| | `/` | Filter rows |
+| | `Esc` | Close the results |
| Result table | arrows, `PgUp`, `PgDn` | Move; `Ctrl+Home` and `Ctrl+End` go to the first and last row |
-| | `Enter` | Row details |
| | `C`, `Shift+C` | Copy the cell, or the row as TSV |
| | `S`, `Shift+S` | Sort by the column; again to reverse; with Shift, add a sort key |
-| | `F` | Column filter: `text`, `=exact`, `!not`, `>0x10`, `<=5`, `/regex/`, `-` for empty |
-| | `H` | Open the address under the cursor in the memory viewer |
-| | `P` | Open the row's process |
-| Process tree | arrows | Move, collapse, expand |
-| | `Enter` | Open the process view |
-| | `*` | Expand everything |
-| | `a` to `z` | Jump to a process by name |
-| Memory viewer | `G` | Go to an address |
-| | `Enter` | Follow the pointer under the cursor |
-| | `Backspace` | Back |
-| | `D` | Disassemble from the cursor |
-| | `Shift+Left`, `Shift+Right` | Select a range; `C` copies it as hex |
+| | `F` | Filter the column |
+| Splitters | arrows | Resize |
### HTTP API
@@ -163,35 +232,47 @@ The API exists for the UI and for scripts. Every call under `/api/` needs the to
| Method and path | Purpose |
| ------------------------------------ | ------------------------------------------------------------------ |
| `GET /api/session` | Current image, analysis state, OS, and the facts of the overview |
-| `POST /api/session` | Open another image: `{"file": "", "symbol_dirs": [""]}` |
+| `POST /api/session` | Open another image: `{"file": "", "symbol_dirs": [""]}` (`symbol_dirs` optional, set like `-s` in Options); its saved analysis comes back |
| `GET /api/plugins` | Every plugin with its options |
-| `GET /api/runs` | All runs |
-| `POST /api/runs` | Start a run: `{"plugin": "", "args": {"