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.
- offsec_ai/__init__.py +91 -0
- offsec_ai/__main__.py +12 -0
- offsec_ai/cli.py +2764 -0
- offsec_ai/core/__init__.py +1 -0
- offsec_ai/core/ai_owasp_scanner.py +389 -0
- offsec_ai/core/cert_analyzer.py +721 -0
- offsec_ai/core/hybrid_identity_checker.py +585 -0
- offsec_ai/core/l7_detector.py +1628 -0
- offsec_ai/core/llm_judge.py +183 -0
- offsec_ai/core/mcp_attacker.py +384 -0
- offsec_ai/core/mcp_scanner.py +506 -0
- offsec_ai/core/mtls_checker.py +990 -0
- offsec_ai/core/owasp_scanner.py +653 -0
- offsec_ai/core/port_scanner.py +277 -0
- offsec_ai/core/security_headers.py +472 -0
- offsec_ai/models/__init__.py +1 -0
- offsec_ai/models/ai_owasp_result.py +161 -0
- offsec_ai/models/l7_result.py +231 -0
- offsec_ai/models/mcp_result.py +148 -0
- offsec_ai/models/mtls_result.py +95 -0
- offsec_ai/models/owasp_result.py +282 -0
- offsec_ai/models/scan_result.py +143 -0
- offsec_ai/py.typed +0 -0
- offsec_ai/utils/__init__.py +1 -0
- offsec_ai/utils/ai_owasp_payloads.py +283 -0
- offsec_ai/utils/ai_owasp_remediation.py +248 -0
- offsec_ai/utils/common_ports.py +316 -0
- offsec_ai/utils/exporters.py +441 -0
- offsec_ai/utils/l7_signatures.py +460 -0
- offsec_ai/utils/mcp_cve_db.py +263 -0
- offsec_ai/utils/mcp_payloads.py +121 -0
- offsec_ai/utils/owasp_remediation.py +787 -0
- offsec_ai-2.0.0.dist-info/METADATA +601 -0
- offsec_ai-2.0.0.dist-info/RECORD +37 -0
- offsec_ai-2.0.0.dist-info/WHEEL +4 -0
- offsec_ai-2.0.0.dist-info/entry_points.txt +2 -0
- offsec_ai-2.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|