changedetection.io-osint-processor 0.0.1__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.
- changedetection_io_osint_processor-0.0.1.dist-info/METADATA +274 -0
- changedetection_io_osint_processor-0.0.1.dist-info/RECORD +29 -0
- changedetection_io_osint_processor-0.0.1.dist-info/WHEEL +5 -0
- changedetection_io_osint_processor-0.0.1.dist-info/entry_points.txt +2 -0
- changedetection_io_osint_processor-0.0.1.dist-info/licenses/LICENSE +661 -0
- changedetection_io_osint_processor-0.0.1.dist-info/top_level.txt +1 -0
- changedetectionio_osint/__init__.py +22 -0
- changedetectionio_osint/forms.py +289 -0
- changedetectionio_osint/plugin.py +37 -0
- changedetectionio_osint/processor.py +655 -0
- changedetectionio_osint/steps/__init__.py +4 -0
- changedetectionio_osint/steps/base.py +76 -0
- changedetectionio_osint/steps/bgp.py +88 -0
- changedetectionio_osint/steps/dns.py +147 -0
- changedetectionio_osint/steps/dns_scan.py +88 -0
- changedetectionio_osint/steps/dnssec.py +260 -0
- changedetectionio_osint/steps/email_security.py +236 -0
- changedetectionio_osint/steps/http_fingerprint.py +359 -0
- changedetectionio_osint/steps/http_scan.py +31 -0
- changedetectionio_osint/steps/mac_lookup.py +209 -0
- changedetectionio_osint/steps/os_detection.py +245 -0
- changedetectionio_osint/steps/portscan.py +113 -0
- changedetectionio_osint/steps/registry.py +49 -0
- changedetectionio_osint/steps/smtp_fingerprint.py +517 -0
- changedetectionio_osint/steps/ssh_fingerprint.py +310 -0
- changedetectionio_osint/steps/tls_analysis.py +332 -0
- changedetectionio_osint/steps/traceroute.py +127 -0
- changedetectionio_osint/steps/whois_lookup.py +125 -0
- changedetectionio_osint/steps/whois_scan.py +123 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SMTP/Email Server Fingerprinting Step
|
|
3
|
+
Connects to MX (Mail Exchange) servers to extract server banner, capabilities, and security features
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
# SOCKS5 proxy support: SMTP fingerprinting supports SOCKS5 via python-socks
|
|
8
|
+
supports_socks5 = True
|
|
9
|
+
import socket
|
|
10
|
+
from loguru import logger
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def scan_smtp_mx_records(mx_records, dns_resolver, ports=[25, 587, 465], timeout=5, proxy_url=None, ehlo_hostname='localhost.localdomain', watch_uuid=None, update_signal=None):
|
|
14
|
+
"""
|
|
15
|
+
Perform SMTP server fingerprinting on MX (Mail Exchange) records
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
mx_records: List of MX record strings (format: "10 mail.example.com.")
|
|
19
|
+
dns_resolver: Configured dns.resolver.Resolver instance for resolving MX hostnames
|
|
20
|
+
ports: List of SMTP ports to check (default: [25, 587, 465])
|
|
21
|
+
timeout: Connection timeout in seconds
|
|
22
|
+
proxy_url: Optional SOCKS5 proxy URL (socks5://user:pass@host:port)
|
|
23
|
+
ehlo_hostname: Hostname to use in SMTP EHLO command (default: localhost.localdomain)
|
|
24
|
+
watch_uuid: Optional watch UUID for status updates
|
|
25
|
+
update_signal: Optional blinker signal for status updates
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
dict: SMTP fingerprint data for all MX servers
|
|
29
|
+
"""
|
|
30
|
+
if not mx_records:
|
|
31
|
+
return {
|
|
32
|
+
'mx_servers': [],
|
|
33
|
+
'no_mx_records': True
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# Parse MX records (format: "10 mail.example.com.")
|
|
37
|
+
mx_servers = []
|
|
38
|
+
for mx_record in mx_records:
|
|
39
|
+
try:
|
|
40
|
+
parts = mx_record.split(' ', 1)
|
|
41
|
+
if len(parts) == 2:
|
|
42
|
+
priority = int(parts[0])
|
|
43
|
+
hostname = parts[1].rstrip('.')
|
|
44
|
+
mx_servers.append({'priority': priority, 'hostname': hostname})
|
|
45
|
+
except Exception as e:
|
|
46
|
+
logger.debug(f"Failed to parse MX record '{mx_record}': {e}")
|
|
47
|
+
|
|
48
|
+
if not mx_servers:
|
|
49
|
+
return {
|
|
50
|
+
'mx_servers': [],
|
|
51
|
+
'no_mx_records': True
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Sort by priority (lower number = higher priority)
|
|
55
|
+
mx_servers.sort(key=lambda x: x['priority'])
|
|
56
|
+
|
|
57
|
+
async def smtp_fingerprint_mx_server(mx_server):
|
|
58
|
+
"""Fingerprint SMTP on a specific MX server"""
|
|
59
|
+
hostname = mx_server['hostname']
|
|
60
|
+
priority = mx_server['priority']
|
|
61
|
+
|
|
62
|
+
# Resolve MX hostname to IP (unless using SOCKS5 proxy)
|
|
63
|
+
# CRITICAL: When using SOCKS5, pass hostname to proxy - don't resolve locally (DNS leak!)
|
|
64
|
+
if proxy_url and proxy_url.strip():
|
|
65
|
+
# Using SOCKS5: Let proxy resolve hostname remotely - no local DNS leak
|
|
66
|
+
ip_address = None # Will pass hostname to connection instead
|
|
67
|
+
target_host = hostname # Connect to hostname via SOCKS5
|
|
68
|
+
logger.debug(f"Scanning MX server {hostname} via SOCKS5 (remote DNS) priority {priority}")
|
|
69
|
+
else:
|
|
70
|
+
# No proxy: Resolve locally
|
|
71
|
+
try:
|
|
72
|
+
answers = dns_resolver.resolve(hostname, 'A')
|
|
73
|
+
ip_address = str(answers[0])
|
|
74
|
+
except Exception:
|
|
75
|
+
try:
|
|
76
|
+
answers = dns_resolver.resolve(hostname, 'AAAA')
|
|
77
|
+
ip_address = str(answers[0])
|
|
78
|
+
except Exception as e:
|
|
79
|
+
logger.debug(f"Failed to resolve MX server {hostname}: {e}")
|
|
80
|
+
return {
|
|
81
|
+
'mx_hostname': hostname,
|
|
82
|
+
'priority': priority,
|
|
83
|
+
'ip_address': None,
|
|
84
|
+
'error': f"DNS resolution failed: {e}",
|
|
85
|
+
'port_results': []
|
|
86
|
+
}
|
|
87
|
+
target_host = ip_address
|
|
88
|
+
logger.debug(f"Scanning MX server {hostname} ({ip_address}) priority {priority}")
|
|
89
|
+
|
|
90
|
+
if update_signal and watch_uuid:
|
|
91
|
+
status_msg = f"SMTP: {hostname}" + (f" ({ip_address})" if ip_address else " (via SOCKS5)")
|
|
92
|
+
update_signal.send(watch_uuid=watch_uuid, status=status_msg)
|
|
93
|
+
|
|
94
|
+
# Scan all ports on this MX server
|
|
95
|
+
port_tasks = [smtp_fingerprint_port(target_host, port, hostname, timeout, proxy_url, ehlo_hostname) for port in ports]
|
|
96
|
+
port_results = await asyncio.gather(*port_tasks, return_exceptions=True)
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
'mx_hostname': hostname,
|
|
100
|
+
'priority': priority,
|
|
101
|
+
'ip_address': ip_address,
|
|
102
|
+
'port_results': [r for r in port_results if not isinstance(r, Exception)],
|
|
103
|
+
'open_ports': [r['port'] for r in port_results if not isinstance(r, Exception) and r.get('port_open')]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async def smtp_fingerprint_port(target_host, port, mx_hostname, timeout, proxy_url=None, ehlo_hostname='localhost.localdomain'):
|
|
107
|
+
"""
|
|
108
|
+
Fingerprint SMTP on a specific port
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
target_host: IP address (no proxy) or hostname (with SOCKS5 proxy for remote DNS)
|
|
112
|
+
port: SMTP port to scan
|
|
113
|
+
mx_hostname: MX hostname for logging
|
|
114
|
+
timeout: Connection timeout
|
|
115
|
+
proxy_url: Optional SOCKS5 proxy URL
|
|
116
|
+
ehlo_hostname: Hostname to use in SMTP EHLO command
|
|
117
|
+
"""
|
|
118
|
+
result = {
|
|
119
|
+
'port': port,
|
|
120
|
+
'port_open': False,
|
|
121
|
+
'banner': None,
|
|
122
|
+
'server': None,
|
|
123
|
+
'ehlo_response': [],
|
|
124
|
+
'capabilities': [],
|
|
125
|
+
'supports_starttls': False,
|
|
126
|
+
'supports_auth': False,
|
|
127
|
+
'auth_methods': [],
|
|
128
|
+
'supports_pipelining': False,
|
|
129
|
+
'supports_smtputf8': False,
|
|
130
|
+
'supports_chunking': False,
|
|
131
|
+
'supports_8bitmime': False,
|
|
132
|
+
'max_message_size': None,
|
|
133
|
+
'is_ssl_wrapped': port == 465, # Port 465 is implicit TLS
|
|
134
|
+
'error': None
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
# Connect to SMTP port (with optional SOCKS5 proxy support)
|
|
139
|
+
if proxy_url and proxy_url.strip():
|
|
140
|
+
# Use SOCKS5 proxy for connection
|
|
141
|
+
try:
|
|
142
|
+
from python_socks.async_.asyncio import Proxy
|
|
143
|
+
|
|
144
|
+
# Connect through SOCKS5 proxy
|
|
145
|
+
# target_host is hostname - proxy will resolve it remotely (no DNS leak)
|
|
146
|
+
proxy = Proxy.from_url(proxy_url)
|
|
147
|
+
sock = await asyncio.wait_for(
|
|
148
|
+
proxy.connect(dest_host=target_host, dest_port=port, timeout=timeout),
|
|
149
|
+
timeout=timeout
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Create reader/writer from the socket
|
|
153
|
+
reader, writer = await asyncio.open_connection(sock=sock)
|
|
154
|
+
|
|
155
|
+
except ImportError:
|
|
156
|
+
logger.error("SOCKS5 proxy requested but 'python-socks[asyncio]' is not installed")
|
|
157
|
+
result['error'] = "SOCKS5 proxy support requires 'python-socks[asyncio]' package"
|
|
158
|
+
return result
|
|
159
|
+
else:
|
|
160
|
+
# Direct connection (no proxy)
|
|
161
|
+
# target_host is IP address (already resolved)
|
|
162
|
+
reader, writer = await asyncio.wait_for(
|
|
163
|
+
asyncio.open_connection(target_host, port),
|
|
164
|
+
timeout=timeout
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
result['port_open'] = True
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
# Read SMTP banner (220 response)
|
|
171
|
+
banner_line = await asyncio.wait_for(reader.readline(), timeout=timeout)
|
|
172
|
+
banner = banner_line.decode('utf-8', errors='ignore').strip()
|
|
173
|
+
result['banner'] = banner
|
|
174
|
+
|
|
175
|
+
# Parse server from banner (format: "220 hostname ESMTP server-software")
|
|
176
|
+
if ' ESMTP ' in banner or ' SMTP ' in banner:
|
|
177
|
+
parts = banner.split(' ', 3)
|
|
178
|
+
if len(parts) >= 3:
|
|
179
|
+
result['server'] = parts[2] if len(parts) == 3 else parts[3]
|
|
180
|
+
|
|
181
|
+
logger.debug(f"SMTP banner on {mx_hostname}:{port}: {banner}")
|
|
182
|
+
|
|
183
|
+
# Send EHLO command to get capabilities
|
|
184
|
+
# Use configured hostname (default: localhost.localdomain) for privacy
|
|
185
|
+
ehlo_cmd = f"EHLO {ehlo_hostname}\r\n"
|
|
186
|
+
writer.write(ehlo_cmd.encode())
|
|
187
|
+
await writer.drain()
|
|
188
|
+
|
|
189
|
+
# Read EHLO response (multiple lines, ends with code without -)
|
|
190
|
+
ehlo_lines = []
|
|
191
|
+
while True:
|
|
192
|
+
line = await asyncio.wait_for(reader.readline(), timeout=timeout)
|
|
193
|
+
line_str = line.decode('utf-8', errors='ignore').strip()
|
|
194
|
+
ehlo_lines.append(line_str)
|
|
195
|
+
|
|
196
|
+
# SMTP multiline responses: "250-" continues, "250 " ends
|
|
197
|
+
if line_str and len(line_str) >= 4 and line_str[3] == ' ':
|
|
198
|
+
break
|
|
199
|
+
if len(ehlo_lines) > 50: # Safety limit
|
|
200
|
+
break
|
|
201
|
+
|
|
202
|
+
result['ehlo_response'] = ehlo_lines
|
|
203
|
+
|
|
204
|
+
# Parse capabilities from EHLO response
|
|
205
|
+
for line in ehlo_lines:
|
|
206
|
+
# Skip the first line (usually just "250-hostname")
|
|
207
|
+
if not line.startswith('250'):
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
# Remove "250-" or "250 " prefix
|
|
211
|
+
capability = line[4:].strip() if len(line) > 4 else ''
|
|
212
|
+
|
|
213
|
+
if capability:
|
|
214
|
+
result['capabilities'].append(capability)
|
|
215
|
+
|
|
216
|
+
# Parse specific capabilities
|
|
217
|
+
cap_upper = capability.upper()
|
|
218
|
+
|
|
219
|
+
if cap_upper.startswith('STARTTLS'):
|
|
220
|
+
result['supports_starttls'] = True
|
|
221
|
+
elif cap_upper.startswith('AUTH'):
|
|
222
|
+
result['supports_auth'] = True
|
|
223
|
+
# Parse auth methods (format: "AUTH LOGIN PLAIN CRAM-MD5")
|
|
224
|
+
auth_parts = capability.split()
|
|
225
|
+
if len(auth_parts) > 1:
|
|
226
|
+
result['auth_methods'] = auth_parts[1:]
|
|
227
|
+
elif cap_upper.startswith('PIPELINING'):
|
|
228
|
+
result['supports_pipelining'] = True
|
|
229
|
+
elif cap_upper.startswith('SMTPUTF8'):
|
|
230
|
+
result['supports_smtputf8'] = True
|
|
231
|
+
elif cap_upper.startswith('CHUNKING'):
|
|
232
|
+
result['supports_chunking'] = True
|
|
233
|
+
elif cap_upper.startswith('8BITMIME'):
|
|
234
|
+
result['supports_8bitmime'] = True
|
|
235
|
+
elif cap_upper.startswith('SIZE'):
|
|
236
|
+
# Parse max message size (format: "SIZE 52428800")
|
|
237
|
+
size_parts = capability.split()
|
|
238
|
+
if len(size_parts) > 1:
|
|
239
|
+
try:
|
|
240
|
+
result['max_message_size'] = int(size_parts[1])
|
|
241
|
+
except ValueError:
|
|
242
|
+
pass
|
|
243
|
+
|
|
244
|
+
# Send QUIT command
|
|
245
|
+
writer.write(b"QUIT\r\n")
|
|
246
|
+
await writer.drain()
|
|
247
|
+
|
|
248
|
+
finally:
|
|
249
|
+
writer.close()
|
|
250
|
+
await writer.wait_closed()
|
|
251
|
+
|
|
252
|
+
except asyncio.TimeoutError:
|
|
253
|
+
result['error'] = f"Connection timeout"
|
|
254
|
+
logger.debug(f"SMTP connection timeout: {target_host}:{port}")
|
|
255
|
+
except ConnectionRefusedError:
|
|
256
|
+
result['error'] = f"Connection refused"
|
|
257
|
+
except socket.gaierror as e:
|
|
258
|
+
result['error'] = f"DNS error: {e}"
|
|
259
|
+
except Exception as e:
|
|
260
|
+
result['error'] = f"Connection error: {str(e)}"
|
|
261
|
+
logger.debug(f"SMTP fingerprint error on {target_host}:{port}: {e}")
|
|
262
|
+
|
|
263
|
+
return result
|
|
264
|
+
|
|
265
|
+
# Scan all MX servers in parallel
|
|
266
|
+
results = await asyncio.gather(*[smtp_fingerprint_mx_server(mx) for mx in mx_servers])
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
'mx_servers': results,
|
|
270
|
+
'total_mx_count': len(mx_servers),
|
|
271
|
+
'no_mx_records': False
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def format_smtp_results(smtp_results):
|
|
276
|
+
"""Format SMTP fingerprint results for output"""
|
|
277
|
+
lines = []
|
|
278
|
+
lines.append("=== SMTP/Email Server Fingerprint ===")
|
|
279
|
+
|
|
280
|
+
if not smtp_results:
|
|
281
|
+
lines.append("No SMTP scan results")
|
|
282
|
+
lines.append("")
|
|
283
|
+
return '\n'.join(lines)
|
|
284
|
+
|
|
285
|
+
if smtp_results.get('no_mx_records'):
|
|
286
|
+
lines.append("Status: ✗ No MX (Mail Exchange) records found")
|
|
287
|
+
lines.append("Note: This domain may not accept email, or MX records are not configured")
|
|
288
|
+
lines.append("")
|
|
289
|
+
return '\n'.join(lines)
|
|
290
|
+
|
|
291
|
+
mx_servers = smtp_results.get('mx_servers', [])
|
|
292
|
+
if not mx_servers:
|
|
293
|
+
lines.append("Status: ✗ No MX servers to scan")
|
|
294
|
+
lines.append("")
|
|
295
|
+
return '\n'.join(lines)
|
|
296
|
+
|
|
297
|
+
# Count how many MX servers have open ports
|
|
298
|
+
mx_with_open_ports = [mx for mx in mx_servers if mx.get('open_ports')]
|
|
299
|
+
|
|
300
|
+
if not mx_with_open_ports:
|
|
301
|
+
lines.append(f"Status: ✗ No SMTP ports open on {len(mx_servers)} MX server(s)")
|
|
302
|
+
lines.append("")
|
|
303
|
+
# Still show the MX servers that were checked
|
|
304
|
+
for mx in mx_servers:
|
|
305
|
+
lines.append(f"MX Server: {mx.get('mx_hostname')} (priority {mx.get('priority')})")
|
|
306
|
+
if mx.get('error'):
|
|
307
|
+
lines.append(f" Error: {mx['error']}")
|
|
308
|
+
lines.append("")
|
|
309
|
+
return '\n'.join(lines)
|
|
310
|
+
|
|
311
|
+
# Summary
|
|
312
|
+
lines.append(f"Status: ✓ Found {len(mx_with_open_ports)} active MX server(s) out of {len(mx_servers)} total")
|
|
313
|
+
lines.append("")
|
|
314
|
+
|
|
315
|
+
# Detail each MX server
|
|
316
|
+
for mx in mx_servers:
|
|
317
|
+
mx_hostname = mx.get('mx_hostname', 'unknown')
|
|
318
|
+
priority = mx.get('priority', '?')
|
|
319
|
+
ip_address = mx.get('ip_address', 'unresolved')
|
|
320
|
+
|
|
321
|
+
lines.append("=" * 70)
|
|
322
|
+
lines.append(f"MX Server: {mx_hostname}")
|
|
323
|
+
lines.append(f"Priority: {priority} (lower = higher priority)")
|
|
324
|
+
lines.append(f"IP Address: {ip_address}")
|
|
325
|
+
lines.append("=" * 70)
|
|
326
|
+
|
|
327
|
+
if mx.get('error'):
|
|
328
|
+
lines.append(f"Error: {mx['error']}")
|
|
329
|
+
lines.append("")
|
|
330
|
+
continue
|
|
331
|
+
|
|
332
|
+
port_results = mx.get('port_results', [])
|
|
333
|
+
open_ports = mx.get('open_ports', [])
|
|
334
|
+
|
|
335
|
+
if not open_ports:
|
|
336
|
+
lines.append("Status: ✗ No SMTP ports responding")
|
|
337
|
+
lines.append("")
|
|
338
|
+
continue
|
|
339
|
+
|
|
340
|
+
lines.append(f"Open Ports: {', '.join(map(str, open_ports))}")
|
|
341
|
+
lines.append("")
|
|
342
|
+
|
|
343
|
+
# Detail each port
|
|
344
|
+
for result in port_results:
|
|
345
|
+
if not result.get('port_open'):
|
|
346
|
+
continue
|
|
347
|
+
|
|
348
|
+
port = result['port']
|
|
349
|
+
lines.append(f"--- Port {port} ---")
|
|
350
|
+
|
|
351
|
+
# Port type description
|
|
352
|
+
if port == 25:
|
|
353
|
+
lines.append(" Port Type: SMTP (standard mail transfer)")
|
|
354
|
+
elif port == 587:
|
|
355
|
+
lines.append(" Port Type: Submission (client mail submission, usually requires auth)")
|
|
356
|
+
elif port == 465:
|
|
357
|
+
lines.append(" Port Type: SMTPS (implicit TLS/SSL)")
|
|
358
|
+
else:
|
|
359
|
+
lines.append(f" Port Type: Custom SMTP port")
|
|
360
|
+
|
|
361
|
+
# Banner and server
|
|
362
|
+
if result.get('banner'):
|
|
363
|
+
lines.append(f" Banner: {result['banner']}")
|
|
364
|
+
if result.get('server'):
|
|
365
|
+
lines.append(f" Server Software: {result['server']}")
|
|
366
|
+
|
|
367
|
+
# Identify server type
|
|
368
|
+
server = result['server'].lower()
|
|
369
|
+
if 'postfix' in server:
|
|
370
|
+
lines.append(" Server Type: Postfix (popular open-source MTA)")
|
|
371
|
+
elif 'exim' in server:
|
|
372
|
+
lines.append(" Server Type: Exim (flexible Unix MTA)")
|
|
373
|
+
elif 'sendmail' in server:
|
|
374
|
+
lines.append(" Server Type: Sendmail (classic Unix MTA)")
|
|
375
|
+
elif 'microsoft' in server or 'exchange' in server:
|
|
376
|
+
lines.append(" Server Type: Microsoft Exchange")
|
|
377
|
+
elif 'zimbra' in server:
|
|
378
|
+
lines.append(" Server Type: Zimbra Collaboration Suite")
|
|
379
|
+
elif 'qmail' in server:
|
|
380
|
+
lines.append(" Server Type: Qmail (secure MTA)")
|
|
381
|
+
elif 'haraka' in server:
|
|
382
|
+
lines.append(" Server Type: Haraka (modern Node.js MTA)")
|
|
383
|
+
|
|
384
|
+
# Capabilities
|
|
385
|
+
if result.get('capabilities'):
|
|
386
|
+
lines.append(f" Capabilities: {len(result['capabilities'])} feature(s)")
|
|
387
|
+
|
|
388
|
+
# Security features
|
|
389
|
+
lines.append(" Security Features:")
|
|
390
|
+
has_security = False
|
|
391
|
+
|
|
392
|
+
if result.get('supports_starttls'):
|
|
393
|
+
lines.append(" ✓ STARTTLS - Opportunistic TLS encryption supported")
|
|
394
|
+
has_security = True
|
|
395
|
+
elif not result.get('is_ssl_wrapped'):
|
|
396
|
+
lines.append(" ✗ STARTTLS - NOT supported (unencrypted connection)")
|
|
397
|
+
|
|
398
|
+
if result.get('is_ssl_wrapped'):
|
|
399
|
+
lines.append(" ✓ Implicit TLS - Connection is encrypted from start")
|
|
400
|
+
has_security = True
|
|
401
|
+
|
|
402
|
+
if result.get('supports_auth'):
|
|
403
|
+
lines.append(f" ✓ Authentication required")
|
|
404
|
+
if result.get('auth_methods'):
|
|
405
|
+
auth_methods = result['auth_methods']
|
|
406
|
+
lines.append(f" Methods: {', '.join(auth_methods)}")
|
|
407
|
+
|
|
408
|
+
# Security assessment of auth methods
|
|
409
|
+
for method in auth_methods:
|
|
410
|
+
method_upper = method.upper()
|
|
411
|
+
if method_upper == 'PLAIN':
|
|
412
|
+
if result.get('supports_starttls') or result.get('is_ssl_wrapped'):
|
|
413
|
+
lines.append(f" {method}: ⚠ Plaintext auth (safe over TLS)")
|
|
414
|
+
else:
|
|
415
|
+
lines.append(f" {method}: ✗ INSECURE (plaintext over unencrypted connection)")
|
|
416
|
+
elif method_upper == 'LOGIN':
|
|
417
|
+
if result.get('supports_starttls') or result.get('is_ssl_wrapped'):
|
|
418
|
+
lines.append(f" {method}: ⚠ Base64 encoded (safe over TLS)")
|
|
419
|
+
else:
|
|
420
|
+
lines.append(f" {method}: ✗ INSECURE (base64 over unencrypted connection)")
|
|
421
|
+
elif method_upper in ['CRAM-MD5', 'DIGEST-MD5']:
|
|
422
|
+
lines.append(f" {method}: ✓ Challenge-response (resistant to replay)")
|
|
423
|
+
elif method_upper in ['SCRAM-SHA-1', 'SCRAM-SHA-256']:
|
|
424
|
+
lines.append(f" {method}: ✓ Modern SCRAM authentication")
|
|
425
|
+
elif method_upper == 'XOAUTH2':
|
|
426
|
+
lines.append(f" {method}: ✓ OAuth 2.0 token authentication")
|
|
427
|
+
else:
|
|
428
|
+
if port == 25:
|
|
429
|
+
lines.append(" ⚠ No authentication (open relay risk if not restricted)")
|
|
430
|
+
else:
|
|
431
|
+
lines.append(" ✗ No authentication advertised")
|
|
432
|
+
|
|
433
|
+
if not has_security and not result.get('supports_starttls') and not result.get('is_ssl_wrapped'):
|
|
434
|
+
lines.append(" ✗ No encryption - traffic is sent in plaintext")
|
|
435
|
+
|
|
436
|
+
# Extended features
|
|
437
|
+
extended_features = []
|
|
438
|
+
if result.get('supports_pipelining'):
|
|
439
|
+
extended_features.append("PIPELINING (performance)")
|
|
440
|
+
if result.get('supports_8bitmime'):
|
|
441
|
+
extended_features.append("8BITMIME (international characters)")
|
|
442
|
+
if result.get('supports_smtputf8'):
|
|
443
|
+
extended_features.append("SMTPUTF8 (international email addresses)")
|
|
444
|
+
if result.get('supports_chunking'):
|
|
445
|
+
extended_features.append("CHUNKING (efficient large messages)")
|
|
446
|
+
|
|
447
|
+
if extended_features:
|
|
448
|
+
lines.append(" Extended Features:")
|
|
449
|
+
for feature in extended_features:
|
|
450
|
+
lines.append(f" • {feature}")
|
|
451
|
+
|
|
452
|
+
# Message size limit
|
|
453
|
+
if result.get('max_message_size'):
|
|
454
|
+
size_mb = result['max_message_size'] / (1024 * 1024)
|
|
455
|
+
lines.append(f" Max Message Size: {size_mb:.1f} MB ({result['max_message_size']:,} bytes)")
|
|
456
|
+
|
|
457
|
+
lines.append("")
|
|
458
|
+
|
|
459
|
+
# Overall security assessment across all MX servers
|
|
460
|
+
lines.append("=" * 70)
|
|
461
|
+
lines.append("Overall Email Infrastructure Security:")
|
|
462
|
+
lines.append("=" * 70)
|
|
463
|
+
|
|
464
|
+
# Check if any MX server has TLS and auth
|
|
465
|
+
any_has_tls = False
|
|
466
|
+
any_has_auth = False
|
|
467
|
+
all_have_tls = True
|
|
468
|
+
all_have_auth = True
|
|
469
|
+
|
|
470
|
+
for mx in mx_servers:
|
|
471
|
+
for result in mx.get('port_results', []):
|
|
472
|
+
if result.get('port_open'):
|
|
473
|
+
has_tls = result.get('supports_starttls') or result.get('is_ssl_wrapped')
|
|
474
|
+
has_auth = result.get('supports_auth')
|
|
475
|
+
|
|
476
|
+
if has_tls:
|
|
477
|
+
any_has_tls = True
|
|
478
|
+
else:
|
|
479
|
+
all_have_tls = False
|
|
480
|
+
|
|
481
|
+
if has_auth:
|
|
482
|
+
any_has_auth = True
|
|
483
|
+
else:
|
|
484
|
+
all_have_auth = False
|
|
485
|
+
|
|
486
|
+
if all_have_tls and any_has_auth:
|
|
487
|
+
lines.append(" ✓ Excellent: All mail servers support encryption and authentication")
|
|
488
|
+
elif any_has_tls and any_has_auth:
|
|
489
|
+
lines.append(" ✓ Good: Encryption and authentication available on some servers")
|
|
490
|
+
elif any_has_tls:
|
|
491
|
+
lines.append(" ⚠ Moderate: Encryption available but check authentication requirements")
|
|
492
|
+
elif any_has_auth:
|
|
493
|
+
lines.append(" ⚠ Weak: Authentication available but no encryption (credentials exposed)")
|
|
494
|
+
else:
|
|
495
|
+
lines.append(" ✗ Poor: No encryption or authentication detected on any MX server")
|
|
496
|
+
|
|
497
|
+
# Recommendations
|
|
498
|
+
lines.append("")
|
|
499
|
+
lines.append("Recommendations:")
|
|
500
|
+
|
|
501
|
+
if not all_have_tls:
|
|
502
|
+
lines.append(" • Enable STARTTLS on all MX servers for encrypted mail transport")
|
|
503
|
+
|
|
504
|
+
if not any_has_auth:
|
|
505
|
+
lines.append(" • Configure SMTP authentication on submission ports (587/465)")
|
|
506
|
+
|
|
507
|
+
# Check for multiple MX servers (redundancy)
|
|
508
|
+
if len(mx_with_open_ports) == 1:
|
|
509
|
+
lines.append(" • Consider adding backup MX servers for redundancy")
|
|
510
|
+
elif len(mx_with_open_ports) > 1:
|
|
511
|
+
lines.append(f" ✓ Good: {len(mx_with_open_ports)} MX servers provide redundancy")
|
|
512
|
+
|
|
513
|
+
if all_have_tls and any_has_auth and len(mx_with_open_ports) > 1:
|
|
514
|
+
lines.append(" ✓ Email infrastructure looks solid - continue monitoring")
|
|
515
|
+
|
|
516
|
+
lines.append("")
|
|
517
|
+
return '\n'.join(lines)
|