diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml
index 7aaed6d..9e72599 100644
--- a/.github/workflows/python-ci.yml
+++ b/.github/workflows/python-ci.yml
@@ -9,6 +9,10 @@ on:
jobs:
test:
runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
+ fail-fast: false
steps:
- name: Checkout
@@ -17,7 +21,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
- python-version: "3.14"
+ python-version: ${{ matrix.python-version }}
- name: Install project
run: |
@@ -38,7 +42,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
- python-version: "3.14"
+ python-version: "3.10"
- name: Install project and pyright
run: |
diff --git a/.gitignore b/.gitignore
index 1022dca..efba4d8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,6 +46,7 @@ presentation/output/
# Markdown
*.md
!README.md
+!Fixes-roadmap.md
# AI Agent Skills, everyone should be able to choose their own skills.
skills-lock.json
diff --git a/.python-version b/.python-version
index 6324d40..c8cfe39 100644
--- a/.python-version
+++ b/.python-version
@@ -1 +1 @@
-3.14
+3.10
diff --git a/Fixes-roadmap.md b/Fixes-roadmap.md
new file mode 100644
index 0000000..582ef6f
--- /dev/null
+++ b/Fixes-roadmap.md
@@ -0,0 +1,395 @@
+# NetDocIT — Critical Fixes & Implementation Roadmap
+
+Date: 2026-05-09
+Scope: Top 5 critical issues blocking competitiveness against Advanced IP Scanner
+
+---
+
+## Issue 1: Replace subprocess ping with raw sockets
+
+**Current state:** `scanner.py:_python_ping_sweep()` spawns `subprocess.run(["ping", ...])` per IP.
+Each call forks a new OS process. On a /24 (254 hosts), that's 254 process forks.
+
+**Target:** Use Python raw sockets (`AF_INET, SOCK_RAW, IPPROTO_ICMP`) to send ICMP echo
+requests directly. This matches how Advanced IP Scanner operates.
+
+**Implementation plan:**
+
+### Phase 1A — ICMP socket module (new file)
+- Create `src/backend/transports/icmp.py`
+- Implement `IcmpScanner` class using `socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)`
+- Payload: ICMP echo request (type 8, code 0), 32-byte payload, sequence number tracking
+- Timeout model: send batch of N packets, `select()` / `poll()` for responses
+- Windows caveat: raw sockets require admin/elevated privileges on modern Windows
+ - Detection: check `IsUserAnAdmin()` before attempting raw socket path
+ - Graceful fallback: if not admin, fall back to the existing `ping` subprocess path
+- Concurrency: 64–128 concurrent ICMP probes via `ThreadPoolExecutor` or `asyncio`
+ using a single raw socket (thread-safe with a send lock)
+
+### Phase 1B — Benchmark harness (new file)
+- Create `tools/benchmark_scan.py`
+- Compare: old subprocess-ping vs new raw-socket ICMP on same /24 target
+- Metrics: wall-clock time, hosts found, false positive rate
+- Target: <5 seconds for a /24 subnet (Advanced IP Scanner benchmark)
+
+### Phase 1C — Integration into `_python_ping_sweep()`
+- Replace `_icmp_ping()` call with `IcmpScanner.batch_ping(ips)`
+- Keep ARP pre-seeding path intact
+- Keep TCP fallback intact (only triggers if ICMP yields zero results)
+- Condition: only use raw socket path on Windows when admin; always on Linux/macOS
+
+### Phase 1D — Tests
+- `tests/test_icmp_scanner.py`: unit tests for packet construction, batch dispatch, timeout
+- `tests/test_scan_speed.py` (update): assert /24 scan completes under 10s in safe profile
+
+### Success criteria
+- `/24 subnet scanned in <10s (safe profile), <5s (aggressive)`
+- ICMP echo reply correctly parsed with RTT measurement
+- Admin detection prevents crash on unprivileged Windows
+- No process-spawning per IP in the primary code path
+
+| Files to touch | Effort |
+|---|---|
+| `src/backend/transports/__init__.py` (new) | — |
+| `src/backend/transports/icmp.py` (new) | Medium |
+| `src/backend/scanner.py` | Small |
+| `tools/benchmark_scan.py` (new) | Small |
+| `tests/test_icmp_scanner.py` (new) | Medium |
+
+---
+
+## Issue 2: Add a GUI / Web Dashboard
+
+**Current state:** Terminal-only Rich TUI. Network admins expect a visual interface.
+
+**Target:** A web-based dashboard served locally that replicates the key workflows:
+- Live scan view with sortable/filterable host table
+- Click-to-copy IP/MAC, click to open HTTP/HTTPS/RDP
+- Topology visualization
+- Export buttons (CSV, JSON, HTML)
+
+**Why web vs native GUI:**
+- Python GUI libraries (tkinter, PyQt, wxPython) add heavy dependencies and packaging complexity
+- A web dashboard uses the existing jinja2/markdown2/html skills from the reporting layer
+- It runs in any browser on the scanning machine or remotely
+- Works cross-platform if the Windows PowerShell dependency is ever relaxed
+
+**Implementation plan:**
+
+### Phase 2A — Lightweight HTTP server
+- Use `http.server` from stdlib (no new dependency)
+- Endpoint design:
+ - `GET /` → SPA dashboard
+ - `GET /api/devices` → JSON device list
+ - `GET /api/scan/status` → current scan progress
+ - `POST /api/scan/start` → trigger discovery
+ - `GET /api/topology` → topology graph data
+ - `GET /api/export/csv` → CSV download
+ - `GET /api/export/json` → JSON download
+
+### Phase 2B — SPA frontend (new directory)
+- Create `src/presentation/web/`
+- Single HTML file with embedded CSS/JS (no build step, no npm)
+- Use a lightweight table/grid library or vanilla JS
+- Features:
+ - Sortable device table (IP, hostname, vendor, OS, confidence)
+ - Search/filter bar
+ - Click IP → copy to clipboard
+ - Click hostname → attempt `http://hostname` in new tab
+ - Action buttons: RDP (`mstsc /v:ip`), ping, traceroute
+ - Live scan progress bar with ETA
+ - Embedded topology from pyvis (already generated as topology.html)
+ - "Last scan" timestamp and drift summary
+
+### Phase 2C — CLI integration
+- New subcommand: `netdocit web` or `netdocit serve`
+- Binds to `localhost:8080` by default, configurable port
+- Opens browser automatically (`webbrowser.open()`)
+- Runs scan on startup if `--scan` flag provided
+- Exits on Ctrl+C
+
+### Phase 2D — Keep TUI as fallback
+- `netdocit` with no args still opens TUI
+- `netdocit web` opens web dashboard
+- `netdocit scan --quiet` runs headless
+
+### Success criteria
+- Dashboard loads in browser at `localhost:8080`
+- Device table populates from last scan or live scan
+- Actions (copy IP, open RDP, open HTTP) function correctly
+- Zero npm/build dependencies — single `.html` file with inline everything
+
+| Files to touch | Effort |
+|---|---|
+| `src/presentation/web/__init__.py` (new) | — |
+| `src/presentation/web/server.py` (new) | Medium |
+| `src/presentation/web/dashboard.html` (new) | Large |
+| `src/main.py` | Small |
+
+---
+
+## Issue 3: Add Port Scanning & Service Detection
+
+**Current state:** No port scanning. TCP connect is only used as an ICMP fallback for
+liveness (`_tcp_connect` in `scanner.py`). No service identification beyond WMI/SNMP.
+
+**Target:** Fast configurable port sweep with service fingerprinting on live hosts.
+
+**Implementation plan:**
+
+### Phase 3A — Port scan module
+- Create `src/backend/transports/tcp_scan.py`
+- `TcpPortScanner` class:
+ - Configurable port list: default `[21, 22, 23, 25, 53, 80, 135, 139, 443, 445, 3389, 8080, 8443]`
+ - Half-open (SYN) scan on admin Windows using raw sockets (fast, stealthy)
+ - Connect scan fallback on non-admin (slower, uses OS TCP stack)
+ - Concurrent per host: scan all ports on a host in parallel
+ - Concurrent across hosts: scan multiple hosts simultaneously
+ - Timeout per port: 200ms default, adaptive based on RTT
+
+### Phase 3B — Service fingerprinting
+- Create `src/backend/transports/fingerprint.py`
+- Banner grab on connect: for HTTP, SMTP, FTP, SSH — read first 256 bytes after connect
+- Identify services:
+ - Port 22 → SSH (check banner for "SSH")
+ - Port 80/8080 → HTTP (send `GET / HTTP/1.0\r\n\r\n`, parse `Server:` header)
+ - Port 443/8443 → HTTPS (detect TLS handshake, not full connection)
+ - Port 445 → SMB (check for SMB negotiation)
+ - Port 3389 → RDP (check for RDP negotiation header)
+ - Port 21 → FTP (check for "220" banner)
+ - Port 25 → SMTP (check for "220" banner)
+- Store results as `EvidenceRecord` (already defined in `evidence_model.py`)
+- Feed into existing `service_fingerprint.py` pipeline
+
+### Phase 3C — Integration into discovery pipeline
+- After ICMP ping sweep, before WMI/SNMP enrichment, run port scan on live hosts
+- Add `ProbeTask` entries for `tcp` probe type (already defined in `PROBE_TYPES`)
+- `AdaptiveProbeScheduler` already supports `tcp` worker pool
+- Add port scan results to `host_data` or a new `service_data` key in summary
+- Display open ports in TUI device detail panel and web dashboard
+
+### Phase 3D — Tests
+- `tests/test_tcp_port_scanner.py`
+- `tests/test_service_fingerprint.py` (update existing)
+
+### Success criteria
+- `/24 port scan completes in <30s (top 13 ports per host)`
+- Services correctly identified for HTTP, SSH, RDP, SMB at >90% accuracy
+- Open ports displayed per device in both TUI and web UI
+
+| Files to touch | Effort |
+|---|---|
+| `src/backend/transports/tcp_scan.py` (new) | Medium |
+| `src/backend/transports/fingerprint.py` (new) | Medium |
+| `src/backend/discovery.py` | Small |
+| `src/backend/adaptive_scheduler.py` | Small (tcp pool already exists) |
+| `src/presentation/tui.py` | Small |
+| `tests/test_tcp_port_scanner.py` (new) | Small |
+
+---
+
+## Issue 4: Drop Python 3.14 requirement to 3.10+
+
+**Current state:** `pyproject.toml` declares `requires-python = ">=3.14"`.
+Python 3.14 is not stable. No enterprise environment, no package manager, no CI runner
+ships with it. This single line blocks all real-world adoption.
+
+**Target:** Support Python 3.10 through 3.14. Python 3.10 is the oldest version
+still receiving security updates and is widely deployed on Windows (shipped with
+VS 2022 tools, available in winget/chocolatey/scoop).
+
+**Implementation plan:**
+
+### Phase 4A — Audit dependencies for 3.10 compatibility
+- Check each dependency's minimum Python version:
+ - `jinja2>=3.1.6` — supports 3.7+
+ - `markdown2>=2.5.5` — supports 3.7+
+ - `networkx>=3.6.1` — needs checking (3.x line may require 3.10+)
+ - `pysnmp>=7.1.15` — supports 3.8+
+ - `pyvis>=0.3.2` — supports 3.8+
+ - `rich>=14.3.3` — supports 3.8+
+- Likely result: all dependencies support 3.10+ without changes
+
+### Phase 4B — Code compatibility audit
+- Check for Python 3.11+ only syntax:
+ - `Self` type (3.11) — not used in codebase
+ - `except*` (3.11) — not used
+ - PEP 695 type params (3.12) — not used
+ - `type` statement (3.12) — not used
+ - `@override` (3.12) — not used
+ - PEP 701 f-strings (3.12) — check usage
+- Check stdlib imports for 3.10 availability
+- The `msvcrt` shim uses `ModuleNotFoundError` which exists in 3.6+ — fine
+
+### Phase 4C — Update metadata
+- `pyproject.toml`: `requires-python = ">=3.10"`
+- `.python-version`: `3.10`
+- `uv.lock`: regenerate with `uv lock` targeting 3.10
+- `README.md`: update version requirement
+- `Roadmap.md` and any docs referencing Python version
+
+### Phase 4D — CI matrix
+- Update `.github/workflows/python-ci.yml`:
+ - Test matrix: `[3.10, 3.11, 3.12, 3.13, 3.14]`
+ - Windows runner for 3.10 and latest
+ - Ubuntu runner for all versions
+
+### Success criteria
+- `uv run netdocit` works on Python 3.10, 3.11, 3.12, 3.13, 3.14
+- CI passes on all versions
+- No runtime `SyntaxError` or import failures on 3.10
+
+| Files to touch | Effort |
+|---|---|
+| `pyproject.toml` | Trivial |
+| `.python-version` | Trivial |
+| `uv.lock` | Regenerate |
+| `.github/workflows/python-ci.yml` | Small |
+| `README.md` | Trivial |
+
+---
+
+## Issue 5: Fix MAC Resolution in Python Fallback Scanner
+
+**Current state:** `_python_ping_sweep()` returns `"mac": None` for all devices.
+`_parse_arp_table()` parses `arp -a` output but only extracts IPs, discarding MACs.
+The PowerShell `ping_sweep.ps1` correctly resolves MACs via `Get-NetNeighbor`, but
+it's never called (Issue 1 of the full report).
+
+**Target:** Every discovered host has a MAC address when available from the ARP table.
+
+**Implementation plan:**
+
+### Phase 5A — Fix `_parse_arp_table()` to return IP → MAC mapping
+- Current return type: `list[str]` (just IPs)
+- New return type: `dict[str, str]` → `{ip: mac}`
+- Parse the Windows `arp -a` output format:
+ ```
+ Interface: 192.168.1.10 --- 0x5
+ Internet Address Physical Address Type
+ 192.168.1.1 00-11-22-33-44-55 dynamic
+ 192.168.1.5 aa-bb-cc-dd-ee-ff dynamic
+ ```
+- Regex: `r"(\d+\.\d+\.\d+\.\d+)\s+([0-9a-fA-F-]{17})"`
+- Normalize MAC format: uppercase, colon-separated (`00:11:22:33:44:55`)
+
+### Phase 5B — Update `_python_ping_sweep()` to use MAC mapping
+- After ARP parse, build `arp_map: dict[str, str]`
+- When building result dicts, look up MAC from `arp_map`:
+ ```python
+ {"ip": ip, "mac": arp_map.get(ip), "hostname": ip}
+ ```
+- For seeded ARP IPs that responded, MAC is already known
+- For newly discovered ICMP responders, also try reverse ARP lookup
+ (run `arp -a` again after pings, since Windows populates the ARP cache on outbound ping)
+
+### Phase 5C — Add reverse DNS resolution (bonus)
+- After MAC fix, add optional `socket.gethostbyaddr()` for hostname resolution
+- Timeout-wrapped (can be slow), disabled by default, enabled via `--resolve-hostnames`
+
+### Phase 5D — Restore or deprecate `ping_sweep.ps1`
+- Option A: Fix any PowerShell parse errors and re-enable the PowerShell path
+ as an alternative (faster ARP resolution via `Get-NetNeighbor`)
+- Option B: Acknowledge it as deprecated and remove the file
+- Recommendation: Option A — keep PowerShell as the primary path on Windows
+ with the Python fallback for headless/core servers without PowerShell
+
+### Phase 5E — Tests
+- `tests/test_arp_parser.py`: parse real `arp -a` output samples, verify MAC extraction
+- `tests/test_ping_sweep_script.py` (update): assert MAC is not None for ARP-resolvable IPs
+
+### Success criteria
+- `_parse_arp_table()` returns correct `{ip: mac}` mapping
+- `_python_ping_sweep()` output includes MAC for ARP-cached hosts
+- No regression: existing tests pass with updated return types
+
+| Files to touch | Effort |
+|---|---|
+| `src/backend/scanner.py` | Medium |
+| `tests/test_arp_parser.py` (new) | Small |
+| `tests/test_ping_sweep_script.py` | Small |
+
+---
+
+## Execution Order Recommendation
+
+```
+Week 1-2: Issue 4 (Python 3.10) → Issue 5 (MAC resolution)
+ Rationale: Fast wins, unblock CI, fix data quality immediately
+
+Week 3-4: Issue 1 (Raw socket ICMP)
+ Rationale: Biggest perf win, requires Issue 5's ARP fix first
+
+Week 5-6: Issue 3 (Port scanning + service detection)
+ Rationale: Builds on Issue 1's transport layer
+
+Week 7-9: Issue 2 (Web dashboard)
+ Rationale: Largest effort, builds on all previous fixes for display data
+```
+
+---
+
+## Appendix: Full Audit Report
+
+### Critical Issues
+1. PowerShell `ping_sweep.ps1` is dead code — always redirected to Python fallback
+2. Python ping sweep uses `subprocess.run("ping")` per IP — extremely slow
+3. No real MAC resolution in Python fallback — all MACs are `None`
+4. `python >=3.14` requirement blocks nearly all real-world users
+5. `discover_all()` is a 600-line god function with 4 repeated summary dicts
+
+### Major Feature Gaps vs Advanced IP Scanner
+
+| Feature | Advanced IP Scanner | NetDocIT |
+|---|---|---|
+| GUI | Native Win32 GUI | Terminal-only (Rich TUI) |
+| Port scanning | Yes (configurable) | No |
+| HTTP/HTTPS browser | Built-in | None |
+| FTP browser | Built-in | None |
+| RDP/Radmin launcher | One-click | None |
+| NetBIOS/mDNS/LLMNR | Yes | No |
+| Wake-on-LAN | Yes | No |
+| Remote shutdown | Yes | No |
+| CSV export | Yes | No (JSON/HTML/MD only) |
+| Right-click actions | Extensive context menu | Keyboard-only shortcuts |
+| Favorites/bookmarks | Yes | No |
+| Scan speed | Sub-second per /24 (raw sockets) | Minutes (subprocess per IP) |
+| Dead host history | Shows last-seen alongside live | DB only, not surfaced in TUI |
+| Shared folder detection | Yes | No |
+
+### Performance & Architecture Issues
+
+6. **SQLite connection per operation** — `database.py` opens/closes a connection for every CRUD call. No WAL mode, no connection pooling.
+7. **`devices` table fully cleared per ingestion** — `DELETE FROM devices` on every scan loses history.
+8. **TCP fallback is extremely aggressive** — 9 ports x 128 IPs = 1152 connections, triggers IDS/IPS.
+9. **Repeated `load_config()` calls** — called in `discover_all()`, `report_readiness()`, `processor.py`.
+10. **No incremental/differential scanning** — every scan is full sweep despite existing temporal state model.
+
+### Usability & UX Issues
+
+11. **Terminal-only — no GUI** — biggest adoption barrier for network admins.
+12. **Only 20 devices visible at once** — `tui.py:292` hardcodes `visible_devices[:20]`.
+13. **CLI dispatch is fragile** — `nargs="*"` plus manual string-splitting for `schedule` prefix.
+14. **Module-level mutable global `QUIET`** — state leak if `main()` called twice.
+15. **No progress bar / ETA during scan** — users have no idea how long remains.
+
+### Security Concerns
+
+16. **Hardcoded default SNMP communities** — `["public", "monitor", "read-only"]` in `secrets.py`.
+17. **SNMP community strings in process listings** — PowerShell command lines are visible in Task Manager.
+18. **No encrypted credential storage** — plaintext in both config file and SQLite `credentials` table.
+19. **No scope policy validation on CLI overrides** — `--community` and `--timeout` bypass config.
+
+### Code Quality Issues
+
+20. **Duplicate imports** — `src/main.py:145` re-imports already-imported functions.
+21. **`time` imported mid-file** — `src/main.py:191`.
+22. **`_as_dict_list` duplicated** — defined locally in `discovery.py` and duplicated in `database.py`.
+23. **Missing type annotations** — `scanner.py`, `processor.py`, `topology.py`.
+24. **SNMP engine has independent config loading** — `snmp_engine.py` bypasses `config_parser.py`.
+25. **Bare try/except in scan thread** — `src/main.py:287` catches `Exception` but the prior audit's bare `except: pass` was fixed.
+
+### Test Quality Concerns
+
+26. **No end-to-end integration test** — 78 unit tests but no full `discover_all()` pipeline test.
+27. **Ubuntu CI can't test PowerShell code path** — primary Windows path untested in CI.
diff --git a/README.md b/README.md
index a7e4309..96ca8d2 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@ It collects interface and route information, performs live host discovery, enric
For contributors, maintainers, and users forking this repository:
-- Python 3.14+
+- Python 3.10+
- Windows PowerShell (for discovery scripts)
For end users using the packaged installer:
diff --git a/pyproject.toml b/pyproject.toml
index df6c04d..861066d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,11 +3,11 @@ name = "netdocit"
version = "0.1.0"
description = "Automated network discovery, topology mapping, and inventory reporting for Windows environments"
readme = "README.md"
-requires-python = ">=3.14"
+requires-python = ">=3.10"
dependencies = [
"jinja2>=3.1.6",
"markdown2>=2.5.5",
- "networkx>=3.6.1",
+ "networkx>=3.4",
"pysnmp>=7.1.15",
"pyvis>=0.3.2",
"rich>=14.3.3"
diff --git a/src/backend/database.py b/src/backend/database.py
index 1bc4e30..9935a83 100644
--- a/src/backend/database.py
+++ b/src/backend/database.py
@@ -901,6 +901,20 @@ def _observation_rows(summary, scan_run_id):
json.dumps(observation, separators=(',', ':'), sort_keys=True),
))
+ for svc in summary.get('service_data', []):
+ if not isinstance(svc, dict):
+ continue
+ rows.append((
+ scan_run_id,
+ 'tcp',
+ None,
+ None,
+ None,
+ None,
+ None,
+ json.dumps(svc, separators=(',', ':'), sort_keys=True),
+ ))
+
return rows
def persist_probe_observations(summary, scan_run_id=None, batch_size=200):
diff --git a/src/backend/discovery.py b/src/backend/discovery.py
index 13df5d9..4ad859b 100644
--- a/src/backend/discovery.py
+++ b/src/backend/discovery.py
@@ -3,6 +3,8 @@
from typing import Any
from .adaptive_scheduler import AdaptiveProbeScheduler, ProbeTask, PROBE_TYPES
from .scanner import run_ps_script, get_scan_profile
+from .transports.tcp_scan import TcpPortScanner
+from .transports.fingerprint import classify_host_services
def _as_dict_list(value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list):
@@ -318,6 +320,10 @@ def should_abort() -> bool:
}
summary["host_data_count"] = 0
summary["snmp_data_count"] = 0
+ summary["tcp_port_scan_data"] = {}
+ summary["tcp_port_scan_target_count"] = 0
+ summary["tcp_port_scan_result_count"] = 0
+ summary["service_data"] = []
summary["service_identity"] = build_service_identity_summary(summary)
# Attach provenance metadata
try:
@@ -431,6 +437,24 @@ def should_abort() -> bool:
)
found_ips = [str(dev['ip']) for dev in scan_devices if 'ip' in dev]
+ tcp_port_scan_data: dict[str, list[dict]] = {}
+ tcp_port_scan_target_count = 0
+ tcp_port_scan_result_count = 0
+ service_evidence: list[dict[str, Any]] = []
+ if found_ips and not scan_error:
+ tcp_port_scan_target_count = len(found_ips)
+ log(f"scanning {tcp_port_scan_target_count} hosts for open TCP ports...")
+ emit_progress("phase", state="tcp_port_scan")
+ tcp_scanner = TcpPortScanner(timeout_s=0.2)
+ tcp_port_scan_data = tcp_scanner.scan_hosts(found_ips)
+ for ip, entries in tcp_port_scan_data.items():
+ evidence = classify_host_services(entries)
+ service_evidence.extend(evidence)
+ for e in entries:
+ if e.get("open"):
+ tcp_port_scan_result_count += 1
+ log(f"TCP scan complete: {tcp_port_scan_result_count} open ports across {tcp_port_scan_target_count} hosts")
+
safety_abort_reason = None
if not scan_error:
current_monotonic = _monotonic_now(last_monotonic)
@@ -491,6 +515,10 @@ def should_abort() -> bool:
}
summary["host_data_count"] = 0
summary["snmp_data_count"] = 0
+ summary["tcp_port_scan_data"] = tcp_port_scan_data
+ summary["tcp_port_scan_target_count"] = tcp_port_scan_target_count
+ summary["tcp_port_scan_result_count"] = tcp_port_scan_result_count
+ summary["service_data"] = service_evidence
summary["service_identity"] = build_service_identity_summary(summary)
persist_log("WARNING", f"Discovery stopped by safety profile: {safety_abort_reason}", "Scanner")
# Attach provenance metadata
@@ -584,6 +612,10 @@ def should_abort() -> bool:
}
summary["host_data_count"] = 0
summary["snmp_data_count"] = 0
+ summary["tcp_port_scan_data"] = tcp_port_scan_data
+ summary["tcp_port_scan_target_count"] = tcp_port_scan_target_count
+ summary["tcp_port_scan_result_count"] = tcp_port_scan_result_count
+ summary["service_data"] = service_evidence
summary["service_identity"] = build_service_identity_summary(summary)
persist_log("WARNING", f"Discovery stopped by scope policy: {host_check_decision.reason_code}", "Scanner")
# Attach provenance metadata
@@ -711,6 +743,10 @@ def should_abort() -> bool:
summary["host_data_count"] = len(_as_dict_list(summary["host_data"]))
summary["snmp_data_count"] = len(_as_dict_list(summary["snmp_data"]))
+ summary["tcp_port_scan_data"] = tcp_port_scan_data
+ summary["tcp_port_scan_target_count"] = tcp_port_scan_target_count
+ summary["tcp_port_scan_result_count"] = tcp_port_scan_result_count
+ summary["service_data"] = service_evidence
summary["service_identity"] = build_service_identity_summary(summary)
# Attach provenance metadata for completed run
diff --git a/src/backend/protocol_depth.py b/src/backend/protocol_depth.py
index 7d3816a..152c080 100644
--- a/src/backend/protocol_depth.py
+++ b/src/backend/protocol_depth.py
@@ -26,6 +26,9 @@ def build_service_identity_summary(discovery_summary: dict[str, Any]) -> dict[st
}
)
+ for item in discovery_summary.get("service_data", []):
+ evidence_items.append(item)
+
if not evidence_items:
return {
"display_name": "unknown",
diff --git a/src/backend/scanner.py b/src/backend/scanner.py
index 2855f9a..7f732aa 100644
--- a/src/backend/scanner.py
+++ b/src/backend/scanner.py
@@ -1,12 +1,14 @@
import subprocess
import json
import os
+import re
import socket
import concurrent.futures
import ipaddress
from typing import Iterable
from .runtime_paths import resource_path
+from .transports.icmp import IcmpScanner
SCAN_PROFILES = {
@@ -54,43 +56,75 @@ def run_ps_script(script_name, args=None, timeout_seconds=60):
return {"error": "Script output was not valid JSON"}
-def _parse_arp_table(subnet_list: list[str] | None = None) -> list[str]:
- """Parse the system ARP table (Windows `arp -a`) and return IPv4 host addresses."""
+_MAC_PATTERN = re.compile(r"[0-9a-fA-F]{2}[-:][0-9a-fA-F]{2}[-:][0-9a-fA-F]{2}[-:][0-9a-fA-F]{2}[-:][0-9a-fA-F]{2}[-:][0-9a-fA-F]{2}")
+
+
+def _normalize_mac(raw: str) -> str | None:
+ if not raw or not isinstance(raw, str):
+ return None
+ hex_only = re.sub(r"[^0-9a-fA-F]", "", raw)
+ if len(hex_only) != 12:
+ return None
+ return ":".join(hex_only[i:i + 2] for i in range(0, 12, 2)).upper()
+
+
+def _build_subnet_networks(subnet_list):
+ if not subnet_list:
+ return []
+ networks = []
+ for subnet in subnet_list:
+ try:
+ network = ipaddress.IPv4Network(subnet, strict=False)
+ except Exception:
+ continue
+ if network.prefixlen <= 0 or network.prefixlen >= 32:
+ continue
+ if network.is_loopback or network.is_multicast or network.is_unspecified or network.is_reserved or network.is_link_local:
+ continue
+ networks.append(network)
+ return networks
+
+
+def _parse_arp_table(subnet_list=None):
+ """Parse the system ARP table (Windows `arp -a`).
+
+ Returns a {ip: mac} mapping. Normalises MAC addresses to colon-separated
+ uppercase (``00:11:22:33:44:55``). When ``subnet_list`` is provided only
+ entries that fall within one of the supplied CIDRs are returned.
+ """
try:
proc = subprocess.run(["arp", "-a"], capture_output=True, text=True, check=True)
lines = proc.stdout.splitlines()
except Exception:
- return []
+ return {}
- networks: list[ipaddress.IPv4Network] = []
- if subnet_list:
- for subnet in subnet_list:
- try:
- network = ipaddress.IPv4Network(subnet, strict=False)
- except Exception:
- continue
- if network.prefixlen <= 0 or network.prefixlen >= 32:
- continue
- if network.is_loopback or network.is_multicast or network.is_unspecified or network.is_reserved or network.is_link_local:
- continue
- networks.append(network)
+ networks = _build_subnet_networks(subnet_list)
- ips = []
+ result: dict[str, str] = {}
for line in lines:
+ match = _MAC_PATTERN.search(line)
+ if not match:
+ continue
+ mac_raw = match.group(0)
+ mac = _normalize_mac(mac_raw)
+ if not mac:
+ continue
+
parts = line.strip().split()
- if len(parts) >= 2:
- ip = parts[0]
+ for p in parts:
try:
- ip_addr = ipaddress.IPv4Address(ip)
- if ip_addr.is_multicast or ip_addr.is_loopback or ip_addr.is_unspecified or ip_addr.is_reserved or ip_addr.is_link_local:
- continue
- if networks:
- if not any((ip_addr in network and ip_addr not in (network.network_address, network.broadcast_address)) for network in networks):
- continue
- ips.append(ip)
+ ip_addr = ipaddress.IPv4Address(p)
except Exception:
continue
- return ips
+ if ip_addr.is_multicast or ip_addr.is_loopback or ip_addr.is_unspecified or ip_addr.is_reserved or ip_addr.is_link_local:
+ continue
+ if networks:
+ if not any(ip_addr in net and ip_addr not in (net.network_address, net.broadcast_address) for net in networks):
+ continue
+ result[str(ip_addr)] = mac
+ break
+
+ return result
def _icmp_ping(ip: str, timeout_ms: int = 500) -> bool:
@@ -136,38 +170,25 @@ def _python_ping_sweep(subnets, timeout_seconds=60, concurrency=64):
subnets = []
subnet_list = [s for s in (subnets or []) if isinstance(s, str) and s]
- arp_ips = _parse_arp_table(subnet_list)
- seeded_ips = []
- if arp_ips:
- for ip in arp_ips:
- if subnet_list:
- in_scope = any(ipaddress.IPv4Address(ip) in ipaddress.IPv4Network(s, strict=False) for s in subnet_list)
- if not in_scope:
- continue
- seeded_ips.append(ip)
+ arp_map = _parse_arp_table(subnet_list)
+ seeded_ips = list(arp_map.keys())
ips_to_scan = _iter_ips_for_subnets(subnet_list)
if seeded_ips:
seeded_set = set(seeded_ips)
ips_to_scan = [ip for ip in ips_to_scan if ip not in seeded_set]
if not ips_to_scan:
unique_ips = sorted(set(seeded_ips))
- return [{"ip": ip, "mac": None, "hostname": ip} for ip in unique_ips]
+ return [{"ip": ip, "mac": arp_map.get(ip), "hostname": ip} for ip in unique_ips]
timeout_ms = min(1000, max(150, int((timeout_seconds / 10) * 1000)))
responsive = list(seeded_ips)
- with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as ex:
- futures = {ex.submit(_icmp_ping, ip, timeout_ms): ip for ip in ips_to_scan}
- try:
- for fut in concurrent.futures.as_completed(futures, timeout=max(1, timeout_seconds)):
- ip = futures[fut]
- try:
- if fut.result():
- responsive.append(ip)
- except Exception:
- continue
- except TimeoutError:
- pass
+
+ scanner = IcmpScanner(timeout_ms=timeout_ms)
+ ping_results = scanner.batch_ping(ips_to_scan)
+ for ip, rtt in ping_results.items():
+ if rtt is not None:
+ responsive.append(ip)
if len(set(responsive)) <= len(set(seeded_ips)):
ports = [22, 80, 443, 445, 3389, 135, 139, 8080, 8443]
@@ -190,8 +211,11 @@ def _python_ping_sweep(subnets, timeout_seconds=60, concurrency=64):
responsive.append(ip)
except TimeoutError:
pass
+
unique_ips = sorted(set(responsive))
- return [{"ip": ip, "mac": None, "hostname": ip} for ip in unique_ips]
+ refreshed_arp = _parse_arp_table(subnet_list)
+ arp_map = {**arp_map, **refreshed_arp}
+ return [{"ip": ip, "mac": arp_map.get(ip), "hostname": ip} for ip in unique_ips]
if __name__ == "__main__":
print("Scanner module initialized.")
diff --git a/src/backend/transports/__init__.py b/src/backend/transports/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/backend/transports/fingerprint.py b/src/backend/transports/fingerprint.py
new file mode 100644
index 0000000..c2e65c2
--- /dev/null
+++ b/src/backend/transports/fingerprint.py
@@ -0,0 +1,115 @@
+"""Service identification from TCP port scan results.
+
+Feeds into the existing ``service_fingerprint.resolve_service_identity``
+pipeline via ``evidence_model.EvidenceRecord``-compatible dicts.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+PORT_SERVICE_MAP: dict[int, str] = {
+ 21: "ftp",
+ 22: "ssh",
+ 23: "telnet",
+ 25: "smtp",
+ 53: "dns",
+ 80: "http",
+ 135: "msrpc",
+ 139: "netbios",
+ 443: "https",
+ 445: "smb",
+ 3389: "rdp",
+ 8080: "http-proxy",
+ 8443: "https-alt",
+}
+
+HTTP_PORTS = {80, 8080}
+HTTPS_PORTS = {443, 8443}
+MAIL_PORTS = {25}
+
+
+def _normalize_banner(raw: str | None) -> str:
+ return str(raw or "").strip().lower()
+
+
+def _match_from_banner(banner: str) -> str | None:
+ b = _normalize_banner(banner)
+ if not b:
+ return None
+ if b.startswith("ssh-"):
+ return "ssh"
+ if "ftp" in b:
+ return "ftp"
+ if "smtp" in b or "mail" in b:
+ return "smtp"
+ if b.startswith("220"):
+ return "smtp"
+ if "http" in b or b.startswith("http/") or " dict[str, Any]:
+ """Produce a single evidence item from a single port probe.
+
+ Returns a dict with ``service_hint``, ``confidence``, ``transport`` and
+ supporting metadata that the ``service_fingerprint`` pipeline can group
+ and rank.
+ """
+ base_confidence = 0.3
+ service_hint = PORT_SERVICE_MAP.get(port, "unknown")
+
+ banner_match = _match_from_banner(banner) if banner else None
+ if banner_match:
+ service_hint = banner_match
+ base_confidence = 0.7
+
+ if service_hint in {"ssh", "rdp", "smb"}:
+ base_confidence += 0.1
+
+ confidence = min(0.95, base_confidence)
+
+ return {
+ "service_hint": service_hint,
+ "confidence": confidence,
+ "transport": "tcp",
+ "port": port,
+ "banner": _normalize_banner(banner),
+ "rtt_ms": rtt_ms,
+ }
+
+
+def classify_host_services(
+ host_results: list[dict],
+) -> list[dict[str, Any]]:
+ """Turn a TCP scan result list for one host into evidence items.
+
+ Expected input is the output of ``TcpPortScanner.scan_hosts()[ip]``.
+ """
+ evidence: list[dict[str, Any]] = []
+ for entry in host_results:
+ if not entry.get("open"):
+ continue
+ item = identify_service(
+ port=entry["port"],
+ banner=entry.get("banner"),
+ rtt_ms=entry.get("rtt_ms"),
+ )
+ evidence.append(item)
+ return evidence
+
+
+def build_service_summary(host_results_by_ip: dict[str, list[dict]]) -> dict[str, list[str]]:
+ """Quick per-host summary: ``{ip: ["http", "ssh", ...]}``."""
+ summary: dict[str, list[str]] = {}
+ for ip, entries in host_results_by_ip.items():
+ open_ports = [e["port"] for e in entries if e.get("open")]
+ services = []
+ for port in sorted(set(open_ports)):
+ service = PORT_SERVICE_MAP.get(port, f"port-{port}")
+ services.append(service)
+ summary[ip] = sorted(set(services))
+ return summary
diff --git a/src/backend/transports/icmp.py b/src/backend/transports/icmp.py
new file mode 100644
index 0000000..811c002
--- /dev/null
+++ b/src/backend/transports/icmp.py
@@ -0,0 +1,189 @@
+"""Raw-socket ICMP echo scanner.
+
+Uses ``socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)`` to batch-ping IP lists
+without spawning a subprocess per target. That delivers performance on par
+with native tools like Advanced IP Scanner (sub-second for a /24 subnet).
+
+On platforms where raw sockets are denied (non-admin Windows, unprivileged
+Linux) the scanner falls back automatically to the subprocess ``ping`` path.
+"""
+
+from __future__ import annotations
+
+import os
+import select
+import socket
+import struct
+import subprocess
+import time
+from typing import Optional
+
+ICMP_ECHO_REQUEST = 8
+ICMP_ECHO_REPLY = 0
+ICMP_DEFAULT_TIMEOUT_MS = 500
+ICMP_DEFAULT_CONCURRENCY = 128
+
+
+def _checksum(data: bytes) -> int:
+ """16-bit one's complement checksum (RFC 792)."""
+ if len(data) & 1:
+ data += b"\x00"
+ s = 0
+ for i in range(0, len(data), 2):
+ s += (data[i] << 8) + data[i + 1]
+ s = (s >> 16) + (s & 0xFFFF)
+ s += s >> 16
+ return (~s) & 0xFFFF
+
+
+def _build_echo_request(seq: int, identifier: int = 0) -> bytes:
+ """Assemble an ICMP echo request (type=8, code=0)."""
+ header = struct.pack("!BBHHH", ICMP_ECHO_REQUEST, 0, 0, identifier, seq)
+ payload = struct.pack("!d", time.time())
+ csum = _checksum(header + payload)
+ header = struct.pack("!BBHHH", ICMP_ECHO_REQUEST, 0, csum, identifier, seq)
+ return header + payload
+
+
+def _raw_socket_available() -> bool:
+ """Return *True* if the process can open a raw ICMP socket."""
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP):
+ return True
+ except (PermissionError, OSError):
+ return False
+
+
+def _subprocess_ping(ip: str, timeout_ms: int) -> bool:
+ """Single-host ICMP probe via OS ``ping`` command (slow fallback)."""
+ try:
+ return (
+ subprocess.run(
+ ["ping", "-n", "1", "-w", str(int(timeout_ms)), ip],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ ).returncode
+ == 0
+ )
+ except Exception:
+ return False
+
+
+class IcmpScanner:
+ """Batch ICMP echo scanner backed by a raw socket."""
+
+ def __init__(self, timeout_ms: int = ICMP_DEFAULT_TIMEOUT_MS):
+ self._timeout_ms = int(timeout_ms)
+ self._identifier = (os.getpid() & 0xFFFF) ^ (int(time.monotonic() * 1000) & 0xFFFF)
+ self._raw_ok = _raw_socket_available()
+
+ @property
+ def uses_raw_sockets(self) -> bool:
+ return self._raw_ok
+
+ # ------------------------------------------------------------------
+ # Public API
+ # ------------------------------------------------------------------
+
+ def batch_ping(self, ips: list[str]) -> dict[str, Optional[float]]:
+ """Ping every IP in *ips*.
+
+ Returns ``{ip: rtt_seconds | None}``. *None* means the host did
+ not respond within the scanner's timeout window.
+ """
+ if not ips:
+ return {}
+ if self._raw_ok:
+ return self._batch_raw(ips)
+ return self._batch_subprocess(ips)
+
+ # ------------------------------------------------------------------
+ # Raw-socket codepath
+ # ------------------------------------------------------------------
+
+ def _batch_raw(self, ips: list[str]) -> dict[str, Optional[float]]:
+ try:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP)
+ except OSError:
+ self._raw_ok = False
+ return self._batch_subprocess(ips)
+
+ results: dict[str, Optional[float]] = {}
+ try:
+ sock.setblocking(False)
+
+ # --- send phase -------------------------------------------
+ seq_to_ip: dict[int, str] = {}
+ for seq, ip in enumerate(ips, start=1):
+ if seq > 65535:
+ break
+ packet = _build_echo_request(seq, self._identifier)
+ try:
+ sock.sendto(packet, (ip, 0))
+ seq_to_ip[seq] = ip
+ results.setdefault(ip, None)
+ except OSError:
+ results[ip] = None
+
+ # --- receive phase ----------------------------------------
+ deadline = time.monotonic() + (self._timeout_ms / 1000.0) + 2.0
+ while seq_to_ip and time.monotonic() < deadline:
+ ready, _, _ = select.select([sock], [], [], 0.1)
+ if not ready:
+ continue
+ try:
+ data, _addr = sock.recvfrom(1024)
+ if len(data) < 28:
+ continue
+ icmp_type = data[20]
+ if icmp_type != ICMP_ECHO_REPLY:
+ continue
+ recv_id = (data[24] << 8) + data[25]
+ if recv_id != self._identifier:
+ continue
+ recv_seq = (data[26] << 8) + data[27]
+ ip = seq_to_ip.pop(recv_seq, None)
+ if ip is not None:
+ try:
+ sent = struct.unpack("!d", data[28:36])[0]
+ except Exception:
+ sent = 0.0
+ results[ip] = max(0.0, time.time() - sent)
+ except (BlockingIOError, OSError):
+ continue
+ finally:
+ sock.close()
+
+ return results
+
+ # ------------------------------------------------------------------
+ # Subprocess fallback
+ # ------------------------------------------------------------------
+
+ def _batch_subprocess(self, ips: list[str]) -> dict[str, Optional[float]]:
+ import concurrent.futures
+
+ results: dict[str, Optional[float]] = {}
+ to_scan = list(ips)
+ if not to_scan:
+ return results
+
+ # Scale concurrency reasonably; never exceed 256 workers.
+ workers = min(ICMP_DEFAULT_CONCURRENCY, len(to_scan))
+
+ with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
+ futures = {
+ ex.submit(_subprocess_ping, ip, self._timeout_ms): ip
+ for ip in to_scan
+ }
+ for fut in concurrent.futures.as_completed(futures):
+ ip = futures[fut]
+ try:
+ if fut.result():
+ results[ip] = 0.0 # no RTT from subprocess fallback
+ else:
+ results[ip] = None
+ except Exception:
+ results[ip] = None
+
+ return results
diff --git a/src/backend/transports/tcp_scan.py b/src/backend/transports/tcp_scan.py
new file mode 100644
index 0000000..3447f7b
--- /dev/null
+++ b/src/backend/transports/tcp_scan.py
@@ -0,0 +1,97 @@
+"""TCP port scanner using connect() with per-port timeouts.
+
+Scans a configurable port list across live hosts using ThreadPoolExecutor
+for concurrency. Fast enough for discovery use-cases (top ~13 ports on a
+/24 subnet in under 30 seconds).
+"""
+
+from __future__ import annotations
+
+import socket
+import time
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from typing import Optional
+
+DEFAULT_PORTS = [21, 22, 23, 25, 53, 80, 135, 139, 443, 445, 3389, 8080, 8443]
+DEFAULT_TIMEOUT_S = 0.2
+DEFAULT_CONCURRENCY = 128
+RECV_BYTES = 256
+
+
+def _connect_port(ip: str, port: int, timeout_s: float) -> dict:
+ """Probe a single TCP port. Grabs an initial banner after connect."""
+ result: dict = {"ip": ip, "port": port, "open": False, "banner": None, "rtt_ms": None}
+ try:
+ t0 = time.monotonic()
+ with socket.create_connection((ip, port), timeout=timeout_s) as sock:
+ result["open"] = True
+ result["rtt_ms"] = round((time.monotonic() - t0) * 1000, 1)
+ sock.settimeout(0.3)
+ try:
+ data = sock.recv(RECV_BYTES)
+ try:
+ result["banner"] = data.decode("utf-8", errors="replace")
+ except Exception:
+ result["banner"] = data.hex()
+ except socket.timeout:
+ pass
+ except OSError:
+ pass
+ except (TimeoutError, socket.timeout, OSError):
+ pass
+ except Exception:
+ pass
+ return result
+
+
+class TcpPortScanner:
+ """Configurable TCP port scanner."""
+
+ def __init__(
+ self,
+ ports: list[int] | None = None,
+ timeout_s: float = DEFAULT_TIMEOUT_S,
+ max_workers: int = DEFAULT_CONCURRENCY,
+ ):
+ self.ports = list(ports or DEFAULT_PORTS)
+ self.timeout_s = max(0.05, float(timeout_s))
+ self.max_workers = min(512, max(1, int(max_workers)))
+
+ def scan_hosts(self, ips: list[str]) -> dict[str, list[dict]]:
+ """Scan all configured ports on every IP.
+
+ Returns ``{ip: [{"port": 80, "open": True, "banner": ..., "rtt_ms": 2.3}, ...]}``
+ """
+ if not ips:
+ return {}
+
+ results: dict[str, list[dict]] = {ip: [] for ip in ips}
+ tasks: list[tuple[str, int]] = []
+ for ip in ips:
+ for port in self.ports:
+ tasks.append((ip, port))
+
+ if not tasks:
+ return results
+
+ with ThreadPoolExecutor(max_workers=self.max_workers) as ex:
+ futures = {
+ ex.submit(_connect_port, ip, port, self.timeout_s): (ip, port)
+ for ip, port in tasks
+ }
+ for fut in as_completed(futures):
+ try:
+ entry = fut.result()
+ results[entry["ip"]].append(entry)
+ except Exception:
+ pass
+
+ return results
+
+ def open_ports(self, ips: list[str]) -> dict[str, list[int]]:
+ """Shorthand: returns only open port numbers per IP."""
+ host_results = self.scan_hosts(ips)
+ return {
+ ip: sorted(r["port"] for r in entries if r["open"])
+ for ip, entries in host_results.items()
+ }
diff --git a/src/main.py b/src/main.py
index a4a0d36..8d8e944 100644
--- a/src/main.py
+++ b/src/main.py
@@ -239,7 +239,7 @@ def main():
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("command", nargs="*",
- help="Action to perform (D)iscover, (M)ap, (R)eport, (L)ogs, (S)chedule")
+ help="Action: (D)iscover/scan, (M)ap, (R)eport, (L)ogs, (W)eb, (S)chedule")
parser.add_argument("-v", "--version", action="version", version=f"NetDocIT v{__version__}")
parser.add_argument("-q", "--quiet", "--silent", action="store_true", dest="quiet", help="Background mode")
parser.add_argument("-t", "--time", default="08:00", help="Time for daily schedule (HH:mm)")
@@ -381,6 +381,11 @@ def run():
table.add_row(ts, lvl, msg, src)
console.print(table)
+ elif choice in ['w', 'web', 'serve']:
+ from .presentation.web.server import start_server
+ q_print("\nLaunching web dashboard...")
+ start_server(open_browser=True)
+
elif choice in ['s', 'schedule']:
if args.timeout is None:
result = install_scheduler(sched_time, profile=args.profile)
diff --git a/src/presentation/tui.py b/src/presentation/tui.py
index 16b1b4b..117c2b2 100644
--- a/src/presentation/tui.py
+++ b/src/presentation/tui.py
@@ -189,6 +189,28 @@ def _selected_device_detail_text(self, device):
how = explainability.get("how") or "unknown"
lines.append(f"[dim]Why:[/dim] {why}")
lines.append(f"[dim]How:[/dim] {how}")
+
+ tcp_scan = {}
+ if isinstance(self.last_discovery_summary, dict):
+ tcp_scan = self.last_discovery_summary.get("tcp_port_scan_data") or {}
+ ip = device.get("ip")
+ host_ports = tcp_scan.get(ip, []) if ip else []
+ open_entries = [e for e in host_ports if isinstance(e, dict) and e.get("open")]
+ if open_entries:
+ from ..backend.transports.fingerprint import PORT_SERVICE_MAP
+ lines.append("")
+ lines.append("[bold]Open TCP Ports[/bold]")
+ for entry in sorted(open_entries, key=lambda e: e.get("port", 0)):
+ port = entry.get("port")
+ svc = PORT_SERVICE_MAP.get(port, "") if isinstance(port, int) else ""
+ banner = entry.get("banner")
+ rtt = entry.get("rtt_ms")
+ if banner:
+ first_line = (banner or "").split("\n")[0].strip()[:60]
+ lines.append(f"[dim]{port}[/dim]/{svc} {first_line}")
+ else:
+ rtt_str = f" {rtt}ms" if rtt else ""
+ lines.append(f"[dim]{port}[/dim]/{svc}{rtt_str}")
if isinstance(provenance, dict) and provenance:
lines.append("")
lines.append("[bold]Run Provenance[/bold]")
diff --git a/src/presentation/web/__init__.py b/src/presentation/web/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/presentation/web/dashboard.html b/src/presentation/web/dashboard.html
new file mode 100644
index 0000000..93e9e98
--- /dev/null
+++ b/src/presentation/web/dashboard.html
@@ -0,0 +1,277 @@
+
+
+
+
+
+NetDocIT — Network Inventory
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | IP ▲ |
+ MAC |
+ Hostname |
+ OS ▲ |
+ Vendor |
+ Actions |
+
+
+
+
+ No devices found. Run a scan to discover your network.
+
+
+
+
+
+
diff --git a/src/presentation/web/server.py b/src/presentation/web/server.py
new file mode 100644
index 0000000..18cf696
--- /dev/null
+++ b/src/presentation/web/server.py
@@ -0,0 +1,286 @@
+"""Lightweight HTTP server and REST API for the NetDocIT web dashboard.
+
+Powered by stdlib ``http.server`` — zero extra dependencies.
+"""
+
+from __future__ import annotations
+
+import csv
+import io
+import json
+import os
+import threading
+import time
+import webbrowser
+from http.server import HTTPServer, BaseHTTPRequestHandler
+from pathlib import Path
+from typing import Any
+
+from ...backend.database import (
+ get_devices_sorted_by_ip,
+ get_device_counts_by_os,
+ get_all_subnets,
+ get_all_interfaces,
+ get_all_routes,
+ get_logs,
+ init_db,
+)
+from ...backend.runtime_paths import runtime_path
+
+_script_dir = Path(__file__).resolve().parent
+_DASHBOARD_HTML = _script_dir / "dashboard.html"
+
+# ---- scan state (shared across threads) ----------------------------------
+
+_scan_lock = threading.Lock()
+_scan_state: dict[str, Any] = {
+ "running": False,
+ "phase": "idle",
+ "found": 0,
+ "enriched": 0,
+ "started_at": None,
+ "finished_at": None,
+ "error": None,
+ "devices": [],
+ "summary": {},
+}
+
+_scan_thread: threading.Thread | None = None
+
+
+def _run_scan_in_background(profile: str = "balanced", timeout: float | None = None):
+ """Called in a daemon thread; updates _scan_state as discovery progresses."""
+ from ...backend.discovery import discover_all
+ from ...backend.database import ingest_live_data, add_log_entry
+
+ global _scan_state
+ with _scan_lock:
+ _scan_state["running"] = True
+ _scan_state["phase"] = "starting"
+ _scan_state["found"] = 0
+ _scan_state["enriched"] = 0
+ _scan_state["started_at"] = time.time()
+ _scan_state["finished_at"] = None
+ _scan_state["error"] = None
+ _scan_state["devices"] = []
+ _scan_state["summary"] = {}
+
+ def progress_callback(event: str, payload: dict | None = None):
+ payload = payload or {}
+ with _scan_lock:
+ _scan_state["phase"] = event
+ if event == "scan_targets_found":
+ _scan_state["found"] = int(payload.get("count", 0))
+ _scan_state["devices"] = payload.get("targets", [])
+ elif event == "host_details_ready":
+ _scan_state["enriched"] = len(payload.get("host_data", [])) + len(
+ payload.get("snmp_data", [])
+ )
+
+ try:
+ discovery = discover_all(
+ log_fn=None,
+ progress_fn=progress_callback,
+ scan_profile=profile,
+ script_timeout_seconds=timeout,
+ )
+ ingest_live_data(discovery)
+ devices = get_devices_sorted_by_ip()
+ with _scan_lock:
+ _scan_state["phase"] = "completed"
+ _scan_state["finished_at"] = time.time()
+ _scan_state["summary"] = discovery
+ _scan_state["devices"] = [
+ {
+ "ip": d[0],
+ "mac": d[1],
+ "hostname": d[2],
+ "os": d[3],
+ "vendor": d[4],
+ }
+ for d in devices
+ ]
+ except Exception as exc:
+ with _scan_lock:
+ _scan_state["phase"] = "error"
+ _scan_state["error"] = str(exc)
+ _scan_state["finished_at"] = time.time()
+ finally:
+ with _scan_lock:
+ _scan_state["running"] = False
+
+
+# ---- HTTP handler ---------------------------------------------------------
+
+
+class _Handler(BaseHTTPRequestHandler):
+ def log_message(self, format, *args):
+ pass # silence access logs
+
+ def _json_response(self, data, status=200):
+ body = json.dumps(data, default=str).encode("utf-8")
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _static_response(self, content_type, content):
+ if isinstance(content, str):
+ content = content.encode("utf-8")
+ self.send_response(200)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Content-Length", str(len(content)))
+ self.end_headers()
+ self.wfile.write(content)
+
+ def _read_body(self) -> bytes:
+ length = int(self.headers.get("Content-Length", 0))
+ return self.rfile.read(length)
+
+ def do_GET(self):
+ path = self.path.split("?")[0]
+
+ if path == "/":
+ return self._serve_dashboard()
+ if path == "/api/devices":
+ return self._api_devices()
+ if path == "/api/summary":
+ return self._api_summary()
+ if path == "/api/scan/status":
+ return self._api_scan_status()
+ if path == "/api/export/csv":
+ return self._api_export_csv()
+ if path == "/api/export/json":
+ return self._api_export_json()
+
+ self._json_response({"error": "not found"}, 404)
+
+ def do_POST(self):
+ path = self.path.split("?")[0]
+
+ if path == "/api/scan/start":
+ body = self._read_body()
+ try:
+ params = json.loads(body) if body else {}
+ except json.JSONDecodeError:
+ params = {}
+ profile = str(params.get("profile", "balanced"))
+ timeout = params.get("timeout")
+ if timeout is not None:
+ timeout = float(timeout)
+ return self._api_scan_start(profile, timeout)
+
+ self._json_response({"error": "not found"}, 404)
+
+ def do_OPTIONS(self):
+ self.send_response(204)
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
+ self.end_headers()
+
+ # -- endpoints ----------------------------------------------------------
+
+ def _serve_dashboard(self):
+ if _DASHBOARD_HTML.is_file():
+ html = _DASHBOARD_HTML.read_text(encoding="utf-8")
+ self._static_response("text/html; charset=utf-8", html)
+ else:
+ self._json_response({"error": "dashboard.html not found"}, 500)
+
+ def _api_devices(self):
+ devices = get_devices_sorted_by_ip()
+ data = [
+ {
+ "ip": d[0],
+ "mac": d[1],
+ "hostname": d[2],
+ "os": d[3],
+ "vendor": d[4],
+ }
+ for d in devices
+ ]
+ with _scan_lock:
+ live = _scan_state
+ self._json_response({"devices": data, "live": live.get("devices", [])})
+
+ def _api_summary(self):
+ stats = get_device_counts_by_os()
+ subnets = get_all_subnets()
+ self._json_response({
+ "windows_count": stats.get("windows", 0),
+ "appliance_count": stats.get("appliances", 0),
+ "subnet_count": len(subnets),
+ "total_devices": stats.get("windows", 0) + stats.get("appliances", 0),
+ })
+
+ def _api_scan_status(self):
+ with _scan_lock:
+ state = dict(_scan_state)
+ self._json_response(state)
+
+ def _api_scan_start(self, profile: str, timeout: float | None):
+ global _scan_thread
+
+ with _scan_lock:
+ if _scan_state["running"]:
+ self._json_response({"error": "scan already running"}, 409)
+ return
+
+ _scan_thread = threading.Thread(
+ target=_run_scan_in_background,
+ args=(profile, timeout),
+ daemon=True,
+ )
+ _scan_thread.start()
+ self._json_response({"status": "started", "profile": profile})
+
+ def _api_export_csv(self):
+ devices = get_devices_sorted_by_ip()
+ buf = io.StringIO()
+ writer = csv.writer(buf)
+ writer.writerow(["IP", "MAC", "Hostname", "OS", "Vendor"])
+ for d in devices:
+ writer.writerow(d)
+ self._static_response("text/csv; charset=utf-8", buf.getvalue())
+
+ def _api_export_json(self):
+ devices = get_devices_sorted_by_ip()
+ data = {
+ "devices": [
+ {"ip": d[0], "mac": d[1], "hostname": d[2], "os": d[3], "vendor": d[4]}
+ for d in devices
+ ],
+ "subnets": get_all_subnets(),
+ "stats": get_device_counts_by_os(),
+ }
+ body = json.dumps(data, indent=2, default=str).encode("utf-8")
+ self._static_response("application/json; charset=utf-8", body)
+
+
+# ---- public API -----------------------------------------------------------
+
+
+def start_server(host: str = "127.0.0.1", port: int = 8080, open_browser: bool = True):
+ """Launch the web dashboard server. Blocks until Ctrl+C."""
+ init_db()
+
+ server = HTTPServer((host, port), _Handler)
+ url = f"http://{host}:{port}"
+
+ print(f"NetDocIT web dashboard: {url}")
+ print("Press Ctrl+C to stop.\n")
+
+ if open_browser:
+ try:
+ webbrowser.open(url)
+ except Exception:
+ pass
+
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ print("\nShutting down.")
+ server.shutdown()
diff --git a/tests/test_arp_parser.py b/tests/test_arp_parser.py
new file mode 100644
index 0000000..0c3f58c
--- /dev/null
+++ b/tests/test_arp_parser.py
@@ -0,0 +1,106 @@
+import unittest
+from unittest.mock import patch
+
+from src.backend.scanner import _normalize_mac, _parse_arp_table
+
+
+class TestNormalizeMac(unittest.TestCase):
+ def test_dash_separated(self):
+ self.assertEqual(_normalize_mac("aa-bb-cc-dd-ee-ff"), "AA:BB:CC:DD:EE:FF")
+
+ def test_colon_separated(self):
+ self.assertEqual(_normalize_mac("aa:bb:cc:dd:ee:ff"), "AA:BB:CC:DD:EE:FF")
+
+ def test_no_separators(self):
+ self.assertEqual(_normalize_mac("aabbccddeeff"), "AA:BB:CC:DD:EE:FF")
+
+ def test_mixed_case(self):
+ self.assertEqual(_normalize_mac("Aa-Bb-Cc-Dd-Ee-Ff"), "AA:BB:CC:DD:EE:FF")
+
+ def test_invalid_length_returns_none(self):
+ self.assertIsNone(_normalize_mac("aa-bb-cc-dd"))
+
+ def test_empty_returns_none(self):
+ self.assertIsNone(_normalize_mac(""))
+
+ def test_none_returns_none(self):
+ self.assertIsNone(_normalize_mac(None))
+
+ def test_non_string_returns_none(self):
+ self.assertIsNone(_normalize_mac(12345))
+
+
+class TestParseArpTable(unittest.TestCase):
+ WIN_OUTPUT = """
+Interface: 192.168.0.186 --- 0x12
+ Internet Address Physical Address Type
+ 192.168.0.1 aa-bb-cc-dd-ee-ff dynamic
+ 192.168.0.42 11-22-33-44-55-66 dynamic
+ 10.0.0.1 77-88-99-aa-bb-cc dynamic
+"""
+
+ @patch("src.backend.scanner.subprocess.run")
+ def test_returns_ip_to_mac_mapping(self, mock_run):
+ mock_run.return_value.stdout = self.WIN_OUTPUT
+ result = _parse_arp_table()
+ self.assertEqual(
+ result,
+ {
+ "192.168.0.1": "AA:BB:CC:DD:EE:FF",
+ "192.168.0.42": "11:22:33:44:55:66",
+ "10.0.0.1": "77:88:99:AA:BB:CC",
+ },
+ )
+
+ @patch("src.backend.scanner.subprocess.run")
+ def test_filters_by_subnet(self, mock_run):
+ mock_run.return_value.stdout = self.WIN_OUTPUT
+ result = _parse_arp_table(["192.168.0.0/24"])
+ self.assertEqual(
+ result,
+ {
+ "192.168.0.1": "AA:BB:CC:DD:EE:FF",
+ "192.168.0.42": "11:22:33:44:55:66",
+ },
+ )
+
+ @patch("src.backend.scanner.subprocess.run")
+ def test_excludes_broadcast_address(self, mock_run):
+ mock_run.return_value.stdout = """
+Interface: 192.168.0.186 --- 0x12
+ Internet Address Physical Address Type
+ 192.168.0.1 aa-bb-cc-dd-ee-ff dynamic
+ 192.168.0.255 ff-ff-ff-ff-ff-ff static
+"""
+ result = _parse_arp_table(["192.168.0.0/24"])
+ self.assertIn("192.168.0.1", result)
+ self.assertNotIn("192.168.0.255", result)
+
+ @patch("src.backend.scanner.subprocess.run")
+ def test_excludes_loopback_and_multicast(self, mock_run):
+ mock_run.return_value.stdout = """
+Interface: 192.168.0.186 --- 0x12
+ 127.0.0.1 00-00-00-00-00-00 static
+ 224.0.0.1 01-00-5e-00-00-01 static
+ 192.168.0.1 aa-bb-cc-dd-ee-ff dynamic
+"""
+ result = _parse_arp_table()
+ self.assertIn("192.168.0.1", result)
+ self.assertNotIn("127.0.0.1", result)
+ self.assertNotIn("224.0.0.1", result)
+
+ @patch("src.backend.scanner.subprocess.run")
+ def test_subprocess_error_returns_empty(self, mock_run):
+ mock_run.side_effect = OSError("arp not found")
+ result = _parse_arp_table()
+ self.assertEqual(result, {})
+
+ @patch("src.backend.scanner.subprocess.run")
+ def test_garbled_output_returns_empty(self, mock_run):
+ mock_run.return_value.stdout = "not a real arp table output"
+ result = _parse_arp_table()
+ self.assertEqual(result, {})
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_icmp_scanner.py b/tests/test_icmp_scanner.py
new file mode 100644
index 0000000..a6a33c0
--- /dev/null
+++ b/tests/test_icmp_scanner.py
@@ -0,0 +1,169 @@
+import socket
+import struct
+import unittest
+from unittest.mock import patch, MagicMock
+
+from src.backend.transports.icmp import (
+ ICMP_ECHO_REQUEST,
+ ICMP_ECHO_REPLY,
+ _checksum,
+ _build_echo_request,
+ _subprocess_ping,
+ _raw_socket_available,
+ IcmpScanner,
+)
+
+
+class TestChecksum(unittest.TestCase):
+ def test_empty_payload(self):
+ self.assertEqual(_checksum(b""), 0xFFFF)
+
+ def test_known_vector(self):
+ # Echo request with seq=0, id=0, zero checksum
+ pkt = struct.pack("!BBHHH", 8, 0, 0, 0, 0) + struct.pack("!d", 0.0)
+ csum = _checksum(pkt)
+ self.assertIsInstance(csum, int)
+ self.assertGreaterEqual(csum, 0)
+ self.assertLessEqual(csum, 0xFFFF)
+
+ def test_echo_request_has_valid_checksum(self):
+ pkt = _build_echo_request(1, 0x1234)
+ csum_calculated = _checksum(pkt)
+ self.assertEqual(csum_calculated, 0)
+
+
+class TestBuildEchoRequest(unittest.TestCase):
+ def test_correct_type_and_code(self):
+ pkt = _build_echo_request(42, 0xABCD)
+ self.assertEqual(pkt[0], ICMP_ECHO_REQUEST) # type
+ self.assertEqual(pkt[1], 0) # code
+
+ def test_identifier_and_sequence(self):
+ pkt = _build_echo_request(42, 0xABCD)
+ recv_id = (pkt[4] << 8) + pkt[5]
+ recv_seq = (pkt[6] << 8) + pkt[7]
+ self.assertEqual(recv_id, 0xABCD)
+ self.assertEqual(recv_seq, 42)
+
+ def test_checksum_is_network_order(self):
+ pkt = _build_echo_request(1, 1)
+ stored_checksum = (pkt[2] << 8) + pkt[3]
+ self.assertNotEqual(stored_checksum, 0)
+
+
+class TestRawSocketAvailable(unittest.TestCase):
+ @patch("src.backend.transports.icmp.socket.socket")
+ def test_returns_true_when_socket_opens(self, mock_sock_cls):
+ mock_sock = MagicMock()
+ mock_sock_cls.return_value.__enter__.return_value = mock_sock
+ self.assertTrue(_raw_socket_available())
+
+ @patch("src.backend.transports.icmp.socket.socket")
+ def test_returns_false_on_permission_error(self, mock_sock_cls):
+ mock_sock_cls.side_effect = PermissionError("access denied")
+ self.assertFalse(_raw_socket_available())
+
+ @patch("src.backend.transports.icmp.socket.socket")
+ def test_returns_false_on_os_error(self, mock_sock_cls):
+ mock_sock_cls.side_effect = OSError("no raw support")
+ self.assertFalse(_raw_socket_available())
+
+
+class TestSubprocessPing(unittest.TestCase):
+ @patch("src.backend.transports.icmp.subprocess.run")
+ def test_returns_true_when_ping_succeeds(self, mock_run):
+ mock_run.return_value.returncode = 0
+ self.assertTrue(_subprocess_ping("8.8.8.8", 500))
+
+ @patch("src.backend.transports.icmp.subprocess.run")
+ def test_returns_false_when_ping_fails(self, mock_run):
+ mock_run.return_value.returncode = 1
+ self.assertFalse(_subprocess_ping("192.168.0.99", 500))
+
+ @patch("src.backend.transports.icmp.subprocess.run")
+ def test_returns_false_on_exception(self, mock_run):
+ mock_run.side_effect = OSError("no ping")
+ self.assertFalse(_subprocess_ping("10.0.0.1", 500))
+
+
+def _build_mock_echo_reply(identifier: int, seq: int, send_time: float) -> bytes:
+ """Build a minimal ICMP echo reply byte string as received on a raw socket."""
+ # Real reply: 20-byte IP header + 8-byte ICMP header + payload
+ ip_header = b"\x45\x00\x00\x1c" + b"\x00" * 8 + b"\x00" * 8
+ icmp_header = struct.pack("!BBHHH", ICMP_ECHO_REPLY, 0, 0, identifier, seq)
+ # recompute checksum
+ payload = struct.pack("!d", send_time)
+ tmp = icmp_header[:2] + b"\x00\x00" + icmp_header[4:] + payload
+ csum = _checksum(tmp)
+ icmp_header = struct.pack("!BBHHH", ICMP_ECHO_REPLY, 0, csum, identifier, seq)
+ return ip_header + icmp_header + payload
+
+
+class TestIcmpScannerBatchRaw(unittest.TestCase):
+ def setUp(self):
+ self.scanner = IcmpScanner(timeout_ms=200)
+
+ @patch("src.backend.transports.icmp.socket.socket")
+ @patch("src.backend.transports.icmp.select.select")
+ def test_alive_host_returns_rtt(self, mock_select, mock_sock_cls):
+ mock_sock = MagicMock()
+ mock_sock_cls.return_value = mock_sock
+ reply_bytes = _build_mock_echo_reply(self.scanner._identifier, 1, 100.0)
+ mock_sock.recvfrom.return_value = (reply_bytes, ("192.168.0.1", 0))
+ mock_select.side_effect = [([mock_sock], [], []), ([], [], [])]
+
+ with patch.object(self.scanner, "_raw_ok", True):
+ results = self.scanner.batch_ping(["192.168.0.1"])
+
+ self.assertIn("192.168.0.1", results)
+ self.assertIsNotNone(results["192.168.0.1"])
+
+
+class TestIcmpScannerBatchSubprocess(unittest.TestCase):
+ @patch("src.backend.transports.icmp._subprocess_ping")
+ def test_returns_alive_for_responding_host(self, mock_ping):
+ mock_ping.return_value = True
+ scanner = IcmpScanner(timeout_ms=200)
+ with patch.object(scanner, "_raw_ok", False):
+ results = scanner.batch_ping(["192.168.0.1", "192.168.0.2"])
+ self.assertIsNotNone(results["192.168.0.1"])
+ self.assertIsNotNone(results["192.168.0.2"])
+
+ @patch("src.backend.transports.icmp._subprocess_ping")
+ def test_returns_none_for_dead_host(self, mock_ping):
+ mock_ping.return_value = False
+ scanner = IcmpScanner(timeout_ms=200)
+ with patch.object(scanner, "_raw_ok", False):
+ results = scanner.batch_ping(["10.0.0.99"])
+ self.assertIsNone(results["10.0.0.99"])
+
+
+class TestIcmpScannerEmptyInput(unittest.TestCase):
+ def test_empty_list_returns_empty_dict(self):
+ scanner = IcmpScanner()
+ self.assertEqual(scanner.batch_ping([]), {})
+
+
+class TestIcmpScannerUsesRawSockets(unittest.TestCase):
+ @patch("src.backend.transports.icmp._raw_socket_available")
+ def test_exposes_raw_socket_flag(self, mock_avail):
+ mock_avail.return_value = True
+ scanner = IcmpScanner()
+ self.assertTrue(scanner.uses_raw_sockets)
+
+
+class TestIcmpScannerFallbackBehaviour(unittest.TestCase):
+ @patch("src.backend.transports.icmp._raw_socket_available")
+ def test_raw_failure_falls_back_to_subprocess(self, mock_avail):
+ mock_avail.return_value = False
+ scanner = IcmpScanner(timeout_ms=100)
+ self.assertFalse(scanner.uses_raw_sockets)
+ with patch.object(scanner, "_batch_subprocess") as mock_sub:
+ mock_sub.return_value = {"10.0.0.1": None}
+ result = scanner.batch_ping(["10.0.0.1"])
+ mock_sub.assert_called_once()
+ self.assertEqual(result, {"10.0.0.1": None})
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_scan_speed.py b/tests/test_scan_speed.py
index 149cd59..5efb8e8 100644
--- a/tests/test_scan_speed.py
+++ b/tests/test_scan_speed.py
@@ -8,7 +8,7 @@
class TestScanSpeed(unittest.TestCase):
@patch("src.backend.scanner._tcp_connect", return_value=False)
@patch("src.backend.scanner._icmp_ping", return_value=False)
- @patch("src.backend.scanner._parse_arp_table", return_value=[])
+ @patch("src.backend.scanner._parse_arp_table", return_value={})
@patch("src.backend.scanner._iter_ips_for_subnets")
def test_local_24_completes_under_10s(self, mock_iter, _arp, _icmp, _tcp):
mock_iter.return_value = [f"192.168.0.{i}" for i in range(1, 255)]
diff --git a/tests/test_scanner_arp_first.py b/tests/test_scanner_arp_first.py
index 4959a63..b99a228 100644
--- a/tests/test_scanner_arp_first.py
+++ b/tests/test_scanner_arp_first.py
@@ -21,13 +21,13 @@ def test_arp_table_filters_broadcast_entries(self, mock_run):
self.assertIn("192.168.0.186", ips)
self.assertNotIn("192.168.0.255", ips)
- @patch("src.backend.scanner._icmp_ping")
+ @patch("src.backend.scanner.IcmpScanner.batch_ping")
@patch("src.backend.scanner._iter_ips_for_subnets")
@patch("src.backend.scanner._parse_arp_table")
- def test_arp_seed_does_not_skip_active_probing(self, mock_arp, mock_iter, mock_icmp):
- mock_arp.return_value = ["192.168.0.1"]
+ def test_arp_seed_does_not_skip_active_probing(self, mock_arp, mock_iter, mock_ping):
+ mock_arp.return_value = {"192.168.0.1": "AA:BB:CC:DD:EE:FF"}
mock_iter.return_value = ["192.168.0.1", "192.168.0.10"]
- mock_icmp.side_effect = lambda ip, _timeout_ms: ip == "192.168.0.10"
+ mock_ping.return_value = {"192.168.0.10": 0.05}
results = _python_ping_sweep(["192.168.0.0/24"], timeout_seconds=2)
diff --git a/tests/test_tcp_port_scanner.py b/tests/test_tcp_port_scanner.py
new file mode 100644
index 0000000..59e0f88
--- /dev/null
+++ b/tests/test_tcp_port_scanner.py
@@ -0,0 +1,192 @@
+import socket
+import unittest
+from unittest.mock import patch, MagicMock
+
+from src.backend.transports.tcp_scan import (
+ _connect_port,
+ TcpPortScanner,
+ DEFAULT_PORTS,
+ DEFAULT_TIMEOUT_S,
+)
+from src.backend.transports.fingerprint import (
+ PORT_SERVICE_MAP,
+ identify_service,
+ classify_host_services,
+ build_service_summary,
+ _match_from_banner,
+)
+
+
+class TestPortServiceMap(unittest.TestCase):
+ def test_known_ports_mapped(self):
+ self.assertEqual(PORT_SERVICE_MAP[22], "ssh")
+ self.assertEqual(PORT_SERVICE_MAP[80], "http")
+ self.assertEqual(PORT_SERVICE_MAP[443], "https")
+ self.assertEqual(PORT_SERVICE_MAP[3389], "rdp")
+ self.assertEqual(PORT_SERVICE_MAP[445], "smb")
+
+
+class TestConnectPort(unittest.TestCase):
+ @patch("src.backend.transports.tcp_scan.socket.create_connection")
+ def test_open_port_returns_correct_structure(self, mock_connect):
+ mock_sock = MagicMock()
+ mock_sock.recv.return_value = b"SSH-2.0-OpenSSH_8.9\r\n"
+ mock_connect.return_value.__enter__.return_value = mock_sock
+ result = _connect_port("192.168.0.1", 22, 0.2)
+ self.assertTrue(result["open"])
+ self.assertEqual(result["port"], 22)
+ self.assertIn("SSH", result["banner"])
+ self.assertIsNotNone(result["rtt_ms"])
+
+ @patch("src.backend.transports.tcp_scan.socket.create_connection")
+ def test_closed_port_returns_open_false(self, mock_connect):
+ mock_connect.side_effect = OSError("connection refused")
+ result = _connect_port("192.168.0.1", 99, 0.1)
+ self.assertFalse(result["open"])
+ self.assertIsNone(result["banner"])
+
+ @patch("src.backend.transports.tcp_scan.socket.create_connection")
+ def test_timeout_returns_open_false(self, mock_connect):
+ mock_connect.side_effect = socket.timeout("timed out")
+ result = _connect_port("10.0.0.1", 80, 0.05)
+ self.assertFalse(result["open"])
+
+ @patch("src.backend.transports.tcp_scan.socket.create_connection")
+ def test_empty_banner_when_nothing_received(self, mock_connect):
+ mock_sock = MagicMock()
+ mock_sock.recv.side_effect = socket.timeout("no data")
+ mock_connect.return_value.__enter__.return_value = mock_sock
+ result = _connect_port("192.168.0.1", 3389, 0.2)
+ self.assertTrue(result["open"])
+ self.assertIsNone(result["banner"])
+
+
+class TestTcpPortScanner(unittest.TestCase):
+ @patch("src.backend.transports.tcp_scan._connect_port")
+ def test_scan_hosts_returns_per_ip_entries(self, mock_connect):
+ mock_connect.side_effect = lambda ip, port, timeout_s: {
+ "ip": ip,
+ "port": port,
+ "open": port in {22, 80},
+ "banner": "SSH-2.0" if port == 22 else None,
+ "rtt_ms": 2.0,
+ }
+ scanner = TcpPortScanner(ports=[22, 80], timeout_s=0.1, max_workers=4)
+ results = scanner.scan_hosts(["192.168.0.1", "192.168.0.2"])
+ self.assertIn("192.168.0.1", results)
+ self.assertIn("192.168.0.2", results)
+ for entries in results.values():
+ self.assertEqual(len(entries), 2)
+
+ @patch("src.backend.transports.tcp_scan._connect_port")
+ def test_open_ports_returns_only_open_port_numbers(self, mock_connect):
+ mock_connect.side_effect = lambda ip, port, timeout_s: {
+ "ip": ip,
+ "port": port,
+ "open": port == 80,
+ "banner": None,
+ "rtt_ms": 1.0,
+ }
+ scanner = TcpPortScanner(ports=[22, 80, 443])
+ result = scanner.open_ports(["192.168.0.1"])
+ self.assertEqual(result, {"192.168.0.1": [80]})
+
+ def test_empty_hosts_returns_empty_dict(self):
+ scanner = TcpPortScanner()
+ self.assertEqual(scanner.scan_hosts([]), {})
+
+ def test_default_ports_configured(self):
+ scanner = TcpPortScanner()
+ self.assertGreater(len(scanner.ports), 10)
+
+
+class TestIdentifyService(unittest.TestCase):
+ def test_port_80_produces_http_hint(self):
+ result = identify_service(80, None)
+ self.assertEqual(result["service_hint"], "http")
+ self.assertEqual(result["transport"], "tcp")
+
+ def test_ssh_banner_overrides_port_unknown(self):
+ result = identify_service(9999, "SSH-2.0-OpenSSH")
+ self.assertEqual(result["service_hint"], "ssh")
+ self.assertGreater(result["confidence"], 0.6)
+
+ def test_html_banner_overrides_http(self):
+ result = identify_service(8080, "")
+ self.assertEqual(result["service_hint"], "http")
+
+ def test_unknown_port_defaults(self):
+ result = identify_service(12345, None)
+ self.assertEqual(result["service_hint"], "unknown")
+ self.assertLess(result["confidence"], 0.5)
+
+
+class TestClassifyHostServices(unittest.TestCase):
+ def test_filters_closed_ports(self):
+ host_results = [
+ {"port": 22, "open": True, "banner": "SSH-2.0"},
+ {"port": 80, "open": False, "banner": None},
+ {"port": 443, "open": True, "banner": None},
+ ]
+ evidence = classify_host_services(host_results)
+ self.assertEqual(len(evidence), 2)
+ hints = [e["service_hint"] for e in evidence]
+ self.assertIn("ssh", hints)
+ self.assertIn("https", hints)
+ self.assertNotIn("http", hints)
+
+
+class TestBuildServiceSummary(unittest.TestCase):
+ def test_groups_services_per_ip(self):
+ host_results = {
+ "192.168.0.1": [
+ {"port": 22, "open": True, "banner": "SSH-2.0"},
+ {"port": 80, "open": True, "banner": "HTTP/1.0"},
+ {"port": 443, "open": False, "banner": None},
+ ],
+ "192.168.0.2": [
+ {"port": 3389, "open": True, "banner": None},
+ ],
+ }
+ summary = build_service_summary(host_results)
+ self.assertIn("192.168.0.1", summary)
+ self.assertIn("192.168.0.2", summary)
+ self.assertIn("ssh", summary["192.168.0.1"])
+ self.assertIn("http", summary["192.168.0.1"])
+ self.assertIn("rdp", summary["192.168.0.2"])
+
+
+class TestMatchFromBanner(unittest.TestCase):
+ def test_ssh_banner_detected(self):
+ self.assertEqual(_match_from_banner("SSH-2.0-OpenSSH_8.9"), "ssh")
+
+ def test_smtp_banner_detected(self):
+ self.assertEqual(_match_from_banner("220 mail.example.com ESMTP"), "smtp")
+
+ def test_ftp_banner_detected(self):
+ self.assertEqual(_match_from_banner("220 FTP server ready"), "ftp")
+
+ def test_html_detected(self):
+ self.assertEqual(_match_from_banner("HTTP/1.0 200 OK"), "http")
+
+ def test_empty_banner_returns_none(self):
+ self.assertIsNone(_match_from_banner(""))
+
+ def test_none_returns_none(self):
+ self.assertIsNone(_match_from_banner(None))
+
+
+class TestFingerprintModuleImports(unittest.TestCase):
+ def test_port_service_map_completeness(self):
+ for port in DEFAULT_PORTS:
+ self.assertIn(port, PORT_SERVICE_MAP,
+ f"port {port} has no service mapping")
+
+ def test_identify_service_returns_all_required_keys(self):
+ result = identify_service(80, "HTTP/1.0 200 OK", rtt_ms=2.5)
+ for key in ("service_hint", "confidence", "transport", "port", "banner", "rtt_ms"):
+ self.assertIn(key, result)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_web_server.py b/tests/test_web_server.py
new file mode 100644
index 0000000..7f92ed8
--- /dev/null
+++ b/tests/test_web_server.py
@@ -0,0 +1,167 @@
+import io
+import json
+import unittest
+from unittest.mock import patch, MagicMock
+
+from src.presentation.web.server import _Handler, _scan_state, _scan_lock
+
+
+def _make_handler(path: str = "/", method: str = "GET", body: bytes | None = None):
+ """Construct a handler bypassing the real __init__/parse_request chain."""
+ h = _Handler.__new__(_Handler)
+ h.path = path
+ h.command = method
+ h.headers = MagicMock()
+ h.headers.get.return_value = str(len(body or b""))
+ h.rfile = io.BytesIO(body or b"")
+ h.wfile = io.BytesIO()
+ h.responses = []
+
+ def _send_response(code, message=None):
+ h.responses.append(code)
+
+ def _send_header(k, v):
+ pass
+
+ def _end_headers():
+ pass
+
+ h.send_response = _send_response
+ h.send_header = _send_header
+ h.end_headers = _end_headers
+
+ def _json_response(data, status=200):
+ h.send_response(status)
+ body_out = json.dumps(data, default=str).encode("utf-8")
+ h.send_header("Content-Type", "application/json")
+ h.send_header("Content-Length", str(len(body_out)))
+ h.end_headers()
+ h.wfile.write(body_out)
+
+ def _static_response(ct, content):
+ if isinstance(content, str):
+ content = content.encode("utf-8")
+ h.send_response(200)
+ h.send_header("Content-Type", ct)
+ h.send_header("Content-Length", str(len(content)))
+ h.end_headers()
+ h.wfile.write(content)
+
+ def _read_body():
+ return body or b""
+
+ h._json_response = _json_response
+ h._static_response = _static_response
+ h._read_body = _read_body
+
+ return h
+
+
+class TestDashboardServing(unittest.TestCase):
+ def test_root_serves_html_when_present(self):
+ h = _make_handler("/")
+ h._serve_dashboard()
+ self.assertIn(200, h.responses)
+
+
+class TestApiDevices(unittest.TestCase):
+ @patch("src.presentation.web.server.get_devices_sorted_by_ip")
+ def test_returns_200(self, mock_devices):
+ mock_devices.return_value = [
+ ("192.168.0.1", "AA:BB:CC:DD:EE:FF", "router", "Linux", "TP-Link"),
+ ]
+ h = _make_handler("/api/devices")
+ h.do_GET()
+ self.assertIn(200, h.responses)
+
+
+class TestApiSummary(unittest.TestCase):
+ @patch("src.presentation.web.server.get_device_counts_by_os")
+ @patch("src.presentation.web.server.get_all_subnets")
+ def test_returns_200(self, mock_subnets, mock_counts):
+ mock_counts.return_value = {"windows": 3, "appliances": 2}
+ mock_subnets.return_value = ["192.168.0.0/24"]
+ h = _make_handler("/api/summary")
+ h.do_GET()
+ self.assertIn(200, h.responses)
+
+
+class TestApiScanStatus(unittest.TestCase):
+ def test_returns_200(self):
+ h = _make_handler("/api/scan/status")
+ h.do_GET()
+ self.assertIn(200, h.responses)
+
+
+class TestApiScanStart(unittest.TestCase):
+ def test_refuses_when_already_running(self):
+ with _scan_lock:
+ _scan_state["running"] = True
+ try:
+ h = _make_handler("/api/scan/start", method="POST", body=b'{"profile":"safe"}')
+ h.do_POST()
+ self.assertIn(409, h.responses)
+ finally:
+ with _scan_lock:
+ _scan_state["running"] = False
+
+ @patch("src.presentation.web.server.threading.Thread")
+ def test_accepts_when_idle(self, mock_thread):
+ with _scan_lock:
+ _scan_state["running"] = False
+ h = _make_handler("/api/scan/start", method="POST", body=b'{"profile":"balanced"}')
+ h.do_POST()
+ self.assertIn(200, h.responses)
+ with _scan_lock:
+ _scan_state["running"] = False
+
+
+class TestApiExportCsv(unittest.TestCase):
+ @patch("src.presentation.web.server.get_devices_sorted_by_ip")
+ def test_returns_200(self, mock_devices):
+ mock_devices.return_value = [
+ ("192.168.0.1", "AA:BB:CC:DD:EE:FF", "router", "Linux", "TP-Link"),
+ ]
+ h = _make_handler("/api/export/csv")
+ h.do_GET()
+ self.assertIn(200, h.responses)
+
+
+class TestApiExportJson(unittest.TestCase):
+ @patch("src.presentation.web.server.get_all_subnets")
+ @patch("src.presentation.web.server.get_device_counts_by_os")
+ @patch("src.presentation.web.server.get_devices_sorted_by_ip")
+ def test_returns_200(self, mock_devices, mock_counts, mock_subnets):
+ mock_devices.return_value = []
+ mock_counts.return_value = {"windows": 0, "appliances": 0}
+ mock_subnets.return_value = []
+ h = _make_handler("/api/export/json")
+ h.do_GET()
+ self.assertIn(200, h.responses)
+
+
+class TestCorsOptions(unittest.TestCase):
+ def test_options_returns_204(self):
+ h = _make_handler("/api/devices", method="OPTIONS")
+ h.do_OPTIONS()
+ self.assertIn(204, h.responses)
+
+
+class TestNotFound(unittest.TestCase):
+ def test_bad_path_returns_404(self):
+ h = _make_handler("/api/nope")
+ h.do_GET()
+ self.assertIn(404, h.responses)
+
+
+class TestScanStateFields(unittest.TestCase):
+ def test_scan_state_has_required_fields(self):
+ expected = {"running", "phase", "found", "enriched", "started_at",
+ "finished_at", "error", "devices", "summary"}
+ with _scan_lock:
+ keys = set(_scan_state.keys())
+ self.assertTrue(expected.issubset(keys))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tools/benchmark_scan.py b/tools/benchmark_scan.py
new file mode 100644
index 0000000..bcd5d2a
--- /dev/null
+++ b/tools/benchmark_scan.py
@@ -0,0 +1,69 @@
+"""Benchmark: compare subprocess-based ping against raw-socket ICMP scanner.
+
+Usage:
+ python tools/benchmark_scan.py 192.168.0.0/24 # single subnet
+ python tools/benchmark_scan.py 192.168.0.0/24 10.0.0.0/24 # multiple
+"""
+
+import sys
+import time
+
+# Ensure project root is on sys.path
+sys.path.insert(0, ".")
+
+from src.backend.transports.icmp import IcmpScanner, _raw_socket_available
+from src.backend.scanner import _iter_ips_for_subnets, _python_ping_sweep
+
+
+def banner(text: str) -> None:
+ print(f"\n{'=' * 60}")
+ print(f" {text}")
+ print("=" * 60)
+
+
+def main() -> None:
+ if len(sys.argv) < 2:
+ print("Usage: python tools/benchmark_scan.py [...]")
+ sys.exit(1)
+
+ subnets = sys.argv[1:]
+
+ banner(f"Benchmark: {' '.join(subnets)}")
+
+ # Expand subnets to IP lists
+ all_ips = _iter_ips_for_subnets(subnets)
+ print(f"Targets expanded: {len(all_ips)} IPs")
+
+ # ---- Raw-socket scanner ----
+ raw_available = _raw_socket_available()
+ print(f"Raw sockets available: {raw_available}")
+
+ banner("Phase 1: _python_ping_sweep (integrated pipeline)")
+ t0 = time.monotonic()
+ results_old = _python_ping_sweep(subnets, timeout_seconds=30, concurrency=128)
+ t1 = time.monotonic()
+ dt_old = t1 - t0
+ print(f" Duration : {dt_old:.2f}s")
+ print(f" Hosts : {len(results_old)}")
+ macs = sum(1 for r in results_old if r.get("mac"))
+ print(f" With MAC : {macs}")
+
+ # ---- Standalone raw-socket scanner (bypasses ARP pre-seed) ----
+ if raw_available:
+ banner("Phase 2: IcmpScanner.batch_ping (raw socket)")
+ scanner = IcmpScanner(timeout_ms=500)
+ t0 = time.monotonic()
+ ping_results = scanner.batch_ping(all_ips)
+ t1 = time.monotonic()
+ dt_new = t1 - t0
+ alive = sum(1 for v in ping_results.values() if v is not None)
+ print(f" Duration : {dt_new:.2f}s")
+ print(f" Alive : {alive}")
+ if dt_old > 0:
+ print(f" Speedup : {dt_old / dt_new:.1f}x faster than subprocess path")
+
+ banner("Done")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/uv.lock b/uv.lock
index 5cec844..6a5e3e1 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,6 +1,11 @@
version = 1
revision = 3
-requires-python = ">=3.14"
+requires-python = ">=3.10"
+resolution-markers = [
+ "python_full_version >= '3.12'",
+ "python_full_version == '3.11.*'",
+ "python_full_version < '3.11'",
+]
[[package]]
name = "asttokens"
@@ -29,6 +34,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" },
]
+[[package]]
+name = "exceptiongroup"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
+]
+
[[package]]
name = "executing"
version = "2.2.1"
@@ -38,33 +55,87 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
]
+[[package]]
+name = "ipython"
+version = "8.39.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11'",
+]
+dependencies = [
+ { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" },
+ { name = "decorator", marker = "python_full_version < '3.11'" },
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+ { name = "jedi", marker = "python_full_version < '3.11'" },
+ { name = "matplotlib-inline", marker = "python_full_version < '3.11'" },
+ { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
+ { name = "prompt-toolkit", marker = "python_full_version < '3.11'" },
+ { name = "pygments", marker = "python_full_version < '3.11'" },
+ { name = "stack-data", marker = "python_full_version < '3.11'" },
+ { name = "traitlets", marker = "python_full_version < '3.11'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c0/56/4cc7fc9e9e3f38fd324f24f8afe0ad8bb5fa41283f37f1aaf9de0612c968/ipython-8.39.0-py3-none-any.whl", hash = "sha256:bb3c51c4fa8148ab1dea07a79584d1c854e234ea44aa1283bcb37bc75054651f", size = 831849, upload-time = "2026-03-27T10:02:07.846Z" },
+]
+
[[package]]
name = "ipython"
version = "9.12.0"
source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.12'",
+]
dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "decorator" },
- { name = "ipython-pygments-lexers" },
- { name = "jedi" },
- { name = "matplotlib-inline" },
- { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
- { name = "prompt-toolkit" },
- { name = "pygments" },
- { name = "stack-data" },
- { name = "traitlets" },
+ { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" },
+ { name = "decorator", marker = "python_full_version >= '3.12'" },
+ { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.12'" },
+ { name = "jedi", marker = "python_full_version >= '3.12'" },
+ { name = "matplotlib-inline", marker = "python_full_version >= '3.12'" },
+ { name = "pexpect", marker = "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
+ { name = "prompt-toolkit", marker = "python_full_version >= '3.12'" },
+ { name = "pygments", marker = "python_full_version >= '3.12'" },
+ { name = "stack-data", marker = "python_full_version >= '3.12'" },
+ { name = "traitlets", marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3a/73/7114f80a8f9cabdb13c27732dce24af945b2923dcab80723602f7c8bc2d8/ipython-9.12.0.tar.gz", hash = "sha256:01daa83f504b693ba523b5a407246cabde4eb4513285a3c6acaff11a66735ee4", size = 4428879, upload-time = "2026-03-27T09:42:45.312Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/22/906c8108974c673ebef6356c506cebb6870d48cedea3c41e949e2dd556bb/ipython-9.12.0-py3-none-any.whl", hash = "sha256:0f2701e8ee86e117e37f50563205d36feaa259d2e08d4a6bc6b6d74b18ce128d", size = 625661, upload-time = "2026-03-27T09:42:42.831Z" },
]
+[[package]]
+name = "ipython"
+version = "9.13.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version == '3.11.*'",
+]
+dependencies = [
+ { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" },
+ { name = "decorator", marker = "python_full_version == '3.11.*'" },
+ { name = "ipython-pygments-lexers", marker = "python_full_version == '3.11.*'" },
+ { name = "jedi", marker = "python_full_version == '3.11.*'" },
+ { name = "matplotlib-inline", marker = "python_full_version == '3.11.*'" },
+ { name = "pexpect", marker = "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
+ { name = "prompt-toolkit", marker = "python_full_version == '3.11.*'" },
+ { name = "psutil", marker = "python_full_version == '3.11.*'" },
+ { name = "pygments", marker = "python_full_version == '3.11.*'" },
+ { name = "stack-data", marker = "python_full_version == '3.11.*'" },
+ { name = "traitlets", marker = "python_full_version == '3.11.*'" },
+ { name = "typing-extensions", marker = "python_full_version == '3.11.*'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/87cda5842cf5c31837c06ddb588e11c3c35d8ece89b7a0108c06b8c9b00a/ipython-9.13.0.tar.gz", hash = "sha256:7e834b6afc99f020e3f05966ced34792f40267d64cb1ea9043886dab0dde5967", size = 4430549, upload-time = "2026-04-24T12:24:55.221Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b9/86/3060e8029b7cc505cce9a0137431dda81d0a3fde93a8f0f50ee0bf37a795/ipython-9.13.0-py3-none-any.whl", hash = "sha256:57f9d4639e20818d328d287c7b549af3d05f12486ea8f2e7f73e52a36ec4d201", size = 627274, upload-time = "2026-04-24T12:24:53.038Z" },
+]
+
[[package]]
name = "ipython-pygments-lexers"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pygments" },
+ { name = "pygments", marker = "python_full_version >= '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" }
wheels = [
@@ -131,6 +202,61 @@ version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
+ { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" },
+ { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" },
+ { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" },
+ { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" },
+ { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" },
+ { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" },
+ { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
+ { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
+ { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
+ { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
+ { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
+ { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
+ { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
+ { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
+ { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
+ { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
+ { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
+ { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
+ { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
+ { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
+ { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
+ { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
+ { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
@@ -183,7 +309,8 @@ source = { virtual = "." }
dependencies = [
{ name = "jinja2" },
{ name = "markdown2" },
- { name = "networkx" },
+ { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "pysnmp" },
{ name = "pyvis" },
{ name = "rich" },
@@ -193,16 +320,32 @@ dependencies = [
requires-dist = [
{ name = "jinja2", specifier = ">=3.1.6" },
{ name = "markdown2", specifier = ">=2.5.5" },
- { name = "networkx", specifier = ">=3.6.1" },
+ { name = "networkx", specifier = ">=3.4" },
{ name = "pysnmp", specifier = ">=7.1.15" },
{ name = "pyvis", specifier = ">=0.3.2" },
{ name = "rich", specifier = ">=14.3.3" },
]
+[[package]]
+name = "networkx"
+version = "3.4.2"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" },
+]
+
[[package]]
name = "networkx"
version = "3.6.1"
source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.12'",
+ "python_full_version == '3.11.*'",
+]
sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" },
@@ -241,6 +384,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
]
+[[package]]
+name = "psutil"
+version = "7.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
+ { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
+ { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
+ { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
+ { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
+ { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
+ { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
+ { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
+]
+
[[package]]
name = "ptyprocess"
version = "0.7.0"
@@ -294,10 +465,13 @@ name = "pyvis"
version = "0.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "ipython" },
+ { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "ipython", version = "9.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
+ { name = "ipython", version = "9.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
{ name = "jinja2" },
{ name = "jsonpickle" },
- { name = "networkx" },
+ { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/4b/e37e4e5d5ee1179694917b445768bdbfb084f5a59ecd38089d3413d4c70f/pyvis-0.3.2-py3-none-any.whl", hash = "sha256:5720c4ca8161dc5d9ab352015723abb7a8bb8fb443edeb07f7a322db34a97555", size = 756038, upload-time = "2023-02-24T20:29:46.758Z" },
@@ -339,6 +513,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" },
]
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+]
+
[[package]]
name = "wcwidth"
version = "0.6.0"