Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/app/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class ScanOut(BaseModel):
duration_seconds: float | None
devices_found: int | None
error_message: str | None
warning_message: str | None
risks_critical: int | None
risks_high: int | None
risks_medium: int | None
Expand Down Expand Up @@ -142,6 +143,7 @@ def _scan_to_out(s) -> ScanOut: # noqa: ANN001 — SQLAlchemy instance
duration_seconds=s.duration_seconds,
devices_found=s.devices_found,
error_message=s.error_message,
warning_message=s.warning_message,
risks_critical=s.risks_critical,
risks_high=s.risks_high,
risks_medium=s.risks_medium,
Expand Down
2 changes: 2 additions & 0 deletions backend/app/models/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ class Scan(Base):
duration_seconds = Column(Float, nullable=True)
devices_found = Column(Integer, nullable=True)
error_message = Column(Text, nullable=True)
warning_message = Column(Text, nullable=True)
# "completed" scans may have a warning_message when nmap failed but ARP succeeded
# Risk counts snapshotted at scan completion
risks_critical = Column(Integer, nullable=True)
risks_high = Column(Integer, nullable=True)
Expand Down
2 changes: 2 additions & 0 deletions backend/app/scan_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ def run_scan_and_persist(triggered_by: str = "scheduler") -> int:
scan.risks_high = risk_counts["high"]
scan.risks_medium = risk_counts["medium"]
scan.risks_low = risk_counts["low"]
if result.warnings:
scan.warning_message = "; ".join(result.warnings)
db.commit()
logger.info("Scan %d completed: %d devices", scan_id, devices_found)

Expand Down
16 changes: 13 additions & 3 deletions backend/app/scanner/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ class ScanResult:
arp_only: list[ArpHost] = field(default_factory=list)
"""Hosts found by arp-scan that nmap returned no data for."""

warnings: list[str] = field(default_factory=list)
"""Non-fatal warnings accumulated during the scan (e.g. nmap failure)."""


def orchestrate_scan(
interface: str | None = None,
Expand Down Expand Up @@ -56,8 +59,15 @@ def orchestrate_scan(
arp_by_ip = {h.ip: h for h in arp_hosts}

logger.info("Starting nmap scan on %d hosts", len(ip_list))
nmap_hosts = run_nmap_scan(hosts=ip_list, interface=iface)
logger.info("nmap scan returned %d hosts", len(nmap_hosts))
nmap_hosts: list[NmapHost] = []
warnings: list[str] = []
try:
nmap_hosts = run_nmap_scan(hosts=ip_list, interface=iface)
logger.info("nmap scan returned %d hosts", len(nmap_hosts))
except RuntimeError as exc:
msg = f"nmap unavailable — showing ARP-only results: {exc}"
logger.warning(msg)
warnings.append(msg)

# Enrich nmap results with MAC / vendor from arp-scan
nmap_ips: set[str] = set()
Expand All @@ -75,4 +85,4 @@ def orchestrate_scan(
# Hosts arp found but nmap returned nothing for (e.g. ICMP-filtered)
arp_only = [h for h in arp_hosts if h.ip not in nmap_ips]

return ScanResult(hosts=nmap_hosts, arp_only=arp_only)
return ScanResult(hosts=nmap_hosts, arp_only=arp_only, warnings=warnings)
51 changes: 21 additions & 30 deletions backend/app/scanner/dns_lookup.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,64 +9,55 @@
from __future__ import annotations

import logging
import signal
import socket
from contextlib import contextmanager
from concurrent.futures import ThreadPoolExecutor

from app.scanner.nmap_scan import NmapHost

logger = logging.getLogger(__name__)

_LOOKUP_TIMEOUT_SECONDS = 1


@contextmanager
def _timeout(seconds: int):
"""SIGALRM-based timeout context for blocking socket calls."""

def _handler(signum, frame): # noqa: ANN001 — signal handler signature is fixed by the stdlib
raise TimeoutError

old = signal.signal(signal.SIGALRM, _handler)
signal.alarm(seconds)
try:
yield
finally:
signal.alarm(0)
signal.signal(signal.SIGALRM, old)
_LOOKUP_TIMEOUT_SECONDS = 2
_MAX_WORKERS = 20


def _rdns(ip: str) -> str:
"""
Return the PTR hostname for *ip*, or empty string on any failure.

Uses a SIGALRM timeout so a single slow DNS server cannot stall
the entire scan cycle.
Uses socket.setdefaulttimeout so each lookup is individually bounded.
"""
try:
with _timeout(_LOOKUP_TIMEOUT_SECONDS):
hostname, _, _ = socket.gethostbyaddr(ip)
return hostname
except (OSError, TimeoutError):
socket.setdefaulttimeout(_LOOKUP_TIMEOUT_SECONDS)
hostname, _, _ = socket.gethostbyaddr(ip)
return hostname
except OSError:
return ""
finally:
socket.setdefaulttimeout(None)


def resolve_hostnames(hosts: list[NmapHost]) -> None:
"""
Fill in missing hostnames on *hosts* via reverse DNS (in-place).

Only queries IPs where nmap returned no hostname. Each lookup is
individually timeout-guarded so a non-responsive resolver doesn't
block the scan.
Only queries IPs where nmap returned no hostname. All lookups run
concurrently via a thread pool so a slow resolver doesn't serialise
the scan cycle.
"""
missing = [h for h in hosts if not h.hostname]
if not missing:
return

logger.debug("Reverse DNS lookup for %d host(s) with no hostname", len(missing))

futures = {}
with ThreadPoolExecutor(max_workers=min(_MAX_WORKERS, len(missing))) as pool:
for host in missing:
futures[pool.submit(_rdns, host.ip)] = host

resolved = 0
for host in missing:
name = _rdns(host.ip)
for future, host in futures.items():
name = future.result()
if name:
host.hostname = name
resolved += 1
Expand Down
14 changes: 14 additions & 0 deletions backend/tests/test_enrichment.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,3 +211,17 @@ def test_empty_list_is_noop(self):
with patch("app.scanner.dns_lookup.socket.gethostbyaddr") as mock_dns:
resolve_hostnames([])
mock_dns.assert_not_called()

@pytest.mark.unit
def test_resolves_multiple_hosts_concurrently(self):
"""All hosts in the list should be resolved regardless of concurrency."""
hosts = [NmapHost(ip=f"192.168.1.{i}", hostname="") for i in range(1, 6)]

def fake_rdns(ip):
return (f"host-{ip.split('.')[-1]}.local", [], [ip])

with patch("app.scanner.dns_lookup.socket.gethostbyaddr", side_effect=fake_rdns):
resolve_hostnames(hosts)

for i, host in enumerate(hosts, 1):
assert host.hostname == f"host-{i}.local"
32 changes: 32 additions & 0 deletions backend/tests/test_scan_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,3 +233,35 @@ def test_run_scan_populates_risk_counts(in_memory_session_factory, monkeypatch):
assert scan.risks_medium >= 0 # type: ignore[union-attr]
assert scan.risks_low >= 0 # type: ignore[union-attr]
db.close()


@pytest.mark.unit
def test_partial_scan_persists_arp_devices_when_nmap_fails(in_memory_session_factory, monkeypatch):
"""When nmap fails, ARP-discovered devices must still be persisted and
the scan marked 'completed' with a warning_message."""
from app.models.device import Device
from app.models.scan import Scan
from app.scan_runner import run_scan_and_persist
from app.scanner import ScanResult
from app.scanner.arp_scan import ArpHost

monkeypatch.setattr("app.scan_runner.SessionLocal", in_memory_session_factory)

partial_result = ScanResult(
hosts=[],
arp_only=[ArpHost(ip="192.168.1.50", mac="aa:bb:cc:dd:ee:ff", vendor="Acme")],
warnings=["nmap unavailable — showing ARP-only results: nmap failed"],
)

with patch("app.scan_runner.orchestrate_scan", return_value=partial_result):
scan_id = run_scan_and_persist("manual")

db = in_memory_session_factory()
scan = db.get(Scan, scan_id)
assert scan.status == "completed" # type: ignore[union-attr]
assert scan.warning_message is not None # type: ignore[union-attr]
assert "nmap" in scan.warning_message.lower() # type: ignore[union-attr]

devices = db.query(Device).filter(Device.ip_address == "192.168.1.50").all()
assert len(devices) == 1
db.close()
5 changes: 5 additions & 0 deletions frontend/src/pages/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,11 @@ export function DashboardPage() {
{lastScan.devices_found !== 1 ? "s" : ""} found
</span>
)}
{lastScan.warning_message && (
<span className="text-[var(--color-accent-warning)] text-xs">
⚠ {lastScan.warning_message}
</span>
)}
</div>
) : (
<p className="text-sm text-[var(--color-text-secondary)]">
Expand Down
12 changes: 11 additions & 1 deletion frontend/src/pages/HistoryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,17 @@ export function HistoryPage() {
<td className="px-4 py-2 text-[var(--color-text-secondary)]">
{shortDate(s.started_at)}
</td>
<td className="px-4 py-2 capitalize">{s.triggered_by}</td>
<td className="px-4 py-2 capitalize">
{s.triggered_by}
{s.warning_message && (
<span
title={s.warning_message}
className="ml-1 cursor-help text-[var(--color-accent-warning)]"
>
</span>
)}
</td>
<td className="px-4 py-2 text-[var(--color-text-secondary)]">
{s.duration_seconds != null
? `${s.duration_seconds}s`
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface Scan {
duration_seconds: number | null;
devices_found: number | null;
error_message: string | null;
warning_message: string | null;
risks_critical: number | null;
risks_high: number | null;
risks_medium: number | null;
Expand Down