Skip to content

Commit 42ab242

Browse files
committed
feat: add host discovery phase before port scanning
1 parent 1227b2d commit 42ab242

7 files changed

Lines changed: 376 additions & 14 deletions

File tree

NetSleuth/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@
1616
from .utils import is_valid_port, format_duration, calculate_scan_estimate
1717
from .vuln import Vulnerability, load_cve_db, find_vulnerabilities, create_sample_cve_db
1818
from .mitre import get_mitre_mapping, get_all_techniques, format_mitre_report
19+
from .discovery import (
20+
HostDiscoveryResult,
21+
HostDiscoverySummary,
22+
discover_host,
23+
discover_hosts,
24+
tcp_probe,
25+
to_resolved_target_map,
26+
)
1927

2028
__all__ = [
2129
"ScanResult",
@@ -41,4 +49,10 @@
4149
"get_mitre_mapping",
4250
"get_all_techniques",
4351
"format_mitre_report",
52+
"HostDiscoveryResult",
53+
"HostDiscoverySummary",
54+
"discover_host",
55+
"discover_hosts",
56+
"tcp_probe",
57+
"to_resolved_target_map",
4458
]

NetSleuth/cli.py

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
import argparse
22
import json
3-
from .scanner import resolve_host, scan_targets
3+
from .scanner import resolve_host, scan_targets_resolved
44
from .ports import parse_ports
55
from .targets import parse_targets
66
from .models import ScanResult
77
from .reporter import results_to_dict, write_json, write_ndjson
88
from .fingerprint import fingerprint_service
99
from .vuln import load_cve_db, find_vulnerabilities
1010
from .mitre import get_mitre_mapping, format_mitre_report
11+
from .discovery import discover_hosts, to_resolved_target_map, DEFAULT_DISCOVERY_PORTS
12+
13+
14+
def _parse_discovery_ports(spec: str) -> list[int]:
15+
ports = parse_ports(spec)
16+
if not ports:
17+
raise ValueError("Discovery ports cannot be empty")
18+
return ports
1119

1220
def format_results(results: list[ScanResult], show_service: bool = False, show_vulns: bool = False) -> str:
1321
"""
@@ -79,6 +87,18 @@ def main(argv: list[str] | None = None) -> int:
7987
parser.add_argument("--mitre", action="store_true", help="Display MITRE ATT&CK techniques")
8088
parser.add_argument("--json", dest="json_path", help="Write results to a JSON file")
8189
parser.add_argument("--ndjson", dest="ndjson_path", help="Write results to an NDJSON file (one result per line)")
90+
parser.add_argument("--no-discovery", action="store_true", help="Skip host discovery and scan all resolvable targets")
91+
parser.add_argument(
92+
"--discovery-ports",
93+
default=",".join(str(p) for p in DEFAULT_DISCOVERY_PORTS),
94+
help="Discovery probe ports (e.g., '443,80,22')",
95+
)
96+
parser.add_argument(
97+
"--discovery-timeout",
98+
type=float,
99+
default=0.3,
100+
help="Timeout in seconds for host discovery probes (default: 0.3)",
101+
)
82102

83103
args = parser.parse_args(argv)
84104

@@ -100,24 +120,58 @@ def main(argv: list[str] | None = None) -> int:
100120
print("=" * 60 + "\n")
101121

102122
# Parse target specification
103-
targets = parse_targets(args.target)
123+
targets = [t for t in parse_targets(args.target) if t]
104124
if not targets:
105125
print(f"No valid targets found in: {args.target}")
106126
return 1
127+
128+
# Preserve order while deduplicating targets.
129+
targets = list(dict.fromkeys(targets))
107130

108131
# Parse port specification
109132
ports = parse_ports(args.ports)
133+
134+
resolved_targets: dict[str, str]
135+
if args.no_discovery:
136+
resolved_targets = {}
137+
unresolved = 0
138+
for target in targets:
139+
ip = resolve_host(target)
140+
if ip is not None:
141+
resolved_targets[target] = ip
142+
else:
143+
unresolved += 1
144+
145+
print(
146+
f"Discovery: skipped, {len(resolved_targets)} resolvable target(s), "
147+
f"{unresolved} unresolved"
148+
)
149+
else:
150+
discovery_ports = _parse_discovery_ports(args.discovery_ports)
151+
summary = discover_hosts(
152+
targets,
153+
probe_ports=discovery_ports,
154+
timeout=args.discovery_timeout,
155+
workers=args.workers,
156+
)
157+
resolved_targets = to_resolved_target_map(summary)
158+
print(
159+
f"Discovery: {len(summary.alive)} alive, "
160+
f"{len(summary.down)} down/unresponsive, "
161+
f"{len(summary.unresolved)} unresolved"
162+
)
163+
164+
if not resolved_targets:
165+
print("No reachable or resolvable targets to scan.")
166+
return 1
110167

111-
# Scan all targets
112-
all_results = scan_targets(targets, ports, args.timeout, args.workers)
168+
# Scan discovered/reachable targets
169+
all_results = scan_targets_resolved(resolved_targets, ports, args.timeout, args.workers)
113170

114171
# Display results for each target
115-
for target, results in all_results.items():
116-
ip = resolve_host(target)
117-
if ip is None:
118-
print(f"\nTarget: {target} (unable to resolve)")
119-
continue
120-
172+
for target in resolved_targets:
173+
results = all_results.get(target, [])
174+
ip = resolved_targets[target]
121175
print(f"\nTarget: {target} ({ip})")
122176

123177
# Apply fingerprinting if requested

NetSleuth/discovery.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import errno
2+
import socket
3+
import time
4+
from dataclasses import dataclass, field
5+
from queue import Empty, Queue
6+
from threading import Lock, Thread
7+
from typing import Dict, Iterable, List, Optional
8+
9+
from .scanner import resolve_host
10+
11+
DEFAULT_DISCOVERY_PORTS = (443, 80, 22)
12+
13+
14+
@dataclass
15+
class HostDiscoveryResult:
16+
target: str
17+
ip: Optional[str]
18+
alive: bool
19+
reason: str
20+
latency_ms: Optional[float] = None
21+
22+
23+
@dataclass
24+
class HostDiscoverySummary:
25+
alive: List[HostDiscoveryResult] = field(default_factory=list)
26+
down: List[HostDiscoveryResult] = field(default_factory=list)
27+
unresolved: List[HostDiscoveryResult] = field(default_factory=list)
28+
29+
30+
def tcp_probe(ip: str, port: int, timeout: float) -> tuple[bool, str, Optional[float]]:
31+
"""Probe a TCP port and return (is_host_reachable, outcome, latency_ms)."""
32+
start = time.perf_counter()
33+
try:
34+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
35+
sock.settimeout(timeout)
36+
code = sock.connect_ex((ip, port))
37+
latency_ms = (time.perf_counter() - start) * 1000.0
38+
39+
if code == 0:
40+
return True, "open", latency_ms
41+
42+
# RST/connection refused indicates the host is reachable.
43+
if code == errno.ECONNREFUSED:
44+
return True, "refused", latency_ms
45+
46+
return False, f"code:{code}", latency_ms
47+
except socket.timeout:
48+
return False, "timeout", (time.perf_counter() - start) * 1000.0
49+
except OSError as e:
50+
return False, f"error:{e.errno}", (time.perf_counter() - start) * 1000.0
51+
52+
53+
def discover_host(target: str, probe_ports: Iterable[int], timeout: float) -> HostDiscoveryResult:
54+
"""Resolve and probe a single target to determine if it is likely reachable."""
55+
ip = resolve_host(target)
56+
if ip is None:
57+
return HostDiscoveryResult(target=target, ip=None, alive=False, reason="dns_failed")
58+
59+
for port in probe_ports:
60+
alive, outcome, latency_ms = tcp_probe(ip, port, timeout)
61+
if alive:
62+
return HostDiscoveryResult(
63+
target=target,
64+
ip=ip,
65+
alive=True,
66+
reason=f"{outcome}:{port}",
67+
latency_ms=latency_ms,
68+
)
69+
70+
return HostDiscoveryResult(target=target, ip=ip, alive=False, reason="all_probes_failed")
71+
72+
73+
def _discovery_worker(
74+
probe_ports: List[int],
75+
timeout: float,
76+
q: Queue[str],
77+
summary: HostDiscoverySummary,
78+
lock: Lock,
79+
) -> None:
80+
while True:
81+
try:
82+
target = q.get_nowait()
83+
except Empty:
84+
return
85+
86+
try:
87+
result = discover_host(target, probe_ports, timeout)
88+
with lock:
89+
if result.ip is None:
90+
summary.unresolved.append(result)
91+
elif result.alive:
92+
summary.alive.append(result)
93+
else:
94+
summary.down.append(result)
95+
finally:
96+
q.task_done()
97+
98+
99+
def discover_hosts(
100+
targets: List[str],
101+
probe_ports: List[int] | None = None,
102+
timeout: float = 0.3,
103+
workers: int = 100,
104+
) -> HostDiscoverySummary:
105+
"""
106+
Discover reachable hosts from a list of targets.
107+
108+
A host is considered reachable when any discovery probe returns an open
109+
or connection-refused response.
110+
"""
111+
deduped_targets = [t for t in dict.fromkeys(targets) if t]
112+
ports = list(probe_ports) if probe_ports else list(DEFAULT_DISCOVERY_PORTS)
113+
if not ports:
114+
raise ValueError("Discovery probe port list cannot be empty")
115+
116+
summary = HostDiscoverySummary()
117+
if not deduped_targets:
118+
return summary
119+
120+
if workers <= 1:
121+
for target in deduped_targets:
122+
result = discover_host(target, ports, timeout)
123+
if result.ip is None:
124+
summary.unresolved.append(result)
125+
elif result.alive:
126+
summary.alive.append(result)
127+
else:
128+
summary.down.append(result)
129+
return summary
130+
131+
q: Queue[str] = Queue()
132+
for target in deduped_targets:
133+
q.put(target)
134+
135+
lock = Lock()
136+
threads: list[Thread] = []
137+
w = min(max(1, workers), len(deduped_targets))
138+
for _ in range(w):
139+
t = Thread(target=_discovery_worker, args=(ports, timeout, q, summary, lock), daemon=True)
140+
t.start()
141+
threads.append(t)
142+
143+
q.join()
144+
for t in threads:
145+
t.join(timeout=0.1)
146+
147+
# Preserve input order for deterministic output.
148+
alive_map = {r.target: r for r in summary.alive}
149+
down_map = {r.target: r for r in summary.down}
150+
unresolved_map = {r.target: r for r in summary.unresolved}
151+
summary.alive = [alive_map[t] for t in deduped_targets if t in alive_map]
152+
summary.down = [down_map[t] for t in deduped_targets if t in down_map]
153+
summary.unresolved = [unresolved_map[t] for t in deduped_targets if t in unresolved_map]
154+
return summary
155+
156+
157+
def to_resolved_target_map(summary: HostDiscoverySummary) -> Dict[str, str]:
158+
"""Return target -> ip mapping for discovered reachable hosts."""
159+
return {r.target: r.ip for r in summary.alive if r.ip is not None}

NetSleuth/scanner.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,27 @@ def scan_host_ports(host: str, ports: Iterable[int], timeout: float, workers: in
6969
return []
7070
return scan_ports(ip, list(ports), timeout=timeout, workers=workers, target=host)
7171

72+
73+
def scan_targets_resolved(
74+
resolved_targets: Dict[str, str],
75+
ports: Iterable[int],
76+
timeout: float,
77+
workers: int,
78+
) -> Dict[str, List[ScanResult]]:
79+
"""Scan multiple targets using a pre-resolved target->ip mapping."""
80+
results: Dict[str, List[ScanResult]] = {}
81+
for target, ip in resolved_targets.items():
82+
results[target] = scan_ports(ip, list(ports), timeout=timeout, workers=workers, target=target)
83+
return results
84+
7285
def scan_targets(targets: Iterable[str], ports: Iterable[int], timeout: float, workers: int) -> Dict[str, List[ScanResult]]:
7386
"""Scan multiple targets and return results grouped by target."""
74-
results: Dict[str, List[ScanResult]] = {}
87+
resolved_targets: Dict[str, str] = {}
7588
for target in targets:
76-
results[target] = scan_host_ports(target, ports, timeout, workers)
77-
return results
89+
ip = resolve_host(target)
90+
if ip is not None:
91+
resolved_targets[target] = ip
92+
return scan_targets_resolved(resolved_targets, ports, timeout, workers)
7893

7994
def _worker(host: str, timeout: float, q: Queue[int], results: list[ScanResult], lock: Lock, target: str | None = None) -> None:
8095
while True:

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,7 @@ NetSleuth is an **educational tool** with intentional limitations:
418418

419419
## Legal & Ethical Usage
420420

421-
⚠️ **AUTHORIZATION REQUIRED** - Only scan systems you own or have explicit written permission to test.
421+
**AUTHORIZATION REQUIRED** - Only scan systems you own or have explicit written permission to test.
422422

423423
Unauthorized scanning may violate:
424424
- Computer Fraud and Abuse Act (CFAA - US)

0 commit comments

Comments
 (0)