Skip to content

Commit 4d05922

Browse files
authored
discovery node - add port detection and subnet filter for passive scan (#1225)
1 parent ed49452 commit 4d05922

File tree

6 files changed

+57
-24
lines changed

6 files changed

+57
-24
lines changed

misc/discoverynode/src/nmap.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import logging
99
import datetime
1010
import argparse
11+
import json
1112

1213
state = mock.MagicMock()
1314

@@ -22,7 +23,7 @@
2223
parser.add_argument("targets", help="nmap target", type=str)
2324
args = parser.parse_args()
2425

25-
a = udmi.discovery.ether.EtherDiscovery(state, print)
26+
a = udmi.discovery.ether.EtherDiscovery(state, lambda x: print(x.to_json()))
2627
a.controller(
2728
{
2829
"discovery": {

misc/discoverynode/src/passive.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import sys
88
import logging
99
import datetime
10+
import json
11+
import argparse
1012

1113
state = mock.MagicMock()
1214

@@ -17,7 +19,11 @@
1719
stderr.setLevel(logging.WARNING)
1820
logging.root.setLevel(logging.INFO)
1921

20-
a = udmi.discovery.passive.PassiveNetworkDiscovery(state, print)
22+
parser = argparse.ArgumentParser(description="subnet cidr")
23+
parser.add_argument("subnet_filter", help="subnet_filter", type=str)
24+
args = parser.parse_args()
25+
26+
a = udmi.discovery.passive.PassiveNetworkDiscovery(state, lambda x: print(x.to_json()), subnet_filter = args.subnet_filter)
2127
a.controller(
2228
{
2329
"discovery": {

misc/discoverynode/src/tests/test_integration.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ def test_nmap():
135135
"timestamp": timestamp_now(),
136136
"discovery": {
137137
"families": {
138-
"ether": {"generation": timestamp_now(), "depth": "ports", "addrs": ["192.168.12.1/24"]}
138+
"ether": {"generation": timestamp_now(), "depth": "services", "addrs": ["192.168.12.1/24"]}
139139
}
140140
},
141141
})

misc/discoverynode/src/udmi/core.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,9 @@ def enable_discovery(self,*,bacnet=True,vendor=True,ipv4=True,ether=True):
175175

176176
if ipv4:
177177
passive_discovery = udmi.discovery.passive.PassiveNetworkDiscovery(
178-
self.state, self.publish_discovery
178+
self.state,
179+
self.publish_discovery,
180+
subnet_filter = self.config.get("ip", {}).get("subnet_filter")
179181
)
180182

181183
self.add_config_route(

misc/discoverynode/src/udmi/discovery/ether.py

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def magic_bathometer(self, depth: str) -> str:
6161
match depth:
6262
case "ping":
6363
return "ping"
64-
case "ports":
64+
case "ports" | "services":
6565
return "nmap"
6666
case _:
6767
raise RuntimeError(f"unmatched depth {depth}")
@@ -147,20 +147,15 @@ def nmap_stop_discovery(self):
147147
def nmap_runner(self):
148148
OUTPUT_FILE = "nmaplocalhost.xml"
149149

150+
cmd = ["/usr/bin/nmap"]
151+
if self.config.depth == "services":
152+
cmd.extend(["--script", "banner", "-A"])
153+
cmd.extend(["-p-", "-T3"])
154+
cmd.extend(self.config.addrs)
155+
cmd.extend(["-oX", OUTPUT_FILE, "--stats-every", "5s"])
156+
150157
with subprocess.Popen(
151-
[
152-
"/usr/bin/nmap",
153-
"--script",
154-
"banner",
155-
"-p-",
156-
"-T4",
157-
"-A",
158-
*self.config.addrs,
159-
"-oX",
160-
OUTPUT_FILE,
161-
"--stats-every",
162-
"5s",
163-
],
158+
cmd,
164159
stdout=subprocess.PIPE,
165160
stderr=subprocess.STDOUT,
166161
encoding="utf-8",
@@ -178,6 +173,7 @@ def nmap_runner(self):
178173
return
179174

180175
logging.info("nmap scan complete, parsing results")
176+
181177
for host in nmap.results_reader(OUTPUT_FILE):
182178
event = udmi.schema.discovery_event.DiscoveryEvent(
183179
generation=self.generation,

misc/discoverynode/src/udmi/discovery/passive.py

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,11 @@ class PassiveNetworkDiscovery(discovery.DiscoveryController):
4040

4141
family = "ipv4"
4242

43-
def __init__(self, state, publisher, *, interface=None):
43+
def __init__(self, state, publisher, *, interface=None, subnet_filter=None):
4444

4545
self.queue = queue.SimpleQueue()
46-
self.interface = None
46+
self.interface = interface
47+
self.subnet_filter = subnet_filter
4748
self.addresses_seen = set()
4849
self.device_records = set()
4950
self.devices_records_published = set()
@@ -90,7 +91,6 @@ def start_discovery(self):
9091
self.devices_records_published.clear()
9192
self.device_records.clear()
9293
self.addresses_seen.clear()
93-
9494
# Turn-on the queue worker to consume sniffed packets
9595
self.queue_thread = threading.Thread(
9696
target=self.queue_worker, args=[], daemon=True
@@ -103,8 +103,34 @@ def start_discovery(self):
103103
)
104104
self.service_thread.start()
105105

106+
if subnet := self.subnet_filter:
107+
try:
108+
iface = ipaddress.ip_interface(subnet)
109+
network = iface.network
110+
bpf_filter = f"ip and src net {network} and (dst net {network} or broadcast or multicast)"
111+
# Exclude network and broadcast addresses from being discovered as source
112+
bpf_filter += f" and not src host {network.network_address}"
113+
if network.broadcast_address:
114+
bpf_filter += f" and not src host {network.broadcast_address}"
115+
# Assume first host is gateway and exclude it
116+
if first_host := next(network.hosts(), None):
117+
bpf_filter += f" and not src host {first_host}"
118+
# Exclude our own IP if provided in the subnet filter
119+
if iface.ip != network.network_address:
120+
bpf_filter += f" and not src host {iface.ip}"
121+
except ValueError:
122+
logging.warning("Invalid subnet filter: %s", subnet)
123+
bpf_filter = f"ip and src net {subnet} and (dst net {subnet} or broadcast or multicast)"
124+
else:
125+
bpf_filter = PRIVATE_IP_BPF_FILTER
126+
logging.info("Using BPF filter: %s", bpf_filter)
127+
106128
self.sniffer = scapy.sendrecv.AsyncSniffer(
107-
prn=self.queue.put, store=False, iface=self.interface, started_callback=self.scapy_is_go, filter=PRIVATE_IP_BPF_FILTER
129+
prn=self.queue.put,
130+
store=False,
131+
iface=self.interface,
132+
started_callback=self.scapy_is_go,
133+
filter=bpf_filter,
108134
)
109135

110136
self.sniffer.start()
@@ -175,8 +201,10 @@ def queue_worker(self):
175201
if scapy.layers.inet.IP in item:
176202
self.ip_packets_seen += 1
177203

178-
# A packet "sees" two devices - the source and the destination
179-
for x in ["src", "dst"]:
204+
# A packet "sees" two devices - the source and the destination.
205+
# However, we only care about the source, as the destination may
206+
# not be online (e.g. if we are pinging it).
207+
for x in ["src"]:
180208

181209
if x == "src":
182210
if scapy.layers.inet.UDP in item and (

0 commit comments

Comments
 (0)