lanscape 1.3.5a1__py3-none-any.whl → 1.3.6a1__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.

Potentially problematic release.


This version of lanscape might be problematic. Click here for more details.

Files changed (36) hide show
  1. lanscape/__init__.py +9 -1
  2. lanscape/libraries/app_scope.py +0 -1
  3. lanscape/libraries/decorators.py +26 -9
  4. lanscape/libraries/device_alive.py +227 -0
  5. lanscape/libraries/errors.py +10 -0
  6. lanscape/libraries/ip_parser.py +73 -1
  7. lanscape/libraries/logger.py +29 -1
  8. lanscape/libraries/mac_lookup.py +5 -0
  9. lanscape/libraries/net_tools.py +156 -188
  10. lanscape/libraries/port_manager.py +83 -0
  11. lanscape/libraries/scan_config.py +173 -19
  12. lanscape/libraries/service_scan.py +3 -3
  13. lanscape/libraries/subnet_scan.py +111 -26
  14. lanscape/libraries/version_manager.py +50 -7
  15. lanscape/libraries/web_browser.py +75 -58
  16. lanscape/resources/mac_addresses/convert_csv.py +13 -2
  17. lanscape/resources/ports/convert_csv.py +13 -3
  18. lanscape/ui/app.py +24 -6
  19. lanscape/ui/blueprints/__init__.py +4 -1
  20. lanscape/ui/blueprints/api/__init__.py +2 -0
  21. lanscape/ui/blueprints/api/port.py +46 -0
  22. lanscape/ui/blueprints/api/scan.py +57 -5
  23. lanscape/ui/blueprints/api/tools.py +1 -0
  24. lanscape/ui/blueprints/web/__init__.py +4 -0
  25. lanscape/ui/blueprints/web/routes.py +52 -2
  26. lanscape/ui/main.py +1 -10
  27. lanscape/ui/shutdown_handler.py +5 -1
  28. lanscape/ui/static/css/style.css +35 -24
  29. lanscape/ui/static/js/scan-config.js +76 -2
  30. lanscape/ui/templates/main.html +0 -7
  31. lanscape/ui/templates/scan/config.html +71 -10
  32. {lanscape-1.3.5a1.dist-info → lanscape-1.3.6a1.dist-info}/METADATA +1 -1
  33. {lanscape-1.3.5a1.dist-info → lanscape-1.3.6a1.dist-info}/RECORD +36 -35
  34. {lanscape-1.3.5a1.dist-info → lanscape-1.3.6a1.dist-info}/WHEEL +0 -0
  35. {lanscape-1.3.5a1.dist-info → lanscape-1.3.6a1.dist-info}/licenses/LICENSE +0 -0
  36. {lanscape-1.3.5a1.dist-info → lanscape-1.3.6a1.dist-info}/top_level.txt +0 -0
@@ -1,14 +1,14 @@
1
+ """Network tools for scanning and managing devices on a network."""
2
+
1
3
  import logging
2
4
  import ipaddress
3
5
  import traceback
4
6
  import subprocess
5
- from time import sleep
6
7
  from typing import List, Dict
7
8
  import socket
8
9
  import struct
9
10
  import re
10
11
  import psutil
11
- from icmplib import ping
12
12
 
13
13
  from scapy.sendrecv import srp
14
14
  from scapy.layers.l2 import ARP, Ether
@@ -18,116 +18,13 @@ from lanscape.libraries.service_scan import scan_service
18
18
  from lanscape.libraries.mac_lookup import MacLookup, get_macs
19
19
  from lanscape.libraries.ip_parser import get_address_count, MAX_IPS_ALLOWED
20
20
  from lanscape.libraries.errors import DeviceError
21
- from lanscape.libraries.decorators import job_tracker, JobStatsMixin, timeout_enforcer
22
- from lanscape.libraries.scan_config import ScanType, PingConfig, ArpConfig
21
+ from lanscape.libraries.decorators import job_tracker
23
22
 
24
23
  log = logging.getLogger('NetTools')
24
+ mac_lookup = MacLookup()
25
25
 
26
26
 
27
- class IPAlive(JobStatsMixin):
28
- """Class to check if a device is alive using ARP and/or ping scans."""
29
- caught_errors: List[DeviceError] = []
30
- _icmp_alive: bool = False
31
- _arp_alive: bool = False
32
-
33
-
34
- @job_tracker
35
- def is_alive(
36
- self,
37
- ip: str,
38
- scan_type: ScanType = ScanType.BOTH,
39
- arp_config: ArpConfig = ArpConfig(),
40
- ping_config: PingConfig = PingConfig()
41
- ) -> bool:
42
- """
43
- Check if a device is alive by performing ARP and/or ping scans.
44
- """
45
- if scan_type == ScanType.ARP:
46
- return self._arp_lookup(ip, arp_config)
47
- if scan_type == ScanType.PING:
48
- return self._ping_lookup(ip, ping_config)
49
- return self._ping_lookup(ip, ping_config) or self._arp_lookup(ip, arp_config)
50
-
51
- @job_tracker
52
- def _arp_lookup(
53
- self, ip: str,
54
- cfg: ArpConfig = ArpConfig()
55
- ) -> bool:
56
- """Perform an ARP lookup to check if the device is alive."""
57
- enforcer_timeout = cfg.timeout * 1.3
58
-
59
- @timeout_enforcer(enforcer_timeout, raise_on_timeout=True)
60
- def do_arp_lookup():
61
- arp_request = ARP(pdst=ip)
62
- broadcast = Ether(dst="ff:ff:ff:ff:ff:ff")
63
- packet = broadcast / arp_request
64
-
65
- answered, _ = srp(packet, timeout=cfg.timeout, verbose=False)
66
- self._arp_alive = any(resp.psrc == ip for _, resp in answered)
67
- return self._arp_alive
68
-
69
- try:
70
- for _ in range(cfg.attempts):
71
- if do_arp_lookup():
72
- return True
73
- except Exception as e:
74
- self.caught_errors.append(DeviceError(e))
75
- return False
76
-
77
- @job_tracker
78
- def _ping_lookup(
79
- self, ip: str,
80
- cfg: PingConfig = PingConfig()
81
- ) -> bool:
82
- """Perform a ping lookup to check if the device is alive using icmplib."""
83
- enforcer_timeout = cfg.timeout * cfg.ping_count * 1.3
84
-
85
- @timeout_enforcer(enforcer_timeout, raise_on_timeout=False)
86
- def do_icmp_ping():
87
- try:
88
- result = ping(
89
- ip,
90
- count=cfg.ping_count,
91
- interval=cfg.retry_delay,
92
- timeout=cfg.timeout,
93
- privileged=psutil.WINDOWS # Use privileged mode on Windows
94
- )
95
- return result.is_alive
96
- except Exception as e:
97
- self.caught_errors.append(DeviceError(e))
98
- # Fallback to system ping command
99
- try:
100
- if psutil.WINDOWS:
101
- cmd = [
102
- "ping", "-n", str(cfg.ping_count),
103
- "-w", str(int(cfg.timeout * 1000)), ip
104
- ]
105
- else:
106
- cmd = ["ping", "-c", str(cfg.ping_count), "-W", str(cfg.timeout), ip]
107
-
108
- result = subprocess.run(
109
- cmd, stdout=subprocess.PIPE,
110
- stderr=subprocess.PIPE,
111
- text=True, check=False
112
- )
113
- return result.returncode == 0
114
- except subprocess.CalledProcessError as fallback_error:
115
- self.caught_errors.append(DeviceError(fallback_error))
116
- return False
117
-
118
- try:
119
- for _ in range(cfg.attempts):
120
- if do_icmp_ping():
121
- self._icmp_alive = True
122
- return True
123
- sleep(cfg.retry_delay)
124
- except Exception as e:
125
- self.caught_errors.append(DeviceError(e))
126
- self._icmp_alive = False
127
- return False
128
-
129
-
130
- class Device(IPAlive):
27
+ class Device:
131
28
  """Represents a network device with metadata and scanning capabilities."""
132
29
 
133
30
  def __init__(self, ip: str):
@@ -142,13 +39,12 @@ class Device(IPAlive):
142
39
  self.services: Dict[str, List[int]] = {}
143
40
  self.caught_errors: List[DeviceError] = []
144
41
  self.log = logging.getLogger('Device')
145
- self._mac_lookup = MacLookup()
146
42
 
147
43
  def get_metadata(self):
148
44
  """Retrieve metadata such as hostname and MAC addresses."""
149
45
  if self.alive:
150
46
  self.hostname = self._get_hostname()
151
- self.macs = self._get_mac_addresses()
47
+ self._get_mac_addresses()
152
48
 
153
49
  def dict(self) -> dict:
154
50
  """Convert the device object to a dictionary."""
@@ -189,9 +85,12 @@ class Device(IPAlive):
189
85
  @job_tracker
190
86
  def _get_mac_addresses(self):
191
87
  """Get the possible MAC addresses of a network device given its IP address."""
192
- macs = get_macs(self.ip)
193
- mac_selector.import_macs(macs)
194
- return macs
88
+ # job may already be done depending on
89
+ # the strat from isalive
90
+ if not self.macs:
91
+ self.macs = get_macs(self.ip)
92
+ mac_selector.import_macs(self.macs)
93
+ return self.macs
195
94
 
196
95
  @job_tracker
197
96
  def _get_hostname(self):
@@ -206,7 +105,7 @@ class Device(IPAlive):
206
105
  @job_tracker
207
106
  def _get_manufacturer(self, mac_addr=None):
208
107
  """Get the manufacturer of a network device given its MAC address."""
209
- return self._mac_lookup.lookup_vendor(mac_addr) if mac_addr else None
108
+ return mac_lookup.lookup_vendor(mac_addr) if mac_addr else None
210
109
 
211
110
 
212
111
  class MacSelector:
@@ -245,6 +144,7 @@ class MacSelector:
245
144
  self.macs[mac] = self.macs.get(mac, 0) + 1
246
145
 
247
146
  def clear(self):
147
+ """Clear the stored MAC addresses."""
248
148
  self.macs = {}
249
149
 
250
150
 
@@ -281,15 +181,15 @@ def get_ip_address(interface: str):
281
181
  # Call the appropriate function based on the platform
282
182
  if psutil.WINDOWS:
283
183
  return windows()
284
- else: # Linux, macOS, and other Unix-like systems
285
- return unix_like()
184
+
185
+ # Linux, macOS, and other Unix-like systems
186
+ return unix_like()
286
187
 
287
188
 
288
189
  def get_netmask(interface: str):
289
190
  """
290
191
  Get the netmask of a network interface.
291
192
  """
292
-
293
193
  def unix_like(): # Combined Linux and macOS
294
194
  try:
295
195
  # pylint: disable=import-outside-toplevel, import-error
@@ -316,8 +216,9 @@ def get_netmask(interface: str):
316
216
 
317
217
  if psutil.WINDOWS:
318
218
  return windows()
319
- else: # Linux, macOS, and other Unix-like systems
320
- return unix_like()
219
+
220
+ # Linux, macOS, and other Unix-like systems
221
+ return unix_like()
321
222
 
322
223
 
323
224
  def get_cidr_from_netmask(netmask: str):
@@ -329,71 +230,122 @@ def get_cidr_from_netmask(netmask: str):
329
230
  return str(len(binary_str.rstrip('0')))
330
231
 
331
232
 
233
+ def _find_interface_by_default_gateway_windows():
234
+ """Find the network interface with the default gateway on Windows."""
235
+ try:
236
+ output = subprocess.check_output(
237
+ "route print 0.0.0.0", shell=True, text=True)
238
+ return _parse_windows_route_output(output)
239
+ except Exception as e:
240
+ log.debug(f"Error finding Windows interface by gateway: {e}")
241
+ return None
242
+
243
+
244
+ def _parse_windows_route_output(output):
245
+ """Parse the output of Windows route command to extract interface index."""
246
+ lines = output.strip().split('\n')
247
+ interface_idx = None
248
+
249
+ # First find the interface index from the routing table
250
+ for line in lines:
251
+ if '0.0.0.0' in line and 'Gateway' not in line: # Skip header
252
+ parts = [p for p in line.split() if p]
253
+ if len(parts) >= 4:
254
+ interface_idx = parts[3]
255
+ break
256
+
257
+ # If we found an index, find the corresponding interface name
258
+ if interface_idx:
259
+ for iface_name in psutil.net_if_addrs():
260
+ if str(interface_idx) in iface_name:
261
+ return iface_name
262
+
263
+ return None
264
+
265
+
266
+ def _find_interface_by_default_gateway_unix():
267
+ """Find the network interface with the default gateway on Unix-like systems."""
268
+ try:
269
+ cmd = "ip route show default 2>/dev/null || netstat -rn | grep default"
270
+ output = subprocess.check_output(cmd, shell=True, text=True)
271
+ return _parse_unix_route_output(output)
272
+ except Exception as e:
273
+ log.debug(f"Error finding Unix interface by gateway: {e}")
274
+ return None
275
+
276
+
277
+ def _parse_unix_route_output(output):
278
+ """Parse the output of Unix route commands to extract interface name."""
279
+ for line in output.split('\n'):
280
+ # Parse lines with 'default via ... dev ...'
281
+ if 'default via' in line and 'dev' in line:
282
+ return line.split('dev')[1].split()[0]
283
+
284
+ # Parse simpler 'default ...' lines
285
+ if 'default' in line:
286
+ parts = line.split()
287
+ if len(parts) > 3:
288
+ # Interface is usually the last column
289
+ return parts[-1]
290
+ return None
291
+
292
+
293
+ def _get_candidate_interfaces():
294
+ """Get a list of candidate network interfaces."""
295
+ candidates = []
296
+ for interface, addrs in psutil.net_if_addrs().items():
297
+ stats = psutil.net_if_stats().get(interface)
298
+ if not stats or not stats.isup:
299
+ continue
300
+
301
+ ipv4_addrs = [addr for addr in addrs if addr.family == socket.AF_INET]
302
+ if not ipv4_addrs:
303
+ continue
304
+
305
+ # Skip loopback and common virtual interfaces
306
+ is_loopback = any(addr.address.startswith('127.')
307
+ for addr in ipv4_addrs)
308
+ if is_loopback:
309
+ continue
310
+
311
+ virtual_names = ['loop', 'vmnet', 'vbox', 'docker', 'virtual', 'veth']
312
+ is_virtual = any(name in interface.lower() for name in virtual_names)
313
+ if is_virtual:
314
+ continue
315
+
316
+ candidates.append(interface)
317
+ return candidates
318
+
319
+
332
320
  def get_primary_interface():
333
321
  """
334
322
  Get the primary network interface that is likely handling internet traffic.
335
323
  Uses heuristics to identify the most probable interface.
336
324
  """
337
325
  # Try to find the interface with the default gateway
338
- try:
339
- if psutil.WINDOWS:
340
- # On Windows, parse route print output
341
- output = subprocess.check_output(
342
- "route print 0.0.0.0", shell=True, text=True)
343
- lines = output.strip().split('\n')
344
- for line in lines:
345
- if '0.0.0.0' in line and 'Gateway' not in line: # Skip header
346
- parts = [p for p in line.split() if p]
347
- if len(parts) >= 4:
348
- interface_idx = parts[3]
349
- # Find interface name in the output
350
- for iface_name, addrs in psutil.net_if_addrs().items():
351
- if str(interface_idx) in iface_name:
352
- return iface_name
353
- else:
354
- # Linux/Unix/Mac - use ip route or netstat
355
- try:
356
- output = subprocess.check_output(
357
- "ip route show default 2>/dev/null || netstat -rn | grep default", shell=True, text=True)
358
- for line in output.split('\n'):
359
- if 'default via' in line and 'dev' in line:
360
- return line.split('dev')[1].split()[0]
361
- elif 'default' in line:
362
- parts = line.split()
363
- if len(parts) > 3:
364
- # Interface is usually the last column
365
- return parts[-1]
366
- except (subprocess.SubprocessError, IndexError, FileNotFoundError):
367
- pass
368
- except Exception as e:
369
- log.debug(f"Error determining primary interface: {e}")
326
+ if psutil.WINDOWS:
327
+ interface = _find_interface_by_default_gateway_windows()
328
+ if interface:
329
+ return interface
330
+ else:
331
+ interface = _find_interface_by_default_gateway_unix()
332
+ if interface:
333
+ return interface
370
334
 
371
335
  # Fallback: Identify likely candidates based on heuristics
372
- candidates = []
373
-
374
- for interface, addrs in psutil.net_if_addrs().items():
375
- stats = psutil.net_if_stats().get(interface)
376
- if stats and stats.isup:
377
- ipv4_addrs = [
378
- addr for addr in addrs if addr.family == socket.AF_INET]
379
- if ipv4_addrs:
380
- # Skip loopback and common virtual interfaces
381
- is_loopback = any(addr.address.startswith('127.')
382
- for addr in ipv4_addrs)
383
- is_virtual = any(name in interface.lower() for name in
384
- ['loop', 'vmnet', 'vbox', 'docker', 'virtual', 'veth'])
385
-
386
- if not is_loopback and not is_virtual:
387
- candidates.append(interface)
336
+ candidates = _get_candidate_interfaces()
337
+ if not candidates:
338
+ return None
388
339
 
389
340
  # Prioritize interfaces with names typically used for physical connections
390
- for prefix in ['eth', 'en', 'wlan', 'wifi', 'wl', 'wi']:
341
+ physical_prefixes = ['eth', 'en', 'wlan', 'wifi', 'wl', 'wi']
342
+ for prefix in physical_prefixes:
391
343
  for interface in candidates:
392
344
  if interface.lower().startswith(prefix):
393
345
  return interface
394
346
 
395
- # Otherwise return the first candidate or None
396
- return candidates[0] if candidates else None
347
+ # Otherwise return the first candidate
348
+ return candidates[0]
397
349
 
398
350
 
399
351
  def get_host_ip_mask(ip_with_cidr: str):
@@ -456,13 +408,16 @@ def network_from_snicaddr(snicaddr: psutil._common.snicaddr) -> str:
456
408
  """
457
409
  if not snicaddr.address or not snicaddr.netmask:
458
410
  return None
459
- elif snicaddr.family == socket.AF_INET:
411
+
412
+ if snicaddr.family == socket.AF_INET:
460
413
  addr = f"{snicaddr.address}/{get_cidr_from_netmask(snicaddr.netmask)}"
461
- elif snicaddr.family == socket.AF_INET6:
414
+ return get_host_ip_mask(addr)
415
+
416
+ if snicaddr.family == socket.AF_INET6:
462
417
  addr = f"{snicaddr.address}/{snicaddr.netmask}"
463
- else:
464
- return f"{snicaddr.address}"
465
- return get_host_ip_mask(addr)
418
+ return get_host_ip_mask(addr)
419
+
420
+ return f"{snicaddr.address}"
466
421
 
467
422
 
468
423
  def smart_select_primary_subnet(subnets: List[dict] = None) -> str:
@@ -504,19 +459,32 @@ def smart_select_primary_subnet(subnets: List[dict] = None) -> str:
504
459
  return selected.get("subnet", "")
505
460
 
506
461
 
462
+ class ArpSupportChecker:
463
+ """
464
+ Singleton class to check if ARP requests are supported on the current system.
465
+ The check is only performed once.
466
+ """
467
+ _supported = None
468
+
469
+ @classmethod
470
+ def is_supported(cls):
471
+ """one time check if ARP requests are supported on this system"""
472
+ if cls._supported is not None:
473
+ return cls._supported
474
+ try:
475
+ arp_request = ARP(pdst='0.0.0.0')
476
+ broadcast = Ether(dst="ff:ff:ff:ff:ff:ff")
477
+ packet = broadcast / arp_request
478
+ srp(packet, timeout=0, verbose=False)
479
+ cls._supported = True
480
+ except (Scapy_Exception, PermissionError, RuntimeError):
481
+ cls._supported = False
482
+ return cls._supported
483
+
484
+
507
485
  def is_arp_supported():
508
486
  """
509
- Check if ARP requests are supported on the current platform.
487
+ Check if ARP requests are supported on the current system.
488
+ Only runs the check once.
510
489
  """
511
- try:
512
- arp_request = ARP(pdst='0.0.0.0')
513
- broadcast = Ether(dst="ff:ff:ff:ff:ff:ff")
514
- packet = broadcast / arp_request
515
-
516
- srp(packet, timeout=0, verbose=False)
517
- return True
518
- # Scapy_Exception = MacOS
519
- # PermissionError = Linux
520
- # RuntimeError = Windows
521
- except (Scapy_Exception, PermissionError, RuntimeError):
522
- return False
490
+ return ArpSupportChecker.is_supported()
@@ -1,3 +1,12 @@
1
+ """
2
+ Port Manager module for managing port list configurations.
3
+
4
+ This module provides functionality to create, read, update, and delete port lists
5
+ that are stored as JSON files. Each port list contains port numbers and their
6
+ associated services. The module handles validation of port data and provides
7
+ methods for working with port list configurations.
8
+ """
9
+
1
10
  import json
2
11
  from typing import List
3
12
  from pathlib import Path
@@ -7,15 +16,46 @@ PORT_DIR = 'ports'
7
16
 
8
17
 
9
18
  class PortManager:
19
+ """
20
+ Manager class for port list operations.
21
+
22
+ Handles the creation, retrieval, updating, and deletion of port lists.
23
+ Port lists are stored as JSON files with port numbers as keys and
24
+ service names as values.
25
+ """
26
+
10
27
  def __init__(self):
28
+ """
29
+ Initialize the PortManager.
30
+
31
+ Creates the ports directory if it doesn't exist and initializes
32
+ the ResourceManager for file operations.
33
+ """
11
34
  Path(PORT_DIR).mkdir(parents=True, exist_ok=True)
12
35
  self.rm = ResourceManager(PORT_DIR)
13
36
 
14
37
  def get_port_lists(self) -> List[str]:
38
+ """
39
+ Get a list of all available port list names.
40
+
41
+ Returns:
42
+ List[str]: Names of all available port lists (without .json extension)
43
+ """
15
44
  return [f.replace('.json', '') for f in self.rm.list() if f.endswith('.json')]
16
45
 
17
46
  def get_port_list(self, port_list: str) -> dict:
47
+ """
48
+ Retrieve a port list by name.
49
+
50
+ Args:
51
+ port_list (str): The name of the port list to retrieve
18
52
 
53
+ Returns:
54
+ dict: A dictionary of port numbers to service names
55
+
56
+ Raises:
57
+ ValueError: If the specified port list does not exist
58
+ """
19
59
  if port_list not in self.get_port_lists():
20
60
  msg = f"Port list '{port_list}' does not exist. "
21
61
  msg += f"Available port lists: {self.get_port_lists()}"
@@ -26,6 +66,16 @@ class PortManager:
26
66
  return data if self.validate_port_data(data) else None
27
67
 
28
68
  def create_port_list(self, port_list: str, data: dict) -> bool:
69
+ """
70
+ Create a new port list.
71
+
72
+ Args:
73
+ port_list (str): Name for the new port list
74
+ data (dict): Dictionary mapping port numbers to service names
75
+
76
+ Returns:
77
+ bool: True if creation was successful, False otherwise
78
+ """
29
79
  if port_list in self.get_port_lists():
30
80
  return False
31
81
  if not self.validate_port_data(data):
@@ -36,6 +86,16 @@ class PortManager:
36
86
  return True
37
87
 
38
88
  def update_port_list(self, port_list: str, data: dict) -> bool:
89
+ """
90
+ Update an existing port list.
91
+
92
+ Args:
93
+ port_list (str): Name of the port list to update
94
+ data (dict): New dictionary mapping port numbers to service names
95
+
96
+ Returns:
97
+ bool: True if update was successful, False otherwise
98
+ """
39
99
  if port_list not in self.get_port_lists():
40
100
  return False
41
101
  if not self.validate_port_data(data):
@@ -46,6 +106,15 @@ class PortManager:
46
106
  return True
47
107
 
48
108
  def delete_port_list(self, port_list: str) -> bool:
109
+ """
110
+ Delete a port list.
111
+
112
+ Args:
113
+ port_list (str): Name of the port list to delete
114
+
115
+ Returns:
116
+ bool: True if deletion was successful, False otherwise
117
+ """
49
118
  if port_list not in self.get_port_lists():
50
119
  return False
51
120
 
@@ -54,6 +123,20 @@ class PortManager:
54
123
  return True
55
124
 
56
125
  def validate_port_data(self, port_data: dict) -> bool:
126
+ """
127
+ Validate port data structure and content.
128
+
129
+ Ensures that:
130
+ - Keys can be converted to integers
131
+ - Values are strings
132
+ - Port numbers are within valid range (0-65535)
133
+
134
+ Args:
135
+ port_data (dict): Dictionary mapping port numbers to service names
136
+
137
+ Returns:
138
+ bool: True if data is valid, False otherwise
139
+ """
57
140
  try:
58
141
  for port, service in port_data.items():
59
142
  port = int(port) # throws if not int