certmonitor 0.1.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,3 @@
1
+ from certmonitor.core import CertMonitor
2
+
3
+ __all__ = ["CertMonitor"]
@@ -0,0 +1,135 @@
1
+ # cipher_algorithms.py
2
+
3
+ from functools import lru_cache
4
+ import re
5
+
6
+ """
7
+ This module defines:
8
+ - Patterns for parsing cipher suites into their components.
9
+ - Centrally managed allowed TLS versions and cipher suites.
10
+ - Functions to update these allowed lists at runtime.
11
+
12
+ By using allowed lists, the validator fails if the target negotiates
13
+ a version or cipher suite not present in these lists.
14
+
15
+ Users and maintainers can:
16
+ 1. View current algorithms using `list_algorithms()`.
17
+ 2. Update cipher parsing patterns using `update_algorithms()`.
18
+ 3. Update allowed TLS versions and cipher suites using `update_allowed_lists()`.
19
+
20
+ Default values are based on commonly accepted industry standards.
21
+ """
22
+
23
+ ALL_ALGORITHMS = {
24
+ "encryption": {
25
+ "AES": r"AES",
26
+ "CHACHA20": r"CHACHA20",
27
+ "3DES": r"3DES|DES-EDE3",
28
+ "CAMELLIA": r"CAMELLIA",
29
+ "ARIA": r"ARIA",
30
+ "SEED": r"SEED",
31
+ "SM4": r"SM4",
32
+ "IDEA": r"IDEA",
33
+ "RC4": r"RC4",
34
+ },
35
+ "key_exchange": {
36
+ "ECDHE": r"ECDHE|EECDH",
37
+ "DHE": r"DHE|EDH",
38
+ "ECDH": r"ECDH",
39
+ "DH": r"DH",
40
+ "RSA": r"RSA",
41
+ "PSK": r"PSK",
42
+ "SRP": r"SRP",
43
+ "GOST": r"GOST",
44
+ "ECCPWD": r"ECCPWD",
45
+ "SM2": r"SM2",
46
+ },
47
+ "mac": {
48
+ "SHA384": r"SHA384",
49
+ "SHA256": r"SHA256",
50
+ "SHA224": r"SHA224",
51
+ "SHA": r"SHA1?", # Matches 'SHA' or 'SHA1'
52
+ "MD5": r"MD5",
53
+ "POLY1305": r"POLY1305",
54
+ "AEAD": r"GCM|CCM|OCB",
55
+ "GOST": r"GOST28147|GOST34\.11",
56
+ "SM3": r"SM3",
57
+ },
58
+ }
59
+
60
+ # Compile all regex patterns
61
+ for category in ALL_ALGORITHMS.values():
62
+ for alg, pattern in category.items():
63
+ category[alg] = re.compile(pattern)
64
+
65
+
66
+ @lru_cache(maxsize=128)
67
+ def parse_cipher_suite(cipher_suite):
68
+ """
69
+ Parse a cipher suite string to identify encryption, key exchange, and MAC algorithms.
70
+ """
71
+ result = {"encryption": "Unknown", "key_exchange": "Unknown", "mac": "Unknown"}
72
+
73
+ for category, algorithms in ALL_ALGORITHMS.items():
74
+ for alg, pattern in algorithms.items():
75
+ if pattern.search(cipher_suite):
76
+ result[category] = alg
77
+ break
78
+
79
+ return result
80
+
81
+
82
+ def list_algorithms():
83
+ """
84
+ List all known algorithms by category.
85
+ """
86
+ alg_list = {}
87
+ for category, alg_dict in ALL_ALGORITHMS.items():
88
+ alg_list[category] = list(alg_dict.keys())
89
+ return alg_list
90
+
91
+
92
+ def update_algorithms(custom_algorithms):
93
+ """
94
+ Update the ALL_ALGORITHMS dictionary with user-provided custom algorithms.
95
+ """
96
+ global ALL_ALGORITHMS
97
+
98
+ for category, algs in custom_algorithms.items():
99
+ if category not in ALL_ALGORITHMS:
100
+ ALL_ALGORITHMS[category] = {}
101
+ for alg_name, pattern in algs.items():
102
+ ALL_ALGORITHMS[category][alg_name] = re.compile(pattern)
103
+
104
+ parse_cipher_suite.cache_clear()
105
+
106
+
107
+ # Default allowed lists for TLS versions and cipher suites.
108
+ # If a negotiated version or cipher is not in these sets, validation fails.
109
+ ALLOWED_TLS_VERSIONS = {"TLSv1.2", "TLSv1.3"}
110
+
111
+ ALLOWED_CIPHER_SUITES = {
112
+ # Following industry guidelines (e.g., Mozilla's "Intermediate" TLS configuration)
113
+ "ECDHE-ECDSA-AES128-GCM-SHA256",
114
+ "ECDHE-RSA-AES128-GCM-SHA256",
115
+ "ECDHE-ECDSA-CHACHA20-POLY1305",
116
+ "ECDHE-RSA-CHACHA20-POLY1305",
117
+ "ECDHE-ECDSA-AES256-GCM-SHA384",
118
+ "ECDHE-RSA-AES256-GCM-SHA384",
119
+ }
120
+
121
+
122
+ def update_allowed_lists(custom_tls_versions=None, custom_ciphers=None):
123
+ """
124
+ Update the sets of allowed TLS versions and cipher suites.
125
+
126
+ Args:
127
+ custom_tls_versions (set): A set of allowed TLS versions. E.g., {"TLSv1.2", "TLSv1.3"}
128
+ custom_ciphers (set): A set of allowed cipher suites. E.g., {"ECDHE-RSA-AES128-GCM-SHA256"}
129
+ """
130
+ global ALLOWED_TLS_VERSIONS, ALLOWED_CIPHER_SUITES
131
+ if custom_tls_versions is not None:
132
+ ALLOWED_TLS_VERSIONS = custom_tls_versions
133
+
134
+ if custom_ciphers is not None:
135
+ ALLOWED_CIPHER_SUITES = custom_ciphers
certmonitor/config.py ADDED
@@ -0,0 +1,14 @@
1
+ # config.py
2
+
3
+ import os
4
+
5
+ # Default validators if not set in environment
6
+ DEFAULT_VALIDATORS = ["expiration", "hostname", "root_certificate"]
7
+
8
+ # Read from environment variable, fall back to default if not set
9
+ ENABLED_VALIDATORS = (
10
+ os.environ.get("ENABLED_VALIDATORS", "").split(",") or DEFAULT_VALIDATORS
11
+ )
12
+
13
+ # Remove any empty strings that might result from splitting
14
+ ENABLED_VALIDATORS = [v.strip() for v in ENABLED_VALIDATORS if v.strip()]
certmonitor/core.py ADDED
@@ -0,0 +1,438 @@
1
+ # core.py
2
+
3
+ import ipaddress
4
+ import logging
5
+ import os
6
+ import socket
7
+ import ssl
8
+ import tempfile
9
+ from typing import Any, Dict, Optional
10
+
11
+ import certinfo
12
+
13
+ from certmonitor import config
14
+ from certmonitor.cipher_algorithms import parse_cipher_suite
15
+ from certmonitor.error_handlers import ErrorHandler
16
+ from certmonitor.protocol_handlers.ssh_handler import SSHHandler
17
+ from certmonitor.protocol_handlers.ssl_handler import SSLHandler
18
+ from certmonitor.validators import VALIDATORS
19
+
20
+
21
+ class CertMonitor:
22
+ """Class for monitoring and retrieving certificate details from a given host."""
23
+
24
+ def __init__(
25
+ self,
26
+ host: str,
27
+ port: int = 443,
28
+ enabled_validators: list = config.DEFAULT_VALIDATORS,
29
+ ):
30
+ """Initialize the CertMonitor with the specified host and port."""
31
+ self.host = host
32
+ self.port = port
33
+ self.is_ip = self._is_ip_address(host)
34
+ self.der = None
35
+ self.pem = None
36
+ self.cert_info = None
37
+ self.validators = VALIDATORS
38
+ self.enabled_validators = enabled_validators or config.ENABLED_VALIDATORS
39
+ self.error_handler = ErrorHandler()
40
+ self.handler = None
41
+ self.protocol = None
42
+ self.connected = False
43
+
44
+ def __enter__(self):
45
+ """Enter the runtime context related to this object."""
46
+ self.connect()
47
+ return self
48
+
49
+ def __exit__(self, exc_type, exc_value, traceback):
50
+ """Exit the runtime context related to this object."""
51
+ self.close()
52
+
53
+ def connect(self) -> Optional[Dict[str, Any]]:
54
+ """Establishes a connection to the host if not already connected."""
55
+ if self.connected:
56
+ logging.debug("Already connected, skipping connection attempt")
57
+ return None
58
+
59
+ self.protocol = self.detect_protocol()
60
+ if isinstance(self.protocol, dict) and "error" in self.protocol:
61
+ return self.protocol
62
+
63
+ if self.protocol == "ssl":
64
+ self.handler = SSLHandler(self.host, self.port, self.error_handler)
65
+ elif self.protocol == "ssh":
66
+ self.handler = SSHHandler(self.host, self.port, self.error_handler)
67
+ else:
68
+ return self.error_handler.handle_error(
69
+ "ProtocolError",
70
+ f"Unsupported protocol: {self.protocol}",
71
+ self.host,
72
+ self.port,
73
+ )
74
+
75
+ connection_result = self.handler.connect()
76
+ if connection_result is not None: # This means there was an error
77
+ return connection_result
78
+
79
+ self.connected = True
80
+ logging.debug(f"Successfully connected to {self.host}:{self.port}")
81
+ return None
82
+
83
+ def close(self):
84
+ """Close the connection and reset the handler."""
85
+ if self.handler:
86
+ self.handler.close()
87
+ self.handler = None
88
+
89
+ def detect_protocol(self):
90
+ """Detect the protocol used by the host."""
91
+ try:
92
+ with socket.create_connection((self.host, self.port), timeout=10) as sock:
93
+ sock.setblocking(False)
94
+ try:
95
+ data = sock.recv(4, socket.MSG_PEEK)
96
+ if data.startswith(b"SSH-"):
97
+ return "ssh"
98
+ elif data[0] in [22, 128, 160]: # Common first bytes for SSL/TLS
99
+ return "ssl"
100
+ else:
101
+ return self.error_handler.handle_error(
102
+ "ProtocolDetectionError",
103
+ f"Unable to determine protocol. First bytes: {data.hex()}",
104
+ self.host,
105
+ self.port,
106
+ )
107
+ except socket.error:
108
+ # If no data is received, assume it's SSL
109
+ return "ssl"
110
+ finally:
111
+ sock.setblocking(True)
112
+ except Exception as e:
113
+ return self.error_handler.handle_error(
114
+ "ConnectionError", str(e), self.host, self.port
115
+ )
116
+
117
+ def _ensure_connection(self):
118
+ """Ensures that a valid connection is established."""
119
+ if not self.connected:
120
+ connect_result = self.connect()
121
+ if connect_result is not None: # This means there was an error
122
+ raise ConnectionError(
123
+ f"Failed to establish connection: {connect_result}"
124
+ )
125
+ else:
126
+ try:
127
+ self.handler.check_connection()
128
+ except ConnectionError:
129
+ logging.warning("Connection lost, attempting to reconnect")
130
+ self.connected = False
131
+ connect_result = self.connect()
132
+ if connect_result is not None: # This means there was an error
133
+ raise ConnectionError(
134
+ f"Failed to re-establish connection: {connect_result}"
135
+ )
136
+
137
+ def _is_ip_address(self, host: str) -> bool:
138
+ """Check if the provided host is an IP address."""
139
+ try:
140
+ ipaddress.ip_address(host)
141
+ return True
142
+ except ValueError:
143
+ return False
144
+
145
+ def _fetch_raw_cert(self) -> Dict[str, Any]:
146
+ """Fetches the raw certificate from the connected host."""
147
+ self._ensure_connection()
148
+ cert_data = self.handler.fetch_raw_cert()
149
+
150
+ if isinstance(cert_data, dict) and "error" in cert_data:
151
+ return cert_data
152
+
153
+ cert_info = cert_data["cert_info"]
154
+ self.der = cert_data["der"]
155
+ self.pem = cert_data["pem"]
156
+ self.public_key_info = {}
157
+
158
+ if not cert_info:
159
+ # If getpeercert() returns an empty dict, we'll parse the cert ourselves
160
+ cert_data["cert_info"] = self._parse_pem_cert(self.pem)
161
+
162
+ if self.der:
163
+ try:
164
+ # parse_public_key_info expects DER bytes and returns e.g.
165
+ # {"algorithm": "rsaEncryption", "size": 2048, "curve": None}
166
+ pubkey = certinfo.parse_public_key_info(self.der)
167
+ cert_data["public_key_info"] = pubkey
168
+ self.public_key_info = pubkey
169
+ except Exception as e:
170
+ logging.error(f"Unable to parse public key info: {e}")
171
+ # If you want, store a partial or error object here instead
172
+ cert_data["public_key_info"] = {
173
+ "error": f"Failed to parse public key info: {e}"
174
+ }
175
+ else:
176
+ # If there's no DER, we can't parse the public key
177
+ cert_data["public_key_info"] = {"error": "DER bytes not available"}
178
+
179
+ self.cert_data = cert_data
180
+ return cert_data
181
+
182
+ def _fetch_raw_cipher(self) -> tuple:
183
+ """Fetch the raw cipher information."""
184
+ self._ensure_connection()
185
+ if self.protocol != "ssl":
186
+ return self.error_handler.handle_error(
187
+ "ProtocolError",
188
+ "Cipher information is only available for SSL/TLS connections",
189
+ self.host,
190
+ self.port,
191
+ )
192
+ return self.handler.fetch_raw_cipher()
193
+
194
+ def _parse_pem_cert(self, pem_cert: str) -> dict:
195
+ """Parse a PEM formatted certificate to extract relevant details."""
196
+ with tempfile.NamedTemporaryFile(delete=False, mode="w") as temp_file:
197
+ temp_file.write(pem_cert)
198
+ temp_file.flush()
199
+ temp_file_path = temp_file.name
200
+
201
+ try:
202
+ cert_details = ssl._ssl._test_decode_cert(temp_file_path)
203
+ finally:
204
+ os.remove(temp_file_path)
205
+
206
+ return cert_details
207
+
208
+ def _to_structured_dict(self, data) -> dict:
209
+ """Convert the certificate data into a structured dictionary format.
210
+
211
+ Args:
212
+ data (dict): The certificate data.
213
+
214
+ Returns:
215
+ dict: A dictionary containing the structured certificate data.
216
+ """
217
+
218
+ def _handle_duplicate_keys(data):
219
+ result = {}
220
+ for key, value in data:
221
+ if key in result:
222
+ if not isinstance(result[key], list):
223
+ result[key] = [result[key]]
224
+ result[key].append(self._to_structured_dict(value))
225
+ else:
226
+ result[key] = self._to_structured_dict(value)
227
+ return result
228
+
229
+ if isinstance(data, (tuple, list)):
230
+ if all(isinstance(item, tuple) and len(item) == 2 for item in data):
231
+ return _handle_duplicate_keys(data)
232
+ return [self._to_structured_dict(item) for item in data]
233
+ elif isinstance(data, dict):
234
+ result = {}
235
+ for key, value in data.items():
236
+ if key in ["subject", "issuer"]:
237
+ result[key] = _handle_duplicate_keys(
238
+ [item for sublist in value for item in sublist]
239
+ )
240
+ else:
241
+ result[key] = self._to_structured_dict(value)
242
+ return result
243
+ else:
244
+ return data
245
+
246
+ def get_cert_info(self) -> Dict[str, Any]:
247
+ """Retrieves and structures the certificate details."""
248
+ if not self.cert_info:
249
+ try:
250
+ self._ensure_connection()
251
+ cert = self._fetch_raw_cert()
252
+
253
+ if isinstance(cert, dict) and "error" in cert:
254
+ logging.error(f"Error in fetching raw certificate: {cert}")
255
+ return cert
256
+
257
+ self.cert_data["cert_info"] = self._to_structured_dict(
258
+ cert["cert_info"]
259
+ )
260
+ self.cert_info = self.cert_data["cert_info"]
261
+ logging.debug("Certificate info retrieved and structured")
262
+ except Exception as e:
263
+ logging.exception("Error while getting certificate info")
264
+ return self.error_handler.handle_error(
265
+ "UnknownError", str(e), self.host, self.port
266
+ )
267
+
268
+ return self.cert_info
269
+
270
+ def get_raw_der(self) -> bytes:
271
+ """Return the raw DER format of the certificate."""
272
+ if self.protocol != "ssl":
273
+ return self.error_handler.handle_error(
274
+ "ProtocolError",
275
+ "DER format is only available for SSL/TLS connections",
276
+ self.host,
277
+ self.port,
278
+ )
279
+
280
+ self._ensure_connection()
281
+
282
+ if self.der is None:
283
+ cert_data = self.handler.fetch_raw_cert()
284
+ self.der = cert_data.get("der")
285
+
286
+ return self.der
287
+
288
+ def get_raw_pem(self) -> str:
289
+ """Return the raw PEM format of the certificate."""
290
+ if self.protocol != "ssl":
291
+ return self.error_handler.handle_error(
292
+ "ProtocolError",
293
+ "PEM format is only available for SSL/TLS connections",
294
+ self.host,
295
+ self.port,
296
+ )
297
+
298
+ self._ensure_connection()
299
+
300
+ if self.pem is None:
301
+ cert_data = self.handler.fetch_raw_cert()
302
+ self.pem = cert_data.get("pem")
303
+
304
+ return self.pem
305
+
306
+ def get_cipher_info(self) -> dict:
307
+ """Retrieve and structure the cipher information of the SSL/TLS connection."""
308
+ raw_cipher = self._fetch_raw_cipher()
309
+
310
+ # Check if raw_cipher is an error response
311
+ if isinstance(raw_cipher, dict) and "error" in raw_cipher:
312
+ return raw_cipher
313
+
314
+ # If raw_cipher is not an error, it should be a tuple of 3 elements
315
+ if not isinstance(raw_cipher, tuple) or len(raw_cipher) != 3:
316
+ return self.error_handler.handle_error(
317
+ "CipherInfoError", "Unexpected cipher info format", self.host, self.port
318
+ )
319
+
320
+ cipher_suite, protocol_version, key_bit_length = raw_cipher
321
+ parsed_cipher = parse_cipher_suite(cipher_suite)
322
+
323
+ result = {
324
+ "cipher_suite": {
325
+ "name": cipher_suite,
326
+ "encryption_algorithm": parsed_cipher["encryption"],
327
+ "message_authentication_code": parsed_cipher["mac"],
328
+ },
329
+ "protocol_version": protocol_version,
330
+ "key_bit_length": key_bit_length,
331
+ }
332
+
333
+ if protocol_version == "TLSv1.3":
334
+ result["cipher_suite"]["key_exchange_algorithm"] = (
335
+ "Not applicable (TLS 1.3 uses ephemeral key exchange by default)"
336
+ )
337
+ else:
338
+ result["cipher_suite"]["key_exchange_algorithm"] = parsed_cipher[
339
+ "key_exchange"
340
+ ]
341
+
342
+ return result
343
+
344
+ def validate(self, validator_args=None) -> dict:
345
+ """
346
+ Validates the target host by running all enabled validators.
347
+
348
+ This method:
349
+ 1. Checks if all requested validators are implemented.
350
+ 2. Separates validators into cert-based and cipher-based groups.
351
+ 3. Fetches cert_info and cipher_info as needed.
352
+ 4. Runs each validator with the appropriate arguments.
353
+ 5. Returns a dictionary of validation results.
354
+
355
+ Args:
356
+ validator_args (dict, optional): Additional arguments for specific validators.
357
+ Example:
358
+ {
359
+ "subject_alt_names": ["example.com", "test.com"]
360
+ }
361
+
362
+ Returns:
363
+ dict: A dictionary keyed by validator name, each value being the result of that validator.
364
+
365
+ Example:
366
+ results = monitor.validate()
367
+ print(results["expiration"]) # Output for expiration validator
368
+ print(results["weak_cipher"]) # Output for weak cipher validator
369
+ """
370
+ results = {}
371
+
372
+ # Check for unknown validators
373
+ for requested_validator in self.enabled_validators:
374
+ if requested_validator not in self.validators:
375
+ results[requested_validator] = {
376
+ "is_valid": False,
377
+ "reason": f"Validator '{requested_validator}' is not implemented.",
378
+ }
379
+
380
+ cert_validators = [
381
+ validator
382
+ for name, validator in self.validators.items()
383
+ if name in self.enabled_validators
384
+ and getattr(validator, "validator_type", "cert") == "cert"
385
+ and name not in results # exclude already marked unknown validators
386
+ ]
387
+
388
+ cipher_validators = [
389
+ validator
390
+ for name, validator in self.validators.items()
391
+ if name in self.enabled_validators
392
+ and getattr(validator, "validator_type", "cert") == "cipher"
393
+ and name not in results
394
+ ]
395
+
396
+ # Certificate-based validations
397
+ if cert_validators:
398
+ cert_data = getattr(self, "cert_data", None)
399
+ if not cert_data or (isinstance(cert_data, dict) and "error" in cert_data):
400
+ error_reason = (
401
+ cert_data["error"]
402
+ if isinstance(cert_data, dict) and "error" in cert_data
403
+ else "Certificate data is missing due to a connection or retrieval error."
404
+ )
405
+ for validator in cert_validators:
406
+ results[validator.name] = {
407
+ "is_valid": False,
408
+ "reason": f"Certificate-based validation could not be performed: {error_reason}",
409
+ }
410
+ else:
411
+ for validator in cert_validators:
412
+ args = [cert_data, self.host, self.port]
413
+ # Pass additional arguments if any
414
+ if validator_args and validator.name in validator_args:
415
+ if validator.name == "subject_alt_names":
416
+ args.append(validator_args[validator.name])
417
+ else:
418
+ args.extend(validator_args[validator.name])
419
+
420
+ results[validator.name] = validator.validate(*args)
421
+
422
+ # Cipher-based validations
423
+ if cipher_validators:
424
+ cipher_info = self.get_cipher_info()
425
+ if isinstance(cipher_info, dict) and "error" in cipher_info:
426
+ logging.error(
427
+ "Skipping cipher-based validations due to cipher info retrieval error."
428
+ )
429
+ else:
430
+ for validator in cipher_validators:
431
+ args = [cipher_info, self.host, self.port]
432
+ # Pass additional arguments if any
433
+ if validator_args and validator.name in validator_args:
434
+ args.extend(validator_args[validator.name])
435
+
436
+ results[validator.name] = validator.validate(*args)
437
+
438
+ return results
@@ -0,0 +1,28 @@
1
+ # error_handlers.py
2
+
3
+
4
+ class ErrorHandler:
5
+ """
6
+ Class for handling errors in a flexible manner.
7
+ """
8
+
9
+ @staticmethod
10
+ def handle_error(error_type: str, message: str, host: str, port: int) -> dict:
11
+ """
12
+ Handles errors encountered during certificate retrieval.
13
+
14
+ Args:
15
+ error_type (str): The type of error.
16
+ message (str): The error message.
17
+ host (str): The host where the error occurred.
18
+ port (int): The port where the error occurred.
19
+
20
+ Returns:
21
+ dict: A dictionary containing the error details.
22
+ """
23
+ return {
24
+ "error": error_type,
25
+ "message": message,
26
+ "host": host,
27
+ "port": port,
28
+ }
@@ -0,0 +1,24 @@
1
+ # protocol_handlers/base.py
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+
6
+ class BaseProtocolHandler(ABC):
7
+ def __init__(self, host, port, error_handler):
8
+ self.host = host
9
+ self.port = port
10
+ self.socket = None
11
+ self.secure_socket = None
12
+ self.error_handler = error_handler
13
+
14
+ @abstractmethod
15
+ def connect(self):
16
+ pass
17
+
18
+ @abstractmethod
19
+ def fetch_raw_cert(self):
20
+ pass
21
+
22
+ @abstractmethod
23
+ def close(self):
24
+ pass
@@ -0,0 +1,51 @@
1
+ # protocol_handlers/ssh_handler.py
2
+
3
+ import re
4
+ import socket
5
+
6
+ from .base import BaseProtocolHandler
7
+
8
+
9
+ class SSHHandler(BaseProtocolHandler):
10
+ def connect(self):
11
+ try:
12
+ self.socket = socket.create_connection((self.host, self.port), timeout=10)
13
+ except socket.error as e:
14
+ return self.error_handler.handle_error(
15
+ "SocketError", str(e), self.host, self.port
16
+ )
17
+ except Exception as e:
18
+ return self.error_handler.handle_error(
19
+ "UnknownError", str(e), self.host, self.port
20
+ )
21
+
22
+ def fetch_raw_cert(self):
23
+ try:
24
+ ssh_banner = self.socket.recv(1024).decode("ascii", errors="ignore").strip()
25
+ match = re.match(r"^SSH-(\d+\.\d+)-(.*)$", ssh_banner)
26
+ if match:
27
+ return {
28
+ "protocol": "ssh",
29
+ "ssh_version_string": ssh_banner,
30
+ "protocol_version": match.group(1),
31
+ "software_version": match.group(2),
32
+ }
33
+ else:
34
+ return self.error_handler.handle_error(
35
+ "SSHError", "Invalid SSH banner", self.host, self.port
36
+ )
37
+ except Exception as e:
38
+ return self.error_handler.handle_error(
39
+ "SSHError", str(e), self.host, self.port
40
+ )
41
+
42
+ def close(self):
43
+ if self.socket:
44
+ self.socket.close()
45
+
46
+ def check_connection(self):
47
+ try:
48
+ self.socket.getpeername()
49
+ return True
50
+ except socket.error:
51
+ return False