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,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)
|