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 ADDED
@@ -0,0 +1,7 @@
1
+ # Uspector – Copyright (c) 2026 Menahem Levinski
2
+
3
+ from .tlsscanner import TLSScanner
4
+
5
+ __version__ = "1.0.0"
6
+
7
+ __all__ = ["TLSScanner", "__version__"]
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ uspector = uspector.cli:main
@@ -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.