Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
6ec93d9
Add Wireshark-style packet browser with payload search
srixivas Apr 30, 2026
36044a8
Preserve aspect ratio when rendering static graph image
srixivas Apr 30, 2026
d84a76b
Add Files column and comma-separated flags to packet browser
srixivas Apr 30, 2026
1ffd83f
Extend graph panel rowspan to cover packet browser row
srixivas Apr 30, 2026
3d591a7
Fix graph image too small when packet browser is visible
srixivas Apr 30, 2026
d699fb4
Add session drill-down on double-click in packet browser
srixivas Apr 30, 2026
297a562
Make static graph image fill and resize with window
srixivas Apr 30, 2026
8348e8b
Add inspect button to packet browser for session drill-down
srixivas Apr 30, 2026
cbcbecd
Center static graph image in canvas
srixivas Apr 30, 2026
188f1db
Increase graphviz output resolution and center image in canvas
srixivas Apr 30, 2026
4ea9cf2
Stretch graph image to always fill canvas at any window size
srixivas Apr 30, 2026
4c407b2
Pre-render graph PNG at canvas pixel dimensions for 1:1 display
srixivas Apr 30, 2026
6e85171
Fix malicious edge visibility — priority-based dedup prevents overdraw
srixivas Apr 30, 2026
11942be
Add traffic filter dropdown to matplotlib graph panel
srixivas Apr 30, 2026
2e82fc0
Fix static graph filename mismatch — include dimensions in PlotLan ou…
srixivas Apr 30, 2026
5d8db05
Add node filter + fix node colors in matplotlib panel
srixivas Apr 30, 2026
8926526
Fix memory churn and canvas leak in image display
srixivas Apr 30, 2026
d3ff993
Render static graph at 2x DPI for sharp zoom
srixivas Apr 30, 2026
efb260f
Fix giant nodes — render at 1.5x canvas pixels at 150 DPI instead of …
srixivas Apr 30, 2026
26f7b8f
Fix blurry zoom: 300 DPI render + correct node sizes + Linux font
srixivas Apr 30, 2026
c91c71d
Auto-size nodes to fit labels — drop fixedsize, switch to ellipse
srixivas Apr 30, 2026
d2df662
Fix giant nodes: natural graphviz layout + aspect-ratio PIL fit
srixivas Apr 30, 2026
c4ec117
Auto-fit graph to viewport on load; add Fit button
srixivas May 1, 2026
91a7199
Add v5.2 static graph screenshot to README
srixivas May 1, 2026
4fe09fb
Remove outdated matplotlib panel screenshot from README
srixivas May 1, 2026
c18c22a
Add code review standards to CLAUDE.md
srixivas May 1, 2026
07e7824
Safety hardening: gitignore, SQLite to Reports, stale closure fix
srixivas May 1, 2026
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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,11 @@ Test/.coverage
# Runtime output (generated by the app during analysis)
Database/
Source/Module/Report/

# Session databases and log files (generated at runtime, never commit)
*.db
*.log

# Local tooling and Claude auto-memory
lib/
memory/
29 changes: 29 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,35 @@ Located at project root. Adds `Source/Module` to `sys.path` and exports `EXAMPLE

---

## Code Review Standards

When reviewing or implementing any change, evaluate it as a staff-level engineer and architect. Every non-trivial PR should be assessed across these axes before merge:

### Correctness and robustness
- **Closure / stale capture bugs** — closures in event handlers (Tkinter `bind`, matplotlib `mpl_connect`) must not capture mutable locals that change after registration. Promote to module-level or instance-level state instead.
- **Race conditions** — debounced `after()` callbacks can fire after the widget is destroyed. Always guard with `winfo_exists()` before touching Tk widgets inside a delayed callback.
- **Thread safety** — any `memory.*` mutation outside `_on_packet` (which holds `self._lock`) is a bug. Never read-modify-write shared containers from the Tk main thread without a lock.
- **Edge cases** — test mentally: empty PCAP (zero sessions), single node, live capture → Stop → Visualize!, re-running Visualize! with a cached PNG, closing the panel mid-refresh.

### Performance and resource safety
- **Main-thread blocking** — `spring_layout`, graphviz render, DNS lookups, file I/O must not run on the Tk main thread. Use `_run_in_thread` + `_poll_thread`. Event bindings that trigger expensive ops (e.g., `<FocusOut>` → layout) are a latent freeze.
- **Resource leaks** — every `StringVar.trace_add()` needs a matching `trace_remove()` on teardown. Every `plt.subplots()` needs `plt.close()`. Every `Toplevel` needs a `destroy()` path. Tk canvas image references must be kept alive on `self`.
- **Memory** — PIL images opened in resize callbacks must be cached, not re-opened per event. Debounce rapid `<Configure>` events (120ms minimum).

### Safety and PII
- **Payload display** — raw packet payload shown in the UI is expected (forensics tool), but never log payload content at INFO/WARNING level; use DEBUG only.
- **No data exfiltration** — the only outbound calls allowed are: DNS reverse-lookup (`gethostbyaddr`), whois/RDAP (`ipwhois`), OUI vendor lookup (`device_details_fetch`), Tor consensus (`stem`). Any new network call must be documented and opt-in.
- **Output path isolation** — all file writes (PNG, HTML, TXT, SQLite) go to the user-specified output directory only. Never write to `/tmp`, `~`, or relative paths.
- **No credentials in logs** — never log HTTP Authorization headers, cookie values, or TLS pre-master secrets even at DEBUG.
- **SQL safety** — all SQLite queries use parameterised statements (`?` placeholders). No f-string or `%`-formatted SQL.

### Architecture consistency
- **Module boundaries** — `interactive_gui` reads `memory.*` but never writes it. `user_interface` drives analysis but delegates rendering to `plot_lan_network` and `interactive_gui`. Cross-module writes outside these contracts are bugs.
- **Global state in `interactive_gui`** — all panel state lives in module-level `_*` variables. Any new state variable needs a reset in `_close()` and a guard in every function that reads it (`if _ax is None: return`).
- **Pydantic models** — use attribute access, never dict-style. Use `.model_dump()` for serialisation.

---

## CI (GitHub Actions)

Workflow: `.github/workflows/test.yml`
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ Make a network diagram with the following features:
*Main application panel*
![Main window](Samples/screen5_1_1.png)

*Static network graph with interactive matplotlib panel*
![Network graph](Samples/screen5_1_2.png)
*Static network graph — natural layout with auto-fit zoom*
![Network graph v5.2](Samples/screen_5_2_2.png)

---

Expand Down
Binary file added Samples/screen_5_2_2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
199 changes: 143 additions & 56 deletions Source/Module/interactive_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,34 +17,61 @@

log = logging.getLogger(__name__)

_FILTER_OPTIONS = ["All", "Malicious", "Tor", "Covert", "HTTP", "HTTPS", "DNS", "ICMP"]

_container: tk.Frame | None = None
_figure = None
_ax = None
_canvas_widget = None
_info_var: tk.StringVar | None = None
_filter_var: tk.StringVar | None = None
_node_filter_var: tk.StringVar | None = None
_live_mode: bool = False
_base: tk.Tk | None = None
_panel_G = None
_panel_pos: dict | None = None


def _build_graph_data(live: bool = False):
def _build_graph_data(live: bool = False, filter_option: str = "All", node_filter: str = ""):
"""Build networkx graph + layout from current memory state.

Returns (G, pos, node_colors, edge_colors) or (None, ...) if nothing to show.
live=True uses spring_layout (~100ms); live=False tries graphviz first.
For each (src, dst) pair only the highest-severity edge is kept so malicious/
Tor edges are never overdrawn by lower-priority normal edges.
node_filter: if non-empty, keep only edges where src or dst label/IP contains it.
"""
try:
import networkx as nx
except ImportError:
return None, None, None, None

G = nx.MultiDiGraph()
seen_edges: set[tuple] = set()
# Collect highest-priority edge per (src_label, dst_label) pair.
# priority: malicious=4 > tor=3 > covert=2 > normal=1
edge_best: dict[tuple, tuple] = {}

for session_key, session in list(memory.packet_db.items()):
parts = session_key.split("/")
if len(parts) != 3:
continue
src_ip, dst_ip, port = parts

# Apply traffic filter
if filter_option == "Malicious" and session_key not in memory.possible_mal_traffic:
continue
if filter_option == "Tor" and session_key not in memory.possible_tor_traffic:
continue
if filter_option == "Covert" and not session.covert:
continue
if filter_option == "HTTP" and port != "80":
continue
if filter_option == "HTTPS" and port != "443":
continue
if filter_option == "DNS" and port != "53":
continue
if filter_option == "ICMP" and port != "ICMP":
continue

eth_src = session.Ethernet.get("src", "")
eth_dst = session.Ethernet.get("dst", "")

Expand Down Expand Up @@ -73,50 +100,56 @@ def _build_graph_data(live: bool = False):
is_tor = session_key in memory.possible_tor_traffic
is_mal = session_key in memory.possible_mal_traffic
color, proto = _edge_attrs(port, session.covert, is_tor, is_mal)
priority = 4 if is_mal else 3 if is_tor else 2 if session.covert else 1

sig = (src_label, dst_label, color)
if sig in seen_edges:
continue
seen_edges.add(sig)
pair = (src_label, dst_label)
if pair not in edge_best or priority > edge_best[pair][0]:
edge_best[pair] = (priority, color, proto, src_ip, dst_ip, src_kind, dst_kind)

# Apply node filter: keep only edges where src or dst label/IP matches
if node_filter:
nf = node_filter.lower()
edge_best = {
(s, d): v for (s, d), v in edge_best.items()
if nf in s.lower() or nf in d.lower()
or nf in v[3].lower() or nf in v[4].lower() # src_ip, dst_ip
}

G = nx.MultiDiGraph()
for (src_label, dst_label), (_, color, proto, src_ip, dst_ip, src_kind, dst_kind) in edge_best.items():
G.add_node(src_label, kind=src_kind, ip=src_ip)
G.add_node(dst_label, kind=dst_kind, ip=dst_ip)
G.add_edge(src_label, dst_label, color=color, proto=proto)

if not G.nodes:
return None, None, None, None

if live:
pos = nx.spring_layout(G, k=2.5, iterations=50, seed=42)
else:
n_lan = len(memory.lan_hosts)
prog = "sfdp" if n_lan > 40 else "circo" if n_lan > 20 else "dot"
try:
pos = nx.nx_pydot.graphviz_layout(G, prog=prog)
except Exception as exc:
log.warning("graphviz_layout failed (%s), falling back to spring_layout", exc)
pos = nx.spring_layout(G, k=2.5, iterations=60, seed=42)
# Always use spring_layout in the interactive panel — no subprocess overhead,
# consistent behaviour on filter changes, and graphviz is reserved for the
# static PNG rendered by plot_lan_network.
iterations = 50 if live else 80
pos = nx.spring_layout(G, k=2.5, iterations=iterations, seed=42)

pos = _normalize_pos(pos)

mal_ips = {s.split("/")[0] for s in memory.possible_mal_traffic} | \
{s.split("/")[1] for s in memory.possible_mal_traffic}
tor_ips = {s.split("/")[1] for s in memory.possible_tor_traffic}
# Threat IPs for external nodes only (destination side); LAN/GW nodes keep role color
mal_dst_ips = {s.split("/")[1] for s in memory.possible_mal_traffic}
tor_dst_ips = {s.split("/")[1] for s in memory.possible_tor_traffic}

node_colors = []
for node in G.nodes:
ip = G.nodes[node].get("ip", "")
kind = G.nodes[node].get("kind", "")
if ip in tor_ips:
node_colors.append("#9c27b0")
elif ip in mal_ips:
node_colors.append("#f44336")
elif kind == "lan":
node_colors.append("#1e88e5")
if kind == "lan":
node_colors.append("#1e88e5") # always blue for LAN hosts
elif kind == "gw":
node_colors.append("#78909c")
node_colors.append("#78909c") # always gray for gateways
elif ip in tor_dst_ips:
node_colors.append("#9c27b0") # purple — Tor exit/relay
elif ip in mal_dst_ips:
node_colors.append("#f44336") # red — known malicious external
else:
node_colors.append("#ff7043")
node_colors.append("#ff7043") # orange — generic external

edge_colors = [d.get("color", "#607d8b") for _, _, d in G.edges(data=True)]
return G, pos, node_colors, edge_colors
Expand Down Expand Up @@ -151,7 +184,9 @@ def refresh_live() -> None:
"""
if _ax is None or _canvas_widget is None:
return
G, pos, node_colors, edge_colors = _build_graph_data(live=True)
filt = _filter_var.get() if _filter_var is not None else "All"
nf = _node_filter_var.get() if _node_filter_var is not None else ""
G, pos, node_colors, edge_colors = _build_graph_data(live=True, filter_option=filt, node_filter=nf)
if G is None:
return
_draw_on_axes(_ax, G, pos, node_colors, edge_colors)
Expand All @@ -160,6 +195,30 @@ def refresh_live() -> None:
log.debug("refresh_live: %d nodes", len(G.nodes))


def _apply_panel_filter(*_) -> None:
"""Redraw the panel with the current traffic + node filter options."""
global _panel_G, _panel_pos
if _ax is None or _canvas_widget is None or _filter_var is None:
return
filt = _filter_var.get()
nf = _node_filter_var.get() if _node_filter_var is not None else ""
G, pos, node_colors, edge_colors = _build_graph_data(live=_live_mode, filter_option=filt, node_filter=nf)
_panel_G = G
_panel_pos = pos
if G is None:
_ax.cla()
_ax.set_facecolor("#1e1e2e")
label = filt if not nf else f"{filt} / node '{nf}'"
_ax.text(0.5, 0.5, f"No {label} traffic", ha="center", va="center",
color="white", fontsize=14, transform=_ax.transAxes)
_ax.axis("off")
else:
_draw_on_axes(_ax, G, pos, node_colors, edge_colors)
_figure.subplots_adjust(left=0.02, right=0.98, top=0.98, bottom=0.02)
_canvas_widget.draw()
log.debug("_apply_panel_filter(%s, node=%r): %d nodes", filt, nf, len(G.nodes) if G else 0)


def open_live_panel(base: tk.Tk) -> None:
"""Open the live graph panel, closing any existing panel first (never toggles)."""
if _container is not None and _container.winfo_exists():
Expand All @@ -173,9 +232,36 @@ def set_panel_title(title: str) -> None:
_info_var.set(title)


def _on_click(event) -> None:
"""Click-to-inspect handler — reads from current _panel_G/_panel_pos globals."""
if _panel_G is None or _panel_pos is None or _ax is None:
return
if event.inaxes != _ax or event.xdata is None:
return
closest, min_dist = None, float("inf")
for node, (x, y) in _panel_pos.items():
d = (event.xdata - x) ** 2 + (event.ydata - y) ** 2
if d < min_dist:
min_dist, closest = d, node
if closest is None or min_dist > 0.25:
return
ip = _panel_G.nodes[closest].get("ip", "?")
sessions = [s for s in memory.packet_db if ip in s.split("/")[:2]]
dst_host = memory.destination_hosts.get(ip)
domain = dst_host.domain_name if dst_host and dst_host.domain_name else "—"
protos = sorted({_panel_G[u][v][k].get("proto", "")
for u, v, k in _panel_G.edges(keys=True)
if u == closest or v == closest})
if _info_var is not None:
_info_var.set(
f"Node: {closest} | IP: {ip} | Domain: {domain} | "
f"Sessions: {len(sessions)} | Protocols: {', '.join(protos)}"
)


def gimmick_initialize(base: tk.Tk, _html_path: str, live: bool = False) -> None:
"""Open (or close) the interactive graph panel."""
global _container, _figure, _ax, _canvas_widget, _info_var, _base
global _container, _figure, _ax, _canvas_widget, _info_var, _filter_var, _node_filter_var, _live_mode, _base, _panel_G, _panel_pos

if _container is not None and _container.winfo_exists():
_close()
Expand All @@ -194,11 +280,14 @@ def gimmick_initialize(base: tk.Tk, _html_path: str, live: bool = False) -> None
return

_base = base
_live_mode = live

G, pos, node_colors, edge_colors = _build_graph_data(live=live)
if G is None and not live:
log.info("Interactive graph: no sessions in memory, nothing to show")
return
_panel_G = G
_panel_pos = pos

# ── Figure ────────────────────────────────────────────────────────────────
fig, ax = plt.subplots(figsize=(9, 7))
Expand All @@ -219,7 +308,7 @@ def gimmick_initialize(base: tk.Tk, _html_path: str, live: bool = False) -> None
base.geometry(f"{base.winfo_width() + 700}x{max(base.winfo_height(), 600)}")

_container = tk.Frame(base, bg="#1e1e2e")
_container.grid(row=10, column=11, rowspan=31, sticky="nsew",
_container.grid(row=10, column=11, rowspan=41, sticky="nsew",
padx=(8, 8), pady=(8, 8))
_container.rowconfigure(1, weight=1)
_container.columnconfigure(0, weight=1)
Expand All @@ -231,9 +320,25 @@ def gimmick_initialize(base: tk.Tk, _html_path: str, live: bool = False) -> None
nav = NavigationToolbar2Tk(cw, toolbar_row, pack_toolbar=False)
nav.update()
nav.pack(side=tk.LEFT)

ttk.Button(toolbar_row, text="Close", command=_close).pack(
side=tk.RIGHT, padx=4, pady=2)

filter_var = tk.StringVar(value="All")
filter_cb = ttk.Combobox(toolbar_row, textvariable=filter_var,
values=_FILTER_OPTIONS, state="readonly", width=10)
filter_cb.pack(side=tk.RIGHT, padx=(2, 6), pady=2)
ttk.Label(toolbar_row, text="Filter:").pack(side=tk.RIGHT, padx=(8, 0), pady=2)
filter_var.trace_add("write", _apply_panel_filter)
_filter_var = filter_var

node_filter_var = tk.StringVar()
node_entry = ttk.Entry(toolbar_row, textvariable=node_filter_var, width=14)
node_entry.pack(side=tk.RIGHT, padx=(2, 2), pady=2)
ttk.Label(toolbar_row, text="Node:").pack(side=tk.RIGHT, padx=(8, 0), pady=2)
node_entry.bind("<Return>", _apply_panel_filter)
_node_filter_var = node_filter_var

cw.get_tk_widget().grid(row=1, column=0, sticky="nsew")

default_info = ("📡 Live — updates every 4s | Blue=LAN Gray=Gateway Red=Malicious Purple=Tor"
Expand All @@ -251,43 +356,25 @@ def gimmick_initialize(base: tk.Tk, _html_path: str, live: bool = False) -> None
_canvas_widget = cw
_info_var = info_var

# Click-to-inspect (only meaningful when G exists)
if G is not None:
def _on_click(event):
if event.inaxes != ax or event.xdata is None:
return
closest, min_dist = None, float("inf")
for node, (x, y) in pos.items():
d = (event.xdata - x) ** 2 + (event.ydata - y) ** 2
if d < min_dist:
min_dist, closest = d, node
if closest is None or min_dist > 0.25:
return
ip = G.nodes[closest].get("ip", "?")
sessions = [s for s in memory.packet_db if ip in s.split("/")[:2]]
dst_host = memory.destination_hosts.get(ip)
domain = dst_host.domain_name if dst_host and dst_host.domain_name else "—"
protos = sorted({G[u][v][k].get("proto", "")
for u, v, k in G.edges(keys=True)
if u == closest or v == closest})
info_var.set(
f"Node: {closest} | IP: {ip} | Domain: {domain} | "
f"Sessions: {len(sessions)} | Protocols: {', '.join(protos)}"
)
fig.canvas.mpl_connect("button_press_event", _on_click)
fig.canvas.mpl_connect("button_press_event", _on_click)

base.after(250, lambda: (base.lift(), base.focus_force()))


def _close() -> None:
global _container, _figure, _ax, _canvas_widget, _info_var, _base
global _container, _figure, _ax, _canvas_widget, _info_var, _filter_var, _node_filter_var, _live_mode, _base, _panel_G, _panel_pos
import matplotlib.pyplot as plt
if _figure is not None:
plt.close(_figure)
_figure = None
_ax = None
_canvas_widget = None
_info_var = None
_filter_var = None
_node_filter_var = None
_live_mode = False
_panel_G = None
_panel_pos = None
if _container is not None and _container.winfo_exists():
b = _container.winfo_toplevel()
_container.destroy()
Expand Down
Loading
Loading