hostlens 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hostlens/__init__.py +18 -0
- hostlens/api.py +28 -0
- hostlens/cache.py +30 -0
- hostlens/cli/__init__.py +5 -0
- hostlens/cli/__main__.py +3 -0
- hostlens/cli/app.py +88 -0
- hostlens/cli/output.py +39 -0
- hostlens/client.py +271 -0
- hostlens/collectors/__init__.py +21 -0
- hostlens/collectors/base.py +41 -0
- hostlens/collectors/basic.py +65 -0
- hostlens/collectors/mdns.py +96 -0
- hostlens/collectors/netbios.py +49 -0
- hostlens/collectors/services.py +45 -0
- hostlens/collectors/ssdp.py +65 -0
- hostlens/collectors/upnp.py +41 -0
- hostlens/config.py +71 -0
- hostlens/exceptions.py +37 -0
- hostlens/identity/__init__.py +5 -0
- hostlens/identity/fingerbank.py +63 -0
- hostlens/identity/fingerprints.py +51 -0
- hostlens/identity/fusion.py +140 -0
- hostlens/models/__init__.py +30 -0
- hostlens/models/device.py +66 -0
- hostlens/models/events.py +42 -0
- hostlens/models/evidence.py +45 -0
- hostlens/models/target.py +13 -0
- hostlens/network/__init__.py +6 -0
- hostlens/network/addressing.py +74 -0
- hostlens/network/discovery.py +131 -0
- hostlens/parsers/__init__.py +7 -0
- hostlens/parsers/dhcp.py +45 -0
- hostlens/parsers/ssdp.py +14 -0
- hostlens/parsers/upnp.py +44 -0
- hostlens-0.1.0.dist-info/METADATA +415 -0
- hostlens-0.1.0.dist-info/RECORD +39 -0
- hostlens-0.1.0.dist-info/WHEEL +4 -0
- hostlens-0.1.0.dist-info/entry_points.txt +2 -0
- hostlens-0.1.0.dist-info/licenses/LICENSE +21 -0
hostlens/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""High-level LAN device discovery and identification"""
|
|
2
|
+
|
|
3
|
+
from hostlens.api import discover, discover_async, identify, identify_async
|
|
4
|
+
from hostlens.client import HostLens
|
|
5
|
+
from hostlens.config import ScanMode
|
|
6
|
+
from hostlens.models import DeviceProfile
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"DeviceProfile",
|
|
10
|
+
"HostLens",
|
|
11
|
+
"ScanMode",
|
|
12
|
+
"discover",
|
|
13
|
+
"discover_async",
|
|
14
|
+
"identify",
|
|
15
|
+
"identify_async",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
hostlens/api.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Convenient module-level sync and async APIs"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
from hostlens.client import HostLens
|
|
8
|
+
from hostlens.models import DeviceProfile
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def identify_async(target: str) -> DeviceProfile:
|
|
12
|
+
"""Identify one network device asynchronously"""
|
|
13
|
+
return await HostLens().identify(target)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def identify(target: str) -> DeviceProfile:
|
|
17
|
+
"""Identify one network device from synchronous code"""
|
|
18
|
+
return asyncio.run(identify_async(target))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def discover_async(subnet: str | None = None) -> list[DeviceProfile]:
|
|
22
|
+
"""Discover devices on a subnet asynchronously"""
|
|
23
|
+
return await HostLens().discover(subnet)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def discover(subnet: str | None = None) -> list[DeviceProfile]:
|
|
27
|
+
"""Discover devices on a subnet from synchronous code"""
|
|
28
|
+
return asyncio.run(discover_async(subnet))
|
hostlens/cache.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Small in-memory TTL cache"""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Generic, TypeVar
|
|
5
|
+
|
|
6
|
+
T = TypeVar("T")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MemoryCache(Generic[T]):
|
|
10
|
+
def __init__(self, ttl: float = 300.0) -> None:
|
|
11
|
+
self.ttl = ttl
|
|
12
|
+
self.entries: dict[str, tuple[T, float]] = {}
|
|
13
|
+
|
|
14
|
+
def get(self, key: str) -> T | None:
|
|
15
|
+
entry = self.entries.get(key)
|
|
16
|
+
if entry is None:
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
value, expires_at = entry
|
|
20
|
+
if expires_at <= time.monotonic():
|
|
21
|
+
del self.entries[key]
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
def set(self, key: str, value: T) -> None:
|
|
27
|
+
self.entries[key] = (value, time.monotonic() + self.ttl)
|
|
28
|
+
|
|
29
|
+
def clear(self) -> None:
|
|
30
|
+
self.entries.clear()
|
hostlens/cli/__init__.py
ADDED
hostlens/cli/__main__.py
ADDED
hostlens/cli/app.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""HostLens command-line interface"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from contextlib import suppress
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from hostlens.cli.output import show_device, show_found, show_summary
|
|
12
|
+
from hostlens.client import HostLens
|
|
13
|
+
from hostlens.config import ScanMode
|
|
14
|
+
from hostlens.models import DeviceFound, DeviceIdentified, ScanCompleted
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(no_args_is_help=True, help="Discover and identify devices on a local network")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _mode(fast: bool, deep: bool) -> ScanMode:
|
|
20
|
+
if fast and deep:
|
|
21
|
+
raise typer.BadParameter("Choose either --fast or --deep")
|
|
22
|
+
if fast:
|
|
23
|
+
return ScanMode.FAST
|
|
24
|
+
if deep:
|
|
25
|
+
return ScanMode.DEEP
|
|
26
|
+
return ScanMode.NORMAL
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command()
|
|
30
|
+
def identify(
|
|
31
|
+
target: str,
|
|
32
|
+
deep: Annotated[bool, typer.Option("--deep", help="Use deeper service probes")] = False,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Identify one device"""
|
|
35
|
+
device = asyncio.run(
|
|
36
|
+
HostLens().scan_host(target, mode=ScanMode.DEEP if deep else ScanMode.NORMAL)
|
|
37
|
+
)
|
|
38
|
+
show_device(device)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.command()
|
|
42
|
+
def scan(
|
|
43
|
+
subnet: str | None = None,
|
|
44
|
+
fast: Annotated[bool, typer.Option("--fast", help="Favor discovery speed")] = False,
|
|
45
|
+
deep: Annotated[bool, typer.Option("--deep", help="Use deeper service probes")] = False,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Discover and identify a local subnet"""
|
|
48
|
+
|
|
49
|
+
async def run() -> None:
|
|
50
|
+
async for event in HostLens().scan_stream(subnet, mode=_mode(fast, deep)):
|
|
51
|
+
if isinstance(event, DeviceFound):
|
|
52
|
+
show_found(event.device.ip)
|
|
53
|
+
elif isinstance(event, DeviceIdentified):
|
|
54
|
+
show_device(event.device)
|
|
55
|
+
elif isinstance(event, ScanCompleted):
|
|
56
|
+
show_summary(event.summary.devices_found, event.summary.duration)
|
|
57
|
+
|
|
58
|
+
asyncio.run(run())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.command()
|
|
62
|
+
def discover(subnet: str | None = None) -> None:
|
|
63
|
+
"""Run a fast discovery scan"""
|
|
64
|
+
devices = asyncio.run(HostLens().discover(subnet))
|
|
65
|
+
for device in devices:
|
|
66
|
+
show_device(device)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command()
|
|
70
|
+
def watch(
|
|
71
|
+
subnet: str | None = None,
|
|
72
|
+
passive: Annotated[bool, typer.Option("--passive", help="Do not probe devices")] = True,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Watch for devices observed by the operating system"""
|
|
75
|
+
if not passive:
|
|
76
|
+
raise typer.BadParameter("watch currently supports passive observation only")
|
|
77
|
+
|
|
78
|
+
async def run() -> None:
|
|
79
|
+
async for event in HostLens().watch_network(subnet):
|
|
80
|
+
if isinstance(event, DeviceFound):
|
|
81
|
+
show_device(event.device)
|
|
82
|
+
|
|
83
|
+
with suppress(KeyboardInterrupt):
|
|
84
|
+
asyncio.run(run())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
app()
|
hostlens/cli/output.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Terminal output for the HostLens CLI"""
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
|
|
6
|
+
from hostlens.models import DeviceProfile
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def show_device(device: DeviceProfile) -> None:
|
|
12
|
+
table = Table(title=device.best_name, show_header=False, box=None)
|
|
13
|
+
table.add_column("Field", style="bold")
|
|
14
|
+
table.add_column("Value")
|
|
15
|
+
|
|
16
|
+
rows = (
|
|
17
|
+
("IP", device.ip),
|
|
18
|
+
("MAC", device.mac),
|
|
19
|
+
("Hostname", device.hostname),
|
|
20
|
+
("Manufacturer", device.manufacturer),
|
|
21
|
+
("Type", device.device_type),
|
|
22
|
+
("Model", device.model),
|
|
23
|
+
("OS", device.os),
|
|
24
|
+
("Confidence", f"{device.confidence:.0%}"),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
for label, value in rows:
|
|
28
|
+
if value is not None:
|
|
29
|
+
table.add_row(label, str(value))
|
|
30
|
+
|
|
31
|
+
console.print(table)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def show_found(ip: str) -> None:
|
|
35
|
+
console.print(f"Found {ip}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def show_summary(devices_found: int, duration: float) -> None:
|
|
39
|
+
console.print(f"Found {devices_found} devices in {duration:.2f}s")
|
hostlens/client.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""The public async HostLens client"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import ipaddress
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import AsyncIterator, Sequence
|
|
8
|
+
|
|
9
|
+
from hostlens.cache import MemoryCache
|
|
10
|
+
from hostlens.collectors import (
|
|
11
|
+
Collector,
|
|
12
|
+
HostnameCollector,
|
|
13
|
+
MdnsCollector,
|
|
14
|
+
NetbiosCollector,
|
|
15
|
+
OuiCollector,
|
|
16
|
+
ServiceCollector,
|
|
17
|
+
SsdpCollector,
|
|
18
|
+
UpnpCollector,
|
|
19
|
+
collect_evidence,
|
|
20
|
+
)
|
|
21
|
+
from hostlens.config import (
|
|
22
|
+
ScanMode,
|
|
23
|
+
parse_scan_mode,
|
|
24
|
+
scan_collectors,
|
|
25
|
+
scan_concurrency,
|
|
26
|
+
scan_timeout,
|
|
27
|
+
validate_options,
|
|
28
|
+
)
|
|
29
|
+
from hostlens.identity.fingerbank import FingerbankClient
|
|
30
|
+
from hostlens.identity.fingerprints import apply_local_rules
|
|
31
|
+
from hostlens.identity.fusion import build_profile
|
|
32
|
+
from hostlens.models import (
|
|
33
|
+
DeviceFound,
|
|
34
|
+
DeviceIdentified,
|
|
35
|
+
DeviceProfile,
|
|
36
|
+
DeviceUpdated,
|
|
37
|
+
ScanCompleted,
|
|
38
|
+
ScanEvent,
|
|
39
|
+
ScanResult,
|
|
40
|
+
ScanSummary,
|
|
41
|
+
Target,
|
|
42
|
+
)
|
|
43
|
+
from hostlens.network.addressing import local_subnet, normalize_ip, validate_subnet
|
|
44
|
+
from hostlens.network.discovery import discover_targets, known_neighbors
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _built_in_collectors() -> tuple[Collector, ...]:
|
|
50
|
+
return (
|
|
51
|
+
HostnameCollector(),
|
|
52
|
+
OuiCollector(),
|
|
53
|
+
MdnsCollector(),
|
|
54
|
+
SsdpCollector(),
|
|
55
|
+
UpnpCollector(),
|
|
56
|
+
NetbiosCollector(),
|
|
57
|
+
ServiceCollector(),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class HostLens:
|
|
62
|
+
"""Discover and identify devices through one async API"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
*,
|
|
67
|
+
interface: str | None = None,
|
|
68
|
+
timeout: float = 3.0,
|
|
69
|
+
concurrency: int = 64,
|
|
70
|
+
cloud: bool = False,
|
|
71
|
+
fingerbank_api_key: str | None = None,
|
|
72
|
+
collectors: Sequence[Collector] | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
validate_options(timeout, concurrency, cloud, fingerbank_api_key)
|
|
75
|
+
|
|
76
|
+
self.interface = interface
|
|
77
|
+
self.timeout = timeout
|
|
78
|
+
self.concurrency = concurrency
|
|
79
|
+
self.cache: MemoryCache[DeviceProfile] = MemoryCache(ttl=300)
|
|
80
|
+
|
|
81
|
+
available = collectors if collectors is not None else _built_in_collectors()
|
|
82
|
+
self.collectors = {collector.name: collector for collector in available}
|
|
83
|
+
|
|
84
|
+
self.fingerbank = None
|
|
85
|
+
if cloud and fingerbank_api_key:
|
|
86
|
+
self.fingerbank = FingerbankClient(fingerbank_api_key, timeout)
|
|
87
|
+
|
|
88
|
+
async def identify(
|
|
89
|
+
self,
|
|
90
|
+
target: str,
|
|
91
|
+
*,
|
|
92
|
+
mode: ScanMode | str = ScanMode.NORMAL,
|
|
93
|
+
) -> DeviceProfile:
|
|
94
|
+
"""Identify one IP address"""
|
|
95
|
+
return await self.scan_host(target, mode=mode)
|
|
96
|
+
|
|
97
|
+
async def scan_host(
|
|
98
|
+
self,
|
|
99
|
+
target: str,
|
|
100
|
+
*,
|
|
101
|
+
mode: ScanMode | str = ScanMode.NORMAL,
|
|
102
|
+
) -> DeviceProfile:
|
|
103
|
+
"""Scan and identify one host"""
|
|
104
|
+
ip = normalize_ip(target)
|
|
105
|
+
cached = self.cache.get(ip)
|
|
106
|
+
if cached:
|
|
107
|
+
return cached
|
|
108
|
+
|
|
109
|
+
resolved_target = await self._find_neighbor(ip)
|
|
110
|
+
device = await self._identify_target(resolved_target, parse_scan_mode(mode))
|
|
111
|
+
self.cache.set(ip, device)
|
|
112
|
+
return device
|
|
113
|
+
|
|
114
|
+
async def scan_hosts(
|
|
115
|
+
self,
|
|
116
|
+
targets: Sequence[str],
|
|
117
|
+
*,
|
|
118
|
+
mode: ScanMode | str = ScanMode.NORMAL,
|
|
119
|
+
) -> list[DeviceProfile]:
|
|
120
|
+
"""Identify several hosts with bounded concurrency"""
|
|
121
|
+
scan_mode = parse_scan_mode(mode)
|
|
122
|
+
limit = min(self.concurrency, scan_concurrency(scan_mode))
|
|
123
|
+
semaphore = asyncio.Semaphore(limit)
|
|
124
|
+
|
|
125
|
+
async def scan_one(target: str) -> DeviceProfile:
|
|
126
|
+
async with semaphore:
|
|
127
|
+
return await self.scan_host(target, mode=scan_mode)
|
|
128
|
+
|
|
129
|
+
tasks = [scan_one(target) for target in targets]
|
|
130
|
+
return list(await asyncio.gather(*tasks))
|
|
131
|
+
|
|
132
|
+
async def scan_network(
|
|
133
|
+
self,
|
|
134
|
+
subnet: str | None = None,
|
|
135
|
+
*,
|
|
136
|
+
mode: ScanMode | str = ScanMode.NORMAL,
|
|
137
|
+
return_result: bool = False,
|
|
138
|
+
) -> list[DeviceProfile] | ScanResult:
|
|
139
|
+
"""Discover and identify devices on one subnet"""
|
|
140
|
+
devices: dict[str, DeviceProfile] = {}
|
|
141
|
+
summary = None
|
|
142
|
+
|
|
143
|
+
async for event in self.scan_stream(subnet, mode=mode):
|
|
144
|
+
if isinstance(event, (DeviceFound, DeviceUpdated, DeviceIdentified)):
|
|
145
|
+
key = event.device.mac or event.device.ip
|
|
146
|
+
devices[key] = event.device
|
|
147
|
+
if isinstance(event, ScanCompleted):
|
|
148
|
+
summary = event.summary
|
|
149
|
+
|
|
150
|
+
found = list(devices.values())
|
|
151
|
+
if return_result:
|
|
152
|
+
if summary is None:
|
|
153
|
+
raise RuntimeError("Scan finished without a summary")
|
|
154
|
+
return ScanResult(devices=found, summary=summary)
|
|
155
|
+
return found
|
|
156
|
+
|
|
157
|
+
async def discover(self, subnet: str | None = None) -> list[DeviceProfile]:
|
|
158
|
+
"""Run a fast network scan"""
|
|
159
|
+
result = await self.scan_network(subnet, mode=ScanMode.FAST)
|
|
160
|
+
if isinstance(result, ScanResult):
|
|
161
|
+
return result.devices
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
async def scan(self, subnet: str | None = None) -> AsyncIterator[ScanEvent]:
|
|
165
|
+
"""Stream a normal network scan"""
|
|
166
|
+
async for event in self.scan_stream(subnet):
|
|
167
|
+
yield event
|
|
168
|
+
|
|
169
|
+
async def scan_stream(
|
|
170
|
+
self,
|
|
171
|
+
subnet: str | None = None,
|
|
172
|
+
*,
|
|
173
|
+
mode: ScanMode | str = ScanMode.NORMAL,
|
|
174
|
+
) -> AsyncIterator[ScanEvent]:
|
|
175
|
+
"""Yield devices early and enrich them as the scan continues"""
|
|
176
|
+
started = time.monotonic()
|
|
177
|
+
scan_mode = parse_scan_mode(mode)
|
|
178
|
+
if scan_mode is ScanMode.PASSIVE:
|
|
179
|
+
raise ValueError("Use watch_network for passive observation")
|
|
180
|
+
|
|
181
|
+
network = self._network(subnet)
|
|
182
|
+
targets: list[Target] = []
|
|
183
|
+
arp_timeout = min(scan_timeout(scan_mode), 1.5)
|
|
184
|
+
|
|
185
|
+
async for target in discover_targets(network, self.interface, arp_timeout):
|
|
186
|
+
targets.append(target)
|
|
187
|
+
yield DeviceFound(device=build_profile(target, []))
|
|
188
|
+
|
|
189
|
+
limit = min(self.concurrency, scan_concurrency(scan_mode))
|
|
190
|
+
semaphore = asyncio.Semaphore(limit)
|
|
191
|
+
|
|
192
|
+
async def enrich(target: Target) -> DeviceProfile:
|
|
193
|
+
async with semaphore:
|
|
194
|
+
return await self._identify_target(target, scan_mode)
|
|
195
|
+
|
|
196
|
+
tasks = [asyncio.create_task(enrich(target)) for target in targets]
|
|
197
|
+
for task in asyncio.as_completed(tasks):
|
|
198
|
+
device = await task
|
|
199
|
+
self.cache.set(device.ip, device)
|
|
200
|
+
yield DeviceUpdated(device=device)
|
|
201
|
+
yield DeviceIdentified(device=device)
|
|
202
|
+
|
|
203
|
+
duration = time.monotonic() - started
|
|
204
|
+
checked = max(0, ipaddress.ip_network(network).num_addresses - 2)
|
|
205
|
+
summary = ScanSummary(
|
|
206
|
+
duration=duration,
|
|
207
|
+
targets_checked=checked,
|
|
208
|
+
devices_found=len(targets),
|
|
209
|
+
mode=scan_mode.value,
|
|
210
|
+
)
|
|
211
|
+
yield ScanCompleted(summary=summary)
|
|
212
|
+
|
|
213
|
+
async def watch_network(
|
|
214
|
+
self,
|
|
215
|
+
subnet: str | None = None,
|
|
216
|
+
*,
|
|
217
|
+
mode: ScanMode | str = ScanMode.PASSIVE,
|
|
218
|
+
interval: float = 2.0,
|
|
219
|
+
) -> AsyncIterator[ScanEvent]:
|
|
220
|
+
"""Watch the OS neighbor table without sending probes"""
|
|
221
|
+
if parse_scan_mode(mode) is not ScanMode.PASSIVE:
|
|
222
|
+
raise ValueError("watch_network only supports passive mode")
|
|
223
|
+
|
|
224
|
+
network = self._network(subnet)
|
|
225
|
+
seen: set[str] = set()
|
|
226
|
+
|
|
227
|
+
while True:
|
|
228
|
+
async for target in known_neighbors(network):
|
|
229
|
+
key = target.mac or target.ip
|
|
230
|
+
if key in seen:
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
seen.add(key)
|
|
234
|
+
device = await self._identify_target(target, ScanMode.PASSIVE)
|
|
235
|
+
yield DeviceFound(device=device)
|
|
236
|
+
|
|
237
|
+
await asyncio.sleep(interval)
|
|
238
|
+
|
|
239
|
+
def preview_cloud_payload(self, device: DeviceProfile) -> dict[str, str]:
|
|
240
|
+
"""Show the data that cloud enrichment would receive"""
|
|
241
|
+
if self.fingerbank is None:
|
|
242
|
+
return {}
|
|
243
|
+
|
|
244
|
+
target = Target(ip=device.ip, mac=device.mac)
|
|
245
|
+
return self.fingerbank.preview_payload(target, device.evidence)
|
|
246
|
+
|
|
247
|
+
async def _identify_target(self, target: Target, mode: ScanMode) -> DeviceProfile:
|
|
248
|
+
names = scan_collectors(mode)
|
|
249
|
+
collectors = [self.collectors[name] for name in names if name in self.collectors]
|
|
250
|
+
timeout = min(self.timeout, scan_timeout(mode))
|
|
251
|
+
evidence = await collect_evidence(collectors, target, timeout)
|
|
252
|
+
|
|
253
|
+
if mode in {ScanMode.NORMAL, ScanMode.DEEP, ScanMode.PASSIVE}:
|
|
254
|
+
evidence.extend(apply_local_rules(evidence))
|
|
255
|
+
|
|
256
|
+
if self.fingerbank and mode is not ScanMode.FAST:
|
|
257
|
+
try:
|
|
258
|
+
evidence.extend(await self.fingerbank.resolve(target, evidence))
|
|
259
|
+
except Exception as error:
|
|
260
|
+
logger.debug("Fingerbank enrichment failed: %s", error)
|
|
261
|
+
|
|
262
|
+
return build_profile(target, evidence)
|
|
263
|
+
|
|
264
|
+
async def _find_neighbor(self, ip: str) -> Target:
|
|
265
|
+
subnet = str(ipaddress.ip_network(f"{ip}/32", strict=False))
|
|
266
|
+
async for target in known_neighbors(subnet):
|
|
267
|
+
return target
|
|
268
|
+
return Target(ip=ip)
|
|
269
|
+
|
|
270
|
+
def _network(self, subnet: str | None) -> str:
|
|
271
|
+
return validate_subnet(subnet or local_subnet(self.interface))
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Built-in evidence collectors"""
|
|
2
|
+
|
|
3
|
+
from hostlens.collectors.base import Collector, collect_evidence
|
|
4
|
+
from hostlens.collectors.basic import HostnameCollector, OuiCollector
|
|
5
|
+
from hostlens.collectors.mdns import MdnsCollector
|
|
6
|
+
from hostlens.collectors.netbios import NetbiosCollector
|
|
7
|
+
from hostlens.collectors.services import ServiceCollector
|
|
8
|
+
from hostlens.collectors.ssdp import SsdpCollector
|
|
9
|
+
from hostlens.collectors.upnp import UpnpCollector
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Collector",
|
|
13
|
+
"HostnameCollector",
|
|
14
|
+
"MdnsCollector",
|
|
15
|
+
"NetbiosCollector",
|
|
16
|
+
"OuiCollector",
|
|
17
|
+
"ServiceCollector",
|
|
18
|
+
"SsdpCollector",
|
|
19
|
+
"UpnpCollector",
|
|
20
|
+
"collect_evidence",
|
|
21
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Shared collector runner"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from hostlens.models import Evidence, Target
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Collector(Protocol):
|
|
14
|
+
name: str
|
|
15
|
+
|
|
16
|
+
async def collect(self, target: Target, timeout: float) -> list[Evidence]: ...
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def collect_evidence(
|
|
20
|
+
collectors: Sequence[Collector],
|
|
21
|
+
target: Target,
|
|
22
|
+
timeout: float,
|
|
23
|
+
) -> list[Evidence]:
|
|
24
|
+
"""Run independent collectors together and isolate their failures"""
|
|
25
|
+
results = await asyncio.gather(
|
|
26
|
+
*(_collect_safely(collector, target, timeout) for collector in collectors)
|
|
27
|
+
)
|
|
28
|
+
return [item for result in results for item in result]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def _collect_safely(
|
|
32
|
+
collector: Collector,
|
|
33
|
+
target: Target,
|
|
34
|
+
timeout: float,
|
|
35
|
+
) -> list[Evidence]:
|
|
36
|
+
try:
|
|
37
|
+
async with asyncio.timeout(timeout):
|
|
38
|
+
return await collector.collect(target, timeout)
|
|
39
|
+
except Exception as error:
|
|
40
|
+
logger.debug("Collector %s failed for %s: %s", collector.name, target.ip, error)
|
|
41
|
+
return []
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Fast hostname and manufacturer collectors"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import socket
|
|
5
|
+
|
|
6
|
+
from hostlens.models import Evidence, Target
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HostnameCollector:
|
|
10
|
+
name = "hostname"
|
|
11
|
+
|
|
12
|
+
async def collect(self, target: Target, timeout: float) -> list[Evidence]:
|
|
13
|
+
try:
|
|
14
|
+
result = await asyncio.wait_for(
|
|
15
|
+
asyncio.to_thread(socket.gethostbyaddr, target.ip),
|
|
16
|
+
timeout=timeout,
|
|
17
|
+
)
|
|
18
|
+
except (TimeoutError, OSError):
|
|
19
|
+
return []
|
|
20
|
+
|
|
21
|
+
hostname = result[0].rstrip(".")
|
|
22
|
+
return [
|
|
23
|
+
Evidence(
|
|
24
|
+
source=self.name,
|
|
25
|
+
field="hostname",
|
|
26
|
+
value=hostname,
|
|
27
|
+
confidence=0.75,
|
|
28
|
+
description=f"Reverse DNS reports hostname {hostname}",
|
|
29
|
+
)
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OuiCollector:
|
|
34
|
+
name = "oui"
|
|
35
|
+
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self.parser: object | None = None
|
|
38
|
+
|
|
39
|
+
async def collect(self, target: Target, timeout: float) -> list[Evidence]:
|
|
40
|
+
del timeout
|
|
41
|
+
if not target.mac:
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
manufacturer = await asyncio.to_thread(self._lookup, target.mac)
|
|
45
|
+
if not manufacturer:
|
|
46
|
+
return []
|
|
47
|
+
|
|
48
|
+
return [
|
|
49
|
+
Evidence(
|
|
50
|
+
source=self.name,
|
|
51
|
+
field="manufacturer",
|
|
52
|
+
value=manufacturer,
|
|
53
|
+
confidence=0.78,
|
|
54
|
+
description=f"MAC prefix matches {manufacturer}",
|
|
55
|
+
)
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
def _lookup(self, mac: str) -> str | None:
|
|
59
|
+
from manuf import manuf # type: ignore[import-untyped]
|
|
60
|
+
|
|
61
|
+
if self.parser is None:
|
|
62
|
+
self.parser = manuf.MacParser(update=False)
|
|
63
|
+
|
|
64
|
+
manufacturer = self.parser.get_manuf_long(mac) or self.parser.get_manuf(mac) # type: ignore[attr-defined]
|
|
65
|
+
return str(manufacturer).strip() if manufacturer else None
|