diff --git a/.gitignore b/.gitignore index f9511e0..9054244 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/CLAUDE.md b/CLAUDE.md index f394aba..1a2d04f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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., `` → 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 `` 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` diff --git a/README.md b/README.md index 6245b51..e00bb8f 100644 --- a/README.md +++ b/README.md @@ -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) --- diff --git a/Samples/screen_5_2_2.png b/Samples/screen_5_2_2.png new file mode 100644 index 0000000..d7830cc Binary files /dev/null and b/Samples/screen_5_2_2.png differ diff --git a/Source/Module/interactive_gui.py b/Source/Module/interactive_gui.py index 891112b..917a239 100644 --- a/Source/Module/interactive_gui.py +++ b/Source/Module/interactive_gui.py @@ -17,27 +17,38 @@ 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("/") @@ -45,6 +56,22 @@ def _build_graph_data(live: bool = False): 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", "") @@ -73,12 +100,23 @@ 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) @@ -86,37 +124,32 @@ def _build_graph_data(live: bool = False): 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 @@ -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) @@ -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(): @@ -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() @@ -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)) @@ -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) @@ -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("", _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" @@ -251,36 +356,13 @@ 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) @@ -288,6 +370,11 @@ def _close() -> 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() diff --git a/Source/Module/plot_lan_network.py b/Source/Module/plot_lan_network.py index de56166..575d15c 100644 --- a/Source/Module/plot_lan_network.py +++ b/Source/Module/plot_lan_network.py @@ -42,21 +42,20 @@ def __init__(self, filename, path, option="Tor", to_ip="All", from_ip="All"): 'fontsize': '16', 'fontcolor': 'black', 'bgcolor': 'grey', - 'rankdir': 'LR', # BT - 'dpi':'300', - 'size': '10, 10', - 'overlap': 'scale' + 'rankdir': 'LR', + 'dpi': '150', + # No size/ratio/overlap — graphviz chooses natural layout dimensions. + # PIL fits the result to the canvas preserving aspect ratio. }, 'nodes': { - 'fontname': 'Helvetica', - 'shape': 'circle', + 'fontname': 'DejaVu Sans', + 'fontsize': '14', + 'shape': 'ellipse', 'fontcolor': 'black', - 'color': ' black', + 'color': 'black', 'style': 'filled', 'fillcolor': 'yellow', - 'fixedsize': 'true', - 'width': '3', - 'height': '3' + # No fixedsize — nodes auto-size to fit their label text. } } @@ -613,7 +612,7 @@ def draw_graph(self, option="All", to_ip="All", from_ip="All"): # Discard all the bloated disconnected nodes — lay out a trivial graph instead. f = Digraph("no_traffic", filename=self.filename, engine="dot", format="png") f.attr(label="No " + option + " Traffic between nodes!", fontsize="20", - bgcolor="grey", size="5,5", dpi="300") + bgcolor="grey") self.apply_styles(f, self.styles) diff --git a/Source/Module/sqlite_store.py b/Source/Module/sqlite_store.py index db89bb8..e844293 100644 --- a/Source/Module/sqlite_store.py +++ b/Source/Module/sqlite_store.py @@ -2,9 +2,9 @@ Module sqlite_store — SQLite session persistence for PcapXray. Saves the full analysis state (packet_db, lan_hosts, destination_hosts, -tor/malicious traffic lists) to ~/PcapXray_sessions.db, keyed by PCAP -filename. On re-analysis of the same file the GUI offers to reload from -the cache instead of re-parsing — making repeat runs near-instant. +tor/malicious traffic lists) to /Report/pcapxray_sessions.db, +keyed by PCAP filename. On re-analysis of the same file the GUI offers to +reload from the cache instead of re-parsing — making repeat runs near-instant. Original concept and prototype: Matt Bernardo (@mbernardo) / Technica Corporation — PR #70 (March 2022). Rewritten here for the Pydantic-based diff --git a/Source/Module/user_interface.py b/Source/Module/user_interface.py index 7ffc838..f6d35f0 100644 --- a/Source/Module/user_interface.py +++ b/Source/Module/user_interface.py @@ -93,8 +93,9 @@ def __init__(self, base): self.report_field = ttk.Entry(FirstFrame, width=30, textvariable=self.destination_report, style="BW.TEntry").grid(column=1, row=0, sticky="WE") ttk.Button(FirstFrame, text="Browse", command=lambda: self.browse_directory("report")).grid(column=2, row=0, padx=10, pady=10, sticky="E") self.zoom = [900, 500] - ttk.Button(FirstFrame, text="zoomIn", command=self.zoom_in).grid(row=0, column=4, padx=5, sticky="E") - ttk.Button(FirstFrame, text="zoomOut", command=self.zoom_out).grid(row=0, column=5, padx=10, sticky="E") + ttk.Button(FirstFrame, text="zoomIn", command=self.zoom_in).grid(row=0, column=4, padx=5, sticky="E") + ttk.Button(FirstFrame, text="zoomOut", command=self.zoom_out).grid(row=0, column=5, padx=2, sticky="E") + ttk.Button(FirstFrame, text="Fit", command=self.zoom_fit).grid(row=0, column=6, padx=5, sticky="E") # Live Capture Frame LiveFrame = ttk.Frame(base, width=50, padding="10 2 0 2", relief=GROOVE) @@ -131,7 +132,7 @@ def __init__(self, base): self.browser_button['state'] = 'disabled' self.img = "" - self._store = sqlite_store.SqliteStore() + self._store: sqlite_store.SqliteStore | None = None ## Filters self.from_ip = StringVar() @@ -175,6 +176,58 @@ def __init__(self, base): self.ThirdFrame.columnconfigure(0, weight=1) self.ThirdFrame.rowconfigure(0, weight=1) + # Fourth Frame — Wireshark-style packet browser (hidden until analysis) + self.FourthFrame = ttk.Frame(base, padding="8 4 8 4", relief=GROOVE) + # Not gridded yet — shown by _populate_packet_table() after analysis + + _search_bar = ttk.Frame(self.FourthFrame) + _search_bar.grid(row=0, column=0, columnspan=2, sticky="EW", pady=(0, 4)) + ttk.Label(_search_bar, text="Search:", style="BW.TLabel").pack(side=LEFT, padx=(0, 4)) + self._search_var = tk.StringVar() + self._search_entry = ttk.Entry(_search_bar, textvariable=self._search_var, width=28) + self._search_entry.pack(side=LEFT, padx=2) + self._search_entry.bind("", lambda e: self._search_packets()) + ttk.Button(_search_bar, text="Search", command=self._search_packets).pack(side=LEFT, padx=4) + self._search_count_label = tk.Label(_search_bar, text="", fg="#aaaaaa", font=("Courier", 10)) + self._search_count_label.pack(side=LEFT, padx=8) + ttk.Button(_search_bar, text="◀", command=lambda: self._nav_packet(-1), width=2).pack(side=LEFT, padx=2) + ttk.Button(_search_bar, text="▶", command=lambda: self._nav_packet(1), width=2).pack(side=LEFT, padx=2) + ttk.Button(_search_bar, text="Clear", command=self._clear_search).pack(side=LEFT, padx=8) + self._detail_btn = ttk.Button(_search_bar, text="inspect →", + command=self._open_selected_detail, state="disabled") + self._detail_btn.pack(side=RIGHT, padx=(8, 4)) + + _cols = ("src", "dst", "port", "flags", "files", "payload") + self._pkt_tree = ttk.Treeview(self.FourthFrame, columns=_cols, show="headings", height=6) + for _hdr, _col, _w, _anchor, _stretch in [ + ("Src IP", "src", 130, "w", False), + ("Dst IP", "dst", 130, "w", False), + ("Port", "port", 65, "center", False), + ("Flags", "flags", 110, "center", False), + ("Files", "files", 110, "center", False), + ("Payload Preview", "payload", 0, "w", True), + ]: + self._pkt_tree.heading(_col, text=_hdr) + self._pkt_tree.column(_col, width=_w, anchor=_anchor, stretch=_stretch) + + _tv_vsb = ttk.Scrollbar(self.FourthFrame, orient=VERTICAL, command=self._pkt_tree.yview) + self._pkt_tree.configure(yscrollcommand=_tv_vsb.set) + self._pkt_tree.grid(row=1, column=0, sticky="NSEW") + _tv_vsb.grid(row=1, column=1, sticky="NS") + self.FourthFrame.columnconfigure(0, weight=1) + self.FourthFrame.rowconfigure(1, weight=1) + + self._pkt_tree.tag_configure("malicious", background="#4a1010", foreground="#ff8a80") + self._pkt_tree.tag_configure("tor", background="#2d1a3e", foreground="#ce93d8") + self._pkt_tree.tag_configure("covert", background="#0d3040", foreground="#80deea") + self._pkt_tree.tag_configure("match", background="#1a3a1a", foreground="#a5d6a7") + self._pkt_tree.bind("<>", self._on_packet_select) + self._pkt_tree.bind("", self._open_session_detail) + + self._pkt_rows: list[str] = [] + self._search_matches: list[str] = [] + self._search_idx: int = -1 + base.resizable(False, False) base.rowconfigure(40, weight=1) base.columnconfigure(10, weight=1) @@ -284,7 +337,7 @@ def _stop_live(self) -> None: finally: self._spin_stop(f"✓ {len(memory.packet_db)} sessions captured") import time - self._store.save_session(f"live_{iface}_{int(time.time())}") + self._open_store().save_session(f"live_{iface}_{int(time.time())}") self.live_button.config(text="▶ Start Live") self._live_status.config(text="📡 Captured — click Visualize! for snapshot", fg="#81c784") @@ -335,6 +388,17 @@ def _force_focus(self) -> None: self.base.focus_force() self.base.after(200, lambda: self.base.attributes('-topmost', False)) + def _open_store(self) -> sqlite_store.SqliteStore: + """Return the session store, creating it under destination_report/Report/ if needed.""" + report_dir = os.path.join(self.destination_report.get(), "Report") + os.makedirs(report_dir, exist_ok=True) + db_path = os.path.join(report_dir, "pcapxray_sessions.db") + if self._store is None or self._store._db_path != db_path: + if self._store is not None: + self._store.close() + self._store = sqlite_store.SqliteStore(db_path) + return self._store + def _spin_start(self, text: str = "Working") -> None: if self._spin_job is not None: self.base.after_cancel(self._spin_job) @@ -392,7 +456,7 @@ def pcap_analyse(self): mb.showerror("Error", "Permission denied to create report! Run with higher privilege.") return - log.info("pcap_analyse: file=%s", self.pcap_file.get()) + log.info("pcap_analyse: file=%s", os.path.basename(self.pcap_file.get())) if not os.path.exists(self.pcap_file.get()): mb.showerror("Error", "File Not Found!") return @@ -409,13 +473,14 @@ def pcap_analyse(self): self.filename = os.path.basename(self.pcap_file.get()).replace(".pcap", "").replace(".pcapng", "") # Offer to reload from SQLite cache if this PCAP was analyzed before - if self.filename and self._store.has_session(self.filename): + store = self._open_store() + if self.filename and store.has_session(self.filename): if mb.askyesno("Reload Session", f"Cached analysis found for '{self.filename}'.\n" "Reload without re-parsing the PCAP?"): log.info("pcap_analyse: reloading session '%s' from cache", self.filename) self._spin_start("Loading cache") - self._store.load_session(self.filename) + store.load_session(self.filename) self._spin_stop(f"✓ {len(memory.packet_db)} sessions (cached)") self._populate_filter_menus() self._re_enable_controls() @@ -438,7 +503,7 @@ def pcap_analyse(self): threading.Thread(target=report_generator.ReportGenerator(self.destination_report.get(), self.filename).packetDetails, args=(), daemon=True).start() if self.filename: - self._store.save_session(self.filename) + self._open_store().save_session(self.filename) self._populate_filter_menus() self._re_enable_controls() @@ -456,6 +521,7 @@ def _populate_filter_menus(self) -> None: self.to_hosts = list(set(self.to_hosts + self.from_hosts)) self.to_menu['values'] = self.to_hosts self.from_menu['values'] = self.from_hosts + self._populate_packet_table() def _re_enable_controls(self) -> None: # Graph Panel and Interactive Graph stay disabled until Visualize! succeeds @@ -480,10 +546,15 @@ def generate_graph(self): threading.Thread(target=rpt.communicationDetailsReport, daemon=True).start() threading.Thread(target=rpt.deviceDetailsReport, daemon=True).start() - options = self.option.get() + "_" + self.to_ip.get().replace(".", "-") + "_" + self.from_ip.get().replace(".", "-") + options = (self.option.get() + "_" + self.to_ip.get().replace(".", "-") + + "_" + self.from_ip.get().replace(".", "-")) self.image_file = os.path.join(self.destination_report.get(), "Report", self.filename + "_" + options + ".png") if not os.path.exists(self.image_file): - t1, exc_box = self._run_in_thread(plot_lan_network.PlotLan, self.filename, self.destination_report.get(), self.option.get(), self.to_ip.get(), self.from_ip.get()) + t1, exc_box = self._run_in_thread( + plot_lan_network.PlotLan, + self.filename, self.destination_report.get(), + self.option.get(), self.to_ip.get(), self.from_ip.get() + ) self._spin_start("Rendering graph") self._poll_thread(t1) if exc_box: @@ -497,6 +568,7 @@ def generate_graph(self): else: self.label.grid_forget() self.load_image() + self._populate_packet_table() # Both graph buttons become available once a graph exists self.ibutton['state'] = 'normal' self.browser_button['state'] = 'normal' @@ -515,39 +587,276 @@ def open_in_browser(self): mb.showerror("Error", "Interactive HTML not found. Click Visualize! first.") def load_image(self): - if not hasattr(self, '_canvas_w'): - # Expand to graph-viewing size on first load, then lock. + if not hasattr(self, '_zoom_factor'): + self._zoom_factor = 1.0 self.base.resizable(True, True) self.base.geometry("1100x780") - probe = Canvas(self.ThirdFrame, bd=0) - probe.grid(column=0, row=0, sticky=(N, W, E, S)) - self.base.update_idletasks() - self._canvas_w = probe.winfo_width() or 900 - self._canvas_h = probe.winfo_height() or 500 - self.zoom = [self._canvas_w, self._canvas_h] - probe.destroy() - self.base.resizable(True, True) - self.canvas = Canvas(self.ThirdFrame, width=self._canvas_w, height=self._canvas_h, - bd=0, bg="navy", + # Destroy previous canvas so its binding stops firing + if hasattr(self, 'canvas') and self.canvas and self.canvas.winfo_exists(): + self.canvas.destroy() + + self._src_img = None # cached PIL source; cleared when image_file changes + self._src_img_path = None + self._resize_job = None # pending debounce after-id + + self.canvas = Canvas(self.ThirdFrame, bd=0, bg="grey", xscrollcommand=self.xscrollbar.set, yscrollcommand=self.yscrollbar.set) self.canvas.grid(column=0, row=0, sticky=(N, W, E, S)) - self._redraw_image() self.xscrollbar.config(command=self.canvas.xview) self.yscrollbar.config(command=self.canvas.yview) - self.canvas.bind("", lambda e: self._redraw_image(e.width, e.height)) + self.canvas.bind("", self._on_canvas_resize) + + def _on_canvas_resize(self, event): + """Debounce rapid events — only redraw after 120ms of quiet.""" + if hasattr(self, '_resize_job') and self._resize_job: + self.base.after_cancel(self._resize_job) + self._resize_job = self.base.after(120, lambda: self._redraw_image(event.width, event.height)) + + def _zoom_fill_factor(self, src_w: int, src_h: int) -> float: + """Return the _zoom_factor that fills the canvas viewport (cover behaviour).""" + w = self.canvas.winfo_width() or 1 + h = self.canvas.winfo_height() or 1 + fit = min(w / src_w, h / src_h) + cover = max(w / src_w, h / src_h) + return cover / fit # always ≥ 1.0 + + def zoom_fit(self): + """Reset zoom to the fill-viewport level (the default on first load).""" + if not (hasattr(self, '_src_img') and self._src_img): + return + if hasattr(self, '_resize_job') and self._resize_job: + self.base.after_cancel(self._resize_job) + self._resize_job = None + src_w, src_h = self._src_img.size + self._zoom_factor = self._zoom_fill_factor(src_w, src_h) + self._redraw_image() def _redraw_image(self, w=None, h=None): if not hasattr(self, 'image_file') or not self.image_file: return - w = w or self.zoom[0] - h = h or self.zoom[1] - self.zoom = [w, h] - self.img = ImageTk.PhotoImage(Image.open(self.image_file).resize((w, h), Image.LANCZOS)) + if w is None or h is None: + w = self.canvas.winfo_width() + h = self.canvas.winfo_height() + if w <= 1 or h <= 1: + return + new_image = self._src_img is None or self._src_img_path != self.image_file + if new_image: + self._src_img = Image.open(self.image_file) + self._src_img_path = self.image_file + src_w, src_h = self._src_img.size + if new_image: + # Auto-zoom: fill the viewport so the graph occupies the full canvas. + # For a tall dot layout (nodes stacked), fills the canvas width with + # vertical scrollbars. For a wide layout, fills the canvas height. + self._zoom_factor = self._zoom_fill_factor(src_w, src_h) + scale = min(w / src_w, h / src_h) * self._zoom_factor + fit_w = max(1, int(src_w * scale)) + fit_h = max(1, int(src_h * scale)) + self.img = ImageTk.PhotoImage(self._src_img.resize((fit_w, fit_h), Image.LANCZOS)) self.canvas.delete("all") - self.canvas.create_image(0, 0, image=self.img, anchor=NW) - self.canvas.config(scrollregion=self.canvas.bbox(ALL)) + x = max(0, (w - fit_w) // 2) + y = max(0, (h - fit_h) // 2) + self.canvas.create_image(x, y, image=self.img, anchor=NW) + self.canvas.config(scrollregion=(0, 0, max(w, fit_w), max(h, fit_h))) + + # ------------------------------------------------------------------ + # Packet browser helpers + # ------------------------------------------------------------------ + + def _populate_packet_table(self) -> None: + self._pkt_tree.delete(*self._pkt_tree.get_children()) + self._pkt_rows = [] + self._search_matches = [] + self._search_idx = -1 + self._search_count_label.config(text="") + + for key, session in memory.packet_db.items(): + parts = key.split("/") + if len(parts) != 3: + continue + src, dst, port = parts + is_mal = key in memory.possible_mal_traffic + is_tor = key in memory.possible_tor_traffic + is_cov = session.covert + flag_parts = [] + if is_mal: flag_parts.append("Malicious") + if is_tor: flag_parts.append("Tor") + if is_cov: flag_parts.append("Covert") + flags = ", ".join(flag_parts) if flag_parts else "—" + files = ", ".join(session.file_signatures) if session.file_signatures else "—" + fwd = session.Payload.get("forward", []) + preview = str(fwd[0])[:80] if fwd else "—" + tag = "malicious" if is_mal else "tor" if is_tor else "covert" if is_cov else "" + self._pkt_tree.insert("", END, iid=key, + values=(src, dst, port, flags, files, preview), + tags=(tag,) if tag else ()) + self._pkt_rows.append(key) + + if not self.FourthFrame.winfo_ismapped(): + self.FourthFrame.grid(column=10, row=50, sticky=(N, W, E, S)) + self.base.rowconfigure(50, weight=0) + + def _search_packets(self) -> None: + import re as _re + query = self._search_var.get().strip() + if not query: + self._clear_search() + return + + use_regex = query.startswith("re:") + pattern = query[3:] if use_regex else query.lower() + + for key in self._search_matches: + self._restore_row_tag(key) + self._search_matches = [] + + for key in self._pkt_rows: + if not self._pkt_tree.exists(key): + continue + session = memory.packet_db.get(key) + if not session: + continue + fwd = session.Payload.get("forward", []) + rev = session.Payload.get("reverse", []) + haystack = (key + " " + " ".join(fwd + rev)).lower() + try: + matched = bool(_re.search(pattern, haystack)) if use_regex else pattern in haystack + except _re.error: + matched = pattern in haystack + if matched: + self._search_matches.append(key) + + for key in self._search_matches: + if self._pkt_tree.exists(key): + self._pkt_tree.item(key, tags=("match",)) + + count = len(self._search_matches) + self._search_count_label.config(text=f"{count} match{'es' if count != 1 else ''}") + if self._search_matches: + self._search_idx = 0 + self._scroll_to_match(0) + else: + self._search_idx = -1 + + def _nav_packet(self, direction: int) -> None: + if not self._search_matches: + return + self._search_idx = (self._search_idx + direction) % len(self._search_matches) + self._scroll_to_match(self._search_idx) + + def _scroll_to_match(self, idx: int) -> None: + key = self._search_matches[idx] + if self._pkt_tree.exists(key): + self._pkt_tree.selection_set(key) + self._pkt_tree.see(key) + count = len(self._search_matches) + self._search_count_label.config( + text=f"{idx + 1}/{count} match{'es' if count != 1 else ''}") + + def _clear_search(self) -> None: + self._search_var.set("") + for key in self._search_matches: + self._restore_row_tag(key) + self._search_matches = [] + self._search_idx = -1 + self._search_count_label.config(text="") + + def _restore_row_tag(self, key: str) -> None: + if not self._pkt_tree.exists(key): + return + session = memory.packet_db.get(key) + if not session: + return + is_mal = key in memory.possible_mal_traffic + is_tor = key in memory.possible_tor_traffic + tag = "malicious" if is_mal else "tor" if is_tor else "covert" if session.covert else "" + self._pkt_tree.item(key, tags=(tag,) if tag else ()) + + def _on_packet_select(self, event) -> None: + sel = self._pkt_tree.selection() + if not sel: + self._detail_btn.configure(state="disabled") + return + self._detail_btn.configure(state="normal") + parts = sel[0].split("/") + if len(parts) != 3: + return + src, dst, _ = parts + if src in self.from_menu["values"]: + self.from_menu.set(src) + if dst in self.to_menu["values"]: + self.to_menu.set(dst) + + def _open_selected_detail(self) -> None: + sel = self._pkt_tree.selection() + if sel: + self._open_session_detail(None) + + def _open_session_detail(self, event) -> None: + sel = self._pkt_tree.selection() + if not sel: + return + key = sel[0] + session = memory.packet_db.get(key) + if not session: + return + + parts = key.split("/") + src, dst, port = parts if len(parts) == 3 else ("?", "?", "?") + + win = Toplevel(self.base) + win.title(f"Session: {key}") + win.geometry("700x520") + win.resizable(True, True) + win.columnconfigure(0, weight=1) + win.rowconfigure(1, weight=1) + + # ── Header ────────────────────────────────────────────────────── + hdr = ttk.Frame(win, padding="12 10 12 6") + hdr.grid(row=0, column=0, sticky="EW") + hdr.columnconfigure(1, weight=1) + + def _row(r, label, value): + ttk.Label(hdr, text=label, style="BW.TLabel", + font=("Courier", 10, "bold")).grid(row=r, column=0, sticky="W", padx=(0, 8)) + ttk.Label(hdr, text=value, style="BW.TLabel", + font=("Courier", 10)).grid(row=r, column=1, sticky="W") + + is_mal = key in memory.possible_mal_traffic + is_tor = key in memory.possible_tor_traffic + flag_parts = (["Malicious"] if is_mal else []) + (["Tor"] if is_tor else []) + (["Covert"] if session.covert else []) + dst_host = memory.destination_hosts.get(dst) + domain = dst_host.domain_name if dst_host and dst_host.domain_name else "—" + + _row(0, "Session:", key) + _row(1, "Flags:", ", ".join(flag_parts) if flag_parts else "None") + _row(2, "Files:", ", ".join(session.file_signatures) if session.file_signatures else "None") + _row(3, "Ethernet:", f"{session.Ethernet.get('src', '—')} → {session.Ethernet.get('dst', '—')}") + _row(4, "Domain:", domain) + + # ── Payload tabs ──────────────────────────────────────────────── + nb = ttk.Notebook(win) + nb.grid(row=1, column=0, sticky="NSEW", padx=10, pady=4) + + for label, lines in [("Forward", session.Payload.get("forward", [])), + ("Reverse", session.Payload.get("reverse", []))]: + tab = ttk.Frame(nb) + nb.add(tab, text=f"{label} ({len(lines)} entries)") + tab.rowconfigure(0, weight=1) + tab.columnconfigure(0, weight=1) + txt = tk.Text(tab, wrap="word", font=("Courier", 10), + bg="#1e1e2e", fg="#e0e0e0", relief="flat") + vsb = ttk.Scrollbar(tab, orient=VERTICAL, command=txt.yview) + txt.configure(yscrollcommand=vsb.set) + txt.grid(row=0, column=0, sticky="NSEW") + vsb.grid(row=0, column=1, sticky="NS") + txt.insert("1.0", "\n".join(lines) if lines else "(no payload)") + txt.configure(state="disabled") + + ttk.Button(win, text="Close", command=win.destroy).grid(row=2, column=0, pady=8) + win.after(100, lambda: (win.lift(), win.focus_force())) def map_select(self, *args): log.debug("map_select: option=%s to=%s from=%s", self.option.get(), self.to_ip.get(), self.from_ip.get()) @@ -562,19 +871,20 @@ def map_select(self, *args): self.base.after(100, self._force_focus) def zoom_in(self): - self.zoom[0] += 100 - self.zoom[1] += 100 if self.img: - self._redraw_image(self.zoom[0], self.zoom[1]) + if hasattr(self, '_resize_job') and self._resize_job: + self.base.after_cancel(self._resize_job) + self._resize_job = None + self._zoom_factor = min(self._zoom_factor * 1.25, 8.0) + self._redraw_image() def zoom_out(self): - min_w = getattr(self, '_canvas_w', 900) - min_h = getattr(self, '_canvas_h', 500) - if self.zoom[0] > min_w and self.zoom[1] > min_h: - self.zoom[0] -= 100 - self.zoom[1] -= 100 if self.img: - self._redraw_image(self.zoom[0], self.zoom[1]) + if hasattr(self, '_resize_job') and self._resize_job: + self.base.after_cancel(self._resize_job) + self._resize_job = None + self._zoom_factor = max(self._zoom_factor / 1.25, 0.1) + self._redraw_image() class OtherFrame(Toplevel): diff --git a/Source/main.py b/Source/main.py index 1f1a218..2348102 100644 --- a/Source/main.py +++ b/Source/main.py @@ -12,6 +12,10 @@ logging.StreamHandler(), ], ) +try: + os.chmod(_log_file, 0o600) +except OSError: + pass log = logging.getLogger(__name__) log.info("PcapXray starting — log file: %s", _log_file)