capiscio-sdk 0.2.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.
- capiscio_sdk/__init__.py +42 -0
- capiscio_sdk/config.py +114 -0
- capiscio_sdk/errors.py +69 -0
- capiscio_sdk/executor.py +216 -0
- capiscio_sdk/infrastructure/__init__.py +5 -0
- capiscio_sdk/infrastructure/cache.py +73 -0
- capiscio_sdk/infrastructure/rate_limiter.py +110 -0
- capiscio_sdk/py.typed +0 -0
- capiscio_sdk/scoring/__init__.py +42 -0
- capiscio_sdk/scoring/availability.py +299 -0
- capiscio_sdk/scoring/compliance.py +314 -0
- capiscio_sdk/scoring/trust.py +340 -0
- capiscio_sdk/scoring/types.py +353 -0
- capiscio_sdk/types.py +234 -0
- capiscio_sdk/validators/__init__.py +18 -0
- capiscio_sdk/validators/agent_card.py +444 -0
- capiscio_sdk/validators/certificate.py +384 -0
- capiscio_sdk/validators/message.py +360 -0
- capiscio_sdk/validators/protocol.py +162 -0
- capiscio_sdk/validators/semver.py +202 -0
- capiscio_sdk/validators/signature.py +234 -0
- capiscio_sdk/validators/url_security.py +269 -0
- capiscio_sdk-0.2.0.dist-info/METADATA +221 -0
- capiscio_sdk-0.2.0.dist-info/RECORD +26 -0
- capiscio_sdk-0.2.0.dist-info/WHEEL +4 -0
- capiscio_sdk-0.2.0.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Certificate validation for TLS/SSL connections.
|
|
2
|
+
|
|
3
|
+
This module provides validation for TLS/SSL certificates used in agent
|
|
4
|
+
communication. It checks certificate validity, expiry, hostname matching,
|
|
5
|
+
and certificate chain integrity.
|
|
6
|
+
|
|
7
|
+
Validates:
|
|
8
|
+
- Certificate validity and expiry
|
|
9
|
+
- Hostname verification
|
|
10
|
+
- Certificate chain integrity
|
|
11
|
+
- Self-signed certificate detection
|
|
12
|
+
- Certificate trust
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from typing import Optional, List
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
import ssl
|
|
18
|
+
import socket
|
|
19
|
+
from urllib.parse import urlparse
|
|
20
|
+
from cryptography import x509
|
|
21
|
+
from cryptography.hazmat.backends import default_backend
|
|
22
|
+
from cryptography.x509.oid import NameOID
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
from ..types import ValidationResult, ValidationIssue, ValidationSeverity, create_simple_validation_result
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CertificateValidator:
|
|
29
|
+
"""Validates TLS/SSL certificates for secure agent communication.
|
|
30
|
+
|
|
31
|
+
Performs certificate validation including expiry checks, hostname
|
|
32
|
+
verification, and chain validation to ensure secure connections.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
# Warning threshold for certificate expiry (days)
|
|
36
|
+
EXPIRY_WARNING_DAYS = 30
|
|
37
|
+
|
|
38
|
+
def __init__(self, http_client: Optional[httpx.AsyncClient] = None):
|
|
39
|
+
"""Initialize certificate validator.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
http_client: Optional HTTP client with certificate verification
|
|
43
|
+
"""
|
|
44
|
+
self.http_client = http_client or httpx.AsyncClient(
|
|
45
|
+
timeout=10.0,
|
|
46
|
+
verify=True # Enable certificate verification
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
async def validate_url_certificate(
|
|
50
|
+
self,
|
|
51
|
+
url: str,
|
|
52
|
+
verify_hostname: bool = True
|
|
53
|
+
) -> ValidationResult:
|
|
54
|
+
"""Validate certificate for a given URL.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
url: URL to validate certificate for
|
|
58
|
+
verify_hostname: Whether to verify hostname matches certificate
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
ValidationResult with certificate validation issues
|
|
62
|
+
"""
|
|
63
|
+
issues: List[ValidationIssue] = []
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
parsed = urlparse(url)
|
|
67
|
+
|
|
68
|
+
# Only validate HTTPS URLs
|
|
69
|
+
if parsed.scheme != "https":
|
|
70
|
+
issues.append(ValidationIssue(
|
|
71
|
+
severity=ValidationSeverity.WARNING,
|
|
72
|
+
code="NON_HTTPS_URL",
|
|
73
|
+
message="Certificate validation only applies to HTTPS URLs",
|
|
74
|
+
path="certificate"
|
|
75
|
+
))
|
|
76
|
+
return create_simple_validation_result(
|
|
77
|
+
success=True,
|
|
78
|
+
issues=issues,
|
|
79
|
+
simple_score=80,
|
|
80
|
+
dimension="trust"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
hostname = parsed.hostname
|
|
84
|
+
port = parsed.port or 443
|
|
85
|
+
|
|
86
|
+
if not hostname:
|
|
87
|
+
issues.append(ValidationIssue(
|
|
88
|
+
severity=ValidationSeverity.ERROR,
|
|
89
|
+
code="INVALID_HOSTNAME",
|
|
90
|
+
message="Cannot extract hostname from URL",
|
|
91
|
+
path="certificate"
|
|
92
|
+
))
|
|
93
|
+
return create_simple_validation_result(
|
|
94
|
+
success=False,
|
|
95
|
+
issues=issues,
|
|
96
|
+
simple_score=0,
|
|
97
|
+
dimension="trust"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Get certificate from server
|
|
101
|
+
cert_pem = self._get_certificate(hostname, port)
|
|
102
|
+
if not cert_pem:
|
|
103
|
+
issues.append(ValidationIssue(
|
|
104
|
+
severity=ValidationSeverity.ERROR,
|
|
105
|
+
code="CERTIFICATE_FETCH_FAILED",
|
|
106
|
+
message=f"Failed to fetch certificate from {hostname}:{port}",
|
|
107
|
+
path="certificate"
|
|
108
|
+
))
|
|
109
|
+
return create_simple_validation_result(
|
|
110
|
+
success=False,
|
|
111
|
+
issues=issues,
|
|
112
|
+
simple_score=0,
|
|
113
|
+
dimension="trust"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Parse certificate
|
|
117
|
+
cert = x509.load_pem_x509_certificate(cert_pem.encode(), default_backend())
|
|
118
|
+
|
|
119
|
+
# Validate certificate
|
|
120
|
+
issues.extend(self._validate_certificate_expiry(cert))
|
|
121
|
+
|
|
122
|
+
if verify_hostname:
|
|
123
|
+
issues.extend(self._validate_hostname(cert, hostname))
|
|
124
|
+
|
|
125
|
+
issues.extend(self._check_self_signed(cert))
|
|
126
|
+
|
|
127
|
+
# Calculate score
|
|
128
|
+
error_count = sum(1 for i in issues if i.severity == ValidationSeverity.ERROR)
|
|
129
|
+
warning_count = sum(1 for i in issues if i.severity == ValidationSeverity.WARNING)
|
|
130
|
+
score = max(0, 100 - (error_count * 25) - (warning_count * 10))
|
|
131
|
+
|
|
132
|
+
return create_simple_validation_result(
|
|
133
|
+
success=error_count == 0,
|
|
134
|
+
issues=issues,
|
|
135
|
+
simple_score=score,
|
|
136
|
+
dimension="trust"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
except Exception as e:
|
|
140
|
+
issues.append(ValidationIssue(
|
|
141
|
+
severity=ValidationSeverity.ERROR,
|
|
142
|
+
code="CERTIFICATE_VALIDATION_ERROR",
|
|
143
|
+
message=f"Certificate validation error: {str(e)}",
|
|
144
|
+
path="certificate"
|
|
145
|
+
))
|
|
146
|
+
return create_simple_validation_result(
|
|
147
|
+
success=False,
|
|
148
|
+
issues=issues,
|
|
149
|
+
simple_score=0,
|
|
150
|
+
dimension="trust"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def _get_certificate(self, hostname: str, port: int) -> Optional[str]:
|
|
154
|
+
"""Retrieve certificate from server.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
hostname: Server hostname
|
|
158
|
+
port: Server port
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
PEM-encoded certificate or None if failed
|
|
162
|
+
"""
|
|
163
|
+
try:
|
|
164
|
+
context = ssl.create_default_context()
|
|
165
|
+
with socket.create_connection((hostname, port), timeout=10) as sock:
|
|
166
|
+
with context.wrap_socket(sock, server_hostname=hostname) as ssock:
|
|
167
|
+
cert_der = ssock.getpeercert(binary_form=True)
|
|
168
|
+
if cert_der:
|
|
169
|
+
cert = x509.load_der_x509_certificate(cert_der, default_backend())
|
|
170
|
+
return cert.public_bytes(encoding=x509.Encoding.PEM).decode() # type: ignore[attr-defined]
|
|
171
|
+
return None
|
|
172
|
+
except Exception:
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
def _validate_certificate_expiry(self, cert: x509.Certificate) -> List[ValidationIssue]:
|
|
176
|
+
"""Validate certificate expiry dates.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
cert: Certificate to validate
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
List of validation issues
|
|
183
|
+
"""
|
|
184
|
+
issues: List[ValidationIssue] = []
|
|
185
|
+
now = datetime.utcnow()
|
|
186
|
+
|
|
187
|
+
# Check if certificate has expired
|
|
188
|
+
if cert.not_valid_after < now:
|
|
189
|
+
days_expired = (now - cert.not_valid_after).days
|
|
190
|
+
issues.append(ValidationIssue(
|
|
191
|
+
severity=ValidationSeverity.ERROR,
|
|
192
|
+
code="CERTIFICATE_EXPIRED",
|
|
193
|
+
message=f"Certificate expired {days_expired} days ago (expired: {cert.not_valid_after.isoformat()})",
|
|
194
|
+
path="certificate.expiry"
|
|
195
|
+
))
|
|
196
|
+
# Check if certificate is not yet valid
|
|
197
|
+
elif cert.not_valid_before > now:
|
|
198
|
+
days_early = (cert.not_valid_before - now).days
|
|
199
|
+
issues.append(ValidationIssue(
|
|
200
|
+
severity=ValidationSeverity.ERROR,
|
|
201
|
+
code="CERTIFICATE_NOT_YET_VALID",
|
|
202
|
+
message=f"Certificate not valid for {days_early} more days (valid from: {cert.not_valid_before.isoformat()})",
|
|
203
|
+
path="certificate.validity"
|
|
204
|
+
))
|
|
205
|
+
# Check if certificate expires soon
|
|
206
|
+
else:
|
|
207
|
+
days_until_expiry = (cert.not_valid_after - now).days
|
|
208
|
+
if days_until_expiry <= self.EXPIRY_WARNING_DAYS:
|
|
209
|
+
issues.append(ValidationIssue(
|
|
210
|
+
severity=ValidationSeverity.WARNING,
|
|
211
|
+
code="CERTIFICATE_EXPIRING_SOON",
|
|
212
|
+
message=f"Certificate expires in {days_until_expiry} days (expires: {cert.not_valid_after.isoformat()})",
|
|
213
|
+
path="certificate.expiry"
|
|
214
|
+
))
|
|
215
|
+
|
|
216
|
+
return issues
|
|
217
|
+
|
|
218
|
+
def _validate_hostname(self, cert: x509.Certificate, hostname: str) -> List[ValidationIssue]:
|
|
219
|
+
"""Validate certificate hostname matches.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
cert: Certificate to validate
|
|
223
|
+
hostname: Expected hostname
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
List of validation issues
|
|
227
|
+
"""
|
|
228
|
+
issues: List[ValidationIssue] = []
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
# Get subject common name
|
|
232
|
+
subject = cert.subject
|
|
233
|
+
common_names = [
|
|
234
|
+
attr.value for attr in subject
|
|
235
|
+
if attr.oid == NameOID.COMMON_NAME
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
# Get subject alternative names
|
|
239
|
+
san_names: List[str] = []
|
|
240
|
+
try:
|
|
241
|
+
san_ext = cert.extensions.get_extension_for_oid(
|
|
242
|
+
x509.oid.ExtensionOID.SUBJECT_ALTERNATIVE_NAME
|
|
243
|
+
)
|
|
244
|
+
san_names = [
|
|
245
|
+
str(name.value) for name in san_ext.value # type: ignore[attr-defined]
|
|
246
|
+
if isinstance(name, x509.DNSName)
|
|
247
|
+
]
|
|
248
|
+
except x509.ExtensionNotFound:
|
|
249
|
+
pass
|
|
250
|
+
|
|
251
|
+
# Check if hostname matches
|
|
252
|
+
all_names = common_names + san_names
|
|
253
|
+
if not any(self._matches_hostname(name, hostname) for name in all_names):
|
|
254
|
+
issues.append(ValidationIssue(
|
|
255
|
+
severity=ValidationSeverity.ERROR,
|
|
256
|
+
code="HOSTNAME_MISMATCH",
|
|
257
|
+
message=f"Certificate hostname mismatch. Expected: {hostname}, Certificate names: {', '.join(all_names) if all_names else 'none'}",
|
|
258
|
+
path="certificate.hostname"
|
|
259
|
+
))
|
|
260
|
+
|
|
261
|
+
except Exception as e:
|
|
262
|
+
issues.append(ValidationIssue(
|
|
263
|
+
severity=ValidationSeverity.WARNING,
|
|
264
|
+
code="HOSTNAME_VALIDATION_ERROR",
|
|
265
|
+
message=f"Failed to validate hostname: {str(e)}",
|
|
266
|
+
path="certificate.hostname"
|
|
267
|
+
))
|
|
268
|
+
|
|
269
|
+
return issues
|
|
270
|
+
|
|
271
|
+
def _matches_hostname(self, cert_name: str, hostname: str) -> bool:
|
|
272
|
+
"""Check if certificate name matches hostname.
|
|
273
|
+
|
|
274
|
+
Supports wildcard certificates (*.example.com).
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
cert_name: Name from certificate
|
|
278
|
+
hostname: Hostname to match
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
True if matches, False otherwise
|
|
282
|
+
"""
|
|
283
|
+
# Exact match
|
|
284
|
+
if cert_name.lower() == hostname.lower():
|
|
285
|
+
return True
|
|
286
|
+
|
|
287
|
+
# Wildcard match (*.example.com matches foo.example.com)
|
|
288
|
+
if cert_name.startswith("*."):
|
|
289
|
+
cert_domain = cert_name[2:].lower()
|
|
290
|
+
if "." in hostname:
|
|
291
|
+
_, host_domain = hostname.split(".", 1)
|
|
292
|
+
if host_domain.lower() == cert_domain:
|
|
293
|
+
return True
|
|
294
|
+
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
def _check_self_signed(self, cert: x509.Certificate) -> List[ValidationIssue]:
|
|
298
|
+
"""Check if certificate is self-signed.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
cert: Certificate to check
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
List of validation issues
|
|
305
|
+
"""
|
|
306
|
+
issues: List[ValidationIssue] = []
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
# A certificate is self-signed if issuer == subject
|
|
310
|
+
if cert.issuer == cert.subject:
|
|
311
|
+
issues.append(ValidationIssue(
|
|
312
|
+
severity=ValidationSeverity.WARNING,
|
|
313
|
+
code="SELF_SIGNED_CERTIFICATE",
|
|
314
|
+
message="Certificate is self-signed (not issued by trusted CA)",
|
|
315
|
+
path="certificate.issuer"
|
|
316
|
+
))
|
|
317
|
+
except Exception:
|
|
318
|
+
pass
|
|
319
|
+
|
|
320
|
+
return issues
|
|
321
|
+
|
|
322
|
+
def validate_certificate_chain(
|
|
323
|
+
self,
|
|
324
|
+
cert: x509.Certificate,
|
|
325
|
+
chain: List[x509.Certificate]
|
|
326
|
+
) -> ValidationResult:
|
|
327
|
+
"""Validate certificate chain integrity.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
cert: End-entity certificate
|
|
331
|
+
chain: Certificate chain (intermediate + root)
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
ValidationResult with chain validation issues
|
|
335
|
+
"""
|
|
336
|
+
issues: List[ValidationIssue] = []
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
# Basic chain validation
|
|
340
|
+
if not chain:
|
|
341
|
+
issues.append(ValidationIssue(
|
|
342
|
+
severity=ValidationSeverity.WARNING,
|
|
343
|
+
code="NO_CERTIFICATE_CHAIN",
|
|
344
|
+
message="No certificate chain provided",
|
|
345
|
+
path="certificate.chain"
|
|
346
|
+
))
|
|
347
|
+
else:
|
|
348
|
+
# Verify each certificate in chain is issued by the next
|
|
349
|
+
current = cert
|
|
350
|
+
for idx, issuer_cert in enumerate(chain):
|
|
351
|
+
if current.issuer != issuer_cert.subject:
|
|
352
|
+
issues.append(ValidationIssue(
|
|
353
|
+
severity=ValidationSeverity.ERROR,
|
|
354
|
+
code="CHAIN_BROKEN",
|
|
355
|
+
message=f"Certificate chain broken at position {idx}",
|
|
356
|
+
path=f"certificate.chain[{idx}]"
|
|
357
|
+
))
|
|
358
|
+
break
|
|
359
|
+
current = issuer_cert
|
|
360
|
+
|
|
361
|
+
error_count = sum(1 for i in issues if i.severity == ValidationSeverity.ERROR)
|
|
362
|
+
warning_count = sum(1 for i in issues if i.severity == ValidationSeverity.WARNING)
|
|
363
|
+
score = max(0, 100 - (error_count * 25) - (warning_count * 10))
|
|
364
|
+
|
|
365
|
+
return create_simple_validation_result(
|
|
366
|
+
success=error_count == 0,
|
|
367
|
+
issues=issues,
|
|
368
|
+
simple_score=score,
|
|
369
|
+
dimension="trust"
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
except Exception as e:
|
|
373
|
+
issues.append(ValidationIssue(
|
|
374
|
+
severity=ValidationSeverity.ERROR,
|
|
375
|
+
code="CHAIN_VALIDATION_ERROR",
|
|
376
|
+
message=f"Certificate chain validation error: {str(e)}",
|
|
377
|
+
path="certificate.chain"
|
|
378
|
+
))
|
|
379
|
+
return create_simple_validation_result(
|
|
380
|
+
success=False,
|
|
381
|
+
issues=issues,
|
|
382
|
+
simple_score=0,
|
|
383
|
+
dimension="trust"
|
|
384
|
+
)
|