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.
@@ -0,0 +1,247 @@
1
+ """Configuration rule engine for security analysis."""
2
+
3
+ import logging
4
+ import re
5
+ from typing import List, Dict, Any, Optional
6
+ from dataclasses import dataclass
7
+
8
+ from pynetworkintel.models import Device, SecurityFinding, Severity, FindingType
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ @dataclass
14
+ class Rule:
15
+ name: str
16
+ device_type: str
17
+ severity: Severity
18
+ description: str
19
+ recommendation: str
20
+ business_impact: str
21
+ checks: List[Dict[str, Any]]
22
+
23
+
24
+ class RuleChecker:
25
+ """Evaluate configuration rules against devices."""
26
+
27
+ def __init__(self):
28
+ self.rules = self._load_default_rules()
29
+
30
+ def _load_default_rules(self) -> List[Rule]:
31
+ """Load default security rules."""
32
+ return [
33
+ Rule(
34
+ name="SSH with password authentication enabled",
35
+ device_type="linux",
36
+ severity=Severity.HIGH,
37
+ description="SSH allows password-based login instead of key-only authentication",
38
+ recommendation="Set 'PasswordAuthentication no' in /etc/ssh/sshd_config, require SSH keys only",
39
+ business_impact="Brute-force attack vulnerability on critical infrastructure",
40
+ checks=[
41
+ {
42
+ "config_key": "/etc/ssh/sshd_config",
43
+ "regex": r"PasswordAuthentication\s+yes",
44
+ }
45
+ ],
46
+ ),
47
+ Rule(
48
+ name="SSH with weak cipher algorithms",
49
+ device_type="linux",
50
+ severity=Severity.MEDIUM,
51
+ description="SSH is configured with weak encryption ciphers below recommended standards",
52
+ recommendation="Configure strong ciphers in sshd_config: aes256-gcm, aes256-ctr, or stronger",
53
+ business_impact="Encrypted connection could be compromised with advanced attack methods",
54
+ checks=[
55
+ {
56
+ "config_key": "/etc/ssh/sshd_config",
57
+ "regex": r"Ciphers.*aes128",
58
+ }
59
+ ],
60
+ ),
61
+ Rule(
62
+ name="SSH root login enabled",
63
+ device_type="linux",
64
+ severity=Severity.HIGH,
65
+ description="Direct SSH login as root is allowed, increasing compromise risk",
66
+ recommendation="Set 'PermitRootLogin no' in /etc/ssh/sshd_config, use sudo for elevated access",
67
+ business_impact="Attacker can directly access system with highest privilege if SSH is compromised",
68
+ checks=[
69
+ {
70
+ "config_key": "/etc/ssh/sshd_config",
71
+ "regex": r"PermitRootLogin\s+yes",
72
+ }
73
+ ],
74
+ ),
75
+ Rule(
76
+ name="Telnet service is accessible",
77
+ device_type="linux",
78
+ severity=Severity.CRITICAL,
79
+ description="Telnet is an unencrypted remote access protocol that transmits passwords in plaintext",
80
+ recommendation="Disable Telnet immediately, use SSH for encrypted remote access",
81
+ business_impact="Credentials and all communication visible to anyone on the network",
82
+ checks=[
83
+ {
84
+ "config_key": "/etc/iptables/rules",
85
+ "regex": r"ACCEPT.*port 23",
86
+ }
87
+ ],
88
+ ),
89
+ Rule(
90
+ name="FTP service is accessible",
91
+ device_type="linux",
92
+ severity=Severity.CRITICAL,
93
+ description="FTP is an unencrypted file transfer protocol that transmits credentials in plaintext",
94
+ recommendation="Disable FTP, use SFTP or SCP for secure file transfer",
95
+ business_impact="Credentials and file transfers visible to anyone on the network",
96
+ checks=[
97
+ {
98
+ "config_key": "/etc/iptables/rules",
99
+ "regex": r"ACCEPT.*port 21",
100
+ }
101
+ ],
102
+ ),
103
+ Rule(
104
+ name="Database port exposed to subnet",
105
+ device_type="linux",
106
+ severity=Severity.HIGH,
107
+ description="Database port (MySQL/PostgreSQL) accessible from internal network, not just app servers",
108
+ recommendation="Restrict database access using firewall rules to specific application server IPs only",
109
+ business_impact="Lateral movement risk - any compromised device in subnet can access database",
110
+ checks=[
111
+ {
112
+ "config_key": "/etc/iptables/rules",
113
+ "regex": r"ACCEPT.*sport (3306|5432)",
114
+ }
115
+ ],
116
+ ),
117
+ Rule(
118
+ name="HTTP access without HTTPS redirect",
119
+ device_type="linux",
120
+ severity=Severity.MEDIUM,
121
+ description="HTTP (port 80) is accessible without automatic redirect to HTTPS",
122
+ recommendation="Configure web server to redirect all HTTP traffic to HTTPS (port 443)",
123
+ business_impact="User credentials or session data could be intercepted if not redirected",
124
+ checks=[
125
+ {
126
+ "config_key": "/etc/iptables/rules",
127
+ "regex": r"ACCEPT.*dport 80",
128
+ }
129
+ ],
130
+ ),
131
+ Rule(
132
+ name="Firewall allows wide open access",
133
+ device_type="linux",
134
+ severity=Severity.CRITICAL,
135
+ description="Firewall rule allows access from all sources (0.0.0.0/0) to sensitive port",
136
+ recommendation="Restrict firewall rules to specific source IPs or networks only",
137
+ business_impact="Any attacker on the internet can attempt to exploit this port",
138
+ checks=[
139
+ {
140
+ "config_key": "/etc/iptables/rules",
141
+ "regex": r"ACCEPT.*0\.0\.0\.0\/0.*dport (22|3306|5432)",
142
+ }
143
+ ],
144
+ ),
145
+ Rule(
146
+ name="SNMP with default community string",
147
+ device_type="linux",
148
+ severity=Severity.HIGH,
149
+ description="SNMP is configured with default community strings (public/private)",
150
+ recommendation="Change SNMP community strings to strong, unique values or disable if not needed",
151
+ business_impact="Attackers can query device for system information and configuration details",
152
+ checks=[
153
+ {
154
+ "config_key": "/etc/snmp/snmpd.conf",
155
+ "regex": r"rocommunity\s+(public|private)",
156
+ }
157
+ ],
158
+ ),
159
+ Rule(
160
+ name="No SSH key-based authentication enforcement",
161
+ device_type="linux",
162
+ severity=Severity.MEDIUM,
163
+ description="SSH allows multiple authentication methods, not strictly key-based",
164
+ recommendation="Configure SSH to use only key-based authentication, disable passwords and other methods",
165
+ business_impact="Reduces effectiveness of SSH key security if weaker auth methods are available",
166
+ checks=[
167
+ {
168
+ "config_key": "/etc/ssh/sshd_config",
169
+ "regex": r"PubkeyAuthentication\s+no|AuthenticationMethods\s+password",
170
+ }
171
+ ],
172
+ ),
173
+ ]
174
+
175
+ def check_device(self, device: Device) -> List[SecurityFinding]:
176
+ """
177
+ Check device against all applicable rules.
178
+
179
+ Args:
180
+ device: Device to check
181
+
182
+ Returns:
183
+ List of security findings
184
+ """
185
+ findings = []
186
+
187
+ for rule in self.rules:
188
+ if rule.device_type not in ("any", device.device_type):
189
+ continue
190
+
191
+ if self._rule_matches(device, rule):
192
+ finding = SecurityFinding(
193
+ device=f"{device.ip} ({device.hostname or 'unknown'})",
194
+ finding_type=FindingType.CONFIGURATION,
195
+ severity=rule.severity,
196
+ title=rule.name,
197
+ description=rule.description,
198
+ evidence=self._extract_evidence(device, rule),
199
+ recommendation=rule.recommendation,
200
+ business_impact=rule.business_impact,
201
+ )
202
+ findings.append(finding)
203
+
204
+ return findings
205
+
206
+ def _rule_matches(self, device: Device, rule: Rule) -> bool:
207
+ """Check if device violates any condition in rule."""
208
+ for check in rule.checks:
209
+ if self._check_matches(device, check):
210
+ return True
211
+ return False
212
+
213
+ def _check_matches(self, device: Device, check: Dict[str, Any]) -> bool:
214
+ """Evaluate a single check condition against device."""
215
+ config_key = check.get("config_key")
216
+ regex = check.get("regex")
217
+
218
+ if not config_key or not regex:
219
+ return False
220
+
221
+ for config in device.configs:
222
+ if config.path == config_key:
223
+ if re.search(regex, config.content, re.MULTILINE | re.IGNORECASE):
224
+ return True
225
+
226
+ return False
227
+
228
+ def _extract_evidence(self, device: Device, rule: Rule) -> str:
229
+ """Extract matching evidence from device configuration."""
230
+ for check in rule.checks:
231
+ config_key = check.get("config_key")
232
+ regex = check.get("regex")
233
+
234
+ if not config_key or not regex:
235
+ continue
236
+
237
+ for config in device.configs:
238
+ if config.path == config_key:
239
+ matches = re.finditer(regex, config.content, re.MULTILINE | re.IGNORECASE)
240
+ for match in matches:
241
+ return f"Found in {config_key}: {match.group(0)}"
242
+
243
+ return f"Check failed for {rule.name}"
244
+
245
+ def add_rule(self, rule: Rule):
246
+ """Add custom rule to checker."""
247
+ self.rules.append(rule)
@@ -0,0 +1,221 @@
1
+ """Device and vulnerability change detection."""
2
+
3
+ import logging
4
+ from datetime import datetime
5
+ from typing import List, Dict, Any, Optional
6
+ from sqlalchemy.orm import Session
7
+
8
+ from pynetworkintel.models import Device, ScanResult
9
+ from pynetworkintel.db import (
10
+ Database, DeviceChange, VulnerabilityChange, Finding,
11
+ Device as DBDevice, Finding as DBFinding
12
+ )
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class ChangeDetector:
18
+ """Detect changes in network state."""
19
+
20
+ def __init__(self, db: Database):
21
+ self.db = db
22
+
23
+ def detect_device_changes(self, current_devices: List[Device], scan_id: int) -> List[Dict[str, Any]]:
24
+ """
25
+ Detect device additions, removals, and status changes.
26
+
27
+ Returns:
28
+ List of change dictionaries
29
+ """
30
+ changes = []
31
+ session = self.db.get_session()
32
+
33
+ try:
34
+ current_ips = {d.ip for d in current_devices}
35
+
36
+ # Find previously seen devices
37
+ previous_ips = set(
38
+ ip[0] for ip in session.query(DBDevice.ip).distinct().all()
39
+ )
40
+
41
+ # New devices
42
+ for ip in current_ips - previous_ips:
43
+ device = next(d for d in current_devices if d.ip == ip)
44
+ change = {
45
+ "type": "device_discovered",
46
+ "ip": ip,
47
+ "hostname": device.hostname,
48
+ "os": device.os,
49
+ "timestamp": datetime.utcnow(),
50
+ }
51
+ changes.append(change)
52
+ self._record_device_change(session, ip, "discovered", device.hostname, device.os)
53
+
54
+ # Removed devices (were online, now offline)
55
+ for ip in previous_ips - current_ips:
56
+ change = {
57
+ "type": "device_removed",
58
+ "ip": ip,
59
+ "timestamp": datetime.utcnow(),
60
+ }
61
+ changes.append(change)
62
+ self._record_device_change(session, ip, "removed", None, None)
63
+
64
+ session.commit()
65
+
66
+ finally:
67
+ session.close()
68
+
69
+ return changes
70
+
71
+ def detect_vulnerability_changes(self, current_findings: List, previous_findings_by_ip: Dict) -> List[Dict[str, Any]]:
72
+ """
73
+ Detect new vulnerabilities and resolved vulnerabilities.
74
+
75
+ Returns:
76
+ List of change dictionaries
77
+ """
78
+ changes = []
79
+ session = self.db.get_session()
80
+
81
+ try:
82
+ # Group current findings by device IP and CVE
83
+ current_by_ip_cve = {}
84
+ for finding in current_findings:
85
+ if finding.cve_id:
86
+ key = (finding.device, finding.cve_id)
87
+ current_by_ip_cve[key] = finding
88
+
89
+ # Check for new vulnerabilities
90
+ for (device_ip, cve_id), finding in current_by_ip_cve.items():
91
+ previous = previous_findings_by_ip.get((device_ip, cve_id))
92
+ if not previous:
93
+ change = {
94
+ "type": "vulnerability_discovered",
95
+ "device_ip": device_ip,
96
+ "cve_id": cve_id,
97
+ "severity": finding.severity.value,
98
+ "title": finding.title,
99
+ "timestamp": datetime.utcnow(),
100
+ }
101
+ changes.append(change)
102
+ self._record_vulnerability_change(
103
+ session,
104
+ device_ip,
105
+ cve_id,
106
+ finding.title,
107
+ finding.severity.value,
108
+ )
109
+
110
+ # Check for resolved vulnerabilities
111
+ for (device_ip, cve_id), previous_finding in previous_findings_by_ip.items():
112
+ if (device_ip, cve_id) not in current_by_ip_cve:
113
+ change = {
114
+ "type": "vulnerability_resolved",
115
+ "device_ip": device_ip,
116
+ "cve_id": cve_id,
117
+ "timestamp": datetime.utcnow(),
118
+ }
119
+ changes.append(change)
120
+ self._mark_vulnerability_resolved(session, device_ip, cve_id)
121
+
122
+ session.commit()
123
+
124
+ finally:
125
+ session.close()
126
+
127
+ return changes
128
+
129
+ def get_previous_scan_findings(self) -> Dict[tuple, Any]:
130
+ """Get findings from previous scan grouped by (device_ip, cve_id)."""
131
+ session = self.db.get_session()
132
+
133
+ try:
134
+ findings = session.query(DBFinding).order_by(DBFinding.found_at.desc()).all()
135
+
136
+ # Keep only the most recent findings for each device
137
+ grouped = {}
138
+ for finding in findings:
139
+ key = (finding.device_ip, finding.cve_id)
140
+ if key not in grouped:
141
+ grouped[key] = finding
142
+
143
+ return grouped
144
+
145
+ finally:
146
+ session.close()
147
+
148
+ def _record_device_change(self, session: Session, ip: str, change_type: str, hostname: Optional[str], os: Optional[str]):
149
+ """Record a device change in database."""
150
+ change = DeviceChange(
151
+ ip=ip,
152
+ hostname=hostname,
153
+ change_type=change_type,
154
+ details={"os": os} if os else None,
155
+ )
156
+ session.add(change)
157
+
158
+ def _record_vulnerability_change(self, session: Session, device_ip: str, cve_id: str, title: str, severity: str):
159
+ """Record a vulnerability discovery."""
160
+ vuln_change = VulnerabilityChange(
161
+ device_ip=device_ip,
162
+ cve_id=cve_id,
163
+ title=title,
164
+ severity=severity,
165
+ )
166
+ session.add(vuln_change)
167
+
168
+ def _mark_vulnerability_resolved(self, session: Session, device_ip: str, cve_id: str):
169
+ """Mark a vulnerability as resolved."""
170
+ change = session.query(VulnerabilityChange).filter(
171
+ VulnerabilityChange.device_ip == device_ip,
172
+ VulnerabilityChange.cve_id == cve_id,
173
+ VulnerabilityChange.resolved_at.is_(None),
174
+ ).first()
175
+
176
+ if change:
177
+ change.resolved_at = datetime.utcnow()
178
+ session.add(change)
179
+
180
+
181
+ class ChangeReporter:
182
+ """Report detected changes in human-readable format."""
183
+
184
+ @staticmethod
185
+ def summarize_changes(changes: List[Dict[str, Any]]) -> str:
186
+ """Generate summary of detected changes."""
187
+ if not changes:
188
+ return "No changes detected since last scan."
189
+
190
+ summary = ["Changes Detected Since Previous Scan:\n"]
191
+
192
+ device_discovered = [c for c in changes if c["type"] == "device_discovered"]
193
+ device_removed = [c for c in changes if c["type"] == "device_removed"]
194
+ vuln_discovered = [c for c in changes if c["type"] == "vulnerability_discovered"]
195
+ vuln_resolved = [c for c in changes if c["type"] == "vulnerability_resolved"]
196
+
197
+ if device_discovered:
198
+ summary.append(f"+ New Devices: {len(device_discovered)}")
199
+ for change in device_discovered[:3]:
200
+ summary.append(f" - {change['ip']} ({change.get('hostname', 'unknown')})")
201
+ if len(device_discovered) > 3:
202
+ summary.append(f" ... and {len(device_discovered) - 3} more")
203
+
204
+ if device_removed:
205
+ summary.append(f"- Removed Devices: {len(device_removed)}")
206
+ for change in device_removed[:3]:
207
+ summary.append(f" - {change['ip']}")
208
+ if len(device_removed) > 3:
209
+ summary.append(f" ... and {len(device_removed) - 3} more")
210
+
211
+ if vuln_discovered:
212
+ summary.append(f"! New Vulnerabilities: {len(vuln_discovered)}")
213
+ for change in vuln_discovered[:3]:
214
+ summary.append(f" - {change['cve_id']} ({change['severity'].upper()}): {change['title'][:50]}")
215
+ if len(vuln_discovered) > 3:
216
+ summary.append(f" ... and {len(vuln_discovered) - 3} more")
217
+
218
+ if vuln_resolved:
219
+ summary.append(f"✓ Resolved Vulnerabilities: {len(vuln_resolved)}")
220
+
221
+ return "\n".join(summary)