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.
@@ -0,0 +1,202 @@
1
+ """Semantic version validation and comparison."""
2
+ import re
3
+ from typing import Optional, Tuple
4
+
5
+ from ..types import ValidationResult, ValidationIssue, ValidationSeverity, create_simple_validation_result
6
+
7
+
8
+ class SemverValidator:
9
+ """Validates and compares semantic versions."""
10
+
11
+ # Semver regex pattern (simplified but covers most cases)
12
+ SEMVER_PATTERN = re.compile(
13
+ r'^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)'
14
+ r'(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)'
15
+ r'(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?'
16
+ r'(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$'
17
+ )
18
+
19
+ def validate_version(self, version: str, field_name: str = "version") -> ValidationResult:
20
+ """
21
+ Validate semantic version format.
22
+
23
+ Args:
24
+ version: Version string to validate
25
+ field_name: Name of the field being validated
26
+
27
+ Returns:
28
+ ValidationResult
29
+ """
30
+ issues = []
31
+ score = 100
32
+
33
+ if not version:
34
+ issues.append(
35
+ ValidationIssue(
36
+ severity=ValidationSeverity.ERROR,
37
+ code="MISSING_VERSION",
38
+ message=f"{field_name}: Version is required",
39
+ path=field_name,
40
+ )
41
+ )
42
+ return create_simple_validation_result(
43
+ success=False,
44
+ issues=issues,
45
+ simple_score=0,
46
+ dimension="compliance"
47
+ )
48
+
49
+ if not isinstance(version, str):
50
+ issues.append(
51
+ ValidationIssue(
52
+ severity=ValidationSeverity.ERROR,
53
+ code="INVALID_VERSION_TYPE",
54
+ message=f"{field_name}: Version must be a string",
55
+ path=field_name,
56
+ )
57
+ )
58
+ return create_simple_validation_result(
59
+ success=False,
60
+ issues=issues,
61
+ simple_score=0,
62
+ dimension="compliance"
63
+ )
64
+
65
+ # Validate semver format
66
+ match = self.SEMVER_PATTERN.match(version)
67
+ if not match:
68
+ issues.append(
69
+ ValidationIssue(
70
+ severity=ValidationSeverity.ERROR,
71
+ code="INVALID_SEMVER_FORMAT",
72
+ message=f"{field_name}: Version must follow semantic versioning (e.g., 1.0.0)",
73
+ path=field_name,
74
+ details={"version": version},
75
+ )
76
+ )
77
+ return create_simple_validation_result(
78
+ success=False,
79
+ issues=issues,
80
+ simple_score=0,
81
+ dimension="compliance"
82
+ )
83
+
84
+ # Extract version parts
85
+ major = int(match.group('major'))
86
+ minor = int(match.group('minor'))
87
+ patch = int(match.group('patch'))
88
+ prerelease = match.group('prerelease')
89
+
90
+ # Warn about pre-release versions in production
91
+ if prerelease:
92
+ issues.append(
93
+ ValidationIssue(
94
+ severity=ValidationSeverity.WARNING,
95
+ code="PRERELEASE_VERSION",
96
+ message=f"{field_name}: Pre-release version detected ({version})",
97
+ path=field_name,
98
+ details={"prerelease": prerelease},
99
+ )
100
+ )
101
+ score -= 10
102
+
103
+ # Warn about 0.x versions
104
+ if major == 0:
105
+ issues.append(
106
+ ValidationIssue(
107
+ severity=ValidationSeverity.INFO,
108
+ code="DEVELOPMENT_VERSION",
109
+ message=f"{field_name}: Development version (0.x.x) - not stable",
110
+ path=field_name,
111
+ )
112
+ )
113
+
114
+ result = create_simple_validation_result(
115
+ success=True,
116
+ issues=issues,
117
+ simple_score=score,
118
+ dimension="compliance"
119
+ )
120
+ result.metadata = {
121
+ "major": major,
122
+ "minor": minor,
123
+ "patch": patch,
124
+ "prerelease": prerelease,
125
+ }
126
+ return result
127
+
128
+ def parse_version(self, version: str) -> Optional[Tuple[int, int, int]]:
129
+ """
130
+ Parse semantic version into (major, minor, patch) tuple.
131
+
132
+ Args:
133
+ version: Version string to parse
134
+
135
+ Returns:
136
+ Tuple of (major, minor, patch) or None if invalid
137
+ """
138
+ match = self.SEMVER_PATTERN.match(version)
139
+ if not match:
140
+ return None
141
+
142
+ return (
143
+ int(match.group('major')),
144
+ int(match.group('minor')),
145
+ int(match.group('patch'))
146
+ )
147
+
148
+ def compare_versions(self, version1: str, version2: str) -> int:
149
+ """
150
+ Compare two semantic versions.
151
+
152
+ Args:
153
+ version1: First version
154
+ version2: Second version
155
+
156
+ Returns:
157
+ -1 if version1 < version2
158
+ 0 if version1 == version2
159
+ 1 if version1 > version2
160
+ """
161
+ v1 = self.parse_version(version1)
162
+ v2 = self.parse_version(version2)
163
+
164
+ if v1 is None or v2 is None:
165
+ raise ValueError(f"Invalid version format: {version1} or {version2}")
166
+
167
+ # Compare major, minor, patch
168
+ if v1[0] != v2[0]:
169
+ return 1 if v1[0] > v2[0] else -1
170
+ if v1[1] != v2[1]:
171
+ return 1 if v1[1] > v2[1] else -1
172
+ if v1[2] != v2[2]:
173
+ return 1 if v1[2] > v2[2] else -1
174
+
175
+ return 0
176
+
177
+ def is_compatible(self, version: str, required: str) -> bool:
178
+ """
179
+ Check if version is compatible with required version.
180
+
181
+ Compatible means version >= required for same major version.
182
+
183
+ Args:
184
+ version: Actual version
185
+ required: Required minimum version
186
+
187
+ Returns:
188
+ True if compatible
189
+ """
190
+ try:
191
+ comparison = self.compare_versions(version, required)
192
+ v1 = self.parse_version(version)
193
+ v2 = self.parse_version(required)
194
+
195
+ # Must be same major version
196
+ if v1 and v2 and v1[0] != v2[0]:
197
+ return False
198
+
199
+ # Must be greater than or equal
200
+ return comparison >= 0
201
+ except ValueError:
202
+ return False
@@ -0,0 +1,234 @@
1
+ """Signature verification for A2A protocol."""
2
+ import logging
3
+ from typing import Any, Dict, List, Optional
4
+
5
+ from ..types import ValidationResult, ValidationIssue, ValidationSeverity, create_simple_validation_result
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class SignatureValidator:
11
+ """Validates JWS signatures on A2A messages and agent cards."""
12
+
13
+ def __init__(self) -> None:
14
+ """Initialize signature validator."""
15
+ self._crypto_available = self._check_crypto_availability()
16
+
17
+ def _check_crypto_availability(self) -> bool:
18
+ """Check if cryptography library is available."""
19
+ try:
20
+ import jwt # PyJWT for JWS validation # noqa: F401
21
+ return True
22
+ except ImportError:
23
+ logger.warning(
24
+ "PyJWT not installed. Signature verification will be limited. "
25
+ "Install with: pip install pyjwt cryptography"
26
+ )
27
+ return False
28
+
29
+ def validate_signature(
30
+ self,
31
+ payload: Dict[str, Any],
32
+ signature: str,
33
+ public_key: Optional[str] = None,
34
+ ) -> ValidationResult:
35
+ """
36
+ Validate a JWS signature.
37
+
38
+ Args:
39
+ payload: The data that was signed
40
+ signature: The JWS signature to verify
41
+ public_key: Optional public key for verification
42
+
43
+ Returns:
44
+ ValidationResult with signature validation results
45
+ """
46
+ issues: List[ValidationIssue] = []
47
+ score = 100
48
+
49
+ if not self._crypto_available:
50
+ issues.append(
51
+ ValidationIssue(
52
+ severity=ValidationSeverity.WARNING,
53
+ code="CRYPTO_NOT_AVAILABLE",
54
+ message="Cryptography library not available for signature verification",
55
+ path="signatures",
56
+ )
57
+ )
58
+ return create_simple_validation_result(
59
+ success=False,
60
+ issues=issues,
61
+ simple_score=0,
62
+ dimension="trust"
63
+ )
64
+
65
+ # Check signature format
66
+ if not signature or not isinstance(signature, str):
67
+ issues.append(
68
+ ValidationIssue(
69
+ severity=ValidationSeverity.ERROR,
70
+ code="INVALID_SIGNATURE_FORMAT",
71
+ message="Signature must be a non-empty string",
72
+ path="signatures",
73
+ )
74
+ )
75
+ return create_simple_validation_result(
76
+ success=False,
77
+ issues=issues,
78
+ simple_score=0,
79
+ dimension="trust"
80
+ )
81
+
82
+ # JWS signatures should have 3 parts separated by dots
83
+ parts = signature.split('.')
84
+ if len(parts) != 3:
85
+ issues.append(
86
+ ValidationIssue(
87
+ severity=ValidationSeverity.ERROR,
88
+ code="INVALID_JWS_FORMAT",
89
+ message=f"JWS signature must have 3 parts (header.payload.signature), found {len(parts)}",
90
+ path="signatures",
91
+ )
92
+ )
93
+ score -= 50
94
+
95
+ # Attempt verification if public key provided
96
+ if public_key:
97
+ try:
98
+ import jwt
99
+
100
+ # Verify signature
101
+ jwt.decode(
102
+ signature,
103
+ public_key,
104
+ algorithms=['RS256', 'ES256', 'PS256'],
105
+ options={"verify_signature": True}
106
+ )
107
+
108
+ # Signature is valid
109
+ logger.debug("Signature verified successfully")
110
+
111
+ except jwt.InvalidSignatureError:
112
+ issues.append(
113
+ ValidationIssue(
114
+ severity=ValidationSeverity.ERROR,
115
+ code="SIGNATURE_VERIFICATION_FAILED",
116
+ message="Signature verification failed - signature is invalid",
117
+ path="signatures",
118
+ )
119
+ )
120
+ score = 0
121
+
122
+ except jwt.ExpiredSignatureError:
123
+ issues.append(
124
+ ValidationIssue(
125
+ severity=ValidationSeverity.ERROR,
126
+ code="SIGNATURE_EXPIRED",
127
+ message="Signature has expired",
128
+ path="signatures",
129
+ )
130
+ )
131
+ score -= 40
132
+
133
+ except jwt.DecodeError as e:
134
+ issues.append(
135
+ ValidationIssue(
136
+ severity=ValidationSeverity.ERROR,
137
+ code="SIGNATURE_DECODE_ERROR",
138
+ message=f"Failed to decode signature: {str(e)}",
139
+ path="signatures",
140
+ )
141
+ )
142
+ score -= 30
143
+
144
+ except Exception as e:
145
+ issues.append(
146
+ ValidationIssue(
147
+ severity=ValidationSeverity.ERROR,
148
+ code="SIGNATURE_VERIFICATION_ERROR",
149
+ message=f"Signature verification error: {str(e)}",
150
+ path="signatures",
151
+ )
152
+ )
153
+ score = 0
154
+ else:
155
+ # No public key provided - can't verify, just validate format
156
+ issues.append(
157
+ ValidationIssue(
158
+ severity=ValidationSeverity.WARNING,
159
+ code="NO_PUBLIC_KEY",
160
+ message="Signature format valid but no public key provided for verification",
161
+ path="signatures",
162
+ )
163
+ )
164
+ score = 70 # Format is OK but not verified
165
+
166
+ # Ensure score doesn't go negative
167
+ score = max(0, score)
168
+
169
+ return create_simple_validation_result(
170
+ success=score >= 60 and not any(i.severity == ValidationSeverity.ERROR for i in issues),
171
+ issues=issues,
172
+ simple_score=score,
173
+ dimension="trust"
174
+ )
175
+
176
+ def validate_signatures(
177
+ self,
178
+ payload: Dict[str, Any],
179
+ signatures: List[str],
180
+ ) -> ValidationResult:
181
+ """
182
+ Validate multiple JWS signatures.
183
+
184
+ Args:
185
+ payload: The data that was signed
186
+ signatures: List of JWS signatures to verify
187
+
188
+ Returns:
189
+ Aggregated ValidationResult
190
+ """
191
+ all_issues: List[ValidationIssue] = []
192
+ total_score = 0
193
+ valid_count = 0
194
+
195
+ if not signatures or len(signatures) == 0:
196
+ all_issues.append(
197
+ ValidationIssue(
198
+ severity=ValidationSeverity.WARNING,
199
+ code="NO_SIGNATURES",
200
+ message="No signatures found to validate",
201
+ path="signatures",
202
+ )
203
+ )
204
+ return create_simple_validation_result(
205
+ success=False,
206
+ issues=all_issues,
207
+ simple_score=50,
208
+ dimension="trust"
209
+ )
210
+
211
+ for i, sig in enumerate(signatures):
212
+ result = self.validate_signature(payload, sig)
213
+ all_issues.extend(result.issues)
214
+ # Use trust.total since signature validation is trust-related
215
+ total_score += result.trust.total if result.trust else 0
216
+
217
+ if result.success:
218
+ valid_count += 1
219
+
220
+ # Calculate average score
221
+ avg_score = total_score // len(signatures) if signatures else 0
222
+
223
+ result = create_simple_validation_result(
224
+ success=valid_count > 0,
225
+ issues=all_issues,
226
+ simple_score=avg_score,
227
+ dimension="trust"
228
+ )
229
+ result.metadata = {
230
+ "total_signatures": len(signatures),
231
+ "valid_signatures": valid_count,
232
+ "failed_signatures": len(signatures) - valid_count,
233
+ }
234
+ return result
@@ -0,0 +1,269 @@
1
+ """URL security validation for A2A protocol."""
2
+ import re
3
+ from typing import TYPE_CHECKING, Any, List, Optional
4
+ from urllib.parse import urlparse
5
+
6
+ from ..types import ValidationResult, ValidationIssue, ValidationSeverity, create_simple_validation_result
7
+
8
+ if TYPE_CHECKING:
9
+ from .certificate import CertificateValidator
10
+
11
+
12
+ class URLSecurityValidator:
13
+ """Validates URL security requirements per A2A specification."""
14
+
15
+ # Private IPv4 ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
16
+ # Link-local: 169.254.0.0/16
17
+ PRIVATE_IPV4_PATTERN = re.compile(
18
+ r'^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.)'
19
+ )
20
+
21
+ # Private IPv6 ranges: fc00::/7, fe80::/10
22
+ PRIVATE_IPV6_PATTERN = re.compile(r'^(fc|fd|fe[89ab])', re.IGNORECASE)
23
+
24
+ LOCALHOST_NAMES = {'localhost', '127.0.0.1', '::1', '0.0.0.0', '::'} # nosec B104 - validation constants, not binding
25
+
26
+ def __init__(self, certificate_validator: Optional['CertificateValidator'] = None):
27
+ """Initialize URL security validator.
28
+
29
+ Args:
30
+ certificate_validator: Optional certificate validator for TLS validation
31
+ """
32
+ self._certificate_validator = certificate_validator
33
+
34
+ def validate_url(
35
+ self,
36
+ url: str,
37
+ field_name: str = "url",
38
+ require_https: bool = True
39
+ ) -> ValidationResult:
40
+ """
41
+ Validate URL for security compliance (synchronous version).
42
+
43
+ Args:
44
+ url: URL to validate
45
+ field_name: Name of the field being validated (for error messages)
46
+ require_https: Whether HTTPS is required (default: True per A2A spec)
47
+
48
+ Returns:
49
+ ValidationResult with security validation results
50
+ """
51
+ issues: List[ValidationIssue] = []
52
+ score = 100
53
+
54
+ # Parse URL
55
+ try:
56
+ parsed = urlparse(url)
57
+ except Exception as e:
58
+ issues.append(
59
+ ValidationIssue(
60
+ severity=ValidationSeverity.ERROR,
61
+ code="INVALID_URL_FORMAT",
62
+ message=f"{field_name}: Invalid URL format - {str(e)}",
63
+ path=field_name,
64
+ )
65
+ )
66
+ return create_simple_validation_result(
67
+ success=False,
68
+ issues=issues,
69
+ simple_score=0,
70
+ dimension="trust"
71
+ )
72
+
73
+ # Continue with existing validation logic...
74
+ return self._validate_url_internal(parsed, url, field_name, require_https, issues, score)
75
+
76
+ async def validate_url_with_certificate(
77
+ self,
78
+ url: str,
79
+ field_name: str = "url",
80
+ require_https: bool = True
81
+ ) -> ValidationResult:
82
+ """
83
+ Validate URL for security compliance including certificate validation (async version).
84
+
85
+ Args:
86
+ url: URL to validate
87
+ field_name: Name of the field being validated (for error messages)
88
+ require_https: Whether HTTPS is required (default: True per A2A spec)
89
+
90
+ Returns:
91
+ ValidationResult with security and certificate validation results
92
+ """
93
+ issues: List[ValidationIssue] = []
94
+ score = 100
95
+
96
+ # Parse URL
97
+ try:
98
+ parsed = urlparse(url)
99
+ except Exception as e:
100
+ issues.append(
101
+ ValidationIssue(
102
+ severity=ValidationSeverity.ERROR,
103
+ code="INVALID_URL_FORMAT",
104
+ message=f"{field_name}: Invalid URL format - {str(e)}",
105
+ path=field_name,
106
+ )
107
+ )
108
+ return create_simple_validation_result(
109
+ success=False,
110
+ issues=issues,
111
+ simple_score=0,
112
+ dimension="trust"
113
+ )
114
+
115
+ # First do standard URL validation
116
+ result = self._validate_url_internal(parsed, url, field_name, require_https, issues, score)
117
+
118
+ # Then optionally verify TLS certificate
119
+ if self._certificate_validator and parsed.scheme == "https":
120
+ cert_result = await self._certificate_validator.validate_url_certificate(url)
121
+ result.issues.extend(cert_result.issues)
122
+ # Reduce trust score based on certificate issues
123
+ if not cert_result.success and cert_result.trust:
124
+ # Merge certificate trust issues into result
125
+ if result.trust and result.trust.total:
126
+ # Certificate issues reduce trust score
127
+ min(result.trust.total, cert_result.trust.total or 0)
128
+ # Update success based on combined issues
129
+ result.success = not any(i.severity == ValidationSeverity.ERROR for i in result.issues)
130
+
131
+ return result
132
+
133
+ def _validate_url_internal(
134
+ self,
135
+ parsed: Any,
136
+ url: str,
137
+ field_name: str,
138
+ require_https: bool,
139
+ issues: List[ValidationIssue],
140
+ score: int
141
+ ) -> ValidationResult:
142
+ """Internal URL validation logic."""
143
+
144
+ # Check scheme
145
+ if not parsed.scheme:
146
+ issues.append(
147
+ ValidationIssue(
148
+ severity=ValidationSeverity.ERROR,
149
+ code="MISSING_URL_SCHEME",
150
+ message=f"{field_name}: URL must include scheme (https://)",
151
+ path=field_name,
152
+ )
153
+ )
154
+ score -= 30
155
+
156
+ # HTTPS enforcement (A2A specification §5.3)
157
+ elif require_https and parsed.scheme != 'https':
158
+ issues.append(
159
+ ValidationIssue(
160
+ severity=ValidationSeverity.ERROR,
161
+ code="HTTPS_REQUIRED",
162
+ message=f"{field_name}: Must use HTTPS (HTTP not allowed per A2A specification)",
163
+ path=field_name,
164
+ )
165
+ )
166
+ score -= 40
167
+
168
+ # Check for hostname
169
+ if not parsed.hostname:
170
+ issues.append(
171
+ ValidationIssue(
172
+ severity=ValidationSeverity.ERROR,
173
+ code="MISSING_HOSTNAME",
174
+ message=f"{field_name}: URL must include hostname",
175
+ path=field_name,
176
+ )
177
+ )
178
+ score -= 30
179
+
180
+ else:
181
+ # SSRF Protection: Check for localhost
182
+ if parsed.hostname.lower() in self.LOCALHOST_NAMES:
183
+ issues.append(
184
+ ValidationIssue(
185
+ severity=ValidationSeverity.ERROR,
186
+ code="LOCALHOST_NOT_ALLOWED",
187
+ message=f"{field_name}: Localhost addresses not allowed (SSRF protection)",
188
+ path=field_name,
189
+ details={"hostname": parsed.hostname},
190
+ )
191
+ )
192
+ score -= 50
193
+
194
+ # SSRF Protection: Check for private IP addresses
195
+ elif self._is_private_ip(parsed.hostname):
196
+ issues.append(
197
+ ValidationIssue(
198
+ severity=ValidationSeverity.ERROR,
199
+ code="PRIVATE_IP_NOT_ALLOWED",
200
+ message=f"{field_name}: Private IP addresses not allowed (SSRF protection)",
201
+ path=field_name,
202
+ details={"hostname": parsed.hostname},
203
+ )
204
+ )
205
+ score -= 50
206
+
207
+ # Check for IP address in hostname (warning)
208
+ elif self._is_ip_address(parsed.hostname):
209
+ issues.append(
210
+ ValidationIssue(
211
+ severity=ValidationSeverity.WARNING,
212
+ code="IP_ADDRESS_HOSTNAME",
213
+ message=f"{field_name}: Using IP address instead of domain name",
214
+ path=field_name,
215
+ details={"hostname": parsed.hostname},
216
+ )
217
+ )
218
+ score -= 10
219
+
220
+ # Check for suspicious ports (if specified)
221
+ if parsed.port:
222
+ # Allow common HTTPS ports
223
+ allowed_ports = {443, 8443}
224
+ if require_https and parsed.port not in allowed_ports:
225
+ issues.append(
226
+ ValidationIssue(
227
+ severity=ValidationSeverity.WARNING,
228
+ code="NON_STANDARD_PORT",
229
+ message=f"{field_name}: Non-standard HTTPS port {parsed.port}",
230
+ path=field_name,
231
+ details={"port": parsed.port},
232
+ )
233
+ )
234
+ score -= 5
235
+
236
+ # Ensure score doesn't go negative
237
+ score = max(0, score)
238
+
239
+ return create_simple_validation_result(
240
+ success=score >= 60 and not any(i.severity == ValidationSeverity.ERROR for i in issues),
241
+ issues=issues,
242
+ simple_score=score,
243
+ dimension="trust"
244
+ )
245
+
246
+ def _is_private_ip(self, hostname: str) -> bool:
247
+ """Check if hostname is a private IP address."""
248
+ # IPv4 private ranges
249
+ if self.PRIVATE_IPV4_PATTERN.match(hostname):
250
+ return True
251
+
252
+ # IPv6 private ranges
253
+ if self.PRIVATE_IPV6_PATTERN.match(hostname):
254
+ return True
255
+
256
+ return False
257
+
258
+ def _is_ip_address(self, hostname: str) -> bool:
259
+ """Check if hostname is an IP address (IPv4 or IPv6)."""
260
+ # IPv4 pattern
261
+ ipv4_pattern = re.compile(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$')
262
+ if ipv4_pattern.match(hostname):
263
+ return True
264
+
265
+ # IPv6 pattern (simplified)
266
+ if ':' in hostname:
267
+ return True
268
+
269
+ return False