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.
- certmonitor/__init__.py +3 -0
- certmonitor/cipher_algorithms.py +135 -0
- certmonitor/config.py +14 -0
- certmonitor/core.py +438 -0
- certmonitor/error_handlers.py +28 -0
- certmonitor/protocol_handlers/base.py +24 -0
- certmonitor/protocol_handlers/ssh_handler.py +51 -0
- certmonitor/protocol_handlers/ssl_handler.py +135 -0
- certmonitor/rust_certinfo/Cargo.lock +562 -0
- certmonitor/rust_certinfo/Cargo.toml +13 -0
- certmonitor/rust_certinfo/src/lib.rs +84 -0
- certmonitor/utils/__init__.py +0 -0
- certmonitor/utils/utils.py +0 -0
- certmonitor/validators/__init__.py +56 -0
- certmonitor/validators/base.py +49 -0
- certmonitor/validators/expiration.py +85 -0
- certmonitor/validators/hostname.py +143 -0
- certmonitor/validators/key_info.py +97 -0
- certmonitor/validators/root_certificate_validator.py +97 -0
- certmonitor/validators/subject_alt_names.py +227 -0
- certmonitor/validators/tls_version.py +65 -0
- certmonitor/validators/weak_cipher.py +63 -0
- certmonitor-0.1.0.dist-info/METADATA +265 -0
- certmonitor-0.1.0.dist-info/RECORD +26 -0
- certmonitor-0.1.0.dist-info/WHEEL +4 -0
- certmonitor-0.1.0.dist-info/licenses/LICENSE +21 -0
certmonitor/__init__.py
ADDED
|
@@ -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
|