diff --git a/backend/app/scanner/nmap_scan.py b/backend/app/scanner/nmap_scan.py index 59e220b..f0ec71d 100644 --- a/backend/app/scanner/nmap_scan.py +++ b/backend/app/scanner/nmap_scan.py @@ -56,6 +56,7 @@ def run_nmap_scan( return [] iface = interface or os.environ.get("NETWORK_INTERFACE", "eth0") + host_timeout = os.environ.get("NMAP_HOST_TIMEOUT", "30s") with tempfile.NamedTemporaryFile(suffix=".xml", delete=False) as tmp: xml_path = tmp.name @@ -67,6 +68,8 @@ def run_nmap_scan( # -O (OS detection) intentionally omitted: nmap hard-codes geteuid()==0 # for OS fingerprinting regardless of file capabilities, so it always # quits when running as non-root uid 1000. os_guess will be empty. + "--host-timeout", + host_timeout, "--top-ports", "1000", "-e", diff --git a/backend/tests/test_scanner.py b/backend/tests/test_scanner.py index 6fe0a23..cb5f78b 100644 --- a/backend/tests/test_scanner.py +++ b/backend/tests/test_scanner.py @@ -454,3 +454,37 @@ def test_uses_env_vars_for_interface_and_subnet(self, monkeypatch): orchestrate_scan() mock_arp.assert_called_once_with(interface="bond0", subnet="172.16.0.0/12") + + @pytest.mark.unit + def test_host_timeout_default_in_command(self): + """--host-timeout 30s must appear in the nmap command by default.""" + fixture_xml = _fixture("nmap_two_hosts.xml") + + def fake_run(cmd, **kwargs): + idx = cmd.index("-oX") + Path(cmd[idx + 1]).write_text(fixture_xml) + return MagicMock(returncode=0, stdout="", stderr="") + + with patch("app.scanner.nmap_scan.subprocess.run", side_effect=fake_run) as mock_run: + run_nmap_scan(hosts=["192.168.1.1"], interface="eth0") + + cmd = mock_run.call_args[0][0] + assert "--host-timeout" in cmd + assert cmd[cmd.index("--host-timeout") + 1] == "30s" + + @pytest.mark.unit + def test_host_timeout_env_override(self, monkeypatch): + """NMAP_HOST_TIMEOUT env var must override the default 30s value.""" + monkeypatch.setenv("NMAP_HOST_TIMEOUT", "60s") + fixture_xml = _fixture("nmap_two_hosts.xml") + + def fake_run(cmd, **kwargs): + idx = cmd.index("-oX") + Path(cmd[idx + 1]).write_text(fixture_xml) + return MagicMock(returncode=0, stdout="", stderr="") + + with patch("app.scanner.nmap_scan.subprocess.run", side_effect=fake_run) as mock_run: + run_nmap_scan(hosts=["192.168.1.1"], interface="eth0") + + cmd = mock_run.call_args[0][0] + assert cmd[cmd.index("--host-timeout") + 1] == "60s" diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 905abca..9acafcb 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -159,6 +159,7 @@ export function DashboardPage() { const lastScan = scans[0] ?? null; const loading = devLoading || scanLoading || summaryLoading; + const noScansYet = !loading && scans.length === 0; const handleTrigger = async () => { const result = await trigger(); @@ -199,6 +200,27 @@ export function DashboardPage() { + {noScansYet && ( + +
🛡️
+

+ Welcome to NetworkCrawler +

+

+ Discover every device on your LAN, identify misconfigurations, and + get actionable hardening advice — all locally, with no data leaving + your network. +

+ +
+ )} + {loading ? (
{Array.from({ length: 4 }).map((_, i) => ( diff --git a/frontend/src/pages/DevicesPage.tsx b/frontend/src/pages/DevicesPage.tsx index f0d01bb..e626882 100644 --- a/frontend/src/pages/DevicesPage.tsx +++ b/frontend/src/pages/DevicesPage.tsx @@ -62,6 +62,7 @@ export function DevicesPage() { const { devices, loading, error } = useDevices(); const { risks } = useRisks(); const [filter, setFilter] = useState(""); + const [osFilter, setOsFilter] = useState(""); const [sortKey, setSortKey] = useState("ip_address"); const [sortDir, setSortDir] = useState("asc"); @@ -73,16 +74,25 @@ export function DevicesPage() { return counts; }, [risks]); + const osOptions = useMemo(() => { + const seen = new Set(); + for (const d of devices) { + if (d.os_guess) seen.add(d.os_guess); + } + return Array.from(seen).sort(); + }, [devices]); + const filtered = useMemo(() => { const q = filter.toLowerCase(); return devices.filter( (d) => - d.ip_address.includes(q) || - (d.hostname ?? "").toLowerCase().includes(q) || - (d.mac_address ?? "").toLowerCase().includes(q) || - (d.os_guess ?? "").toLowerCase().includes(q), + (d.ip_address.includes(q) || + (d.hostname ?? "").toLowerCase().includes(q) || + (d.mac_address ?? "").toLowerCase().includes(q) || + (d.os_guess ?? "").toLowerCase().includes(q)) && + (osFilter === "" || d.os_guess === osFilter), ); - }, [devices, filter]); + }, [devices, filter, osFilter]); const sorted = useMemo( () => sortDevices(filtered, riskCounts, sortKey, sortDir), @@ -111,14 +121,31 @@ export function DevicesPage() { : undefined } action={ - setFilter(e.target.value)} - aria-label="Filter devices" - className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-secondary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-primary)] sm:w-64" - /> +
+ setFilter(e.target.value)} + aria-label="Filter devices" + className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-secondary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-primary)] sm:w-56" + /> + {osOptions.length > 0 && ( + + )} +
} />