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,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
|