uspector 1.0.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.
- uspector/__init__.py +7 -0
- uspector/cli.py +71 -0
- uspector/tlsscanner.py +710 -0
- uspector-1.0.0.dist-info/METADATA +15 -0
- uspector-1.0.0.dist-info/RECORD +8 -0
- uspector-1.0.0.dist-info/WHEEL +4 -0
- uspector-1.0.0.dist-info/entry_points.txt +2 -0
- uspector-1.0.0.dist-info/licenses/LICENSE +21 -0
uspector/__init__.py
ADDED
uspector/cli.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Uspector – Copyright (c) 2026 Menahem Levinski
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from .tlsscanner import TLSScanner
|
|
8
|
+
|
|
9
|
+
def main():
|
|
10
|
+
parser = argparse.ArgumentParser(
|
|
11
|
+
prog="uspector",
|
|
12
|
+
description="Inspect TLS configuration and certificates of a remote host.",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"hostname",
|
|
17
|
+
help="Hostname or IP address to scan.",
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"-p",
|
|
21
|
+
"--port",
|
|
22
|
+
type=int,
|
|
23
|
+
default=443,
|
|
24
|
+
help="Target port (default: 443).",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"-t",
|
|
28
|
+
"--timeout",
|
|
29
|
+
type=float,
|
|
30
|
+
default=5.0,
|
|
31
|
+
help="Connection timeout in seconds (default: 5).",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--json",
|
|
35
|
+
action="store_true",
|
|
36
|
+
help="Output the complete result as JSON.",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
args = parser.parse_args()
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
scanner = TLSScanner(timeout=args.timeout)
|
|
43
|
+
result = scanner.check_tls(args.hostname, args.port)
|
|
44
|
+
|
|
45
|
+
if args.json:
|
|
46
|
+
print(json.dumps(result, indent=2, default=str))
|
|
47
|
+
else:
|
|
48
|
+
print(f"Uspector TLS Scan: {args.hostname}:{args.port}")
|
|
49
|
+
print("=" * 50)
|
|
50
|
+
|
|
51
|
+
if isinstance(result, dict):
|
|
52
|
+
for key, value in result.items():
|
|
53
|
+
print(f"{key.replace('_', ' ').title()}: {value}")
|
|
54
|
+
else:
|
|
55
|
+
print(result)
|
|
56
|
+
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
except KeyboardInterrupt:
|
|
60
|
+
print("\nScan interrupted.", file=sys.stderr)
|
|
61
|
+
return 130
|
|
62
|
+
except (ValueError, TypeError) as exc:
|
|
63
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
64
|
+
return 2
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
print(f"Scan failed: {exc}", file=sys.stderr)
|
|
67
|
+
return 1
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
raise SystemExit(main())
|
uspector/tlsscanner.py
ADDED
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
# Uspector – Copyright (c) 2026 Menahem Levinski
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import ipaddress
|
|
5
|
+
import math
|
|
6
|
+
import socket
|
|
7
|
+
import ssl
|
|
8
|
+
import time
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
import warnings
|
|
12
|
+
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
|
|
15
|
+
from cryptography import x509
|
|
16
|
+
from cryptography.x509 import ocsp
|
|
17
|
+
from cryptography.x509.oid import AuthorityInformationAccessOID
|
|
18
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
19
|
+
from cryptography.hazmat.primitives.asymmetric import ec, rsa
|
|
20
|
+
|
|
21
|
+
# ====== TLS Scanner ======
|
|
22
|
+
class TLSScanner:
|
|
23
|
+
"""Inspect TLS configuration and certificate information for a server."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, timeout=5.0):
|
|
26
|
+
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)):
|
|
27
|
+
raise TypeError("timeout must be a positive number")
|
|
28
|
+
|
|
29
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
30
|
+
raise ValueError("timeout must be a finite positive number")
|
|
31
|
+
|
|
32
|
+
self.timeout = float(timeout)
|
|
33
|
+
|
|
34
|
+
# ====== Input Validation ======
|
|
35
|
+
@staticmethod
|
|
36
|
+
def _normalize_domain(domain):
|
|
37
|
+
"""Validate and normalize a DNS hostname or IP address."""
|
|
38
|
+
|
|
39
|
+
if not isinstance(domain, str) or not domain.strip():
|
|
40
|
+
raise ValueError("domain must be a non-empty string")
|
|
41
|
+
|
|
42
|
+
domain = domain.strip().rstrip(".")
|
|
43
|
+
|
|
44
|
+
if not domain:
|
|
45
|
+
raise ValueError("domain must be a valid hostname")
|
|
46
|
+
|
|
47
|
+
if any(char.isspace() for char in domain):
|
|
48
|
+
raise ValueError("domain must not contain whitespace")
|
|
49
|
+
|
|
50
|
+
if "://" in domain or "/" in domain or "\\" in domain:
|
|
51
|
+
raise ValueError("provide a hostname or IP address, not a URL")
|
|
52
|
+
|
|
53
|
+
# Accept IPv4 and IPv6 literals, including unbracketed IPv6.
|
|
54
|
+
try:
|
|
55
|
+
return ipaddress.ip_address(domain).compressed
|
|
56
|
+
except ValueError:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
# Validate DNS label structure. Internationalized domain names
|
|
60
|
+
# are normalized to ASCII using Python's built-in IDNA codec.
|
|
61
|
+
try:
|
|
62
|
+
ascii_domain = domain.encode("idna").decode("ascii").lower()
|
|
63
|
+
except UnicodeError as exc:
|
|
64
|
+
raise ValueError("invalid hostname") from exc
|
|
65
|
+
|
|
66
|
+
if len(ascii_domain) > 253:
|
|
67
|
+
raise ValueError("hostname is too long")
|
|
68
|
+
|
|
69
|
+
labels = ascii_domain.split(".")
|
|
70
|
+
|
|
71
|
+
for label in labels:
|
|
72
|
+
if (
|
|
73
|
+
not label
|
|
74
|
+
or len(label) > 63
|
|
75
|
+
or label.startswith("-")
|
|
76
|
+
or label.endswith("-")
|
|
77
|
+
or not all(
|
|
78
|
+
char.isalnum() or char == "-"
|
|
79
|
+
for char in label
|
|
80
|
+
)
|
|
81
|
+
):
|
|
82
|
+
raise ValueError("invalid hostname")
|
|
83
|
+
|
|
84
|
+
return ascii_domain
|
|
85
|
+
|
|
86
|
+
# ====== Certificate Conversion ======
|
|
87
|
+
@staticmethod
|
|
88
|
+
def _to_x509_certificate(certificate):
|
|
89
|
+
"""Convert supported certificate representations to an X.509 object."""
|
|
90
|
+
|
|
91
|
+
if isinstance(certificate, x509.Certificate):
|
|
92
|
+
return certificate
|
|
93
|
+
|
|
94
|
+
if isinstance(certificate, str):
|
|
95
|
+
certificate = certificate.encode("ascii")
|
|
96
|
+
|
|
97
|
+
if isinstance(certificate, bytes):
|
|
98
|
+
try:
|
|
99
|
+
return x509.load_der_x509_certificate(certificate)
|
|
100
|
+
except ValueError:
|
|
101
|
+
return x509.load_pem_x509_certificate(certificate)
|
|
102
|
+
|
|
103
|
+
public_bytes = getattr(certificate, "public_bytes", None)
|
|
104
|
+
|
|
105
|
+
if callable(public_bytes):
|
|
106
|
+
try:
|
|
107
|
+
encoded = public_bytes()
|
|
108
|
+
except TypeError:
|
|
109
|
+
encoded = public_bytes(serialization.Encoding.DER)
|
|
110
|
+
|
|
111
|
+
return TLSScanner._to_x509_certificate(encoded)
|
|
112
|
+
|
|
113
|
+
raise TypeError("Unsupported certificate representation")
|
|
114
|
+
|
|
115
|
+
# ====== Certificate Chain ======
|
|
116
|
+
def _get_certificate_chain(self, tls_sock, leaf_certificate):
|
|
117
|
+
"""Return available parsed certificates, with the leaf first."""
|
|
118
|
+
|
|
119
|
+
chain = [leaf_certificate]
|
|
120
|
+
leaf_fingerprint = leaf_certificate.fingerprint(hashes.SHA256())
|
|
121
|
+
|
|
122
|
+
get_chain = getattr(tls_sock, "get_unverified_chain", None)
|
|
123
|
+
|
|
124
|
+
if not callable(get_chain):
|
|
125
|
+
return chain
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
raw_chain = get_chain()
|
|
129
|
+
except Exception:
|
|
130
|
+
return chain
|
|
131
|
+
|
|
132
|
+
for item in raw_chain or []:
|
|
133
|
+
try:
|
|
134
|
+
certificate = self._to_x509_certificate(item)
|
|
135
|
+
except (TypeError, ValueError):
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
fingerprint = certificate.fingerprint(hashes.SHA256())
|
|
139
|
+
|
|
140
|
+
if fingerprint == leaf_fingerprint:
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
if not any(
|
|
144
|
+
fingerprint == existing.fingerprint(hashes.SHA256())
|
|
145
|
+
for existing in chain
|
|
146
|
+
):
|
|
147
|
+
chain.append(certificate)
|
|
148
|
+
|
|
149
|
+
return chain
|
|
150
|
+
|
|
151
|
+
# ====== OCSP Revocation Check ======
|
|
152
|
+
def _check_ocsp(self, certificate, certificate_chain):
|
|
153
|
+
"""
|
|
154
|
+
Query the certificate's OCSP responder.
|
|
155
|
+
|
|
156
|
+
OCSP status is reported as unvalidated. This method does not verify
|
|
157
|
+
the response signature, responder authorization, certificate ID,
|
|
158
|
+
or response freshness, so it cannot establish revocation status.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
result = {
|
|
162
|
+
"status": "Unavailable",
|
|
163
|
+
"url": None,
|
|
164
|
+
"response_validated": False,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if certificate is None:
|
|
168
|
+
result["status"] = "Certificate Unavailable"
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
aia = certificate.extensions.get_extension_for_class(
|
|
173
|
+
x509.AuthorityInformationAccess
|
|
174
|
+
).value
|
|
175
|
+
|
|
176
|
+
for access in aia:
|
|
177
|
+
if (
|
|
178
|
+
access.access_method == AuthorityInformationAccessOID.OCSP
|
|
179
|
+
and isinstance(
|
|
180
|
+
access.access_location,
|
|
181
|
+
x509.UniformResourceIdentifier,
|
|
182
|
+
)
|
|
183
|
+
):
|
|
184
|
+
result["url"] = access.access_location.value
|
|
185
|
+
break
|
|
186
|
+
|
|
187
|
+
except x509.ExtensionNotFound:
|
|
188
|
+
result["status"] = "URL Not Available"
|
|
189
|
+
return result
|
|
190
|
+
|
|
191
|
+
except Exception:
|
|
192
|
+
return result
|
|
193
|
+
|
|
194
|
+
if not result["url"]:
|
|
195
|
+
result["status"] = "URL Not Available"
|
|
196
|
+
return result
|
|
197
|
+
|
|
198
|
+
# Find a candidate issuer certificate.
|
|
199
|
+
# Matching names alone do not establish issuer authenticity.
|
|
200
|
+
issuer = None
|
|
201
|
+
certificate_fingerprint = certificate.fingerprint(hashes.SHA256())
|
|
202
|
+
|
|
203
|
+
for candidate in certificate_chain or []:
|
|
204
|
+
if candidate.fingerprint(hashes.SHA256()) == certificate_fingerprint:
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
if candidate.subject == certificate.issuer:
|
|
208
|
+
issuer = candidate
|
|
209
|
+
break
|
|
210
|
+
|
|
211
|
+
if issuer is None:
|
|
212
|
+
result["status"] = "Issuer Certificate Unavailable"
|
|
213
|
+
return result
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
request = (
|
|
217
|
+
ocsp.OCSPRequestBuilder()
|
|
218
|
+
.add_certificate(certificate, issuer, hashes.SHA1())
|
|
219
|
+
.build()
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
request_data = request.public_bytes(serialization.Encoding.DER)
|
|
223
|
+
|
|
224
|
+
http_request = urllib.request.Request(
|
|
225
|
+
result["url"],
|
|
226
|
+
data=request_data,
|
|
227
|
+
headers={
|
|
228
|
+
"Content-Type": "application/ocsp-request",
|
|
229
|
+
"Accept": "application/ocsp-response",
|
|
230
|
+
},
|
|
231
|
+
method="POST",
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
with urllib.request.urlopen(
|
|
235
|
+
http_request,
|
|
236
|
+
timeout=self.timeout,
|
|
237
|
+
) as response:
|
|
238
|
+
response_data = response.read(1_000_001)
|
|
239
|
+
|
|
240
|
+
if len(response_data) > 1_000_000:
|
|
241
|
+
result["status"] = "Response Too Large"
|
|
242
|
+
return result
|
|
243
|
+
|
|
244
|
+
ocsp_response = ocsp.load_der_ocsp_response(response_data)
|
|
245
|
+
|
|
246
|
+
if (
|
|
247
|
+
ocsp_response.response_status
|
|
248
|
+
!= ocsp.OCSPResponseStatus.SUCCESSFUL
|
|
249
|
+
):
|
|
250
|
+
result["status"] = (
|
|
251
|
+
"Responder Error: "
|
|
252
|
+
f"{ocsp_response.response_status.name}"
|
|
253
|
+
)
|
|
254
|
+
return result
|
|
255
|
+
|
|
256
|
+
# Parsed status only; response authenticity and freshness
|
|
257
|
+
# have not been validated.
|
|
258
|
+
certificate_status = ocsp_response.certificate_status
|
|
259
|
+
|
|
260
|
+
if certificate_status == ocsp.OCSPCertStatus.GOOD:
|
|
261
|
+
result["status"] = "REPORTED_GOOD_UNVALIDATED"
|
|
262
|
+
|
|
263
|
+
elif certificate_status == ocsp.OCSPCertStatus.REVOKED:
|
|
264
|
+
result["status"] = "REPORTED_REVOKED_UNVALIDATED"
|
|
265
|
+
|
|
266
|
+
else:
|
|
267
|
+
result["status"] = "REPORTED_UNKNOWN_UNVALIDATED"
|
|
268
|
+
|
|
269
|
+
except (
|
|
270
|
+
urllib.error.URLError,
|
|
271
|
+
TimeoutError,
|
|
272
|
+
OSError,
|
|
273
|
+
ValueError,
|
|
274
|
+
ssl.SSLError,
|
|
275
|
+
):
|
|
276
|
+
result["status"] = "Unavailable"
|
|
277
|
+
|
|
278
|
+
except Exception:
|
|
279
|
+
result["status"] = "Unavailable"
|
|
280
|
+
|
|
281
|
+
return result
|
|
282
|
+
|
|
283
|
+
# ====== TLS Connection Inspection ======
|
|
284
|
+
def _inspect_connection(self, domain, port, context):
|
|
285
|
+
"""Open a TLS connection and collect certificate details."""
|
|
286
|
+
|
|
287
|
+
raw_sock = socket.create_connection(
|
|
288
|
+
(domain, port),
|
|
289
|
+
timeout=self.timeout,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
with context.wrap_socket(
|
|
294
|
+
raw_sock,
|
|
295
|
+
server_hostname=domain,
|
|
296
|
+
) as tls_sock:
|
|
297
|
+
cert_der = tls_sock.getpeercert(binary_form=True)
|
|
298
|
+
|
|
299
|
+
if not cert_der:
|
|
300
|
+
raise ssl.SSLError(
|
|
301
|
+
"The server did not provide a certificate"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
certificate = x509.load_der_x509_certificate(cert_der)
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
"tls_version": tls_sock.version(),
|
|
308
|
+
"cipher": tls_sock.cipher(),
|
|
309
|
+
"alpn": tls_sock.selected_alpn_protocol(),
|
|
310
|
+
"certificate_obj": certificate,
|
|
311
|
+
"certificate_chain": self._get_certificate_chain(
|
|
312
|
+
tls_sock,
|
|
313
|
+
certificate,
|
|
314
|
+
),
|
|
315
|
+
"fingerprint": hashlib.sha256(cert_der).hexdigest().upper(),
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
finally:
|
|
319
|
+
try:
|
|
320
|
+
raw_sock.close()
|
|
321
|
+
except OSError:
|
|
322
|
+
pass
|
|
323
|
+
|
|
324
|
+
# ====== TLS Protocol Support ======
|
|
325
|
+
def _check_tls_protocol_support(self, domain, port=443):
|
|
326
|
+
"""
|
|
327
|
+
Probe TLS 1.0 through TLS 1.3.
|
|
328
|
+
|
|
329
|
+
Returned versions are confirmed successful probes only. A failed
|
|
330
|
+
probe does not prove a protocol is disabled.
|
|
331
|
+
"""
|
|
332
|
+
|
|
333
|
+
supported_versions = []
|
|
334
|
+
|
|
335
|
+
versions = [
|
|
336
|
+
("TLSv1.0", ssl.TLSVersion.TLSv1, "TLSv1"),
|
|
337
|
+
("TLSv1.1", ssl.TLSVersion.TLSv1_1, "TLSv1.1"),
|
|
338
|
+
("TLSv1.2", ssl.TLSVersion.TLSv1_2, "TLSv1.2"),
|
|
339
|
+
("TLSv1.3", ssl.TLSVersion.TLSv1_3, "TLSv1.3"),
|
|
340
|
+
]
|
|
341
|
+
|
|
342
|
+
for version_name, tls_version, expected in versions:
|
|
343
|
+
raw_sock = None
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
347
|
+
context.check_hostname = False
|
|
348
|
+
context.verify_mode = ssl.CERT_NONE
|
|
349
|
+
|
|
350
|
+
with warnings.catch_warnings():
|
|
351
|
+
warnings.simplefilter("ignore", DeprecationWarning)
|
|
352
|
+
context.minimum_version = tls_version
|
|
353
|
+
context.maximum_version = tls_version
|
|
354
|
+
|
|
355
|
+
if tls_version in (
|
|
356
|
+
ssl.TLSVersion.TLSv1,
|
|
357
|
+
ssl.TLSVersion.TLSv1_1,
|
|
358
|
+
):
|
|
359
|
+
context.set_ciphers("DEFAULT:@SECLEVEL=0")
|
|
360
|
+
|
|
361
|
+
raw_sock = socket.create_connection(
|
|
362
|
+
(domain, port),
|
|
363
|
+
timeout=self.timeout,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
with context.wrap_socket(
|
|
367
|
+
raw_sock,
|
|
368
|
+
server_hostname=domain,
|
|
369
|
+
) as tls_sock:
|
|
370
|
+
if tls_sock.version() == expected:
|
|
371
|
+
supported_versions.append(version_name)
|
|
372
|
+
|
|
373
|
+
except (ssl.SSLError, OSError, ValueError):
|
|
374
|
+
pass
|
|
375
|
+
|
|
376
|
+
finally:
|
|
377
|
+
if raw_sock is not None:
|
|
378
|
+
try:
|
|
379
|
+
raw_sock.close()
|
|
380
|
+
except OSError:
|
|
381
|
+
pass
|
|
382
|
+
|
|
383
|
+
return supported_versions
|
|
384
|
+
|
|
385
|
+
# ====== TLS Check ======
|
|
386
|
+
def check_tls(self, domain, port=443):
|
|
387
|
+
"""Scan a domain and return a structured dictionary of findings."""
|
|
388
|
+
|
|
389
|
+
domain = self._normalize_domain(domain)
|
|
390
|
+
|
|
391
|
+
if isinstance(port, bool) or not isinstance(port, int):
|
|
392
|
+
raise TypeError("port must be an integer")
|
|
393
|
+
|
|
394
|
+
if not 1 <= port <= 65535:
|
|
395
|
+
raise ValueError("port must be between 1 and 65535")
|
|
396
|
+
|
|
397
|
+
result = {
|
|
398
|
+
"domain": domain,
|
|
399
|
+
"port": port,
|
|
400
|
+
"findings": [],
|
|
401
|
+
"verdict": "INCONCLUSIVE",
|
|
402
|
+
"connection_status": "Failed",
|
|
403
|
+
"diagnostic_handshake": "Not Attempted",
|
|
404
|
+
"certificate_trusted": False,
|
|
405
|
+
"hostname_valid": None,
|
|
406
|
+
"resolved_ips": [],
|
|
407
|
+
"supported_tls_versions": [],
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
certificate = None
|
|
411
|
+
certificate_chain = []
|
|
412
|
+
|
|
413
|
+
# DNS resolution.
|
|
414
|
+
try:
|
|
415
|
+
addresses = socket.getaddrinfo(
|
|
416
|
+
domain,
|
|
417
|
+
port,
|
|
418
|
+
type=socket.SOCK_STREAM,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
result["resolved_ips"] = list(
|
|
422
|
+
dict.fromkeys(item[4][0] for item in addresses)
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
except OSError as exc:
|
|
426
|
+
result["error"] = f"DNS resolution failed: {exc}"
|
|
427
|
+
return result
|
|
428
|
+
|
|
429
|
+
if not result["resolved_ips"]:
|
|
430
|
+
result["error"] = "DNS returned no usable addresses"
|
|
431
|
+
return result
|
|
432
|
+
|
|
433
|
+
# A DNS failure should not be confused with a certificate problem.
|
|
434
|
+
# The connection attempt itself determines the final connection status.
|
|
435
|
+
context = ssl.create_default_context()
|
|
436
|
+
context.check_hostname = True
|
|
437
|
+
context.verify_mode = ssl.CERT_REQUIRED
|
|
438
|
+
context.set_alpn_protocols(["h2", "http/1.1"])
|
|
439
|
+
|
|
440
|
+
start_time = time.perf_counter()
|
|
441
|
+
|
|
442
|
+
try:
|
|
443
|
+
inspected = self._inspect_connection(domain, port, context)
|
|
444
|
+
|
|
445
|
+
result.update({
|
|
446
|
+
"connection_status": "Passed",
|
|
447
|
+
"diagnostic_handshake": "Not Needed",
|
|
448
|
+
"certificate_trusted": True,
|
|
449
|
+
"hostname_valid": True,
|
|
450
|
+
"tls_version": inspected["tls_version"],
|
|
451
|
+
"cipher": inspected["cipher"],
|
|
452
|
+
"alpn": inspected["alpn"],
|
|
453
|
+
"fingerprint": inspected["fingerprint"],
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
certificate = inspected["certificate_obj"]
|
|
457
|
+
certificate_chain = inspected["certificate_chain"]
|
|
458
|
+
|
|
459
|
+
except ssl.SSLCertVerificationError as exc:
|
|
460
|
+
result["ssl_error"] = str(exc)
|
|
461
|
+
result["findings"].append(
|
|
462
|
+
"TLS certificate verification failed"
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
error_text = str(exc).lower()
|
|
466
|
+
|
|
467
|
+
if (
|
|
468
|
+
"hostname mismatch" in error_text
|
|
469
|
+
or "not valid for" in error_text
|
|
470
|
+
or "doesn't match" in error_text
|
|
471
|
+
or "ip address mismatch" in error_text
|
|
472
|
+
):
|
|
473
|
+
result["hostname_valid"] = False
|
|
474
|
+
result["findings"].append(
|
|
475
|
+
"Certificate hostname does not match the requested domain"
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
# Diagnostic-only retry. This never marks the connection trusted.
|
|
479
|
+
diagnostic_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
480
|
+
diagnostic_context.check_hostname = False
|
|
481
|
+
diagnostic_context.verify_mode = ssl.CERT_NONE
|
|
482
|
+
diagnostic_context.set_alpn_protocols(["h2", "http/1.1"])
|
|
483
|
+
|
|
484
|
+
try:
|
|
485
|
+
inspected = self._inspect_connection(
|
|
486
|
+
domain,
|
|
487
|
+
port,
|
|
488
|
+
diagnostic_context,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
result.update({
|
|
492
|
+
"diagnostic_handshake": "Passed",
|
|
493
|
+
"tls_version": inspected["tls_version"],
|
|
494
|
+
"cipher": inspected["cipher"],
|
|
495
|
+
"alpn": inspected["alpn"],
|
|
496
|
+
"fingerprint": inspected["fingerprint"],
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
certificate = inspected["certificate_obj"]
|
|
500
|
+
certificate_chain = inspected["certificate_chain"]
|
|
501
|
+
|
|
502
|
+
except (ssl.SSLError, OSError, ValueError) as diagnostic_exc:
|
|
503
|
+
result["diagnostic_handshake"] = "Failed"
|
|
504
|
+
result["diagnostic_error"] = str(diagnostic_exc)
|
|
505
|
+
|
|
506
|
+
except (ssl.SSLError, OSError, ValueError) as exc:
|
|
507
|
+
result["ssl_error"] = str(exc)
|
|
508
|
+
|
|
509
|
+
# Retry with weaker legacy settings for diagnostics only.
|
|
510
|
+
diagnostic_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
511
|
+
diagnostic_context.check_hostname = False
|
|
512
|
+
diagnostic_context.verify_mode = ssl.CERT_NONE
|
|
513
|
+
diagnostic_context.set_alpn_protocols(["h2", "http/1.1"])
|
|
514
|
+
|
|
515
|
+
try:
|
|
516
|
+
diagnostic_context.set_ciphers("DEFAULT:@SECLEVEL=0")
|
|
517
|
+
|
|
518
|
+
with warnings.catch_warnings():
|
|
519
|
+
warnings.simplefilter("ignore", DeprecationWarning)
|
|
520
|
+
diagnostic_context.minimum_version = ssl.TLSVersion.TLSv1
|
|
521
|
+
|
|
522
|
+
inspected = self._inspect_connection(
|
|
523
|
+
domain,
|
|
524
|
+
port,
|
|
525
|
+
diagnostic_context,
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
result.update({
|
|
529
|
+
"diagnostic_handshake": "Passed",
|
|
530
|
+
"tls_version": inspected["tls_version"],
|
|
531
|
+
"cipher": inspected["cipher"],
|
|
532
|
+
"alpn": inspected["alpn"],
|
|
533
|
+
"fingerprint": inspected["fingerprint"],
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
certificate = inspected["certificate_obj"]
|
|
537
|
+
certificate_chain = inspected["certificate_chain"]
|
|
538
|
+
|
|
539
|
+
except (ssl.SSLError, OSError, ValueError) as diagnostic_exc:
|
|
540
|
+
result["diagnostic_handshake"] = "Failed"
|
|
541
|
+
result["diagnostic_error"] = str(diagnostic_exc)
|
|
542
|
+
|
|
543
|
+
finally:
|
|
544
|
+
result["latency_ms"] = round(
|
|
545
|
+
(time.perf_counter() - start_time) * 1000,
|
|
546
|
+
1,
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
# Certificate details.
|
|
550
|
+
if certificate is not None:
|
|
551
|
+
result["subject"] = certificate.subject.rfc4514_string()
|
|
552
|
+
result["issuer"] = certificate.issuer.rfc4514_string()
|
|
553
|
+
|
|
554
|
+
not_before = certificate.not_valid_before_utc
|
|
555
|
+
not_after = certificate.not_valid_after_utc
|
|
556
|
+
|
|
557
|
+
result["not_before"] = not_before.isoformat()
|
|
558
|
+
result["not_after"] = not_after.isoformat()
|
|
559
|
+
|
|
560
|
+
# Public-key type and strength.
|
|
561
|
+
public_key = certificate.public_key()
|
|
562
|
+
|
|
563
|
+
if isinstance(public_key, rsa.RSAPublicKey):
|
|
564
|
+
result["public_key_type"] = "RSA"
|
|
565
|
+
result["public_key_bits"] = public_key.key_size
|
|
566
|
+
|
|
567
|
+
if public_key.key_size < 2048:
|
|
568
|
+
result["findings"].append(
|
|
569
|
+
f"Weak RSA key: {public_key.key_size} bits"
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
elif isinstance(public_key, ec.EllipticCurvePublicKey):
|
|
573
|
+
result["public_key_type"] = "EC"
|
|
574
|
+
result["public_key_bits"] = public_key.key_size
|
|
575
|
+
|
|
576
|
+
if public_key.key_size < 224:
|
|
577
|
+
result["findings"].append(
|
|
578
|
+
f"Weak elliptic-curve key: {public_key.key_size} bits"
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
else:
|
|
582
|
+
result["public_key_type"] = type(public_key).__name__
|
|
583
|
+
|
|
584
|
+
key_size = getattr(public_key, "key_size", None)
|
|
585
|
+
|
|
586
|
+
if key_size is not None:
|
|
587
|
+
result["public_key_bits"] = key_size
|
|
588
|
+
|
|
589
|
+
# Subject Alternative Names.
|
|
590
|
+
try:
|
|
591
|
+
san_ext = certificate.extensions.get_extension_for_class(
|
|
592
|
+
x509.SubjectAlternativeName
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
result["san"] = san_ext.value.get_values_for_type(
|
|
596
|
+
x509.DNSName
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
result["san_ip_addresses"] = [
|
|
600
|
+
str(address)
|
|
601
|
+
for address in san_ext.value.get_values_for_type(
|
|
602
|
+
x509.IPAddress
|
|
603
|
+
)
|
|
604
|
+
]
|
|
605
|
+
|
|
606
|
+
except x509.ExtensionNotFound:
|
|
607
|
+
result["san"] = []
|
|
608
|
+
result["san_ip_addresses"] = []
|
|
609
|
+
|
|
610
|
+
# Subject and issuer name equality is only an indication.
|
|
611
|
+
result["self_signed"] = (
|
|
612
|
+
certificate.subject == certificate.issuer
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
if result["self_signed"]:
|
|
616
|
+
result["findings"].append(
|
|
617
|
+
"Certificate subject and issuer are identical"
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
self._check_certificate_validity(result, certificate)
|
|
621
|
+
|
|
622
|
+
result["ocsp"] = self._check_ocsp(
|
|
623
|
+
certificate,
|
|
624
|
+
certificate_chain,
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
if result["ocsp"]["status"] == "REPORTED_REVOKED_UNVALIDATED":
|
|
628
|
+
result["findings"].append(
|
|
629
|
+
"OCSP responder reported the certificate as revoked; "
|
|
630
|
+
"the response has not been cryptographically validated"
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
# Probe TLS versions. Failed probes are not evidence that versions
|
|
634
|
+
# are disabled; only successful negotiations are reported here.
|
|
635
|
+
result["supported_tls_versions"] = (
|
|
636
|
+
self._check_tls_protocol_support(domain, port)
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
supported_versions = result["supported_tls_versions"]
|
|
640
|
+
|
|
641
|
+
if "TLSv1.0" in supported_versions:
|
|
642
|
+
result["findings"].append(
|
|
643
|
+
"Server supports deprecated TLSv1.0"
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
if "TLSv1.1" in supported_versions:
|
|
647
|
+
result["findings"].append(
|
|
648
|
+
"Server supports deprecated TLSv1.1"
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
# Weak cipher suite indicators for the negotiated connection.
|
|
652
|
+
cipher = result.get("cipher")
|
|
653
|
+
|
|
654
|
+
if cipher:
|
|
655
|
+
cipher_name = cipher[0].upper()
|
|
656
|
+
|
|
657
|
+
if any(
|
|
658
|
+
weak_name in cipher_name
|
|
659
|
+
for weak_name in ("RC4", "3DES", "DES")
|
|
660
|
+
):
|
|
661
|
+
result["findings"].append(
|
|
662
|
+
f"Weak TLS cipher suite: {cipher[0]}"
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
# Final verdict.
|
|
666
|
+
if result["connection_status"] != "Passed":
|
|
667
|
+
result["verdict"] = "INCONCLUSIVE"
|
|
668
|
+
|
|
669
|
+
elif result["hostname_valid"] is not True:
|
|
670
|
+
result["verdict"] = "WARNING"
|
|
671
|
+
|
|
672
|
+
elif result["findings"]:
|
|
673
|
+
result["verdict"] = "WARNING"
|
|
674
|
+
|
|
675
|
+
else:
|
|
676
|
+
result["verdict"] = "SECURE"
|
|
677
|
+
|
|
678
|
+
return result
|
|
679
|
+
|
|
680
|
+
# ====== Certificate Validity ======
|
|
681
|
+
@staticmethod
|
|
682
|
+
def _check_certificate_validity(result, certificate):
|
|
683
|
+
"""Check certificate validity dates and report days remaining."""
|
|
684
|
+
|
|
685
|
+
now = datetime.now(timezone.utc)
|
|
686
|
+
not_before = certificate.not_valid_before_utc
|
|
687
|
+
not_after = certificate.not_valid_after_utc
|
|
688
|
+
|
|
689
|
+
if now < not_before:
|
|
690
|
+
result["findings"].append("Certificate is not yet valid")
|
|
691
|
+
|
|
692
|
+
seconds_remaining = (not_after - now).total_seconds()
|
|
693
|
+
days_remaining = math.floor(seconds_remaining / 86400)
|
|
694
|
+
|
|
695
|
+
result["days_remaining"] = days_remaining
|
|
696
|
+
|
|
697
|
+
if seconds_remaining < 0:
|
|
698
|
+
result["findings"].append("Certificate has expired")
|
|
699
|
+
|
|
700
|
+
elif days_remaining <= 30:
|
|
701
|
+
result["findings"].append(
|
|
702
|
+
f"Certificate expires in {days_remaining} days"
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
# ====== Name To String ======
|
|
706
|
+
@staticmethod
|
|
707
|
+
def _name_to_string(name):
|
|
708
|
+
"""Format a cryptography X.509 name."""
|
|
709
|
+
|
|
710
|
+
return name.rfc4514_string()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: uspector
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python library for TLS configuration and certificate inspection
|
|
5
|
+
Project-URL: Homepage, https://github.com/mennylevinski/uspector-net
|
|
6
|
+
Project-URL: Repository, https://github.com/mennylevinski/uspector-net
|
|
7
|
+
Author: Menahem Levinski
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: cryptography>=44.0.0
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# uspector-net
|
|
15
|
+
A Python library for SSL/TLS testing, diagnostics, and network security analysis.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
uspector/__init__.py,sha256=uQyDeBt4fGb00lr9X0U7ugAdOU6-px_S46hso0AUKNQ,158
|
|
2
|
+
uspector/cli.py,sha256=zkz3vPR3QZjmYNe7i0ZONW6Xn_z9Oh-2_AsWj_53Ii0,1855
|
|
3
|
+
uspector/tlsscanner.py,sha256=8yaqSLTVE4iNBSO0wLk1QSDDaR-uvw5BzfbCaKJCy14,24542
|
|
4
|
+
uspector-1.0.0.dist-info/METADATA,sha256=r9QsONvaPgbAFpi-CODFeGGzx8N3FpIInXlAVeSgJ3c,534
|
|
5
|
+
uspector-1.0.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
+
uspector-1.0.0.dist-info/entry_points.txt,sha256=vbUsJzcoTx29zPUeoya8Pf54QD3IhXwoSM2N2eDNKa4,47
|
|
7
|
+
uspector-1.0.0.dist-info/licenses/LICENSE,sha256=XyqaQLBxGV_wNqXaLUSv2y1e-HmZeIThurQa7Z4qb4c,1092
|
|
8
|
+
uspector-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Menny Levinski
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|