asap-protocol 0.3.0__py3-none-any.whl → 0.5.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.
@@ -32,7 +32,6 @@ Example:
32
32
  >>> middleware = AuthenticationMiddleware(manifest, validator)
33
33
  """
34
34
 
35
- import hashlib
36
35
  import uuid
37
36
  from typing import Any, Awaitable, Callable, Protocol
38
37
  from collections.abc import Sequence
@@ -47,6 +46,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
47
46
 
48
47
  from asap.models.entities import Manifest
49
48
  from asap.observability import get_logger
49
+ from asap.utils.sanitization import sanitize_token
50
50
 
51
51
  logger = get_logger(__name__)
52
52
 
@@ -259,7 +259,8 @@ HTTP_TOO_MANY_REQUESTS = 429
259
259
 
260
260
  # Error messages
261
261
  ERROR_AUTH_REQUIRED = "Authentication required"
262
- ERROR_INVALID_TOKEN = "Invalid authentication token"
262
+ # nosec B105: This is an error message constant, not a hardcoded password
263
+ ERROR_INVALID_TOKEN = "Invalid authentication token" # nosec B105
263
264
  ERROR_SENDER_MISMATCH = "Sender does not match authenticated identity"
264
265
  ERROR_RATE_LIMIT_EXCEEDED = "Rate limit exceeded"
265
266
 
@@ -498,12 +499,12 @@ class AuthenticationMiddleware:
498
499
  agent_id = self.validator(token)
499
500
 
500
501
  if agent_id is None:
501
- # Log token hash instead of prefix to avoid exposing token data
502
- token_hash = hashlib.sha256(token.encode()).hexdigest()[:16]
502
+ # Log sanitized token to avoid exposing full token data
503
+ token_prefix = sanitize_token(token)
503
504
  logger.warning(
504
505
  "asap.auth.invalid_token",
505
506
  manifest_id=self.manifest.id,
506
- token_hash=token_hash,
507
+ token_prefix=token_prefix,
507
508
  )
508
509
  raise HTTPException(
509
510
  status_code=HTTP_UNAUTHORIZED,
asap/transport/server.py CHANGED
@@ -51,11 +51,12 @@ from fastapi.responses import JSONResponse, PlainTextResponse
51
51
  from pydantic import ValidationError
52
52
  from slowapi.errors import RateLimitExceeded
53
53
 
54
- from asap.errors import ThreadPoolExhaustedError
54
+ from asap.errors import InvalidNonceError, InvalidTimestampError, ThreadPoolExhaustedError
55
55
  from asap.models.constants import MAX_REQUEST_SIZE
56
56
  from asap.models.entities import Capability, Endpoint, Manifest, Skill
57
57
  from asap.models.envelope import Envelope
58
58
  from asap.observability import get_logger, get_metrics
59
+ from asap.utils.sanitization import sanitize_nonce
59
60
  from asap.transport.middleware import (
60
61
  AuthenticationMiddleware,
61
62
  BearerTokenValidator,
@@ -83,6 +84,12 @@ from asap.transport.jsonrpc import (
83
84
  JsonRpcRequest,
84
85
  JsonRpcResponse,
85
86
  )
87
+ from asap.transport.validators import (
88
+ InMemoryNonceStore,
89
+ NonceStore,
90
+ validate_envelope_nonce,
91
+ validate_envelope_timestamp,
92
+ )
86
93
 
87
94
  # Module logger
88
95
  logger = get_logger(__name__)
@@ -142,6 +149,7 @@ class ASAPRequestHandler:
142
149
  manifest: Manifest,
143
150
  auth_middleware: AuthenticationMiddleware | None = None,
144
151
  max_request_size: int = MAX_REQUEST_SIZE,
152
+ nonce_store: NonceStore | None = None,
145
153
  ) -> None:
146
154
  """Initialize the request handler.
147
155
 
@@ -150,11 +158,13 @@ class ASAPRequestHandler:
150
158
  manifest: Agent manifest describing capabilities
151
159
  auth_middleware: Optional authentication middleware for request validation
152
160
  max_request_size: Maximum allowed request size in bytes
161
+ nonce_store: Optional nonce store for replay attack prevention
153
162
  """
154
163
  self.registry = registry
155
164
  self.manifest = manifest
156
165
  self.auth_middleware = auth_middleware
157
166
  self.max_request_size = max_request_size
167
+ self.nonce_store = nonce_store
158
168
 
159
169
  def _normalize_payload_type_for_metrics(self, payload_type: str) -> str:
160
170
  """Normalize payload type for metrics to prevent cardinality explosion.
@@ -808,7 +818,9 @@ class ASAPRequestHandler:
808
818
  if parse_error is not None:
809
819
  return parse_error
810
820
  # Type narrowing: rpc_request is not None here
811
- assert rpc_request is not None
821
+ # Use explicit check instead of assert to avoid removal in optimized builds
822
+ if rpc_request is None:
823
+ raise RuntimeError("Internal error: rpc_request is None after validation")
812
824
 
813
825
  # Create request context
814
826
  ctx = RequestContext(
@@ -844,6 +856,58 @@ class ASAPRequestHandler:
844
856
  if sender_error is not None:
845
857
  return sender_error
846
858
 
859
+ # Validate envelope timestamp to prevent replay attacks
860
+ try:
861
+ validate_envelope_timestamp(envelope)
862
+ except InvalidTimestampError as e:
863
+ logger.warning(
864
+ "asap.request.invalid_timestamp",
865
+ envelope_id=envelope.id,
866
+ error=e.message,
867
+ details=e.details,
868
+ )
869
+ duration_seconds = time.perf_counter() - ctx.start_time
870
+ self.record_error_metrics(
871
+ ctx.metrics, payload_type, "invalid_timestamp", duration_seconds
872
+ )
873
+ return self.build_error_response(
874
+ INVALID_PARAMS,
875
+ data={
876
+ "error": "Invalid envelope timestamp",
877
+ "code": e.code,
878
+ "message": e.message,
879
+ "details": e.details,
880
+ },
881
+ request_id=ctx.request_id,
882
+ )
883
+
884
+ # Validate envelope nonce if nonce store is available
885
+ try:
886
+ validate_envelope_nonce(envelope, self.nonce_store)
887
+ except InvalidNonceError as e:
888
+ # Sanitize nonce in logs to prevent full value exposure
889
+ nonce_sanitized = sanitize_nonce(e.nonce)
890
+ logger.warning(
891
+ "asap.request.invalid_nonce",
892
+ envelope_id=envelope.id,
893
+ nonce=nonce_sanitized,
894
+ error=e.message,
895
+ )
896
+ duration_seconds = time.perf_counter() - ctx.start_time
897
+ self.record_error_metrics(
898
+ ctx.metrics, payload_type, "invalid_nonce", duration_seconds
899
+ )
900
+ return self.build_error_response(
901
+ INVALID_PARAMS,
902
+ data={
903
+ "error": "Invalid envelope nonce",
904
+ "code": e.code,
905
+ "message": e.message,
906
+ "details": e.details,
907
+ },
908
+ request_id=ctx.request_id,
909
+ )
910
+
847
911
  # Log request received
848
912
  logger.info(
849
913
  "asap.request.received",
@@ -893,6 +957,7 @@ def create_app(
893
957
  rate_limit: str | None = None,
894
958
  max_request_size: int | None = None,
895
959
  max_threads: int | None = None,
960
+ require_nonce: bool = False,
896
961
  ) -> FastAPI:
897
962
  """Create and configure a FastAPI application for ASAP protocol.
898
963
 
@@ -920,6 +985,9 @@ def create_app(
920
985
  max_threads: Optional maximum number of threads for sync handlers.
921
986
  Defaults to ASAP_MAX_THREADS environment variable or min(32, cpu_count + 4).
922
987
  Set to None to use unbounded executor (not recommended for production).
988
+ require_nonce: If True, enables nonce validation for replay attack prevention.
989
+ When enabled, creates an InMemoryNonceStore and validates nonces in envelopes.
990
+ Defaults to False (nonce validation is optional).
923
991
 
924
992
  Returns:
925
993
  Configured FastAPI application ready to run
@@ -1003,8 +1071,17 @@ def create_app(
1003
1071
  if max_request_size is None:
1004
1072
  max_request_size = int(os.getenv("ASAP_MAX_REQUEST_SIZE", str(MAX_REQUEST_SIZE)))
1005
1073
 
1074
+ # Create nonce store if required
1075
+ nonce_store: NonceStore | None = None
1076
+ if require_nonce:
1077
+ nonce_store = InMemoryNonceStore()
1078
+ logger.info(
1079
+ "asap.server.nonce_validation_enabled",
1080
+ manifest_id=manifest.id,
1081
+ )
1082
+
1006
1083
  # Create request handler
1007
- handler = ASAPRequestHandler(registry, manifest, auth_middleware, max_request_size)
1084
+ handler = ASAPRequestHandler(registry, manifest, auth_middleware, max_request_size, nonce_store)
1008
1085
 
1009
1086
  app = FastAPI(
1010
1087
  title="ASAP Protocol Server",
@@ -0,0 +1,324 @@
1
+ """Validation utilities for ASAP protocol envelopes.
2
+
3
+ This module provides validation functions for envelope security checks,
4
+ including timestamp validation for replay attack prevention and optional
5
+ nonce validation for duplicate detection.
6
+ """
7
+
8
+ import threading
9
+ import time
10
+ from datetime import datetime, timezone
11
+ from typing import Protocol, runtime_checkable
12
+
13
+ from asap.errors import InvalidNonceError, InvalidTimestampError
14
+ from asap.models.constants import (
15
+ MAX_ENVELOPE_AGE_SECONDS,
16
+ MAX_FUTURE_TOLERANCE_SECONDS,
17
+ NONCE_TTL_SECONDS,
18
+ )
19
+ from asap.models.envelope import Envelope
20
+
21
+
22
+ def validate_envelope_timestamp(envelope: Envelope) -> None:
23
+ """Validate envelope timestamp to prevent replay attacks.
24
+
25
+ Checks that the envelope timestamp is:
26
+ - Not older than MAX_ENVELOPE_AGE_SECONDS (default: 5 minutes)
27
+ - Not more than MAX_FUTURE_TOLERANCE_SECONDS in the future (default: 30 seconds)
28
+
29
+ Args:
30
+ envelope: The envelope to validate
31
+
32
+ Raises:
33
+ InvalidTimestampError: If the timestamp is too old or too far in the future
34
+
35
+ Example:
36
+ >>> from asap.models.envelope import Envelope
37
+ >>> from asap.transport.validators import validate_envelope_timestamp
38
+ >>>
39
+ >>> # Recent envelope passes
40
+ >>> recent = Envelope(
41
+ ... asap_version="0.1",
42
+ ... sender="urn:asap:agent:test",
43
+ ... recipient="urn:asap:agent:test",
44
+ ... payload_type="TaskRequest",
45
+ ... payload={}
46
+ ... )
47
+ >>> validate_envelope_timestamp(recent) # No exception
48
+ >>>
49
+ >>> # Old envelope raises error
50
+ >>> from datetime import datetime, timezone, timedelta
51
+ >>> old = Envelope(
52
+ ... asap_version="0.1",
53
+ ... sender="urn:asap:agent:test",
54
+ ... recipient="urn:asap:agent:test",
55
+ ... payload_type="TaskRequest",
56
+ ... payload={},
57
+ ... timestamp=datetime.now(timezone.utc) - timedelta(minutes=10)
58
+ ... )
59
+ >>> validate_envelope_timestamp(old) # Raises InvalidTimestampError
60
+ """
61
+ if envelope.timestamp is None:
62
+ raise InvalidTimestampError(
63
+ timestamp="",
64
+ message="Envelope timestamp is required for validation",
65
+ details={"envelope_id": envelope.id},
66
+ )
67
+
68
+ now = datetime.now(timezone.utc)
69
+ timestamp = envelope.timestamp
70
+
71
+ # Ensure timestamp is timezone-aware (should be UTC)
72
+ if timestamp.tzinfo is None:
73
+ timestamp = timestamp.replace(tzinfo=timezone.utc)
74
+ else:
75
+ # Convert to UTC if needed
76
+ timestamp = timestamp.astimezone(timezone.utc)
77
+
78
+ # Calculate age in seconds
79
+ age_seconds = (now - timestamp).total_seconds()
80
+
81
+ # Check if envelope is too old
82
+ if age_seconds > MAX_ENVELOPE_AGE_SECONDS:
83
+ raise InvalidTimestampError(
84
+ timestamp=timestamp.isoformat(),
85
+ message=(
86
+ f"Envelope timestamp is too old: {age_seconds:.1f} seconds "
87
+ f"(max: {MAX_ENVELOPE_AGE_SECONDS} seconds)"
88
+ ),
89
+ age_seconds=age_seconds,
90
+ details={
91
+ "envelope_id": envelope.id,
92
+ "max_age_seconds": MAX_ENVELOPE_AGE_SECONDS,
93
+ },
94
+ )
95
+
96
+ # Check if envelope timestamp is too far in the future
97
+ future_offset_seconds = (timestamp - now).total_seconds()
98
+ if future_offset_seconds > MAX_FUTURE_TOLERANCE_SECONDS:
99
+ raise InvalidTimestampError(
100
+ timestamp=timestamp.isoformat(),
101
+ message=(
102
+ f"Envelope timestamp is too far in the future: "
103
+ f"{future_offset_seconds:.1f} seconds "
104
+ f"(max tolerance: {MAX_FUTURE_TOLERANCE_SECONDS} seconds)"
105
+ ),
106
+ future_offset_seconds=future_offset_seconds,
107
+ details={
108
+ "envelope_id": envelope.id,
109
+ "max_future_tolerance_seconds": MAX_FUTURE_TOLERANCE_SECONDS,
110
+ },
111
+ )
112
+
113
+
114
+ @runtime_checkable
115
+ class NonceStore(Protocol):
116
+ """Protocol for nonce storage and validation.
117
+
118
+ Implementations must provide thread-safe storage and TTL-based expiration
119
+ for nonce values to prevent replay attacks.
120
+ """
121
+
122
+ def is_used(self, nonce: str) -> bool:
123
+ """Check if a nonce has been used.
124
+
125
+ Args:
126
+ nonce: The nonce value to check
127
+
128
+ Returns:
129
+ True if the nonce has been used, False otherwise
130
+ """
131
+ ...
132
+
133
+ def mark_used(self, nonce: str, ttl_seconds: int) -> None:
134
+ """Mark a nonce as used with a TTL.
135
+
136
+ Args:
137
+ nonce: The nonce value to mark as used
138
+ ttl_seconds: Time-to-live in seconds for the nonce
139
+ """
140
+ ...
141
+
142
+ def check_and_mark(self, nonce: str, ttl_seconds: int) -> bool:
143
+ """Atomically check if nonce is used and mark it if not.
144
+
145
+ This method performs both check and mark operations atomically to
146
+ prevent race conditions between concurrent requests.
147
+
148
+ Args:
149
+ nonce: The nonce value to check and mark
150
+ ttl_seconds: Time-to-live in seconds for the nonce
151
+
152
+ Returns:
153
+ True if nonce was already used, False if it was newly marked
154
+ """
155
+ ...
156
+
157
+
158
+ class InMemoryNonceStore:
159
+ """In-memory nonce store with TTL-based expiration.
160
+
161
+ This implementation uses a dictionary to store nonces with their
162
+ expiration times. Expired nonces are lazily cleaned up on access.
163
+
164
+ Attributes:
165
+ _store: Dictionary mapping nonce to expiration timestamp
166
+ _lock: Thread lock for thread-safe operations
167
+ """
168
+
169
+ def __init__(self) -> None:
170
+ """Initialize in-memory nonce store."""
171
+ self._store: dict[str, float] = {}
172
+ self._lock = threading.RLock()
173
+
174
+ def _cleanup_expired(self) -> None:
175
+ """Remove expired nonces from the store.
176
+
177
+ This is called lazily during access operations to keep the store
178
+ from growing unbounded.
179
+ """
180
+ now = time.time()
181
+ expired = [nonce for nonce, expiry in self._store.items() if expiry < now]
182
+ for nonce in expired:
183
+ self._store.pop(nonce, None)
184
+
185
+ def is_used(self, nonce: str) -> bool:
186
+ """Check if a nonce has been used.
187
+
188
+ Args:
189
+ nonce: The nonce value to check
190
+
191
+ Returns:
192
+ True if the nonce has been used and not expired, False otherwise
193
+ """
194
+ with self._lock:
195
+ self._cleanup_expired()
196
+ if nonce not in self._store:
197
+ return False
198
+ expiry = self._store[nonce]
199
+ return expiry >= time.time()
200
+
201
+ def mark_used(self, nonce: str, ttl_seconds: int) -> None:
202
+ """Mark a nonce as used with a TTL.
203
+
204
+ Args:
205
+ nonce: The nonce value to mark as used
206
+ ttl_seconds: Time-to-live in seconds for the nonce
207
+ """
208
+ with self._lock:
209
+ self._cleanup_expired()
210
+ expiry = time.time() + ttl_seconds
211
+ self._store[nonce] = expiry
212
+
213
+ def check_and_mark(self, nonce: str, ttl_seconds: int) -> bool:
214
+ """Atomically check if nonce is used and mark it if not.
215
+
216
+ This method performs both check and mark operations atomically to
217
+ prevent race conditions between concurrent requests with the same nonce.
218
+
219
+ Args:
220
+ nonce: The nonce value to check and mark
221
+ ttl_seconds: Time-to-live in seconds for the nonce
222
+
223
+ Returns:
224
+ True if nonce was already used, False if it was newly marked
225
+ """
226
+ with self._lock:
227
+ self._cleanup_expired()
228
+ if nonce in self._store and self._store[nonce] >= time.time():
229
+ return True # Already used
230
+ self._store[nonce] = time.time() + ttl_seconds
231
+ return False # Newly marked
232
+
233
+
234
+ def validate_envelope_nonce(envelope: Envelope, nonce_store: NonceStore | None) -> None:
235
+ """Validate envelope nonce to prevent duplicate message replay.
236
+
237
+ If the envelope has a nonce in its extensions, checks that it hasn't
238
+ been used before. If no nonce is present, validation passes (nonce
239
+ is optional). If a nonce_store is not provided, validation is skipped.
240
+
241
+ Args:
242
+ envelope: The envelope to validate
243
+ nonce_store: Optional nonce store for duplicate detection
244
+
245
+ Raises:
246
+ InvalidNonceError: If the nonce has been used before
247
+
248
+ Example:
249
+ >>> from asap.models.envelope import Envelope
250
+ >>> from asap.transport.validators import (
251
+ ... validate_envelope_nonce,
252
+ ... InMemoryNonceStore
253
+ ... )
254
+ >>>
255
+ >>> store = InMemoryNonceStore()
256
+ >>>
257
+ >>> # Envelope without nonce passes
258
+ >>> envelope1 = Envelope(
259
+ ... asap_version="0.1",
260
+ ... sender="urn:asap:agent:test",
261
+ ... recipient="urn:asap:agent:test",
262
+ ... payload_type="TaskRequest",
263
+ ... payload={}
264
+ ... )
265
+ >>> validate_envelope_nonce(envelope1, store) # No exception
266
+ >>>
267
+ >>> # First use of nonce passes
268
+ >>> envelope2 = Envelope(
269
+ ... asap_version="0.1",
270
+ ... sender="urn:asap:agent:test",
271
+ ... recipient="urn:asap:agent:test",
272
+ ... payload_type="TaskRequest",
273
+ ... payload={},
274
+ ... extensions={"nonce": "unique-nonce-123"}
275
+ ... )
276
+ >>> validate_envelope_nonce(envelope2, store) # No exception
277
+ >>>
278
+ >>> # Duplicate nonce raises error
279
+ >>> envelope3 = Envelope(
280
+ ... asap_version="0.1",
281
+ ... sender="urn:asap:agent:test",
282
+ ... recipient="urn:asap:agent:test",
283
+ ... payload_type="TaskRequest",
284
+ ... payload={},
285
+ ... extensions={"nonce": "unique-nonce-123"}
286
+ ... )
287
+ >>> validate_envelope_nonce(envelope3, store) # Raises InvalidNonceError
288
+ """
289
+ # Skip validation if no nonce store provided
290
+ if nonce_store is None:
291
+ return
292
+
293
+ # Skip validation if no nonce in extensions
294
+ if not envelope.extensions or "nonce" not in envelope.extensions:
295
+ return
296
+
297
+ nonce = envelope.extensions["nonce"]
298
+
299
+ # Validate nonce is a non-empty string
300
+ if not isinstance(nonce, str) or not nonce:
301
+ raise InvalidNonceError(
302
+ nonce=str(nonce),
303
+ message=(
304
+ f"Nonce must be a non-empty string, got "
305
+ f"{type(nonce).__name__ if not isinstance(nonce, str) else 'empty string'}"
306
+ ),
307
+ details={"envelope_id": envelope.id},
308
+ )
309
+
310
+ # Atomically check and mark nonce as used to prevent race conditions
311
+ if nonce_store.check_and_mark(nonce, ttl_seconds=NONCE_TTL_SECONDS):
312
+ raise InvalidNonceError(
313
+ nonce=nonce,
314
+ message=f"Duplicate nonce detected: {nonce}",
315
+ details={"envelope_id": envelope.id},
316
+ )
317
+
318
+
319
+ __all__ = [
320
+ "NonceStore",
321
+ "InMemoryNonceStore",
322
+ "validate_envelope_timestamp",
323
+ "validate_envelope_nonce",
324
+ ]
asap/utils/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Utility modules for ASAP protocol.
2
+
3
+ This package contains utility functions used across the ASAP protocol
4
+ implementation, including security utilities like log sanitization.
5
+ """
6
+
7
+ __all__: list[str] = []
@@ -0,0 +1,139 @@
1
+ """Log sanitization utilities for sensitive data protection.
2
+
3
+ This module provides functions to sanitize sensitive data before logging
4
+ to prevent credential leaks, token exposure, and other security issues.
5
+
6
+ Example:
7
+ >>> from asap.utils.sanitization import sanitize_token, sanitize_nonce, sanitize_url
8
+ >>>
9
+ >>> # Sanitize a token (shows only first 8 characters)
10
+ >>> token = "sk_live_1234567890abcdef"
11
+ >>> sanitize_token(token) # Returns: "sk_live_..."
12
+ >>>
13
+ >>> # Sanitize a nonce (shows first 8 characters + "...")
14
+ >>> nonce = "a1b2c3d4e5f6g7h8i9j0"
15
+ >>> sanitize_nonce(nonce) # Returns: "a1b2c3d4..."
16
+ >>>
17
+ >>> # Sanitize a URL with credentials
18
+ >>> url = "https://user:password@example.com/api"
19
+ >>> sanitize_url(url) # Returns: "https://user:***@example.com/api"
20
+ """
21
+
22
+ import re
23
+ from urllib.parse import urlparse, urlunparse
24
+
25
+ # Sanitization configuration
26
+ SANITIZE_PREFIX_LENGTH = 8
27
+ """Number of characters to show when truncating sensitive values.
28
+
29
+ This constant defines how many characters of a sensitive value (token, nonce)
30
+ are preserved when sanitizing for logs. The value balances security (preventing
31
+ full exposure) with debuggability (allowing identification of value types).
32
+ """
33
+
34
+
35
+ def sanitize_token(token: str) -> str:
36
+ """Sanitize a token for safe logging.
37
+
38
+ Returns only the first SANITIZE_PREFIX_LENGTH characters followed by "..."
39
+ to prevent full token exposure in logs while still allowing identification
40
+ of token type (e.g., "sk_live_", "pk_test_").
41
+
42
+ Args:
43
+ token: The token to sanitize
44
+
45
+ Returns:
46
+ Sanitized token string showing only prefix (e.g., "sk_live_...")
47
+
48
+ Example:
49
+ >>> sanitize_token("sk_live_1234567890abcdef")
50
+ 'sk_live_...'
51
+ >>> sanitize_token("short")
52
+ 'short'
53
+ """
54
+ if not token:
55
+ return ""
56
+ if len(token) <= SANITIZE_PREFIX_LENGTH:
57
+ return token
58
+ return f"{token[:SANITIZE_PREFIX_LENGTH]}..."
59
+
60
+
61
+ def sanitize_nonce(nonce: str) -> str:
62
+ """Sanitize a nonce for safe logging.
63
+
64
+ Returns the first SANITIZE_PREFIX_LENGTH characters followed by "..."
65
+ to prevent full nonce exposure in logs while still allowing identification
66
+ for debugging purposes.
67
+
68
+ Args:
69
+ nonce: The nonce to sanitize
70
+
71
+ Returns:
72
+ Sanitized nonce string showing first characters + "..." (e.g., "a1b2c3d4...")
73
+
74
+ Example:
75
+ >>> sanitize_nonce("a1b2c3d4e5f6g7h8i9j0")
76
+ 'a1b2c3d4...'
77
+ >>> sanitize_nonce("short")
78
+ 'short'
79
+ """
80
+ if not nonce:
81
+ return ""
82
+ if len(nonce) <= SANITIZE_PREFIX_LENGTH:
83
+ return nonce
84
+ return f"{nonce[:SANITIZE_PREFIX_LENGTH]}..."
85
+
86
+
87
+ def sanitize_url(url: str) -> str:
88
+ """Sanitize a URL by masking credentials in the userinfo component.
89
+
90
+ Masks passwords in URLs of the form:
91
+ - `https://user:password@example.com` → `https://user:***@example.com`
92
+ - `https://user@example.com` → `https://user@example.com` (no change)
93
+
94
+ Args:
95
+ url: The URL to sanitize
96
+
97
+ Returns:
98
+ Sanitized URL with masked credentials
99
+
100
+ Example:
101
+ >>> sanitize_url("https://user:secret@example.com/api")
102
+ 'https://user:***@example.com/api'
103
+ >>> sanitize_url("https://example.com/api")
104
+ 'https://example.com/api'
105
+ """
106
+ if not url:
107
+ return ""
108
+
109
+ try:
110
+ parsed = urlparse(url)
111
+ if (parsed.username or parsed.password) and parsed.password:
112
+ # Mask password if present
113
+ # Reconstruct netloc with masked password
114
+ netloc = f"{parsed.username}:***@{parsed.hostname}"
115
+ if parsed.port:
116
+ netloc = f"{netloc}:{parsed.port}"
117
+ # Reconstruct URL with sanitized netloc
118
+ return urlunparse(
119
+ (
120
+ parsed.scheme,
121
+ netloc,
122
+ parsed.path,
123
+ parsed.params,
124
+ parsed.query,
125
+ parsed.fragment,
126
+ )
127
+ )
128
+ return url
129
+ except Exception:
130
+ # If URL parsing fails, return a safe fallback
131
+ # Remove any obvious credential patterns
132
+ return re.sub(r"://[^:]+:[^@]+@", r"://***:***@", url)
133
+
134
+
135
+ __all__ = [
136
+ "sanitize_token",
137
+ "sanitize_nonce",
138
+ "sanitize_url",
139
+ ]