PcapXray is a network forensics and visualization tool. It reads PCAP files or captures live traffic, classifies sessions by protocol, detects covert/malicious/Tor traffic, resolves hostnames and OUI vendor info, then renders a graph of the LAN network. It has a Tkinter GUI and a CLI entrypoint (Source/main.py).
Target runtime: Python 3.10+. Python 2 support was dropped; never reintroduce Py2 shims.
pcap_reader.PcapEngine — streams PCAP via pluggable engine → memory state
↓
communication_details_fetch — DNS/whois reverse-lookup on destination IPs
device_details_fetch — OUI vendor lookup on LAN MACs
tor_traffic_handle — Tor consensus download + session match
malicious_traffic_identifier — flags sessions by port/domain heuristic
↓
sqlite_store.SqliteStore — persist / reload session (optional cache)
↓
plot_lan_network.PlotLan — renders graphviz PNG (+ pyvis HTML)
report_generator.ReportGenerator— writes TXT reports
↓
user_interface.pcapXrayGui — Tkinter GUI driving all of the above
pcap_reader.LivePcapEngine — AsyncSniffer → _process_packet() → memory state
↓ (every 4 s)
interactive_gui.refresh_live() — redraws matplotlib panel in-place (spring_layout)
↓ (on Stop)
_run_deferred_covert() — batch DNS covert check post-capture
sqlite_store.SqliteStore — save session for future reload
↓ (user clicks Visualize!)
plot_lan_network.PlotLan — point-in-time static PNG + pyvis HTML
pcap_reader._process_packet(pkt, dns_candidates) and pcap_reader._run_deferred_covert(dns_candidates) are module-level functions used by both PcapEngine and LivePcapEngine. Never inline packet processing into either class.
engines/
├── __init__.py — select_engine(name, pcap_path) → PacketEngine
├── base.py — NormalizedPacket dataclass + PacketEngine Protocol
├── scapy_engine.py — streaming PcapReader, TLS-aware
├── dpkt_engine.py — fast offline, low memory (PCAPng fallback)
└── pyshark_engine.py — tshark-backed, live-capture ready
select_engine("auto", path) tries DpktEngine first, falls back to ScapyEngine. All engines yield NormalizedPacket — the main loop is engine-agnostic.
All inter-module state lives in memory.py as typed Pydantic models inside plain dict containers:
| Container | Key | Value type |
|---|---|---|
memory.packet_db |
"src/dst/port" |
PacketSession |
memory.lan_hosts |
MAC string | LanHost |
memory.destination_hosts |
IP string | DestinationHost |
memory.tor_nodes |
— | list[tuple[str, int]] |
memory.possible_tor_traffic |
— | list[str] |
memory.possible_mal_traffic |
— | list[str] |
Never use dict-style key access on model values. Use attribute access: session.covert, host.domain_name, h.node. The models guarantee field presence so .get() fallbacks are not needed.
Memory is the live source of truth for both file and live modes. SQLite is the persistence layer (saved after analysis or after live stop). The matplotlib panel and static graph both read from memory.
Every source module must have:
__all__listing its public API (at the top, after docstring/imports)log = logging.getLogger(__name__)— never useprint()or barelogging.*calls- Type hints on all function signatures
PEP 8 CamelCase: TrafficDetailsFetch, FetchDeviceDetails, MaliciousTrafficIdentifier, TorTrafficHandle, ReportGenerator, PlotLan, PcapEngine, LivePcapEngine, ScapyEngine, DpktEngine, PySharkEngine. Internal helpers are _snake_case.
- Use
PCAPXRAY_DEBUG=1env var to enable DEBUG level (wired inSource/main.py) - Log file:
~/PcapXray.log(overwritten each run) - Pattern:
log.info("..."),log.warning("..."),log.error("...")
- Never use bare
except:— alwaysexcept Exception:or a specific type - External I/O (DNS, Tor, OUI APIs) must have timeouts and degrade gracefully
- DNS batch:
concurrent.futures.wait(timeout=10.0)hard cap - Tor consensus: daemon thread +
join(timeout=15.0) - Graphviz render:
f.render(timeout=30)withexcept TypeErrorfallback for older lib versions - Live capture
PermissionError: showmb.showerrorand restore UI — never crash
- Models live in
memory.py; import them where needed:from memory import PacketSession, LanHost, DestinationHost - When serializing to JSON (reports), use
_ModelEncoderfromreport_generator.pyor call.model_dump()explicitly - Model fields have safe defaults — no need to guard with
if "key" not in dict
LivePcapEngine._on_packet()runs in scapy's sniffer thread; allmemory.*mutations inside it are protected byself._lock_stop_live()setsself._live_engine = Nonebefore calling_poll_thread()so any_live_refresh()callback that fires duringbase.update()sees None and exits without rescheduling- Use
interactive_gui.open_live_panel(base)to open the live panel — never callgimmick_initialize()directly from_start_live()(it toggles;open_live_panelalways opens)
# Fast suite — no network calls (~55s)
pytest -m "not network" Test/
# Full suite including real DNS + Tor (~95s locally, may be slow in CI)
pytest Test/
# Isolated engine envs (default + dpkt)
tox
tox -e all-engines| File | What it tests |
|---|---|
Test/test_unit.py |
Per-module unit tests with mocked network/OUI/Tor |
Test/test_sanity.py |
End-to-end smoke tests against real PCAP files |
Test/test_pcap_reader_module.py |
Standalone pcap_reader smoke test |
Test/test_engines.py |
Engine architecture: NormalizedPacket, ScapyEngine, DpktEngine, select_engine fallback, _process_packet, LivePcapEngine |
Test/test_sqlite_store.py |
SQLite session persistence and reload |
@pytest.mark.network— marks tests that make real network calls (DNS resolution, Tor consensus). These are skipped in CI with-m "not network".
Located at project root. Adds Source/Module to sys.path and exports EXAMPLES_DIR pointing to Source/Module/examples/. All tests import from there — never hardcode paths.
- Seed
memory.*with model instances, not raw dicts:memory.destination_hosts["1.2.3.4"] = DestinationHost(domain_name="example.com") - Reset memory state in a
@pytest.fixture(autouse=True)— seetest_unit.pyfor the pattern - Mock at the module level:
patch("communication_details_fetch.socket.gethostbyaddr", ...) - 96+ tests must stay green before any commit
- No Python 2 shims — no
try/except ImportErrorfortkinter/Tkinter,queue/Queue, etc. - No
cefpython3— removed. The interactive graph panel usesmatplotlib+networkxembedded viaFigureCanvasTkAgg. - No
print()in source modules — uselog.* - No bare
except:— always catchExceptionor a specific type - No dict-style access on Pydantic model values — use attribute access
- No
netaddr.IPAddress.is_private()— removed in netaddr 0.9.x; useipaddress.ip_address(ip).is_private(stdlib, property not method) - No blocking calls on the Tkinter main thread — use
_run_in_thread()/_poll_thread()fromuser_interface.py - No pushing many small commits to remote — batch related changes locally and push once per logical unit of work
- No graphviz layout during live capture — use
nx.spring_layout(no subprocess); graphviz is for static graph only - No direct
gimmick_initialize()call for live mode — useopen_live_panel(base)which always opens without toggling
| Phase | Status | Notes |
|---|---|---|
| 0 — Critical bug fixes | Done | Pillow ANTIALIAS, is_private(), urllib, bare excepts |
| 1 — Python 2 drop + deps | Done | Py3-only imports, requirements.txt pinned |
| 2 — Test infrastructure | Done | conftest.py, 96 tests, network marker, coverage in CI, tox |
| 3 — Code quality | Done | Pydantic models, PEP 8 names, __all__, logging, dead code |
| 4 — Replace cefpython3 | Done | matplotlib+networkx panel embedded in Tkinter via FigureCanvasTkAgg; interactive_gui.py |
| 5 — Features (partial) | Done | Streaming PCAP, pluggable engines (dpkt/scapy/pyshark), deferred covert detection, SQLite session cache |
| 6 — Live capture | Done | LivePcapEngine, AsyncSniffer, 4s graph refresh, live→static handoff |
| 7 — Features (remaining) | Pending | More protocols (QUIC, HTTP/2, mDNS), web UI, eBPF engine |
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:
- Closure / stale capture bugs — closures in event handlers (Tkinter
bind, matplotlibmpl_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 withwinfo_exists()before touching Tk widgets inside a delayed callback. - Thread safety — any
memory.*mutation outside_on_packet(which holdsself._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.
- 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 matchingtrace_remove()on teardown. Everyplt.subplots()needsplt.close(). EveryToplevelneeds adestroy()path. Tk canvas image references must be kept alive onself. - Memory — PIL images opened in resize callbacks must be cached, not re-opened per event. Debounce rapid
<Configure>events (120ms minimum).
- 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.
- Module boundaries —
interactive_guireadsmemory.*but never writes it.user_interfacedrives analysis but delegates rendering toplot_lan_networkandinteractive_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.
Workflow: .github/workflows/test.yml
- Matrix: Python 3.10, 3.11, 3.12
- System deps:
graphviz python3-tk tsharkvia apt - Test command:
pytest -m "not network" --cov=Source/Module --cov-report=xml Test/ - Also runs:
tox -e all-engines(dpkt isolated env) - Coverage uploaded to Codecov (requires
CODECOV_TOKENsecret in repo settings) - Lint:
flake8— fatal errors only (E9, F63, F7, F82); style issues are advisory