Skip to content

feat(dashboard): local web Hunt Dashboard (/dashboard) - #143

Merged
shuvonsec merged 2 commits into
awarexone:mainfrom
shivsin25:feat/hunt-dashboard
Sep 28, 2026
Merged

shuvonsec merged 2 commits into
awarexone:mainfrom
shivsin25:feat/hunt-dashboard

Conversation

@shivsin25

@shivsin25 shivsin25 commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

What & why

A hunt's state is scattered across memory/leads/<target>.jsonl, recon/, findings/, reports/, screenshot gallery.html files, and hunt-memory/*.jsonl. Nothing shows it all at once, so stale high-priority leads rot (Critical Rule 6) and the memory flywheel stays invisible (TODO-6). This adds one live web view over all of it.

Naming note: this is distinct from tools/dashboard.py (the in-terminal ANSI progress TUI). This is the persistent web view of accumulated hunt state, named hunt_dashboard.py to avoid any collision. The existing TUI is untouched.

What it adds

  • tools/hunt_dashboard.py — pure-stdlib (zero new deps, same tech as demo/app.py):
    • serve — live local server (/, /api/state JSON, safe /file viewer), auto-refresh.
    • export — a single self-contained HTML snapshot (shareable as report evidence).
    • collect_state() / render_dashboard() are pure given a root dir, so they're unit-tested without a running server.
  • Views: stat tiles - lead board (untouched-first, stale HIGH-priority alert) - recon surface - findings/reports - screenshot galleries - memory flywheel.
  • /dashboard slash command + CLAUDE.md command-table and tools-list entries.

Security

  • Binds to 127.0.0.1 only.
  • Every recon-derived value is HTML-escaped — recon data is attacker-controlled, so the dashboard can't become a stored-XSS sink.
  • /file is path-traversal guarded (resolves under repo root only) and extension-allowlisted.

Tests

  • 16 tests in tests/test_hunt_dashboard.py — collection, rendering, XSS-escaping, live/static modes, traversal guard, CLI export. All green.
  • Additive only: 4 files changed, 824 insertions(+), nothing deleted; the existing TUI and its tests are byte-untouched.

Try it

python tools/hunt_dashboard.py serve      # http://127.0.0.1:8777
python tools/hunt_dashboard.py export -o dashboard.html

A hunt's state is scattered across memory/leads/<target>.jsonl, recon/,
findings/, reports/, screenshot gallery.html files, and hunt-memory/*.jsonl,
so stale high-priority leads rot (Critical Rule 6) and the memory flywheel
stays invisible (TODO-6). This adds one live view over all of it.

- bughunter/tools/hunt_dashboard.py — pure-stdlib server (`serve`) + self-
  contained snapshot (`export`). collect_state()/render_dashboard() are pure
  given a root dir, so they're unit-tested without a running server.
- Shows stat tiles, the lead board (untouched-first, stale HIGH alert),
  recon surface, findings/reports, screenshot galleries, memory flywheel.
- Security: binds 127.0.0.1 only; every recon-derived value is HTML-escaped
  (recon data is attacker-controlled); /file endpoint is path-traversal
  guarded and extension-allowlisted.
- Distinct from bughunter/tools/dashboard.py (the in-terminal ANSI progress
  TUI) — named hunt_dashboard.py to avoid collision.
- 16 tests in tests/test_hunt_dashboard.py; /dashboard command; CLAUDE.md
  command table + tools list updated.

Rebased onto the v6.x package layout: tool lives under bughunter/tools/,
data root resolves to the package dir like lead_board.py and poc_bundler.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@shuvonsec shuvonsec left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work - clean zero-dep stdlib dashboard, and safe_path (realpath + commonpath + extension allowlist) is solid. Verified 16 tests pass. Two security asks before merge, one minor:

  1. (security) /file serves allow-listed .html raw as text/html. Any .html under root (a recon/PoC-captured HTTP response saved as .html) then runs as live JS in the dashboard's own origin, which also exposes /api/state + /file same-origin with no auth = data-exfil XSS. Please serve untrusted .html as text/plain (or escape it), not passthrough.
  2. (security) serve accepts --host 0.0.0.0 and does no Host-header check, so the unauthenticated server can be exposed / hit via DNS-rebinding from a malicious page. Add a loopback Host-header allowlist and warn loudly when --host is non-loopback.
  3. (minor) collect_recon(root) runs 3x per page load (once for state, twice in the totals set-expr). Compute once and reuse.

@shivsin25

shivsin25 commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for the sharp review, @shuvonsec — both security points are spot on. Fixed in 33dce87:

1. /file HTML → data-exfil XSS. You're right: a recon/PoC-captured response saved as .html would run as live JS in our own origin (with /api/state + /file same-origin, no auth). /file now serves all .html as text/plain with X-Content-Type-Options: nosniff — no passthrough, captured HTML is inert and can't parse/execute. Added a live test that saves a hostile <script>fetch('/api/state')…exfil</script> as .html and asserts it comes back text/plain + nosniff, never as HTML.

2. DNS-rebinding via --host 0.0.0.0 + no Host check. Added a Host-header allow-list (loopback + the bind host + any --allow-host); every request with a foreign Host gets a 403 before any local state is read — so a page that rebinds its domain to 127.0.0.1 still sends Host: evil.example and is rejected. Binding to a non-loopback --host now prints a loud warning that the unauthenticated dashboard is being network-exposed, and tells you which Host values are accepted. Tests cover loopback-accept / foreign-reject (incl. a live Host: evil.example → 403).

3. collect_recon 3×. Good catch — now computed once in collect_state and reused for both recon and the totals.

21 tests pass. Ready for another look

…on once

Resolves the change requests from @shuvonsec on the /dashboard PR.

1. (security) /file no longer serves .html as text/html. A recon/PoC-captured
   HTTP response saved as .html would otherwise run as live JS in the dashboard's
   own origin and exfil /api/state. It's now served as text/plain with
   X-Content-Type-Options: nosniff, so captured HTML is inert. Applies to all
   .html under root — no passthrough.

2. (security) serve now enforces a Host-header allow-list (loopback + the bind
   host + any --allow-host), returning 403 for anything else. This blocks
   DNS-rebinding: a malicious page pointing its domain at 127.0.0.1 still sends a
   foreign Host and is rejected. Binding to a non-loopback --host now prints a
   loud warning that the unauthenticated dashboard is being network-exposed.

3. (perf) collect_recon(root) is computed once in collect_state and reused for
   both the 'recon' field and the totals, instead of running per page load.

Tests: +5 in tests/test_hunt_dashboard.py — host_allowed / build_allowed_hosts,
a live server test asserting a hostile <script> .html comes back as text/plain +
nosniff (never parsed), and a foreign Host → 403. 21 tests pass. Docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@shuvonsec
shuvonsec merged commit db925b7 into awarexone:main Sep 28, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants