offsec-ai 2.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.
@@ -0,0 +1,721 @@
1
+ """
2
+ Certificate Chain Analysis module for SSL/TLS certificate inspection.
3
+
4
+ This module provides functionality to analyze SSL/TLS certificate chains,
5
+ including certificate validation, chain of trust verification, and intermediate
6
+ CA analysis.
7
+ """
8
+
9
+ import asyncio
10
+ import ssl
11
+ import socket
12
+ import hashlib
13
+ import datetime
14
+ from typing import List, Dict, Any, Optional, Tuple
15
+ from dataclasses import dataclass
16
+ from urllib.parse import urlparse
17
+ import cryptography
18
+ from cryptography import x509
19
+ from cryptography.x509.oid import NameOID, ExtensionOID
20
+ from cryptography.hazmat.primitives import hashes, serialization
21
+ import aiohttp
22
+ import requests
23
+
24
+
25
+ @dataclass
26
+ class CertificateInfo:
27
+ """Information about a single certificate."""
28
+
29
+ subject: str
30
+ issuer: str
31
+ serial_number: str
32
+ fingerprint_sha1: str
33
+ fingerprint_sha256: str
34
+ not_before: datetime.datetime
35
+ not_after: datetime.datetime
36
+ is_ca: bool
37
+ is_self_signed: bool
38
+ is_expired: bool
39
+ is_valid_now: bool
40
+ key_size: int
41
+ signature_algorithm: str
42
+ public_key_algorithm: str
43
+ san_domains: List[str]
44
+ extensions: Dict[str, Any]
45
+ pem_data: str
46
+ raw_cert: x509.Certificate # Store the raw certificate for validation
47
+
48
+
49
+ @dataclass
50
+ class CertificateChain:
51
+ """Complete certificate chain information."""
52
+
53
+ server_cert: CertificateInfo
54
+ intermediate_certs: List[CertificateInfo]
55
+ root_cert: Optional[CertificateInfo]
56
+ chain_valid: bool
57
+ chain_complete: bool
58
+ missing_intermediates: List[str]
59
+ trust_issues: List[str]
60
+ ocsp_urls: List[str]
61
+ crl_urls: List[str]
62
+
63
+
64
+ class CertificateAnalyzer:
65
+ """Analyzer for SSL/TLS certificate chains."""
66
+
67
+ def __init__(self, timeout: float = 10.0):
68
+ """Initialize the certificate analyzer."""
69
+ self.timeout = timeout
70
+
71
+ async def analyze_certificate_chain(self, host: str, port: int = 443) -> CertificateChain:
72
+ """
73
+ Analyze the complete certificate chain for a host.
74
+
75
+ Args:
76
+ host: Target hostname
77
+ port: Target port (default 443)
78
+
79
+ Returns:
80
+ CertificateChain object with complete analysis
81
+ """
82
+ try:
83
+ # Get certificate chain from server
84
+ cert_chain = await self._get_certificate_chain(host, port)
85
+
86
+ if not cert_chain:
87
+ raise ValueError("Could not retrieve certificate chain")
88
+
89
+ # Analyze each certificate in the chain
90
+ server_cert = self._analyze_certificate(cert_chain[0])
91
+ intermediate_certs = [self._analyze_certificate(cert)
92
+ for cert in cert_chain[1:]]
93
+
94
+ # Try to get root certificate
95
+ root_cert = await self._find_root_certificate(cert_chain)
96
+
97
+ # Validate chain of trust
98
+ chain_valid, trust_issues = self._validate_chain_of_trust(cert_chain)
99
+
100
+ # Check for missing intermediates
101
+ missing_intermediates = self._check_missing_intermediates(cert_chain)
102
+
103
+ # Check if chain is complete
104
+ # A chain is complete if either:
105
+ # 1. We have no missing intermediates AND we found a root certificate, OR
106
+ # 2. We have no missing intermediates AND the last cert is a trusted root/intermediate
107
+ has_trusted_endpoint = root_cert is not None or self._is_trusted_root_or_intermediate(cert_chain)
108
+ chain_complete = len(missing_intermediates) == 0 and has_trusted_endpoint
109
+
110
+ # Extract OCSP and CRL URLs
111
+ ocsp_urls, crl_urls = self._extract_revocation_urls(cert_chain)
112
+
113
+ return CertificateChain(
114
+ server_cert=server_cert,
115
+ intermediate_certs=intermediate_certs,
116
+ root_cert=root_cert,
117
+ chain_valid=chain_valid,
118
+ chain_complete=chain_complete,
119
+ missing_intermediates=missing_intermediates,
120
+ trust_issues=trust_issues,
121
+ ocsp_urls=ocsp_urls,
122
+ crl_urls=crl_urls
123
+ )
124
+
125
+ except Exception as e:
126
+ raise RuntimeError(f"Certificate chain analysis failed: {str(e)}")
127
+
128
+ async def _get_certificate_chain(self, host: str, port: int) -> List[x509.Certificate]:
129
+ """Get the certificate chain from the server."""
130
+ import subprocess
131
+ import tempfile
132
+ import os
133
+
134
+ try:
135
+ # Use openssl command to get full certificate chain
136
+ cmd = [
137
+ 'openssl', 's_client',
138
+ '-connect', f'{host}:{port}',
139
+ '-servername', host,
140
+ '-showcerts',
141
+ '-verify_return_error'
142
+ ]
143
+
144
+ # Run openssl command with timeout
145
+ process = await asyncio.create_subprocess_exec(
146
+ *cmd,
147
+ stdin=asyncio.subprocess.PIPE,
148
+ stdout=asyncio.subprocess.PIPE,
149
+ stderr=asyncio.subprocess.PIPE
150
+ )
151
+
152
+ try:
153
+ stdout, _ = await asyncio.wait_for(
154
+ process.communicate(input=b''),
155
+ timeout=self.timeout
156
+ )
157
+ except asyncio.TimeoutError:
158
+ process.kill()
159
+ raise RuntimeError(f"Connection to {host}:{port} timed out")
160
+
161
+ if process.returncode != 0:
162
+ # Fallback to simple SSL connection for server cert only
163
+ return self._get_server_certificate_only(host, port)
164
+
165
+ # Parse certificates from output
166
+ output = stdout.decode('utf-8', errors='ignore')
167
+ certificates = []
168
+
169
+ # Extract PEM certificates from output
170
+ cert_blocks = []
171
+ in_cert = False
172
+ current_cert = []
173
+
174
+ for line in output.split('\n'):
175
+ if '-----BEGIN CERTIFICATE-----' in line:
176
+ in_cert = True
177
+ current_cert = [line]
178
+ elif '-----END CERTIFICATE-----' in line and in_cert:
179
+ current_cert.append(line)
180
+ cert_blocks.append('\n'.join(current_cert))
181
+ current_cert = []
182
+ in_cert = False
183
+ elif in_cert:
184
+ current_cert.append(line)
185
+
186
+ # Convert PEM certificates to cryptography objects
187
+ for pem_cert in cert_blocks:
188
+ try:
189
+ cert = x509.load_pem_x509_certificate(pem_cert.encode('utf-8'))
190
+ certificates.append(cert)
191
+ except Exception:
192
+ continue # Skip invalid certificates
193
+
194
+ if not certificates:
195
+ # Fallback to simple SSL connection
196
+ return self._get_server_certificate_only(host, port)
197
+
198
+ return certificates
199
+
200
+ except Exception:
201
+ # Final fallback to simple SSL connection
202
+ return self._get_server_certificate_only(host, port)
203
+
204
+ def _get_server_certificate_only(self, host: str, port: int) -> List[x509.Certificate]:
205
+ """Fallback method to get only the server certificate."""
206
+ # Note: We intentionally disable certificate verification here because
207
+ # we're analyzing the certificate itself, including potentially invalid ones
208
+ context = ssl.create_default_context()
209
+ context.check_hostname = False # nosec - needed for certificate analysis
210
+ context.verify_mode = ssl.CERT_NONE # nosec - needed for certificate analysis
211
+
212
+ # Connect and get only the server certificate
213
+ sock = socket.create_connection((host, port), timeout=self.timeout)
214
+ try:
215
+ with context.wrap_socket(sock, server_hostname=host) as ssock:
216
+ # Get the peer certificate in DER format
217
+ der_cert_bin = ssock.getpeercert(True)
218
+ if not der_cert_bin:
219
+ raise RuntimeError("Could not retrieve server certificate")
220
+
221
+ cert = x509.load_der_x509_certificate(der_cert_bin)
222
+ return [cert]
223
+
224
+ finally:
225
+ sock.close()
226
+
227
+ def _analyze_certificate(self, cert: x509.Certificate) -> CertificateInfo:
228
+ """Analyze a single certificate."""
229
+ # Extract basic information
230
+ subject = self._format_name(cert.subject)
231
+ issuer = self._format_name(cert.issuer)
232
+ serial_number = str(cert.serial_number)
233
+
234
+ # Generate fingerprints
235
+ cert_der = cert.public_bytes(serialization.Encoding.DER)
236
+ fingerprint_sha1 = hashlib.sha1(cert_der).hexdigest().upper()
237
+ fingerprint_sha256 = hashlib.sha256(cert_der).hexdigest().upper()
238
+
239
+ # Validity dates
240
+ not_before = cert.not_valid_before_utc
241
+ not_after = cert.not_valid_after_utc
242
+ now = datetime.datetime.now(datetime.timezone.utc) # Keep timezone-aware for comparison
243
+ is_expired = now > not_after
244
+ is_valid_now = not_before <= now <= not_after
245
+
246
+ # Certificate type
247
+ is_ca = self._is_ca_certificate(cert)
248
+ is_self_signed = subject == issuer
249
+
250
+ # Key information
251
+ public_key = cert.public_key()
252
+ key_size = public_key.key_size if hasattr(public_key, 'key_size') else 0
253
+ public_key_algorithm = type(public_key).__name__
254
+
255
+ # Signature algorithm
256
+ signature_algorithm = cert.signature_algorithm_oid._name
257
+
258
+ # Subject Alternative Names
259
+ san_domains = self._extract_san_domains(cert)
260
+
261
+ # Extensions
262
+ extensions = self._extract_extensions(cert)
263
+
264
+ # PEM data
265
+ pem_data = cert.public_bytes(serialization.Encoding.PEM).decode('utf-8')
266
+
267
+ return CertificateInfo(
268
+ subject=subject,
269
+ issuer=issuer,
270
+ serial_number=serial_number,
271
+ fingerprint_sha1=fingerprint_sha1,
272
+ fingerprint_sha256=fingerprint_sha256,
273
+ not_before=not_before,
274
+ not_after=not_after,
275
+ is_ca=is_ca,
276
+ is_self_signed=is_self_signed,
277
+ is_expired=is_expired,
278
+ is_valid_now=is_valid_now,
279
+ key_size=key_size,
280
+ signature_algorithm=signature_algorithm,
281
+ public_key_algorithm=public_key_algorithm,
282
+ san_domains=san_domains,
283
+ extensions=extensions,
284
+ pem_data=pem_data,
285
+ raw_cert=cert
286
+ )
287
+
288
+ def _format_name(self, name: x509.Name) -> str:
289
+ """Format X.509 name to readable string."""
290
+ components = []
291
+ for attribute in name:
292
+ if attribute.oid == NameOID.COMMON_NAME:
293
+ components.append(f"CN={attribute.value}")
294
+ elif attribute.oid == NameOID.ORGANIZATION_NAME:
295
+ components.append(f"O={attribute.value}")
296
+ elif attribute.oid == NameOID.ORGANIZATIONAL_UNIT_NAME:
297
+ components.append(f"OU={attribute.value}")
298
+ elif attribute.oid == NameOID.COUNTRY_NAME:
299
+ components.append(f"C={attribute.value}")
300
+ elif attribute.oid == NameOID.STATE_OR_PROVINCE_NAME:
301
+ components.append(f"ST={attribute.value}")
302
+ elif attribute.oid == NameOID.LOCALITY_NAME:
303
+ components.append(f"L={attribute.value}")
304
+ elif attribute.oid == NameOID.STREET_ADDRESS:
305
+ components.append(f"street={attribute.value}")
306
+ elif attribute.oid == NameOID.SERIAL_NUMBER:
307
+ components.append(f"serialNumber={attribute.value}")
308
+ elif attribute.oid.dotted_string == "1.3.6.1.4.1.311.60.2.1.3": # jurisdictionC
309
+ components.append(f"jurisdictionC={attribute.value}")
310
+ elif attribute.oid.dotted_string == "2.5.4.15": # businessCategory
311
+ components.append(f"businessCategory={attribute.value}")
312
+ else:
313
+ # Handle any other OIDs that might be present
314
+ try:
315
+ oid_name = attribute.oid._name if hasattr(attribute.oid, '_name') else f"OID.{attribute.oid.dotted_string}"
316
+ components.append(f"{oid_name}={attribute.value}")
317
+ except Exception:
318
+ # Fallback for unknown OIDs
319
+ components.append(f"OID.{attribute.oid.dotted_string}={attribute.value}")
320
+ return ", ".join(components)
321
+
322
+ def _is_ca_certificate(self, cert: x509.Certificate) -> bool:
323
+ """Check if certificate is a CA certificate."""
324
+ try:
325
+ basic_constraints = cert.extensions.get_extension_for_oid(
326
+ ExtensionOID.BASIC_CONSTRAINTS
327
+ ).value
328
+ return basic_constraints.ca
329
+ except x509.ExtensionNotFound:
330
+ return False
331
+
332
+ def _extract_san_domains(self, cert: x509.Certificate) -> List[str]:
333
+ """Extract Subject Alternative Name domains."""
334
+ try:
335
+ san_ext = cert.extensions.get_extension_for_oid(
336
+ ExtensionOID.SUBJECT_ALTERNATIVE_NAME
337
+ ).value
338
+ return [name.value for name in san_ext if isinstance(name, x509.DNSName)]
339
+ except x509.ExtensionNotFound:
340
+ return []
341
+
342
+ def _extract_extensions(self, cert: x509.Certificate) -> Dict[str, Any]:
343
+ """Extract certificate extensions."""
344
+ extensions = {}
345
+
346
+ for extension in cert.extensions:
347
+ ext_name = extension.oid._name
348
+ if ext_name:
349
+ try:
350
+ if ext_name == "keyUsage":
351
+ extensions[ext_name] = self._format_key_usage(extension.value)
352
+ elif ext_name == "extendedKeyUsage":
353
+ extensions[ext_name] = self._format_extended_key_usage(extension.value)
354
+ elif ext_name == "basicConstraints":
355
+ extensions[ext_name] = self._format_basic_constraints(extension.value)
356
+ else:
357
+ extensions[ext_name] = str(extension.value)
358
+ except Exception:
359
+ extensions[ext_name] = "Unable to parse"
360
+
361
+ return extensions
362
+
363
+ def _format_key_usage(self, key_usage) -> List[str]:
364
+ """Format key usage extension."""
365
+ usages = []
366
+ if key_usage.digital_signature:
367
+ usages.append("Digital Signature")
368
+ if key_usage.key_encipherment:
369
+ usages.append("Key Encipherment")
370
+ if key_usage.data_encipherment:
371
+ usages.append("Data Encipherment")
372
+ if key_usage.key_agreement:
373
+ usages.append("Key Agreement")
374
+ if key_usage.key_cert_sign:
375
+ usages.append("Certificate Sign")
376
+ if key_usage.crl_sign:
377
+ usages.append("CRL Sign")
378
+ return usages
379
+
380
+ def _format_extended_key_usage(self, ext_key_usage) -> List[str]:
381
+ """Format extended key usage extension."""
382
+ usages = []
383
+ for usage in ext_key_usage:
384
+ usages.append(usage._name)
385
+ return usages
386
+
387
+ def _format_basic_constraints(self, basic_constraints) -> Dict[str, Any]:
388
+ """Format basic constraints extension."""
389
+ return {
390
+ "ca": basic_constraints.ca,
391
+ "path_length": basic_constraints.path_length
392
+ }
393
+
394
+ async def _find_root_certificate(self, cert_chain: List[x509.Certificate]) -> Optional[CertificateInfo]:
395
+ """Try to find the root certificate."""
396
+ if not cert_chain:
397
+ return None
398
+
399
+ # Check if the last certificate in chain is self-signed (root)
400
+ last_cert = cert_chain[-1]
401
+ if self._format_name(last_cert.subject) == self._format_name(last_cert.issuer):
402
+ return self._analyze_certificate(last_cert)
403
+
404
+ # Try to fetch root certificate from issuer
405
+ try:
406
+ root_cert = await self._fetch_issuer_certificate(last_cert)
407
+ if root_cert:
408
+ return self._analyze_certificate(root_cert)
409
+ except Exception:
410
+ pass
411
+
412
+ return None
413
+
414
+ async def _fetch_issuer_certificate(self, cert: x509.Certificate) -> Optional[x509.Certificate]:
415
+ """Try to fetch issuer certificate from AIA extension."""
416
+ try:
417
+ aia = cert.extensions.get_extension_for_oid(
418
+ ExtensionOID.AUTHORITY_INFORMATION_ACCESS
419
+ ).value
420
+
421
+ for access_description in aia:
422
+ if access_description.access_method._name == "caIssuers":
423
+ url = access_description.access_location.value
424
+ if url.startswith("http"):
425
+ return await self._download_certificate(url)
426
+ except Exception:
427
+ pass
428
+
429
+ return None
430
+
431
+ async def _download_certificate(self, url: str) -> Optional[x509.Certificate]:
432
+ """Download certificate from URL."""
433
+ try:
434
+ async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=self.timeout)) as session:
435
+ async with session.get(url) as response:
436
+ if response.status == 200:
437
+ cert_data = await response.read()
438
+ try:
439
+ # Try DER format first
440
+ return x509.load_der_x509_certificate(cert_data)
441
+ except Exception:
442
+ # Try PEM format
443
+ return x509.load_pem_x509_certificate(cert_data)
444
+ except Exception:
445
+ pass
446
+
447
+ return None
448
+
449
+ def _validate_chain_of_trust(self, cert_chain: List[x509.Certificate]) -> Tuple[bool, List[str]]:
450
+ """Validate the chain of trust."""
451
+ issues = []
452
+
453
+ if len(cert_chain) < 2:
454
+ issues.append("Certificate chain too short - no intermediate certificates")
455
+ return False, issues
456
+
457
+ # Check each certificate is signed by the next one
458
+ for i in range(len(cert_chain) - 1):
459
+ current_cert = cert_chain[i]
460
+ issuer_cert = cert_chain[i + 1]
461
+
462
+ # Check if issuer matches
463
+ if self._format_name(current_cert.issuer) != self._format_name(issuer_cert.subject):
464
+ issues.append(f"Certificate {i} issuer does not match certificate {i+1} subject")
465
+ continue
466
+
467
+ # Verify signature (simplified check)
468
+ try:
469
+ issuer_public_key = issuer_cert.public_key()
470
+ # This is a simplified verification - in practice, you'd verify the actual signature
471
+ if not issuer_public_key:
472
+ issues.append(f"Cannot extract public key from issuer certificate {i+1}")
473
+ except Exception as e:
474
+ issues.append(f"Signature verification failed for certificate {i}: {str(e)}")
475
+
476
+ return len(issues) == 0, issues
477
+
478
+ def _check_missing_intermediates(self, cert_chain: List[x509.Certificate]) -> List[str]:
479
+ """Check for missing intermediate certificates."""
480
+ missing = []
481
+
482
+ if len(cert_chain) < 2:
483
+ # Only one certificate in chain - check if it's self-signed
484
+ if len(cert_chain) == 1:
485
+ server_cert = cert_chain[0]
486
+ server_subject = self._format_name(server_cert.subject)
487
+ server_issuer = self._format_name(server_cert.issuer)
488
+
489
+ if server_subject != server_issuer:
490
+ # Server certificate is not self-signed, so missing intermediate
491
+ # Extract just the issuer CN for a cleaner message
492
+ import re
493
+ issuer_cn_match = re.search(r'CN=([^,]+)', server_issuer)
494
+ if issuer_cn_match:
495
+ issuer_cn = issuer_cn_match.group(1).strip()
496
+ missing.append(f"Missing intermediate certificate: {issuer_cn}")
497
+ else:
498
+ missing.append(f"Missing intermediate certificates issued by: {server_issuer}")
499
+ missing.append("Server configuration issue: Not providing complete certificate chain")
500
+ else:
501
+ missing.append("No certificates found in chain")
502
+ return missing
503
+
504
+ # Check if we need to fetch additional intermediates
505
+ last_cert = cert_chain[-1]
506
+ last_cert_subject = self._format_name(last_cert.subject)
507
+ last_cert_issuer = self._format_name(last_cert.issuer)
508
+
509
+ # Well-known trusted root certificates that don't need to be self-signed
510
+ trusted_roots = [
511
+ # Amazon Trust Services
512
+ "CN=Amazon Root CA 1", "CN=Amazon Root CA 2", "CN=Amazon Root CA 3", "CN=Amazon Root CA 4",
513
+ "CN=Amazon RSA 2048 M01", "CN=Amazon RSA 2048 M02", "CN=Amazon RSA 2048 M03", "CN=Amazon RSA 2048 M04",
514
+ "CN=Starfield Services Root Certificate Authority - G2", "CN=Starfield Class 2 Certification Authority",
515
+
516
+ # Google Trust Services
517
+ "CN=GTS Root R1", "CN=GTS Root R2", "CN=GTS Root R3", "CN=GTS Root R4",
518
+ "CN=GTS CA 1C3", "CN=GTS CA 1O1", "CN=GTS CA 1D4", "CN=GTS CA 2D2",
519
+
520
+ # GlobalSign (including intermediates that are widely trusted)
521
+ "CN=GlobalSign", "CN=GlobalSign Root CA", "CN=GlobalSign Root CA - R2", "CN=GlobalSign Root CA - R3",
522
+ "CN=GlobalSign Root CA - R6", "CN=GlobalSign Root R46", "CN=GlobalSign ECC Root CA - R4", "CN=GlobalSign ECC Root CA - R5",
523
+ "CN=GlobalSign Extended Validation CA - SHA256 - G2", "CN=GlobalSign Extended Validation CA - SHA256 - G3",
524
+ "CN=GlobalSign Organization Validation CA - SHA256 - G2", "CN=GlobalSign Organization Validation CA - SHA256 - G3",
525
+ "CN=GlobalSign Domain Validation CA - SHA256 - G2", "CN=GlobalSign Domain Validation CA - SHA256 - G3",
526
+
527
+ # DigiCert
528
+ "CN=DigiCert Global Root CA", "CN=DigiCert Global Root G2", "CN=DigiCert Global Root G3",
529
+ "CN=DigiCert High Assurance EV Root CA", "CN=DigiCert Assured ID Root CA", "CN=DigiCert Assured ID Root G2",
530
+ "CN=DigiCert Trusted Root G4", "CN=DigiCert TLS RSA SHA256 2020 CA1", "CN=DigiCert SHA2 Extended Validation Server CA",
531
+ "CN=DigiCert Global G2 TLS RSA SHA256 2020 CA1", # Modern DigiCert intermediate
532
+ "CN=DigiCert EV RSA CA G2", # DigiCert Extended Validation RSA CA G2
533
+
534
+ # Let's Encrypt
535
+ "CN=ISRG Root X1", "CN=ISRG Root X2", "CN=Let's Encrypt Authority X3", "CN=Let's Encrypt Authority X4",
536
+ "CN=E1", "CN=E2", "CN=E3", "CN=E4", "CN=E5", "CN=E6", "CN=E7", "CN=E8", "CN=E9", # Let's Encrypt intermediate CAs
537
+ "CN=R3", "CN=E1", "CN=R10", "CN=E5", # Let's Encrypt intermediate CAs
538
+
539
+ # Cloudflare
540
+ "CN=Cloudflare Inc ECC CA-3", "CN=Cloudflare Inc RSA CA-1", "CN=Cloudflare Origin CA ECC Root",
541
+
542
+ # VeriSign/Symantec/Norton
543
+ "CN=VeriSign Class 3 Public Primary Certification Authority - G5",
544
+ "CN=Symantec Class 3 EV SSL CA - G3", "CN=Symantec Class 3 Secure Server CA - G4",
545
+ "CN=Norton Secured SSL CA G2", "CN=GeoTrust Global CA", "CN=GeoTrust Primary Certification Authority",
546
+
547
+ # Entrust
548
+ "CN=Entrust Root Certification Authority", "CN=Entrust Root Certification Authority - G2",
549
+ "CN=Entrust Root Certification Authority - EC1", "CN=Entrust.net Certification Authority (2048)",
550
+
551
+ # Comodo/Sectigo
552
+ "CN=COMODO RSA Certification Authority", "CN=COMODO ECC Certification Authority",
553
+ "CN=Sectigo RSA Domain Validation Secure Server CA", "CN=Sectigo RSA Organization Validation Secure Server CA",
554
+ "CN=USERTrust RSA Certification Authority", "CN=USERTrust ECC Certification Authority",
555
+
556
+ # Microsoft
557
+ "CN=Microsoft RSA Root Certificate Authority 2017", "CN=Microsoft ECC Root Certificate Authority 2017",
558
+
559
+ # Other major CAs
560
+ "CN=Baltimore CyberTrust Root", "CN=AddTrust External CA Root", "CN=COMODO CA Limited",
561
+ "CN=Go Daddy Root Certificate Authority - G2", "CN=Go Daddy Secure Certificate Authority - G2"
562
+ ]
563
+
564
+ is_self_signed = last_cert_subject == last_cert_issuer
565
+
566
+ # Improved trusted root matching - check if any of the trusted CN values appear in the subject
567
+ is_trusted_root = False
568
+ for trusted_root in trusted_roots:
569
+ if trusted_root in last_cert_subject:
570
+ is_trusted_root = True
571
+ break
572
+
573
+ # Additional check: extract just the CN part and match more precisely
574
+ if not is_trusted_root:
575
+ # Try to extract CN from subject and match against trusted roots
576
+ try:
577
+ import re
578
+ cn_match = re.search(r'CN=([^,]+)', last_cert_subject)
579
+ if cn_match:
580
+ cn_value = cn_match.group(1).strip()
581
+ for trusted_root in trusted_roots:
582
+ trusted_cn = trusted_root.replace('CN=', '')
583
+ if cn_value == trusted_cn:
584
+ is_trusted_root = True
585
+ break
586
+ except Exception:
587
+ pass # If regex fails, continue with original logic
588
+
589
+ if not is_self_signed and not is_trusted_root:
590
+ # Last cert is not self-signed and not a known trusted root
591
+ missing.append(f"Missing root certificate or additional intermediates after {last_cert_subject}")
592
+ elif not is_self_signed and is_trusted_root:
593
+ # This is a trusted root but not self-signed - this is normal and acceptable
594
+ # No need to report as missing
595
+ pass
596
+
597
+ return missing
598
+
599
+ def _extract_revocation_urls(self, cert_chain: List[x509.Certificate]) -> Tuple[List[str], List[str]]:
600
+ """Extract OCSP and CRL URLs from certificates."""
601
+ ocsp_urls = []
602
+ crl_urls = []
603
+
604
+ for cert in cert_chain:
605
+ # Extract OCSP URLs from AIA extension
606
+ try:
607
+ aia = cert.extensions.get_extension_for_oid(
608
+ ExtensionOID.AUTHORITY_INFORMATION_ACCESS
609
+ ).value
610
+
611
+ for access_description in aia:
612
+ if access_description.access_method._name == "OCSP":
613
+ url = access_description.access_location.value
614
+ if url not in ocsp_urls:
615
+ ocsp_urls.append(url)
616
+ except x509.ExtensionNotFound:
617
+ pass
618
+
619
+ # Extract CRL URLs
620
+ try:
621
+ crl_dist = cert.extensions.get_extension_for_oid(
622
+ ExtensionOID.CRL_DISTRIBUTION_POINTS
623
+ ).value
624
+
625
+ for dist_point in crl_dist:
626
+ if dist_point.full_name:
627
+ for name in dist_point.full_name:
628
+ if hasattr(name, 'value'):
629
+ url = name.value
630
+ if url not in crl_urls:
631
+ crl_urls.append(url)
632
+ except x509.ExtensionNotFound:
633
+ pass
634
+
635
+ return ocsp_urls, crl_urls
636
+
637
+ def check_certificate_revocation(self, ocsp_url: str) -> Dict[str, Any]:
638
+ """Check certificate revocation status via OCSP."""
639
+ # This is a placeholder for OCSP checking functionality
640
+ # Full OCSP implementation would require additional libraries
641
+ return {
642
+ "status": "unknown",
643
+ "reason": "OCSP checking not fully implemented",
644
+ "checked_url": ocsp_url
645
+ }
646
+
647
+ def validate_hostname(self, cert: x509.Certificate, hostname: str) -> bool:
648
+ """Validate if certificate is valid for the given hostname."""
649
+ # Check CN in subject
650
+ try:
651
+ cn = None
652
+ for attribute in cert.subject:
653
+ if attribute.oid == NameOID.COMMON_NAME:
654
+ cn = attribute.value
655
+ break
656
+
657
+ if cn and self._match_hostname(hostname, cn):
658
+ return True
659
+ except Exception:
660
+ pass
661
+
662
+ # Check SAN domains
663
+ san_domains = self._extract_san_domains(cert)
664
+ for domain in san_domains:
665
+ if self._match_hostname(hostname, domain):
666
+ return True
667
+
668
+ return False
669
+
670
+ def _match_hostname(self, hostname: str, cert_name: str) -> bool:
671
+ """Check if hostname matches certificate name (supports wildcards)."""
672
+ if cert_name == hostname:
673
+ return True
674
+
675
+ # Handle wildcard certificates
676
+ if cert_name.startswith("*."):
677
+ domain_part = cert_name[2:]
678
+ if hostname.endswith("." + domain_part):
679
+ return True
680
+
681
+ return False
682
+
683
+ def _is_trusted_root_or_intermediate(self, cert_chain: List[x509.Certificate]) -> bool:
684
+ """Check if the last certificate in the chain is a trusted root or intermediate CA."""
685
+ if not cert_chain:
686
+ return False
687
+
688
+ last_cert = cert_chain[-1]
689
+ last_cert_subject = self._format_name(last_cert.subject)
690
+
691
+ # Well-known trusted root and intermediate certificates
692
+ trusted_roots = [
693
+ # Amazon Trust Services
694
+ "CN=Amazon Root CA 1", "CN=Amazon Root CA 2", "CN=Amazon Root CA 3", "CN=Amazon Root CA 4",
695
+ "CN=Amazon RSA 2048 M01", "CN=Amazon RSA 2048 M02", "CN=Amazon RSA 2048 M03", "CN=Amazon RSA 2048 M04",
696
+
697
+ # GlobalSign (including widely trusted intermediates)
698
+ "CN=GlobalSign", "CN=GlobalSign Root CA", "CN=GlobalSign Root CA - R2", "CN=GlobalSign Root CA - R3",
699
+ "CN=GlobalSign Extended Validation CA - SHA256 - G2", "CN=GlobalSign Extended Validation CA - SHA256 - G3",
700
+
701
+ # Let's Encrypt
702
+ "CN=ISRG Root X1", "CN=ISRG Root X2",
703
+ "CN=E1", "CN=E2", "CN=E3", "CN=E4", "CN=E5", "CN=E6", "CN=E7", "CN=E8", "CN=E9",
704
+
705
+ # Google Trust Services
706
+ "CN=GTS Root R1", "CN=GTS Root R2", "CN=GTS Root R3", "CN=GTS Root R4",
707
+
708
+ # DigiCert
709
+ "CN=DigiCert Global Root CA", "CN=DigiCert High Assurance EV Root CA",
710
+ "CN=DigiCert Global Root G2", "CN=DigiCert Global Root G3", "CN=DigiCert Assured ID Root CA",
711
+ "CN=DigiCert Trusted Root G4", "CN=DigiCert TLS RSA SHA256 2020 CA1",
712
+ "CN=DigiCert SHA2 Extended Validation Server CA", "CN=DigiCert Global G2 TLS RSA SHA256 2020 CA1",
713
+ "CN=DigiCert EV RSA CA G2", # DigiCert Extended Validation RSA CA G2
714
+ ]
715
+
716
+ # Check if any trusted root pattern matches
717
+ for trusted_root in trusted_roots:
718
+ if trusted_root in last_cert_subject:
719
+ return True
720
+
721
+ return False