pynetworkintel 0.2.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.
- pynetworkintel/__init__.py +7 -0
- pynetworkintel/alerts.py +276 -0
- pynetworkintel/analysis/__init__.py +7 -0
- pynetworkintel/analysis/cve.py +184 -0
- pynetworkintel/analysis/llm.py +183 -0
- pynetworkintel/analysis/rules.py +247 -0
- pynetworkintel/changes.py +221 -0
- pynetworkintel/cli.py +353 -0
- pynetworkintel/config.py +191 -0
- pynetworkintel/core.py +170 -0
- pynetworkintel/db.py +218 -0
- pynetworkintel/discovery/__init__.py +6 -0
- pynetworkintel/discovery/scanner.py +243 -0
- pynetworkintel/discovery/ssh_config.py +114 -0
- pynetworkintel/models.py +121 -0
- pynetworkintel/progress.py +169 -0
- pynetworkintel/reporting.py +262 -0
- pynetworkintel/scheduler.py +237 -0
- pynetworkintel-0.2.0.dist-info/METADATA +479 -0
- pynetworkintel-0.2.0.dist-info/RECORD +24 -0
- pynetworkintel-0.2.0.dist-info/WHEEL +5 -0
- pynetworkintel-0.2.0.dist-info/entry_points.txt +2 -0
- pynetworkintel-0.2.0.dist-info/licenses/LICENSE +10 -0
- pynetworkintel-0.2.0.dist-info/top_level.txt +1 -0
pynetworkintel/core.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Core scanner and analyzer classes."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from typing import Optional, List
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from pynetworkintel.models import ScanResult, Device
|
|
9
|
+
from pynetworkintel.discovery import DeviceScanner, SSHConfigGrabber
|
|
10
|
+
from pynetworkintel.analysis import RuleChecker, CVEChecker, LLMAnalyzer
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
# Configure logging
|
|
15
|
+
logging.basicConfig(
|
|
16
|
+
level=logging.INFO,
|
|
17
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Scanner:
|
|
22
|
+
"""High-level device discovery scanner."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
nmap_path: str = "nmap",
|
|
27
|
+
ssh_username: str = "root",
|
|
28
|
+
ssh_key_path: Optional[str] = None,
|
|
29
|
+
ssh_password: Optional[str] = None,
|
|
30
|
+
):
|
|
31
|
+
self.device_scanner = DeviceScanner(nmap_path)
|
|
32
|
+
self.ssh_grabber = SSHConfigGrabber(ssh_username, ssh_key_path, ssh_password)
|
|
33
|
+
|
|
34
|
+
def scan(self, target: str, grab_configs: bool = True) -> ScanResult:
|
|
35
|
+
"""
|
|
36
|
+
Scan network for devices and gather configuration.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
target: IP address, CIDR range, or hostname
|
|
40
|
+
grab_configs: Whether to attempt SSH config grab (requires credentials)
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
ScanResult with discovered devices
|
|
44
|
+
"""
|
|
45
|
+
start_time = time.time()
|
|
46
|
+
|
|
47
|
+
logger.info(f"Starting network scan for {target}")
|
|
48
|
+
devices = self.device_scanner.discover(target)
|
|
49
|
+
|
|
50
|
+
if grab_configs and devices:
|
|
51
|
+
logger.info(f"Attempting to grab configs from {len(devices)} devices")
|
|
52
|
+
for device in devices:
|
|
53
|
+
if self.ssh_grabber.grab_configs(device):
|
|
54
|
+
logger.debug(f"Successfully grabbed configs from {device.ip}")
|
|
55
|
+
else:
|
|
56
|
+
logger.debug(f"Could not grab configs from {device.ip}")
|
|
57
|
+
|
|
58
|
+
duration = time.time() - start_time
|
|
59
|
+
|
|
60
|
+
result = ScanResult(devices=devices, scan_duration_seconds=duration)
|
|
61
|
+
|
|
62
|
+
logger.info(
|
|
63
|
+
f"Scan complete: {len(devices)} devices in {duration:.1f} seconds"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Analyzer:
|
|
70
|
+
"""Security analyzer for scan results."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, anthropic_api_key: Optional[str] = None):
|
|
73
|
+
self.rule_checker = RuleChecker()
|
|
74
|
+
self.cve_checker = CVEChecker()
|
|
75
|
+
self.llm_analyzer = LLMAnalyzer(anthropic_api_key)
|
|
76
|
+
|
|
77
|
+
def analyze(self, scan_result: ScanResult) -> ScanResult:
|
|
78
|
+
"""
|
|
79
|
+
Perform full security analysis on scan results.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
scan_result: Results from scanner
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Updated ScanResult with findings
|
|
86
|
+
"""
|
|
87
|
+
logger.info("Starting security analysis")
|
|
88
|
+
|
|
89
|
+
for device in scan_result.devices:
|
|
90
|
+
logger.debug(f"Analyzing device {device.ip}")
|
|
91
|
+
|
|
92
|
+
# Check configuration rules
|
|
93
|
+
rule_findings = self.rule_checker.check_device(device)
|
|
94
|
+
scan_result.findings.extend(rule_findings)
|
|
95
|
+
|
|
96
|
+
# Check for known CVEs
|
|
97
|
+
cve_findings = self.cve_checker.check_device(device)
|
|
98
|
+
scan_result.findings.extend(cve_findings)
|
|
99
|
+
|
|
100
|
+
logger.info(f"Analysis complete: {len(scan_result.findings)} findings")
|
|
101
|
+
|
|
102
|
+
return scan_result
|
|
103
|
+
|
|
104
|
+
def summarize(self, scan_result: ScanResult) -> str:
|
|
105
|
+
"""
|
|
106
|
+
Generate plain English summary of findings.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
scan_result: Analyzed scan results
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Plain English summary
|
|
113
|
+
"""
|
|
114
|
+
logger.info("Generating AI summary")
|
|
115
|
+
return self.llm_analyzer.summarize_findings(scan_result)
|
|
116
|
+
|
|
117
|
+
def get_summary(self, scan_result: ScanResult) -> dict:
|
|
118
|
+
"""Get structured summary of findings."""
|
|
119
|
+
return scan_result.summary()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class Pipeline:
|
|
123
|
+
"""Full scanning and analysis pipeline."""
|
|
124
|
+
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
nmap_path: str = "nmap",
|
|
128
|
+
ssh_username: str = "root",
|
|
129
|
+
ssh_key_path: Optional[str] = None,
|
|
130
|
+
ssh_password: Optional[str] = None,
|
|
131
|
+
anthropic_api_key: Optional[str] = None,
|
|
132
|
+
):
|
|
133
|
+
self.scanner = Scanner(nmap_path, ssh_username, ssh_key_path, ssh_password)
|
|
134
|
+
self.analyzer = Analyzer(anthropic_api_key)
|
|
135
|
+
|
|
136
|
+
def run(self, target: str, grab_configs: bool = True, summarize: bool = False) -> dict:
|
|
137
|
+
"""
|
|
138
|
+
Run complete scan and analysis pipeline.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
target: Network target to scan
|
|
142
|
+
grab_configs: Whether to grab device configs
|
|
143
|
+
summarize: Whether to generate LLM summary
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Dictionary with results
|
|
147
|
+
"""
|
|
148
|
+
logger.info("Starting PyNetworkIntel pipeline")
|
|
149
|
+
|
|
150
|
+
# Scan
|
|
151
|
+
scan_result = self.scanner.scan(target, grab_configs)
|
|
152
|
+
|
|
153
|
+
# Analyze
|
|
154
|
+
scan_result = self.analyzer.analyze(scan_result)
|
|
155
|
+
|
|
156
|
+
result = {
|
|
157
|
+
"devices": [d.to_dict() for d in scan_result.devices],
|
|
158
|
+
"findings": [f.to_dict() for f in scan_result.findings],
|
|
159
|
+
"summary": self.analyzer.get_summary(scan_result),
|
|
160
|
+
"scan_time": scan_result.scan_time.isoformat(),
|
|
161
|
+
"scan_duration_seconds": scan_result.scan_duration_seconds,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
# Generate summary if requested
|
|
165
|
+
if summarize:
|
|
166
|
+
result["ai_summary"] = self.analyzer.summarize(scan_result)
|
|
167
|
+
|
|
168
|
+
logger.info("Pipeline complete")
|
|
169
|
+
|
|
170
|
+
return result
|
pynetworkintel/db.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Database models and ORM configuration."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Optional, List
|
|
5
|
+
from sqlalchemy import create_engine, Column, Integer, String, DateTime, Boolean, Float, ForeignKey, Text, JSON, Index
|
|
6
|
+
from sqlalchemy.ext.declarative import declarative_base
|
|
7
|
+
from sqlalchemy.orm import sessionmaker, relationship, Session
|
|
8
|
+
from sqlalchemy.pool import StaticPool
|
|
9
|
+
|
|
10
|
+
Base = declarative_base()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ScanSession(Base):
|
|
14
|
+
"""Represents a network scan execution."""
|
|
15
|
+
|
|
16
|
+
__tablename__ = "scan_sessions"
|
|
17
|
+
|
|
18
|
+
id = Column(Integer, primary_key=True)
|
|
19
|
+
scan_time = Column(DateTime, default=datetime.utcnow, index=True)
|
|
20
|
+
duration_seconds = Column(Float)
|
|
21
|
+
target = Column(String(255))
|
|
22
|
+
device_count = Column(Integer, default=0)
|
|
23
|
+
finding_count = Column(Integer, default=0)
|
|
24
|
+
status = Column(String(50), default="completed") # completed, failed, in_progress
|
|
25
|
+
error_message = Column(Text, nullable=True)
|
|
26
|
+
|
|
27
|
+
devices = relationship("Device", back_populates="scan", cascade="all, delete-orphan")
|
|
28
|
+
findings = relationship("Finding", back_populates="scan", cascade="all, delete-orphan")
|
|
29
|
+
|
|
30
|
+
__table_args__ = (Index("idx_scan_time", "scan_time"), Index("idx_target", "target"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Device(Base):
|
|
34
|
+
"""Network device discovered during scan."""
|
|
35
|
+
|
|
36
|
+
__tablename__ = "devices"
|
|
37
|
+
|
|
38
|
+
id = Column(Integer, primary_key=True)
|
|
39
|
+
scan_id = Column(Integer, ForeignKey("scan_sessions.id"), index=True)
|
|
40
|
+
ip = Column(String(45), index=True) # IPv4 or IPv6
|
|
41
|
+
hostname = Column(String(255), nullable=True)
|
|
42
|
+
os = Column(String(255), nullable=True)
|
|
43
|
+
device_type = Column(String(50), default="linux")
|
|
44
|
+
is_online = Column(Boolean, default=True)
|
|
45
|
+
last_seen = Column(DateTime, default=datetime.utcnow, index=True)
|
|
46
|
+
first_seen = Column(DateTime, default=datetime.utcnow)
|
|
47
|
+
|
|
48
|
+
scan = relationship("ScanSession", back_populates="devices")
|
|
49
|
+
services = relationship("Service", back_populates="device", cascade="all, delete-orphan")
|
|
50
|
+
configs = relationship("DeviceConfig", back_populates="device", cascade="all, delete-orphan")
|
|
51
|
+
|
|
52
|
+
__table_args__ = (Index("idx_device_ip_scan", "ip", "scan_id"),)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Service(Base):
|
|
56
|
+
"""Network service running on a device."""
|
|
57
|
+
|
|
58
|
+
__tablename__ = "services"
|
|
59
|
+
|
|
60
|
+
id = Column(Integer, primary_key=True)
|
|
61
|
+
device_id = Column(Integer, ForeignKey("devices.id"), index=True)
|
|
62
|
+
port = Column(Integer)
|
|
63
|
+
name = Column(String(100))
|
|
64
|
+
version = Column(String(255), nullable=True)
|
|
65
|
+
protocol = Column(String(10), default="tcp")
|
|
66
|
+
state = Column(String(50), default="open")
|
|
67
|
+
first_seen = Column(DateTime, default=datetime.utcnow)
|
|
68
|
+
last_seen = Column(DateTime, default=datetime.utcnow)
|
|
69
|
+
|
|
70
|
+
device = relationship("Device", back_populates="services")
|
|
71
|
+
|
|
72
|
+
__table_args__ = (Index("idx_service_device_port", "device_id", "port"),)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class DeviceConfig(Base):
|
|
76
|
+
"""Configuration file content from device."""
|
|
77
|
+
|
|
78
|
+
__tablename__ = "device_configs"
|
|
79
|
+
|
|
80
|
+
id = Column(Integer, primary_key=True)
|
|
81
|
+
device_id = Column(Integer, ForeignKey("devices.id"), index=True)
|
|
82
|
+
path = Column(String(500))
|
|
83
|
+
content = Column(Text)
|
|
84
|
+
device_type = Column(String(50))
|
|
85
|
+
captured_at = Column(DateTime, default=datetime.utcnow)
|
|
86
|
+
|
|
87
|
+
device = relationship("Device", back_populates="configs")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Finding(Base):
|
|
91
|
+
"""Security finding from analysis."""
|
|
92
|
+
|
|
93
|
+
__tablename__ = "findings"
|
|
94
|
+
|
|
95
|
+
id = Column(Integer, primary_key=True)
|
|
96
|
+
scan_id = Column(Integer, ForeignKey("scan_sessions.id"), index=True)
|
|
97
|
+
device_ip = Column(String(45), index=True)
|
|
98
|
+
device_hostname = Column(String(255), nullable=True)
|
|
99
|
+
finding_type = Column(String(50)) # configuration, cve, exposure, performance
|
|
100
|
+
severity = Column(String(20), index=True) # critical, high, medium, low, info
|
|
101
|
+
title = Column(String(500))
|
|
102
|
+
description = Column(Text)
|
|
103
|
+
evidence = Column(Text)
|
|
104
|
+
recommendation = Column(Text)
|
|
105
|
+
business_impact = Column(Text)
|
|
106
|
+
cve_id = Column(String(50), nullable=True, index=True)
|
|
107
|
+
cvss_score = Column(Float, nullable=True)
|
|
108
|
+
exploit_available = Column(Boolean, default=False)
|
|
109
|
+
found_at = Column(DateTime, default=datetime.utcnow, index=True)
|
|
110
|
+
resolved_at = Column(DateTime, nullable=True)
|
|
111
|
+
|
|
112
|
+
scan = relationship("ScanSession", back_populates="findings")
|
|
113
|
+
|
|
114
|
+
__table_args__ = (
|
|
115
|
+
Index("idx_finding_severity_scan", "severity", "scan_id"),
|
|
116
|
+
Index("idx_finding_device_ip", "device_ip"),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class VulnerabilityChange(Base):
|
|
121
|
+
"""Track when vulnerabilities appear/disappear."""
|
|
122
|
+
|
|
123
|
+
__tablename__ = "vulnerability_changes"
|
|
124
|
+
|
|
125
|
+
id = Column(Integer, primary_key=True)
|
|
126
|
+
device_ip = Column(String(45), index=True)
|
|
127
|
+
cve_id = Column(String(50), index=True)
|
|
128
|
+
title = Column(String(500))
|
|
129
|
+
appeared_at = Column(DateTime, default=datetime.utcnow, index=True)
|
|
130
|
+
resolved_at = Column(DateTime, nullable=True)
|
|
131
|
+
severity = Column(String(20))
|
|
132
|
+
|
|
133
|
+
__table_args__ = (Index("idx_vuln_device_cve", "device_ip", "cve_id"),)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class DeviceChange(Base):
|
|
137
|
+
"""Track device inventory changes."""
|
|
138
|
+
|
|
139
|
+
__tablename__ = "device_changes"
|
|
140
|
+
|
|
141
|
+
id = Column(Integer, primary_key=True)
|
|
142
|
+
ip = Column(String(45), index=True)
|
|
143
|
+
hostname = Column(String(255), nullable=True)
|
|
144
|
+
change_type = Column(String(50)) # discovered, removed, online, offline
|
|
145
|
+
detected_at = Column(DateTime, default=datetime.utcnow, index=True)
|
|
146
|
+
details = Column(JSON, nullable=True)
|
|
147
|
+
|
|
148
|
+
__table_args__ = (Index("idx_device_change_ip", "ip"),)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class Alert(Base):
|
|
152
|
+
"""Security alert/notification."""
|
|
153
|
+
|
|
154
|
+
__tablename__ = "alerts"
|
|
155
|
+
|
|
156
|
+
id = Column(Integer, primary_key=True)
|
|
157
|
+
alert_type = Column(String(50)) # new_vulnerability, device_offline, config_change
|
|
158
|
+
severity = Column(String(20))
|
|
159
|
+
title = Column(String(500))
|
|
160
|
+
description = Column(Text)
|
|
161
|
+
device_ip = Column(String(45), nullable=True)
|
|
162
|
+
cve_id = Column(String(50), nullable=True)
|
|
163
|
+
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
|
164
|
+
sent_at = Column(DateTime, nullable=True)
|
|
165
|
+
acknowledged_at = Column(DateTime, nullable=True)
|
|
166
|
+
status = Column(String(50), default="pending") # pending, sent, acknowledged, resolved
|
|
167
|
+
|
|
168
|
+
__table_args__ = (
|
|
169
|
+
Index("idx_alert_status", "status"),
|
|
170
|
+
Index("idx_alert_created", "created_at"),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class Database:
|
|
175
|
+
"""Database connection and session management."""
|
|
176
|
+
|
|
177
|
+
def __init__(self, database_url: str = "sqlite:///pynetworkintel.db"):
|
|
178
|
+
self.database_url = database_url
|
|
179
|
+
self.engine = create_engine(
|
|
180
|
+
database_url,
|
|
181
|
+
echo=False,
|
|
182
|
+
# Use StaticPool for SQLite to avoid threading issues
|
|
183
|
+
poolclass=StaticPool if database_url.startswith("sqlite") else None,
|
|
184
|
+
)
|
|
185
|
+
self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine)
|
|
186
|
+
|
|
187
|
+
def create_all(self):
|
|
188
|
+
"""Create all database tables."""
|
|
189
|
+
Base.metadata.create_all(bind=self.engine)
|
|
190
|
+
|
|
191
|
+
def get_session(self) -> Session:
|
|
192
|
+
"""Get a new database session."""
|
|
193
|
+
return self.SessionLocal()
|
|
194
|
+
|
|
195
|
+
def close(self):
|
|
196
|
+
"""Close all connections."""
|
|
197
|
+
self.engine.dispose()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# Singleton database instance
|
|
201
|
+
_db_instance: Optional[Database] = None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def init_db(database_url: str = "sqlite:///pynetworkintel.db") -> Database:
|
|
205
|
+
"""Initialize database singleton."""
|
|
206
|
+
global _db_instance
|
|
207
|
+
_db_instance = Database(database_url)
|
|
208
|
+
_db_instance.create_all()
|
|
209
|
+
return _db_instance
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def get_db() -> Database:
|
|
213
|
+
"""Get singleton database instance."""
|
|
214
|
+
global _db_instance
|
|
215
|
+
if _db_instance is None:
|
|
216
|
+
_db_instance = Database()
|
|
217
|
+
_db_instance.create_all()
|
|
218
|
+
return _db_instance
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Network device discovery engine."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import socket
|
|
7
|
+
from typing import List, Optional, Set
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
import xml.etree.ElementTree as ET
|
|
10
|
+
|
|
11
|
+
from pynetworkintel.models import Device, Service
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NmapScanner:
|
|
17
|
+
"""Wrapper around nmap for service discovery."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, nmap_path: str = "nmap"):
|
|
20
|
+
self.nmap_path = nmap_path
|
|
21
|
+
self._check_nmap_available()
|
|
22
|
+
|
|
23
|
+
def _check_nmap_available(self):
|
|
24
|
+
try:
|
|
25
|
+
subprocess.run([self.nmap_path, "-V"], capture_output=True, timeout=5)
|
|
26
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
27
|
+
logger.warning(f"nmap not found at {self.nmap_path}. Some discovery features will be limited.")
|
|
28
|
+
|
|
29
|
+
def scan(self, target: str) -> List[Device]:
|
|
30
|
+
"""
|
|
31
|
+
Scan target subnet/host for active devices and services.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
target: IP address, CIDR range (10.0.0.0/24), or hostname
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
List of discovered devices with service information
|
|
38
|
+
"""
|
|
39
|
+
devices = []
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
cmd = [
|
|
43
|
+
self.nmap_path,
|
|
44
|
+
"-sV",
|
|
45
|
+
"--script=smb-os-discovery",
|
|
46
|
+
"-oX",
|
|
47
|
+
"-",
|
|
48
|
+
target,
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
|
52
|
+
|
|
53
|
+
if result.returncode not in (0, 1):
|
|
54
|
+
logger.error(f"nmap scan failed: {result.stderr}")
|
|
55
|
+
return devices
|
|
56
|
+
|
|
57
|
+
devices = self._parse_nmap_xml(result.stdout)
|
|
58
|
+
|
|
59
|
+
except subprocess.TimeoutExpired:
|
|
60
|
+
logger.error(f"nmap scan timeout for {target}")
|
|
61
|
+
except Exception as e:
|
|
62
|
+
logger.error(f"nmap scan error: {e}")
|
|
63
|
+
|
|
64
|
+
return devices
|
|
65
|
+
|
|
66
|
+
def _parse_nmap_xml(self, xml_output: str) -> List[Device]:
|
|
67
|
+
"""Parse nmap XML output and extract device information."""
|
|
68
|
+
devices = []
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
root = ET.fromstring(xml_output)
|
|
72
|
+
except ET.ParseError:
|
|
73
|
+
logger.error("Failed to parse nmap XML output")
|
|
74
|
+
return devices
|
|
75
|
+
|
|
76
|
+
for host in root.findall(".//host"):
|
|
77
|
+
status = host.find("status")
|
|
78
|
+
if status is None or status.get("state") != "up":
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
device = self._parse_host(host)
|
|
82
|
+
if device:
|
|
83
|
+
devices.append(device)
|
|
84
|
+
|
|
85
|
+
return devices
|
|
86
|
+
|
|
87
|
+
def _parse_host(self, host_elem: ET.Element) -> Optional[Device]:
|
|
88
|
+
"""Parse individual host element from nmap XML."""
|
|
89
|
+
address_elem = host_elem.find("address")
|
|
90
|
+
if address_elem is None:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
ip = address_elem.get("addr")
|
|
94
|
+
if not ip:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
device = Device(ip=ip, is_online=True)
|
|
98
|
+
|
|
99
|
+
# Get OS information
|
|
100
|
+
os_elem = host_elem.find(".//os/osmatch")
|
|
101
|
+
if os_elem is not None:
|
|
102
|
+
device.os = os_elem.get("name")
|
|
103
|
+
|
|
104
|
+
# Get hostname
|
|
105
|
+
hostnames = host_elem.findall(".//hostname")
|
|
106
|
+
if hostnames:
|
|
107
|
+
device.hostname = hostnames[0].get("name")
|
|
108
|
+
|
|
109
|
+
# Parse services
|
|
110
|
+
ports = host_elem.findall(".//port")
|
|
111
|
+
for port_elem in ports:
|
|
112
|
+
service_data = self._parse_service(port_elem)
|
|
113
|
+
if service_data:
|
|
114
|
+
device.add_service(**service_data)
|
|
115
|
+
|
|
116
|
+
return device
|
|
117
|
+
|
|
118
|
+
def _parse_service(self, port_elem: ET.Element) -> Optional[dict]:
|
|
119
|
+
"""Parse service information from port element."""
|
|
120
|
+
state = port_elem.find("state")
|
|
121
|
+
if state is None or state.get("state") != "open":
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
port = int(port_elem.get("portid"))
|
|
125
|
+
protocol = port_elem.get("protocol", "tcp")
|
|
126
|
+
|
|
127
|
+
service_elem = port_elem.find("service")
|
|
128
|
+
service_name = "unknown"
|
|
129
|
+
service_version = None
|
|
130
|
+
|
|
131
|
+
if service_elem is not None:
|
|
132
|
+
service_name = service_elem.get("name", "unknown")
|
|
133
|
+
service_version = service_elem.get("version")
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
"port": port,
|
|
137
|
+
"name": service_name,
|
|
138
|
+
"version": service_version,
|
|
139
|
+
"protocol": protocol,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ARPScanner:
|
|
144
|
+
"""Simple ARP-based device discovery for local networks."""
|
|
145
|
+
|
|
146
|
+
def scan_local(self, interface: Optional[str] = None) -> List[str]:
|
|
147
|
+
"""
|
|
148
|
+
Discover active devices on local network via ARP.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
interface: Network interface to scan (e.g., 'eth0', 'en0'). Auto-detect if None.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
List of discovered IP addresses
|
|
155
|
+
"""
|
|
156
|
+
ips = []
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
if interface is None:
|
|
160
|
+
interface = self._get_default_interface()
|
|
161
|
+
|
|
162
|
+
cmd = ["arp-scan", "-l", "-I", interface]
|
|
163
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
164
|
+
|
|
165
|
+
for line in result.stdout.split("\n"):
|
|
166
|
+
if "\t" in line:
|
|
167
|
+
parts = line.split("\t")
|
|
168
|
+
if len(parts) > 0:
|
|
169
|
+
ip = parts[0].strip()
|
|
170
|
+
if self._is_valid_ip(ip):
|
|
171
|
+
ips.append(ip)
|
|
172
|
+
|
|
173
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
174
|
+
logger.warning("arp-scan not available, using nmap ping discovery instead")
|
|
175
|
+
|
|
176
|
+
return ips
|
|
177
|
+
|
|
178
|
+
def _get_default_interface(self) -> str:
|
|
179
|
+
"""Get default network interface."""
|
|
180
|
+
try:
|
|
181
|
+
result = subprocess.run(
|
|
182
|
+
["ip", "route", "show"],
|
|
183
|
+
capture_output=True,
|
|
184
|
+
text=True,
|
|
185
|
+
timeout=5,
|
|
186
|
+
)
|
|
187
|
+
for line in result.stdout.split("\n"):
|
|
188
|
+
if "default" in line:
|
|
189
|
+
parts = line.split()
|
|
190
|
+
if len(parts) > 4:
|
|
191
|
+
return parts[4]
|
|
192
|
+
except Exception:
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
return "eth0"
|
|
196
|
+
|
|
197
|
+
@staticmethod
|
|
198
|
+
def _is_valid_ip(ip: str) -> bool:
|
|
199
|
+
try:
|
|
200
|
+
socket.inet_aton(ip)
|
|
201
|
+
return True
|
|
202
|
+
except socket.error:
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class DeviceScanner:
|
|
207
|
+
"""High-level device discovery orchestrator."""
|
|
208
|
+
|
|
209
|
+
def __init__(self, nmap_path: str = "nmap"):
|
|
210
|
+
self.nmap_scanner = NmapScanner(nmap_path)
|
|
211
|
+
self.arp_scanner = ARPScanner()
|
|
212
|
+
|
|
213
|
+
def discover(self, target: str) -> List[Device]:
|
|
214
|
+
"""
|
|
215
|
+
Discover devices in target network.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
target: IP address, CIDR range, or hostname
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
List of discovered devices
|
|
222
|
+
"""
|
|
223
|
+
logger.info(f"Starting device discovery for {target}")
|
|
224
|
+
|
|
225
|
+
devices = self.nmap_scanner.scan(target)
|
|
226
|
+
|
|
227
|
+
logger.info(f"Discovered {len(devices)} devices")
|
|
228
|
+
|
|
229
|
+
return devices
|
|
230
|
+
|
|
231
|
+
def discover_local(self) -> List[Device]:
|
|
232
|
+
"""Discover devices on local network."""
|
|
233
|
+
logger.info("Starting local network discovery via ARP")
|
|
234
|
+
|
|
235
|
+
ips = self.arp_scanner.scan_local()
|
|
236
|
+
logger.info(f"Found {len(ips)} local devices via ARP")
|
|
237
|
+
|
|
238
|
+
devices = []
|
|
239
|
+
for ip in ips:
|
|
240
|
+
device = Device(ip=ip, is_online=True)
|
|
241
|
+
devices.append(device)
|
|
242
|
+
|
|
243
|
+
return devices
|