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.
Files changed (29) hide show
  1. changedetection_io_osint_processor-0.0.1.dist-info/METADATA +274 -0
  2. changedetection_io_osint_processor-0.0.1.dist-info/RECORD +29 -0
  3. changedetection_io_osint_processor-0.0.1.dist-info/WHEEL +5 -0
  4. changedetection_io_osint_processor-0.0.1.dist-info/entry_points.txt +2 -0
  5. changedetection_io_osint_processor-0.0.1.dist-info/licenses/LICENSE +661 -0
  6. changedetection_io_osint_processor-0.0.1.dist-info/top_level.txt +1 -0
  7. changedetectionio_osint/__init__.py +22 -0
  8. changedetectionio_osint/forms.py +289 -0
  9. changedetectionio_osint/plugin.py +37 -0
  10. changedetectionio_osint/processor.py +655 -0
  11. changedetectionio_osint/steps/__init__.py +4 -0
  12. changedetectionio_osint/steps/base.py +76 -0
  13. changedetectionio_osint/steps/bgp.py +88 -0
  14. changedetectionio_osint/steps/dns.py +147 -0
  15. changedetectionio_osint/steps/dns_scan.py +88 -0
  16. changedetectionio_osint/steps/dnssec.py +260 -0
  17. changedetectionio_osint/steps/email_security.py +236 -0
  18. changedetectionio_osint/steps/http_fingerprint.py +359 -0
  19. changedetectionio_osint/steps/http_scan.py +31 -0
  20. changedetectionio_osint/steps/mac_lookup.py +209 -0
  21. changedetectionio_osint/steps/os_detection.py +245 -0
  22. changedetectionio_osint/steps/portscan.py +113 -0
  23. changedetectionio_osint/steps/registry.py +49 -0
  24. changedetectionio_osint/steps/smtp_fingerprint.py +517 -0
  25. changedetectionio_osint/steps/ssh_fingerprint.py +310 -0
  26. changedetectionio_osint/steps/tls_analysis.py +332 -0
  27. changedetectionio_osint/steps/traceroute.py +127 -0
  28. changedetectionio_osint/steps/whois_lookup.py +125 -0
  29. changedetectionio_osint/steps/whois_scan.py +123 -0
@@ -0,0 +1,310 @@
1
+ """
2
+ SSH Fingerprinting Step
3
+ Connects to SSH service to extract server banner, version, and host key fingerprints
4
+ """
5
+
6
+ import asyncio
7
+ # SOCKS5 proxy support: SSH fingerprinting supports SOCKS5 via python-socks
8
+ supports_socks5 = True
9
+ import socket
10
+ import hashlib
11
+ import base64
12
+ from loguru import logger
13
+
14
+
15
+ async def scan_ssh(hostname, port=22, timeout=5, proxy_url=None, watch_uuid=None, update_signal=None):
16
+ """
17
+ Perform SSH server fingerprinting
18
+
19
+ Args:
20
+ hostname: Target hostname or IP address
21
+ port: SSH port (default 22)
22
+ timeout: Connection timeout in seconds
23
+ proxy_url: Optional SOCKS5 proxy URL (socks5://user:pass@host:port)
24
+ watch_uuid: Optional watch UUID for status updates
25
+ update_signal: Optional blinker signal for status updates
26
+
27
+ Returns:
28
+ dict: SSH fingerprint data
29
+ """
30
+ if update_signal and watch_uuid:
31
+ update_signal.send(watch_uuid=watch_uuid, status="SSH")
32
+
33
+ async def ssh_fingerprint():
34
+ results = {
35
+ 'port_open': False,
36
+ 'banner': None,
37
+ 'version': None,
38
+ 'software': None,
39
+ 'host_keys': [],
40
+ 'key_exchange_algorithms': [],
41
+ 'encryption_algorithms': [],
42
+ 'mac_algorithms': [],
43
+ 'compression_algorithms': [],
44
+ 'error': None
45
+ }
46
+
47
+ try:
48
+ # Connect to SSH port (with optional SOCKS5 proxy support)
49
+ if proxy_url and proxy_url.strip():
50
+ # Use SOCKS5 proxy for connection
51
+ try:
52
+ from python_socks.async_.asyncio import Proxy
53
+ from urllib.parse import urlparse
54
+
55
+ # Parse SOCKS5 proxy URL
56
+ parsed = urlparse(proxy_url)
57
+ proxy = Proxy.from_url(proxy_url)
58
+
59
+ # Connect through SOCKS5 proxy
60
+ sock = await asyncio.wait_for(
61
+ proxy.connect(dest_host=hostname, dest_port=port, timeout=timeout),
62
+ timeout=timeout
63
+ )
64
+
65
+ # Create reader/writer from the socket
66
+ reader, writer = await asyncio.open_connection(sock=sock)
67
+
68
+ except ImportError:
69
+ logger.error("SOCKS5 proxy requested but 'python-socks[asyncio]' is not installed")
70
+ results['error'] = "SOCKS5 proxy support requires 'python-socks[asyncio]' package"
71
+ return results
72
+ else:
73
+ # Direct connection (no proxy)
74
+ reader, writer = await asyncio.wait_for(
75
+ asyncio.open_connection(hostname, port),
76
+ timeout=timeout
77
+ )
78
+
79
+ results['port_open'] = True
80
+
81
+ try:
82
+ # Read SSH banner (SSH-2.0-...)
83
+ banner_line = await asyncio.wait_for(reader.readline(), timeout=timeout)
84
+ banner = banner_line.decode('utf-8', errors='ignore').strip()
85
+ results['banner'] = banner
86
+
87
+ # Parse SSH version and software
88
+ # Format: SSH-protoversion-softwareversion [SP comments]
89
+ if banner.startswith('SSH-'):
90
+ parts = banner.split('-', 2)
91
+ if len(parts) >= 3:
92
+ results['version'] = parts[1] # e.g., "2.0"
93
+ software_parts = parts[2].split(' ', 1)
94
+ results['software'] = software_parts[0] # e.g., "OpenSSH_8.2p1"
95
+
96
+ logger.debug(f"SSH banner: {banner}")
97
+
98
+ # Send our banner (minimal SSH client identification)
99
+ writer.write(b'SSH-2.0-ChangeDetectionIO_OSINT_Scanner\r\n')
100
+ await writer.drain()
101
+
102
+ # Try to read key exchange init (this contains algorithm lists)
103
+ # SSH packet format: packet_length (4 bytes) + padding_length (1 byte) + payload + padding + MAC
104
+ try:
105
+ # Read packet length (first 4 bytes after banner exchange)
106
+ packet_length_bytes = await asyncio.wait_for(reader.readexactly(4), timeout=timeout)
107
+ packet_length = int.from_bytes(packet_length_bytes, byteorder='big')
108
+
109
+ # SSH packets are typically < 35000 bytes for key exchange
110
+ if 0 < packet_length < 35000:
111
+ # Read the rest of the packet
112
+ packet_data = await asyncio.wait_for(reader.readexactly(packet_length), timeout=timeout)
113
+
114
+ # Parse SSH_MSG_KEXINIT (message type 20)
115
+ if len(packet_data) > 1 and packet_data[0] == 20:
116
+ # Skip: padding_length(1) + message_type(1) + cookie(16)
117
+ offset = 1 + 16
118
+
119
+ # Helper to read name-list (4-byte length + comma-separated names)
120
+ def read_name_list(data, start_offset):
121
+ if start_offset + 4 > len(data):
122
+ return [], start_offset
123
+ list_len = int.from_bytes(data[start_offset:start_offset+4], byteorder='big')
124
+ start_offset += 4
125
+ if start_offset + list_len > len(data):
126
+ return [], start_offset
127
+ names_bytes = data[start_offset:start_offset+list_len]
128
+ names = names_bytes.decode('utf-8', errors='ignore').split(',')
129
+ return names, start_offset + list_len
130
+
131
+ # Read algorithm name-lists in order
132
+ results['key_exchange_algorithms'], offset = read_name_list(packet_data, offset)
133
+ server_host_key_algorithms, offset = read_name_list(packet_data, offset)
134
+ results['encryption_algorithms'], offset = read_name_list(packet_data, offset)
135
+ offset = read_name_list(packet_data, offset)[1] # Skip encryption_algorithms_server_to_client
136
+ results['mac_algorithms'], offset = read_name_list(packet_data, offset)
137
+ offset = read_name_list(packet_data, offset)[1] # Skip mac_algorithms_server_to_client
138
+ results['compression_algorithms'], offset = read_name_list(packet_data, offset)
139
+
140
+ # Store host key algorithms
141
+ if server_host_key_algorithms:
142
+ for alg in server_host_key_algorithms:
143
+ results['host_keys'].append({
144
+ 'algorithm': alg,
145
+ 'fingerprint': None # Would need full key exchange to get actual keys
146
+ })
147
+
148
+ logger.debug(f"SSH algorithms parsed successfully")
149
+
150
+ except asyncio.TimeoutError:
151
+ logger.debug("Timeout reading SSH key exchange packet")
152
+ except Exception as e:
153
+ logger.debug(f"Could not parse SSH key exchange: {e}")
154
+
155
+ finally:
156
+ writer.close()
157
+ await writer.wait_closed()
158
+
159
+ except asyncio.TimeoutError:
160
+ results['error'] = f"Connection timeout (port {port})"
161
+ logger.debug(f"SSH connection timeout: {hostname}:{port}")
162
+ except ConnectionRefusedError:
163
+ results['error'] = f"Connection refused (port {port} closed or filtered)"
164
+ except socket.gaierror as e:
165
+ results['error'] = f"DNS resolution failed: {e}"
166
+ except Exception as e:
167
+ results['error'] = f"Connection error: {str(e)}"
168
+ logger.debug(f"SSH fingerprint error: {e}")
169
+
170
+ return results
171
+
172
+ return await ssh_fingerprint()
173
+
174
+
175
+ def format_ssh_results(ssh_results, port=22):
176
+ """Format SSH fingerprint results for output"""
177
+ lines = []
178
+ lines.append(f"=== SSH Server Fingerprint (Port {port}) ===")
179
+
180
+ if not ssh_results:
181
+ lines.append("No SSH scan results")
182
+ lines.append("")
183
+ return '\n'.join(lines)
184
+
185
+ if ssh_results.get('error') and not ssh_results.get('port_open'):
186
+ lines.append(f"Status: ✗ {ssh_results['error']}")
187
+ lines.append("")
188
+ return '\n'.join(lines)
189
+
190
+ if not ssh_results.get('port_open'):
191
+ lines.append(f"Status: ✗ Port {port} closed or filtered")
192
+ lines.append("")
193
+ return '\n'.join(lines)
194
+
195
+ # Port is open
196
+ lines.append(f"Status: ✓ SSH service detected on port {port}")
197
+ lines.append("")
198
+
199
+ # Banner and version
200
+ if ssh_results.get('banner'):
201
+ lines.append(f"Banner: {ssh_results['banner']}")
202
+ if ssh_results.get('version'):
203
+ lines.append(f"Protocol Version: SSH-{ssh_results['version']}")
204
+ if ssh_results.get('software'):
205
+ lines.append(f"Server Software: {ssh_results['software']}")
206
+
207
+ # Identify SSH server type
208
+ software = ssh_results['software'].lower()
209
+ if 'openssh' in software:
210
+ lines.append(" Server Type: OpenSSH (most common, widely audited)")
211
+ elif 'dropbear' in software:
212
+ lines.append(" Server Type: Dropbear (lightweight SSH server)")
213
+ elif 'libssh' in software:
214
+ lines.append(" Server Type: libssh (SSH library implementation)")
215
+ elif 'cisco' in software:
216
+ lines.append(" Server Type: Cisco SSH (network device)")
217
+ elif 'rosssh' in software:
218
+ lines.append(" Server Type: ROS SSH (MikroTik RouterOS)")
219
+ else:
220
+ lines.append(" Server Type: Unknown or custom implementation")
221
+
222
+ # Host key algorithms
223
+ if ssh_results.get('host_keys'):
224
+ lines.append("")
225
+ lines.append("Supported Host Key Algorithms:")
226
+ for key in ssh_results['host_keys']:
227
+ alg = key['algorithm']
228
+ lines.append(f" • {alg}")
229
+
230
+ # Security assessment of algorithm
231
+ if 'ed25519' in alg:
232
+ lines.append(" Security: ✓ Excellent (Ed25519 - modern, fast, secure)")
233
+ elif 'ecdsa' in alg:
234
+ lines.append(" Security: ✓ Good (ECDSA - modern elliptic curve)")
235
+ elif 'rsa' in alg and 'sha256' in alg:
236
+ lines.append(" Security: ✓ Good (RSA with SHA-256)")
237
+ elif 'rsa' in alg and 'sha512' in alg:
238
+ lines.append(" Security: ✓ Good (RSA with SHA-512)")
239
+ elif 'rsa' in alg:
240
+ lines.append(" Security: ⚠ Acceptable (RSA - ensure >= 2048 bits)")
241
+ elif 'dsa' in alg or 'dss' in alg:
242
+ lines.append(" Security: ✗ Weak (DSA - deprecated, insecure)")
243
+ elif 'ssh-rsa' == alg:
244
+ lines.append(" Security: ⚠ Legacy (ssh-rsa - being phased out)")
245
+
246
+ # Key exchange algorithms
247
+ if ssh_results.get('key_exchange_algorithms'):
248
+ lines.append("")
249
+ lines.append("Key Exchange Algorithms:")
250
+ # Show only first 5 to avoid clutter
251
+ for alg in ssh_results['key_exchange_algorithms'][:5]:
252
+ lines.append(f" • {alg}")
253
+ if len(ssh_results['key_exchange_algorithms']) > 5:
254
+ lines.append(f" ... and {len(ssh_results['key_exchange_algorithms']) - 5} more")
255
+
256
+ # Encryption algorithms
257
+ if ssh_results.get('encryption_algorithms'):
258
+ lines.append("")
259
+ lines.append("Encryption Algorithms (Ciphers):")
260
+ for alg in ssh_results['encryption_algorithms'][:5]:
261
+ lines.append(f" • {alg}")
262
+ # Flag weak ciphers
263
+ if 'cbc' in alg.lower():
264
+ lines.append(" ⚠ CBC mode (vulnerable to certain attacks)")
265
+ elif 'arcfour' in alg.lower() or 'rc4' in alg.lower():
266
+ lines.append(" ✗ RC4 (broken, should be disabled)")
267
+ elif '3des' in alg.lower():
268
+ lines.append(" ⚠ 3DES (outdated, slow)")
269
+ elif 'chacha20' in alg.lower() or 'aes.*gcm' in alg.lower():
270
+ lines.append(" ✓ Modern authenticated encryption")
271
+ if len(ssh_results['encryption_algorithms']) > 5:
272
+ lines.append(f" ... and {len(ssh_results['encryption_algorithms']) - 5} more")
273
+
274
+ # MAC algorithms
275
+ if ssh_results.get('mac_algorithms'):
276
+ lines.append("")
277
+ lines.append("MAC (Message Authentication) Algorithms:")
278
+ for alg in ssh_results['mac_algorithms'][:5]:
279
+ lines.append(f" • {alg}")
280
+ if len(ssh_results['mac_algorithms']) > 5:
281
+ lines.append(f" ... and {len(ssh_results['mac_algorithms']) - 5} more")
282
+
283
+ # Compression
284
+ if ssh_results.get('compression_algorithms'):
285
+ lines.append("")
286
+ compression = ', '.join(ssh_results['compression_algorithms'])
287
+ lines.append(f"Compression: {compression}")
288
+
289
+ # Security recommendations
290
+ lines.append("")
291
+ lines.append("Security Recommendations:")
292
+ has_issues = False
293
+
294
+ if ssh_results.get('host_keys'):
295
+ weak_keys = [k for k in ssh_results['host_keys'] if 'dsa' in k['algorithm'].lower() or 'dss' in k['algorithm'].lower()]
296
+ if weak_keys:
297
+ lines.append(" ⚠ Disable weak DSA host keys")
298
+ has_issues = True
299
+
300
+ if ssh_results.get('encryption_algorithms'):
301
+ weak_ciphers = [c for c in ssh_results['encryption_algorithms'] if 'cbc' in c.lower() or 'arcfour' in c.lower() or '3des' in c.lower()]
302
+ if weak_ciphers:
303
+ lines.append(" ⚠ Disable weak/legacy ciphers (CBC mode, 3DES, RC4)")
304
+ has_issues = True
305
+
306
+ if not has_issues:
307
+ lines.append(" ✓ No obvious security issues detected")
308
+
309
+ lines.append("")
310
+ return '\n'.join(lines)
@@ -0,0 +1,332 @@
1
+ """
2
+ TLS/SSL Analysis Step
3
+ Deep SSL/TLS certificate and cipher analysis using SSLyze
4
+ """
5
+
6
+ import asyncio
7
+ # SOCKS5 proxy support: SSLyze library doesn't support SOCKS5 proxies (TODO: implement custom socket wrapper)
8
+ supports_socks5 = False
9
+ from loguru import logger
10
+
11
+
12
+ async def scan_tls(hostname, port=443, watch_uuid=None, update_signal=None, vulnerability_scan=False):
13
+ """
14
+ Perform comprehensive TLS/SSL analysis
15
+
16
+ Args:
17
+ hostname: Target hostname
18
+ port: Port number (default 443)
19
+ watch_uuid: Optional watch UUID for status updates
20
+ update_signal: Optional blinker signal for status updates
21
+ vulnerability_scan: Enable vulnerability scanning (Heartbleed, ROBOT, etc.)
22
+
23
+ Returns:
24
+ list: SSL scan results from SSLyze
25
+ """
26
+ if update_signal and watch_uuid:
27
+ update_signal.send(watch_uuid=watch_uuid, status="TLS")
28
+
29
+ def run_sslyze_scan():
30
+ from sslyze import (
31
+ Scanner,
32
+ ServerNetworkLocation,
33
+ ServerScanRequest,
34
+ ScanCommand
35
+ )
36
+
37
+ # Create server location
38
+ server_location = ServerNetworkLocation(hostname=hostname, port=port)
39
+
40
+ # Define scan commands - always include certificate and cipher suites
41
+ scan_commands = {
42
+ ScanCommand.CERTIFICATE_INFO,
43
+ ScanCommand.SSL_2_0_CIPHER_SUITES,
44
+ ScanCommand.SSL_3_0_CIPHER_SUITES,
45
+ ScanCommand.TLS_1_0_CIPHER_SUITES,
46
+ ScanCommand.TLS_1_1_CIPHER_SUITES,
47
+ ScanCommand.TLS_1_2_CIPHER_SUITES,
48
+ ScanCommand.TLS_1_3_CIPHER_SUITES,
49
+ }
50
+
51
+ # Add vulnerability scans if enabled - AUTOMATICALLY discover all security checks
52
+ if vulnerability_scan:
53
+ # Get all available scan commands from sslyze
54
+ all_commands = [cmd for cmd in dir(ScanCommand) if not cmd.startswith('_') and cmd.isupper()]
55
+
56
+ # Filter out cipher suites and certificate commands we already have
57
+ cipher_protocol_commands = {
58
+ 'SSL_2_0_CIPHER_SUITES', 'SSL_3_0_CIPHER_SUITES',
59
+ 'TLS_1_0_CIPHER_SUITES', 'TLS_1_1_CIPHER_SUITES',
60
+ 'TLS_1_2_CIPHER_SUITES', 'TLS_1_3_CIPHER_SUITES',
61
+ 'CERTIFICATE_INFO'
62
+ }
63
+
64
+ # Add all other security/vulnerability check commands
65
+ security_commands = [
66
+ getattr(ScanCommand, cmd)
67
+ for cmd in all_commands
68
+ if cmd not in cipher_protocol_commands
69
+ ]
70
+ scan_commands.update(security_commands)
71
+
72
+ logger.debug(f"TLS vulnerability scanning enabled for {hostname}:{port} - {len(security_commands)} additional checks")
73
+
74
+ scan_request = ServerScanRequest(
75
+ server_location=server_location,
76
+ scan_commands=scan_commands
77
+ )
78
+
79
+ # Run scan
80
+ scanner = Scanner()
81
+ scanner.queue_scans([scan_request])
82
+
83
+ # Get results
84
+ ssl_results = []
85
+ for result in scanner.get_results():
86
+ ssl_results.append(result)
87
+
88
+ return ssl_results
89
+
90
+ try:
91
+ return await asyncio.to_thread(run_sslyze_scan)
92
+ except Exception as e:
93
+ logger.error(f"TLS analysis failed: {e}")
94
+ return []
95
+
96
+
97
+ def format_tls_results(ssl_results, expire_warning_days=3, watch_uuid=None, update_signal=None):
98
+ """Format TLS/SSL results for output
99
+
100
+ Args:
101
+ ssl_results: SSL scan results from SSLyze
102
+ expire_warning_days: Number of days before expiration to show warning (default: 3)
103
+ watch_uuid: Optional watch UUID for status updates
104
+ update_signal: Optional blinker signal for status updates
105
+ """
106
+ lines = []
107
+ lines.append("=== SSL/TLS Analysis (SSLyze) ===")
108
+ lines.append("SERVER TLS Fingerprint - Full capabilities for JA3S analysis")
109
+ lines.append("")
110
+
111
+ if ssl_results:
112
+ scan_result = ssl_results[0]
113
+
114
+ # Check if scan was successful
115
+ if not scan_result.scan_result:
116
+ lines.append("TLS scan failed - target may not support TLS/SSL on this port")
117
+ lines.append("")
118
+ return '\n'.join(lines)
119
+
120
+ # Certificate info
121
+ if scan_result.scan_result.certificate_info and scan_result.scan_result.certificate_info.result:
122
+ cert_scan_result = scan_result.scan_result.certificate_info.result
123
+ lines.append("Certificate Information:")
124
+
125
+ for cert_deployment in cert_scan_result.certificate_deployments:
126
+ cert = cert_deployment.received_certificate_chain[0]
127
+ lines.append(f" Subject: {cert.subject.rfc4514_string()}")
128
+ lines.append(f" Issuer: {cert.issuer.rfc4514_string()}")
129
+
130
+ # Certificate validity dates
131
+ valid_from = cert.not_valid_before_utc
132
+ valid_until = cert.not_valid_after_utc
133
+
134
+ # Get current time in UTC (timezone-aware)
135
+ from datetime import datetime, timezone
136
+ now_utc = datetime.now(timezone.utc)
137
+
138
+ # Check if certificate is currently valid
139
+ is_valid = valid_from <= now_utc <= valid_until
140
+
141
+ # Calculate days until expiry
142
+ days_until_expiry = (valid_until - now_utc).days
143
+
144
+ lines.append(f" Valid From: {valid_from}")
145
+ lines.append(f" Valid Until: {valid_until}")
146
+
147
+ # Add validity status
148
+ if is_valid:
149
+ if days_until_expiry < 0:
150
+ lines.append(f" Status: ✗ EXPIRED")
151
+ elif days_until_expiry <= 7:
152
+ lines.append(f" Status: ⚠ EXPIRING SOON")
153
+ elif days_until_expiry <= 30:
154
+ lines.append(f" Status: ✓ Valid")
155
+ else:
156
+ lines.append(f" Status: ✓ Valid")
157
+ elif now_utc < valid_from:
158
+ lines.append(f" Status: ✗ NOT YET VALID")
159
+ else:
160
+ lines.append(f" Status: ✗ EXPIRED")
161
+
162
+ # Add expiration countdown if within configured warning days
163
+ if expire_warning_days > 0:
164
+ try:
165
+ # Ensure timezone-aware comparison
166
+ cert_valid_until = valid_until
167
+ cert_valid_from = valid_from
168
+
169
+ # Make timezone-aware if needed (though should already be UTC)
170
+ if cert_valid_until.tzinfo is None:
171
+ cert_valid_until = cert_valid_until.replace(tzinfo=timezone.utc)
172
+ if cert_valid_from.tzinfo is None:
173
+ cert_valid_from = cert_valid_from.replace(tzinfo=timezone.utc)
174
+
175
+ # Recalculate for safety
176
+ days_left = (cert_valid_until - now_utc).days
177
+
178
+ if days_left <= expire_warning_days and days_left >= 0:
179
+ if days_left == 0:
180
+ lines.append(" ⚠️ WARNING: Certificate expires TODAY!")
181
+ elif days_left == 1:
182
+ lines.append(" ⚠️ WARNING: Certificate expires in 1 day")
183
+ else:
184
+ lines.append(f" ⚠️ WARNING: Certificate expires in {days_left} days")
185
+ elif days_left < 0:
186
+ lines.append(" ⚠️ WARNING: Certificate has EXPIRED!")
187
+ elif now_utc < cert_valid_from:
188
+ lines.append(" ⚠️ WARNING: Certificate is not yet valid!")
189
+ except Exception as e:
190
+ logger.error(f"Could not calculate certificate expiration countdown: {e}")
191
+ lines.append(f" ⚠️ ERROR: Could not calculate certificate expiration: {e}")
192
+
193
+ lines.append(f" Serial Number: {cert.serial_number}")
194
+
195
+ # Subject Alternative Names
196
+ try:
197
+ from cryptography.x509.oid import ExtensionOID
198
+ san_ext = cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
199
+ san_names = [name.value for name in san_ext.value]
200
+ lines.append(f" SANs: {', '.join(san_names)}")
201
+ except:
202
+ pass
203
+
204
+ # Cipher suites
205
+ lines.append("")
206
+ lines.append("Supported TLS Versions & Cipher Suites:")
207
+
208
+ for tls_version in ['TLS_1_3', 'TLS_1_2', 'TLS_1_1', 'TLS_1_0', 'SSL_3_0', 'SSL_2_0']:
209
+ cipher_attr = tls_version.lower() + '_cipher_suites'
210
+ cipher_scan_attempt = getattr(scan_result.scan_result, cipher_attr, None)
211
+
212
+ if cipher_scan_attempt and cipher_scan_attempt.result:
213
+ cipher_result = cipher_scan_attempt.result
214
+ if cipher_result.accepted_cipher_suites:
215
+ lines.append(f" {tls_version.replace('_', ' ')}:")
216
+ for cipher in cipher_result.accepted_cipher_suites:
217
+ lines.append(f" - {cipher.cipher_suite.name}")
218
+
219
+ # Vulnerability Scan Results (if enabled) - GENERIC PARSER
220
+ vulnerabilities = []
221
+
222
+ # Map of known vulnerability names to display strings (for prettier output)
223
+ vuln_display_names = {
224
+ 'heartbleed': 'Heartbleed (CVE-2014-0160)',
225
+ 'robot': 'ROBOT Attack',
226
+ 'openssl_ccs_injection': 'OpenSSL CCS Injection (CVE-2014-0224)',
227
+ 'tls_compression': 'TLS Compression (CRIME)',
228
+ 'session_renegotiation': 'Insecure Renegotiation',
229
+ 'tls_fallback_scsv': 'TLS Downgrade Protection',
230
+ 'tls_1_3_early_data': 'TLS 1.3 0-RTT',
231
+ 'elliptic_curves': 'Elliptic Curves Support',
232
+ 'session_resumption': 'Session Resumption',
233
+ 'tls_extended_master_secret': 'Extended Master Secret',
234
+ }
235
+
236
+ # Generic vulnerability checker - works for ANY sslyze vulnerability scan
237
+ for attr_name in dir(scan_result.scan_result):
238
+ if attr_name.startswith('_'):
239
+ continue
240
+
241
+ # Skip cipher suite and certificate scans
242
+ if 'cipher' in attr_name.lower() or attr_name == 'certificate_info':
243
+ continue
244
+
245
+ scan_attempt = getattr(scan_result.scan_result, attr_name, None)
246
+ if not scan_attempt or not hasattr(scan_attempt, 'result'):
247
+ continue
248
+
249
+ result_obj = scan_attempt.result
250
+ if not result_obj:
251
+ continue
252
+
253
+ # Send status update for this vulnerability check
254
+ if update_signal and watch_uuid:
255
+ check_name = vuln_display_names.get(attr_name, attr_name.replace('_', ' ').title())
256
+ update_signal.send(watch_uuid=watch_uuid, status=f"TLS: {check_name}")
257
+
258
+ try:
259
+ # Try to determine if this is a vulnerability
260
+ is_vulnerable = False
261
+ vuln_detected = False
262
+
263
+ # Common vulnerability indicator patterns
264
+ result_dict = vars(result_obj) if hasattr(result_obj, '__dict__') else {}
265
+
266
+ # Check for "is_vulnerable_to_*" patterns
267
+ for key, value in result_dict.items():
268
+ if 'vulnerable' in key.lower() and value is True:
269
+ is_vulnerable = True
270
+ vuln_detected = True
271
+ break
272
+ # Check for "supports_" patterns where support might be bad
273
+ if attr_name == 'tls_compression' and key == 'supports_compression' and value:
274
+ is_vulnerable = True
275
+ vuln_detected = True
276
+ # Check for ROBOT result enum
277
+ if 'robot' in key.lower() and 'VULNERABLE' in str(value).upper():
278
+ is_vulnerable = True
279
+ vuln_detected = True
280
+ # Check for insecure renegotiation
281
+ if attr_name == 'session_renegotiation':
282
+ is_secure = getattr(result_obj, 'is_secure_renegotiation_supported', True)
283
+ accepts_client = getattr(result_obj, 'accepts_client_renegotiation', False)
284
+ if not is_secure or accepts_client:
285
+ is_vulnerable = True
286
+ vuln_detected = True
287
+ # Check for missing downgrade protection
288
+ if attr_name == 'tls_fallback_scsv':
289
+ supports = getattr(result_obj, 'supports_fallback_scsv', True)
290
+ if not supports:
291
+ is_vulnerable = True
292
+ vuln_detected = True
293
+
294
+ # If we detected this vulnerability check, add it to results
295
+ if vuln_detected or attr_name in vuln_display_names:
296
+ display_name = vuln_display_names.get(attr_name, attr_name.replace('_', ' ').title())
297
+ vulnerabilities.append((display_name, is_vulnerable))
298
+
299
+ except Exception as e:
300
+ logger.debug(f"Could not parse {attr_name} result: {e}")
301
+
302
+ # Display vulnerability scan report - show all checks
303
+ if vulnerabilities:
304
+ lines.append("")
305
+ lines.append("=== TLS Security Vulnerability Report ===")
306
+
307
+ # Count vulnerable issues
308
+ vulnerable_count = sum(1 for _, is_vuln in vulnerabilities if is_vuln)
309
+
310
+ if vulnerable_count > 0:
311
+ lines.append(f"Status: ⚠️ {vulnerable_count} issue(s) found")
312
+ else:
313
+ lines.append("Status: ✓ All checks passed")
314
+
315
+ lines.append("")
316
+ for vuln_name, is_vulnerable in vulnerabilities:
317
+ status = "✗ VULNERABLE" if is_vulnerable else "✓ Secure"
318
+ lines.append(f" {status}: {vuln_name}")
319
+
320
+ # HTTP Security Headers (if scanned)
321
+ if hasattr(scan_result.scan_result, 'http_headers') and scan_result.scan_result.http_headers:
322
+ if scan_result.scan_result.http_headers.result:
323
+ lines.append("")
324
+ lines.append("HTTP Security Headers:")
325
+ headers = scan_result.scan_result.http_headers.result
326
+ if hasattr(headers, 'strict_transport_security_header') and headers.strict_transport_security_header:
327
+ lines.append(f" ✓ HSTS: {headers.strict_transport_security_header.max_age} seconds")
328
+ else:
329
+ lines.append(" ✗ HSTS: Not set")
330
+
331
+ lines.append("")
332
+ return '\n'.join(lines)