hyperping 1.7.0__tar.gz → 1.8.0__tar.gz

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.
Files changed (71) hide show
  1. {hyperping-1.7.0 → hyperping-1.8.0}/CHANGELOG.md +42 -0
  2. {hyperping-1.7.0 → hyperping-1.8.0}/PKG-INFO +1 -1
  3. {hyperping-1.7.0 → hyperping-1.8.0}/pyproject.toml +1 -1
  4. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_async_client.py +45 -8
  5. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_async_mcp_client.py +2 -0
  6. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_async_mcp_transport.py +19 -5
  7. hyperping-1.8.0/src/hyperping/_internals.py +241 -0
  8. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_mcp_transport.py +20 -5
  9. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_utils.py +17 -3
  10. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/client.py +36 -5
  11. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/exceptions.py +18 -5
  12. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/mcp_client.py +2 -0
  13. {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_mcp_client.py +9 -20
  14. {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_mcp_transport.py +8 -3
  15. hyperping-1.8.0/tests/unit/test_security_base_url.py +163 -0
  16. hyperping-1.8.0/tests/unit/test_security_breaker_cap.py +116 -0
  17. hyperping-1.8.0/tests/unit/test_security_exception_redaction.py +343 -0
  18. hyperping-1.7.0/src/hyperping/_internals.py +0 -31
  19. {hyperping-1.7.0 → hyperping-1.8.0}/.gitignore +0 -0
  20. {hyperping-1.7.0 → hyperping-1.8.0}/CONTRIBUTING.md +0 -0
  21. {hyperping-1.7.0 → hyperping-1.8.0}/LICENSE +0 -0
  22. {hyperping-1.7.0 → hyperping-1.8.0}/README.md +0 -0
  23. {hyperping-1.7.0 → hyperping-1.8.0}/SECURITY.md +0 -0
  24. {hyperping-1.7.0 → hyperping-1.8.0}/scripts/verify_endpoints.py +0 -0
  25. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/__init__.py +0 -0
  26. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_async_healthchecks_mixin.py +0 -0
  27. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_async_incidents_mixin.py +0 -0
  28. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_async_maintenance_mixin.py +0 -0
  29. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_async_monitors_mixin.py +0 -0
  30. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_async_outages_mixin.py +0 -0
  31. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_async_statuspages_mixin.py +0 -0
  32. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_circuit_breaker.py +0 -0
  33. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_healthchecks_mixin.py +0 -0
  34. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_incidents_mixin.py +0 -0
  35. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_maintenance_mixin.py +0 -0
  36. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_monitor_constants.py +0 -0
  37. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_monitors_mixin.py +0 -0
  38. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_outages_mixin.py +0 -0
  39. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_protocols.py +0 -0
  40. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_statuspages_mixin.py +0 -0
  41. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_version.py +0 -0
  42. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/endpoints.py +0 -0
  43. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/models/__init__.py +0 -0
  44. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/models/_healthcheck_models.py +0 -0
  45. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/models/_incident_models.py +0 -0
  46. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/models/_integration_models.py +0 -0
  47. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/models/_maintenance_models.py +0 -0
  48. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/models/_monitor_models.py +0 -0
  49. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/models/_observability_models.py +0 -0
  50. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/models/_oncall_models.py +0 -0
  51. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/models/_outage_models.py +0 -0
  52. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/models/_reporting_models.py +0 -0
  53. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/models/_statuspage_models.py +0 -0
  54. {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/py.typed +0 -0
  55. {hyperping-1.7.0 → hyperping-1.8.0}/tests/__init__.py +0 -0
  56. {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/__init__.py +0 -0
  57. {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/conftest.py +0 -0
  58. {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_async_client.py +0 -0
  59. {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_async_mcp_client.py +0 -0
  60. {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_async_mcp_transport.py +0 -0
  61. {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_async_preexisting.py +0 -0
  62. {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_client_coverage.py +0 -0
  63. {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_healthchecks.py +0 -0
  64. {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_incidents.py +0 -0
  65. {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_maintenance.py +0 -0
  66. {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_monitors.py +0 -0
  67. {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_outages.py +0 -0
  68. {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_pagination.py +0 -0
  69. {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_per_endpoint_circuit_breaker.py +0 -0
  70. {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_sdk_surface.py +0 -0
  71. {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_statuspages.py +0 -0
@@ -5,6 +5,48 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [1.8.0] - 2026-05-31
11
+
12
+ This is a security-focused release. It closes several credential-leak and validation gaps surfaced by an independent audit. One change is breaking for local-development workflows; see Upgrade Notes below.
13
+
14
+ ### Security
15
+
16
+ - `validate_base_url` is now applied at every constructor that accepts a `base_url` or `mcp_url`: both the sync and async REST clients, both MCP transports, and the high-level MCP clients. The validator rejects non-`https` URLs by default, rejects URLs that carry userinfo (`user:pass@host` or bare `user@host`), and rejects URLs with a query string or fragment. The `Authorization: Bearer` header therefore cannot reach an attacker-controlled host through a misconfigured base URL.
17
+ - `HyperpingAPIError.response_body` is now recursively redacted at construction time. Sensitive keys (authorization, tokens, cookies, set-cookie, request headers, request body, emails, webhooks) are replaced with `[REDACTED]`. Free-form string values are scrubbed for `Bearer <token>` and `sk_<token>` shapes. The recursion is bounded to prevent `RecursionError` from pathological payloads. Applies to all subclasses, including `HyperpingRateLimitError`.
18
+ - `HyperpingAPIError` formatted messages now strip C0 control bytes (preserving `\t` and `\n`) and cap at 256 characters, so a server-supplied error string carrying ANSI escapes or a 1 KB blob cannot poison terminal output or downstream log pipelines.
19
+ - The MCP transport no longer captures the first 500 bytes of server response as `response_body["raw"]` on rate-limit (429), generic HTTP error, or JSON-parse failure paths. Subscriber emails or webhook URLs that the server may echo cannot leak through that channel.
20
+ - `_utils.parse_list` no longer logs the full `pydantic.ValidationError` string on per-item parse failures (Pydantic v2 includes the offending input by default). It now logs only the exception class and field locations.
21
+ - The `breaker_key_fn` callback is now used through an LRU-bounded map. A custom callback that returns unbounded unique strings can no longer leak memory in long-running processes: the per-endpoint breaker map is capped at 1024 entries and evicts oldest on overflow. Applies to both sync and async clients.
22
+
23
+ ### Added
24
+
25
+ - `allow_insecure: bool = False` keyword argument on every constructor that accepts a `base_url` or `mcp_url`. When set to `True`, `http://` URLs are permitted and an `InsecureTransportWarning` is emitted. Provided for local-development workflows; not recommended for production.
26
+ - `InsecureTransportWarning` warning class exported from `hyperping`.
27
+
28
+ ### Changed
29
+
30
+ - `_parse_retry_after` documents that it intentionally supports only the delta-seconds form of the `Retry-After` header. HTTP-date values fall through to exponential backoff rather than raising. Trade-off is documented in the docstring; no behavioral change for callers passing the integer form.
31
+
32
+ ### Upgrade Notes
33
+
34
+ If your code instantiates `HyperpingClient` (or any MCP client) against a `http://localhost` URL for local development or against a mock server, the constructor will now raise `ValueError`. Pass `allow_insecure=True` to opt in, and expect an `InsecureTransportWarning` at runtime:
35
+
36
+ ```python
37
+ from hyperping import HyperpingClient
38
+
39
+ client = HyperpingClient(
40
+ api_key="sk_test_xxx",
41
+ base_url="http://localhost:8000",
42
+ allow_insecure=True,
43
+ )
44
+ ```
45
+
46
+ Production callers using `https://api.hyperping.io` are unaffected.
47
+
48
+ URLs that previously carried embedded credentials (`https://user:pass@api.example.com`) or a trailing query string / fragment will now also raise at construction time. Refactor to pass credentials through `api_key` and to keep query strings out of the base URL.
49
+
8
50
  ## [1.7.0] - 2026-05-21
9
51
 
10
52
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperping
3
- Version: 1.7.0
3
+ Version: 1.8.0
4
4
  Summary: Python SDK for the Hyperping uptime monitoring and incident management API
5
5
  Project-URL: Homepage, https://github.com/develeap/hyperping-python
6
6
  Project-URL: Documentation, https://github.com/develeap/hyperping-python#readme
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hyperping"
7
- version = "1.7.0"
7
+ version = "1.8.0"
8
8
  description = "Python SDK for the Hyperping uptime monitoring and incident management API"
9
9
  readme = {file = "README.md", content-type = "text/markdown"}
10
10
  license = {text = "MIT"}
@@ -15,6 +15,7 @@ import asyncio
15
15
  import logging
16
16
  import random
17
17
  import threading
18
+ from collections import OrderedDict
18
19
  from collections.abc import Callable
19
20
  from typing import Any
20
21
  from urllib.parse import urlsplit
@@ -33,8 +34,13 @@ from hyperping._circuit_breaker import (
33
34
  CircuitBreakerConfig,
34
35
  CircuitState,
35
36
  )
36
- from hyperping._internals import DEFAULT_USER_AGENT, RETRY_AFTER_MAX, sanitize_for_log
37
- from hyperping.client import DEFAULT_RETRY_CONFIG, RetryConfig
37
+ from hyperping._internals import (
38
+ DEFAULT_USER_AGENT,
39
+ RETRY_AFTER_MAX,
40
+ sanitize_for_log,
41
+ validate_base_url,
42
+ )
43
+ from hyperping.client import _ENDPOINT_BREAKERS_MAX, DEFAULT_RETRY_CONFIG, RetryConfig
38
44
  from hyperping.endpoints import API_BASE, Endpoint
39
45
  from hyperping.exceptions import (
40
46
  HyperpingAPIError,
@@ -79,6 +85,7 @@ class AsyncHyperpingClient(
79
85
  user_agent: str | None = None,
80
86
  per_endpoint_circuit_breaker: bool = False,
81
87
  breaker_key_fn: Callable[[str], str] | None = None,
88
+ allow_insecure: bool = False,
82
89
  ) -> None:
83
90
  """Initialize the async Hyperping API client.
84
91
 
@@ -106,14 +113,17 @@ class AsyncHyperpingClient(
106
113
  if not raw_key or not raw_key.strip():
107
114
  raise ValueError("api_key must be a non-empty string")
108
115
  self._api_key = SecretStr(raw_key) if isinstance(api_key, str) else api_key
109
- self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
116
+ self.base_url = validate_base_url(
117
+ base_url or self.DEFAULT_BASE_URL,
118
+ allow_insecure=allow_insecure,
119
+ )
110
120
  self.timeout = timeout
111
121
  self.retry_config = retry_config or DEFAULT_RETRY_CONFIG
112
122
  self._circuit_breaker_config = circuit_breaker_config
113
123
  self._circuit_breaker = CircuitBreaker(circuit_breaker_config)
114
124
  self._per_endpoint_circuit_breaker = per_endpoint_circuit_breaker
115
125
  self._breaker_key_fn = breaker_key_fn
116
- self._endpoint_breakers: dict[str, CircuitBreaker] = {}
126
+ self._endpoint_breakers: OrderedDict[str, CircuitBreaker] = OrderedDict()
117
127
  self._endpoint_breakers_lock = threading.Lock()
118
128
 
119
129
  self._client = httpx.AsyncClient(
@@ -167,17 +177,39 @@ class AsyncHyperpingClient(
167
177
  return pure
168
178
 
169
179
  def _breaker_for(self, path: str) -> CircuitBreaker:
170
- """Return the breaker that governs ``path`` (shared, or per-endpoint)."""
180
+ """Return the breaker that governs ``path`` (shared, or per-endpoint).
181
+
182
+ The critical section under ``_endpoint_breakers_lock`` is purely
183
+ CPU-bound (a single ``OrderedDict.get`` / ``__setitem__`` /
184
+ ``move_to_end`` / ``popitem``) and never awaits, so wrapping it in a
185
+ ``threading.Lock`` does not block the event loop in practice; the
186
+ loop only "stalls" for the duration of one dict operation, which is
187
+ well below the resolution of any asyncio scheduling decision.
188
+
189
+ We keep ``threading.Lock`` (rather than ``asyncio.Lock``) so the same
190
+ breaker map remains safe if a caller drives the async client from
191
+ multiple OS threads (e.g. via ``loop.run_in_executor`` or a thread
192
+ pool that re-enters the SDK). Switching to ``asyncio.Lock`` would
193
+ make the per-endpoint path correct only on the loop that owns the
194
+ lock; ``threading.Lock`` is correct in both cases. Regression
195
+ coverage:
196
+ ``tests/unit/test_security_breaker_cap.py
197
+ ::test_async_breaker_lock_does_not_deadlock_under_gather``.
198
+ """
171
199
  if not self._per_endpoint_circuit_breaker:
172
200
  return self._circuit_breaker
173
201
  key = self._resolve_breaker_key(path)
174
- # threading.Lock here is intentional: see HyperpingClient._breaker_for
175
- # for the rationale (works under both pure-asyncio and mixed-thread use).
176
202
  with self._endpoint_breakers_lock:
177
203
  breaker = self._endpoint_breakers.get(key)
178
204
  if breaker is None:
179
205
  breaker = CircuitBreaker(self._circuit_breaker_config)
180
206
  self._endpoint_breakers[key] = breaker
207
+ # Evict LRU once the cap is hit to bound memory under a
208
+ # pathological breaker_key_fn (see HyperpingClient).
209
+ while len(self._endpoint_breakers) > _ENDPOINT_BREAKERS_MAX:
210
+ self._endpoint_breakers.popitem(last=False)
211
+ else:
212
+ self._endpoint_breakers.move_to_end(key)
181
213
  return breaker
182
214
 
183
215
  def circuit_breaker_state_for(self, path: str) -> CircuitState:
@@ -220,7 +252,12 @@ class AsyncHyperpingClient(
220
252
  return {"error": response.text or "Unknown error"}
221
253
 
222
254
  def _parse_retry_after(self, response: httpx.Response) -> int | None:
223
- """Extract and parse the ``Retry-After`` header value."""
255
+ """Extract and parse the ``Retry-After`` header value.
256
+
257
+ Only the delta-seconds form (RFC 7231 7.1.3) is parsed; HTTP-date
258
+ is intentionally not supported (see
259
+ :meth:`HyperpingClient._parse_retry_after` for rationale).
260
+ """
224
261
  retry_after = response.headers.get("Retry-After")
225
262
  if not retry_after:
226
263
  return None
@@ -50,11 +50,13 @@ class AsyncHyperpingMcpClient:
50
50
  api_key: str | SecretStr,
51
51
  base_url: str = MCP_URL,
52
52
  timeout: float = 30.0,
53
+ allow_insecure: bool = False,
53
54
  ) -> None:
54
55
  self._transport = AsyncMcpTransport(
55
56
  api_key=api_key,
56
57
  base_url=base_url,
57
58
  timeout=timeout,
59
+ allow_insecure=allow_insecure,
58
60
  )
59
61
 
60
62
  # ==================== Internal ====================
@@ -12,6 +12,7 @@ from typing import Any
12
12
  import httpx
13
13
  from pydantic import SecretStr
14
14
 
15
+ from hyperping._internals import validate_base_url
15
16
  from hyperping._version import __version__
16
17
  from hyperping.endpoints import MCP_URL
17
18
  from hyperping.exceptions import (
@@ -53,9 +54,14 @@ class AsyncMcpTransport:
53
54
  base_url: str = MCP_URL,
54
55
  timeout: float = 30.0,
55
56
  max_retries: int = 2,
57
+ allow_insecure: bool = False,
56
58
  ) -> None:
57
59
  token = api_key.get_secret_value() if isinstance(api_key, SecretStr) else api_key
58
- self._url = base_url.rstrip("/")
60
+ self._url = validate_base_url(
61
+ base_url,
62
+ allow_insecure=allow_insecure,
63
+ param_name="base_url",
64
+ )
59
65
  self._client = httpx.AsyncClient(
60
66
  headers={
61
67
  "Authorization": f"Bearer {token}",
@@ -116,11 +122,13 @@ class AsyncMcpTransport:
116
122
  retry_after = int(raw_retry)
117
123
  except ValueError:
118
124
  pass
125
+ # Drop the raw body for the same reason as the sync transport:
126
+ # the structured exception still carries retry_after.
119
127
  raise HyperpingRateLimitError(
120
128
  "Rate limit exceeded",
121
129
  retry_after=retry_after,
122
130
  status_code=429,
123
- response_body={"raw": resp.text[:500]},
131
+ response_body=None,
124
132
  )
125
133
  if resp.status_code in (400, 422):
126
134
  raise HyperpingValidationError(
@@ -128,10 +136,14 @@ class AsyncMcpTransport:
128
136
  status_code=resp.status_code,
129
137
  )
130
138
  if resp.status_code != 200:
139
+ # Drop the raw body for the same reason as the 429 path: the server
140
+ # may echo subscriber emails, webhook URLs, or other PII in free-
141
+ # form error text that the structured key-based redactor cannot
142
+ # match. The status code in the exception is enough for callers.
131
143
  raise HyperpingAPIError(
132
144
  f"MCP server returned HTTP {resp.status_code}",
133
145
  status_code=resp.status_code,
134
- response_body={"raw": resp.text[:500]},
146
+ response_body=None,
135
147
  )
136
148
 
137
149
  # HTTP 200. Parse the body so we classify JSON-RPC errors (including
@@ -145,7 +157,7 @@ class AsyncMcpTransport:
145
157
  raise HyperpingAPIError(
146
158
  "MCP server returned 200 with non-JSON body",
147
159
  status_code=200,
148
- response_body={"raw": resp.text[:500]},
160
+ response_body=None,
149
161
  ) from None
150
162
 
151
163
  if isinstance(data, dict) and "error" in data:
@@ -292,10 +304,12 @@ class AsyncMcpTransport:
292
304
  try:
293
305
  return json.loads(text)
294
306
  except json.JSONDecodeError as exc:
307
+ # Server-controlled ``text`` may carry PII; drop it instead of
308
+ # embedding the first 500 bytes into the exception.
295
309
  raise HyperpingAPIError(
296
310
  f"Failed to parse MCP tool response: {exc}",
297
311
  status_code=200,
298
- response_body={"raw": text[:500]},
312
+ response_body=None,
299
313
  ) from exc
300
314
 
301
315
  async def close(self) -> None:
@@ -0,0 +1,241 @@
1
+ """Shared internal constants and helpers used by both sync and async clients.
2
+
3
+ Not part of the public API.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ import warnings
10
+ from typing import Any
11
+ from urllib.parse import urlsplit
12
+
13
+ from hyperping._version import __version__
14
+
15
+ # Maximum time to honour a server-requested Retry-After value (5 minutes)
16
+ RETRY_AFTER_MAX = 300.0
17
+
18
+ # Default User-Agent header value
19
+ DEFAULT_USER_AGENT = f"hyperping-python/{__version__}"
20
+
21
+ # Known JSON body keys whose values must not appear in debug logs (M15)
22
+ _SENSITIVE_LOG_KEYS = frozenset(
23
+ {"authorization", "x-api-key", "api_key", "request_headers", "request_body"}
24
+ )
25
+
26
+
27
+ class InsecureTransportWarning(UserWarning):
28
+ """Warning emitted when the client is configured to use plaintext HTTP.
29
+
30
+ Plaintext transport ships the Bearer API key in clear text on every
31
+ request. Allowed only as an explicit opt-in (``allow_insecure=True``)
32
+ for local development and integration testing.
33
+ """
34
+
35
+
36
+ def validate_base_url(
37
+ url: str,
38
+ *,
39
+ allow_insecure: bool = False,
40
+ param_name: str = "base_url",
41
+ ) -> str:
42
+ """Validate that *url* is a safe, well-formed API base URL.
43
+
44
+ Rejects:
45
+ - non-string / empty input
46
+ - URLs that don't parse to ``scheme://host`` form
47
+ - URLs with userinfo (``user:pass@host``, ``user@host``, ``@host``, etc.);
48
+ credentials in URLs are a common exfiltration vector and are never
49
+ legitimate for this SDK. Even an empty userinfo segment is rejected
50
+ because ``urlsplit`` reports it as ``username == ""`` (falsy), which
51
+ would slip past a simple truthiness check.
52
+ - URLs carrying a query string or fragment; a base URL must be limited
53
+ to ``scheme://host[/path]`` so attacker-controlled ``?api_key=...``
54
+ values cannot be smuggled into every subsequent request.
55
+ - non-``https`` schemes, unless *allow_insecure* is ``True``
56
+
57
+ When *allow_insecure* permits an ``http://`` URL, an
58
+ :class:`InsecureTransportWarning` is emitted so the operator sees the
59
+ downgrade in their logs.
60
+
61
+ Returns the URL with any trailing slash stripped. Raises ``ValueError``
62
+ on any rejection.
63
+ """
64
+ if not isinstance(url, str) or not url.strip():
65
+ raise ValueError(f"{param_name} must be a non-empty string")
66
+
67
+ try:
68
+ parts = urlsplit(url.strip())
69
+ except ValueError as exc:
70
+ raise ValueError(f"{param_name} is not a parseable URL: {url!r}") from exc
71
+
72
+ if parts.scheme not in ("http", "https"):
73
+ raise ValueError(
74
+ f"{param_name} must use the https scheme (got {parts.scheme!r} in {url!r})"
75
+ )
76
+
77
+ # ``urlsplit`` accepts strings without ``//`` (e.g. ``not a url``) and
78
+ # produces an empty netloc; reject any URL without a hostname.
79
+ if not parts.hostname:
80
+ raise ValueError(f"{param_name} must include a host (got {url!r})")
81
+
82
+ # Reject any userinfo, including the empty / partial forms
83
+ # (``https://@host``, ``https://:@host``). ``parts.username`` is an empty
84
+ # string in those cases, so the previous ``or`` truthiness guard let them
85
+ # through. Checking the raw authority for ``@`` is exhaustive.
86
+ if (
87
+ "@" in parts.netloc
88
+ or parts.username is not None
89
+ or parts.password is not None
90
+ ):
91
+ raise ValueError(
92
+ f"{param_name} must not embed userinfo (credentials) in the URL"
93
+ )
94
+
95
+ if parts.query or parts.fragment:
96
+ raise ValueError(
97
+ f"{param_name} must not carry a query string or fragment "
98
+ f"(got {url!r})"
99
+ )
100
+
101
+ if parts.scheme == "http":
102
+ if not allow_insecure:
103
+ raise ValueError(
104
+ f"{param_name} uses the insecure http scheme; pass allow_insecure=True "
105
+ f"to opt in to plaintext transport (development only)"
106
+ )
107
+ warnings.warn(
108
+ f"{param_name}={url!r} uses plaintext http; the Bearer API key "
109
+ "will be transmitted in clear text",
110
+ InsecureTransportWarning,
111
+ stacklevel=3,
112
+ )
113
+
114
+ return url.rstrip("/")
115
+
116
+
117
+ def sanitize_for_log(data: dict[str, Any] | None) -> dict[str, Any] | None:
118
+ """Return a copy of *data* with sensitive values replaced by ``[REDACTED]``.
119
+
120
+ Prevents tokens and header values from leaking into DEBUG-level log output.
121
+ """
122
+ if data is None:
123
+ return None
124
+ return {k: "[REDACTED]" if k.lower() in _SENSITIVE_LOG_KEYS else v for k, v in data.items()}
125
+
126
+
127
+ # Keys whose values are redacted recursively from any structured payload that
128
+ # may end up attached to an exception. Over-redact when uncertain: it is
129
+ # always safer to drop a string from a server response than to ship a secret
130
+ # into a logging pipeline.
131
+ _SENSITIVE_RESPONSE_KEYS = frozenset(
132
+ {
133
+ "authorization",
134
+ "x-api-key",
135
+ "api_key",
136
+ "apikey",
137
+ "token",
138
+ "access_token",
139
+ "refresh_token",
140
+ "secret",
141
+ "password",
142
+ "cookie",
143
+ "set-cookie",
144
+ "session",
145
+ "subscriber_email",
146
+ "subscribers",
147
+ "email",
148
+ "webhook",
149
+ "webhook_url",
150
+ "webhookurl",
151
+ "request_headers",
152
+ "request_body",
153
+ "headers",
154
+ }
155
+ )
156
+
157
+ # Maximum length of an embedded error message in formatted exception output.
158
+ _ERROR_MESSAGE_MAX_LEN = 256
159
+ # Control-byte stripper: drop C0 controls except TAB and LF. CR is dropped to
160
+ # avoid log-injection line splicing.
161
+ _CONTROL_BYTES_RE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f]")
162
+
163
+ # Token-shaped substrings to scrub from server-supplied string values. Captures
164
+ # Bearer-style ("Bearer sk_xxx" / "Bearer eyJ...") and bare sk_-prefixed keys
165
+ # (the documented Hyperping API key shape).
166
+ _TOKEN_VALUE_RES: tuple[re.Pattern[str], ...] = (
167
+ re.compile(r"(?i)bearer\s+\S+"),
168
+ re.compile(r"\bsk_[A-Za-z0-9_\-]{4,}"),
169
+ )
170
+
171
+
172
+ def _scrub_token_strings(value: str) -> str:
173
+ """Replace Bearer / sk_-prefixed tokens inside a free-form string."""
174
+ for pattern in _TOKEN_VALUE_RES:
175
+ value = pattern.sub("[REDACTED]", value)
176
+ return value
177
+
178
+
179
+ # Maximum nesting depth the redactor will descend into before substituting a
180
+ # truncation sentinel. A malicious / malformed server could otherwise return
181
+ # a deeply nested JSON payload that blows the interpreter recursion limit
182
+ # inside ``HyperpingAPIError.__init__`` (masking the original HTTP failure).
183
+ # 32 is comfortably deeper than any real-world API response.
184
+ _REDACT_MAX_DEPTH = 32
185
+
186
+ # Marker substituted in place of a subtree that exceeds the depth cap. Stable
187
+ # shape so callers / tests can detect the truncation.
188
+ _REDACT_TRUNCATED_SENTINEL = {"_truncated": "max depth reached"}
189
+
190
+
191
+ def redact_response_body(value: Any, _depth: int = 0) -> Any:
192
+ """Recursively redact sensitive keys from a parsed JSON response body.
193
+
194
+ Mirrors the key list used by :func:`sanitize_for_log` but applied at any
195
+ depth so server payloads that echo request headers or carry subscriber
196
+ PII can be attached to exceptions without leaking secrets through
197
+ ``logging.exception`` / traceback printing.
198
+
199
+ Lists and tuples are walked element-wise; primitive values are returned
200
+ unchanged. The structure is copied; the input is not mutated.
201
+
202
+ Recursion is capped at :data:`_REDACT_MAX_DEPTH`; deeper subtrees are
203
+ replaced with :data:`_REDACT_TRUNCATED_SENTINEL` so a pathological 5000-
204
+ level payload cannot raise ``RecursionError`` inside the exception
205
+ constructor.
206
+ """
207
+ if _depth >= _REDACT_MAX_DEPTH:
208
+ # Return a fresh copy so callers cannot mutate the shared sentinel.
209
+ return dict(_REDACT_TRUNCATED_SENTINEL)
210
+ if isinstance(value, dict):
211
+ redacted: dict[Any, Any] = {}
212
+ for k, v in value.items():
213
+ key_str = str(k).lower() if isinstance(k, str) else k
214
+ if isinstance(key_str, str) and key_str in _SENSITIVE_RESPONSE_KEYS:
215
+ redacted[k] = "[REDACTED]"
216
+ else:
217
+ redacted[k] = redact_response_body(v, _depth + 1)
218
+ return redacted
219
+ if isinstance(value, list):
220
+ return [redact_response_body(v, _depth + 1) for v in value]
221
+ if isinstance(value, tuple):
222
+ return tuple(redact_response_body(v, _depth + 1) for v in value)
223
+ if isinstance(value, str):
224
+ return _scrub_token_strings(value)
225
+ return value
226
+
227
+
228
+ def sanitize_error_message(message: str) -> str:
229
+ """Strip control bytes and clamp length on an exception message.
230
+
231
+ Server-supplied error strings can carry ANSI escapes (terminal-injection)
232
+ or arbitrarily long payloads; both are unsafe to forward into log lines
233
+ or terminals verbatim. TAB and LF are preserved so multi-line validation
234
+ errors keep their shape.
235
+ """
236
+ if not isinstance(message, str):
237
+ message = str(message)
238
+ cleaned = _CONTROL_BYTES_RE.sub("", message)
239
+ if len(cleaned) > _ERROR_MESSAGE_MAX_LEN:
240
+ cleaned = cleaned[: _ERROR_MESSAGE_MAX_LEN - 3] + "..."
241
+ return cleaned
@@ -12,6 +12,7 @@ from typing import Any
12
12
  import httpx
13
13
  from pydantic import SecretStr
14
14
 
15
+ from hyperping._internals import validate_base_url
15
16
  from hyperping._version import __version__
16
17
  from hyperping.endpoints import MCP_URL
17
18
  from hyperping.exceptions import (
@@ -53,9 +54,14 @@ class McpTransport:
53
54
  base_url: str = MCP_URL,
54
55
  timeout: float = 30.0,
55
56
  max_retries: int = 2,
57
+ allow_insecure: bool = False,
56
58
  ) -> None:
57
59
  token = api_key.get_secret_value() if isinstance(api_key, SecretStr) else api_key
58
- self._url = base_url.rstrip("/")
60
+ self._url = validate_base_url(
61
+ base_url,
62
+ allow_insecure=allow_insecure,
63
+ param_name="base_url",
64
+ )
59
65
  self._client = httpx.Client(
60
66
  headers={
61
67
  "Authorization": f"Bearer {token}",
@@ -116,11 +122,14 @@ class McpTransport:
116
122
  retry_after = int(raw)
117
123
  except ValueError:
118
124
  pass
125
+ # Drop the raw body: the server may echo request fields here, and
126
+ # the structured exception already conveys "Rate limit exceeded"
127
+ # plus retry_after for the caller's back-off logic.
119
128
  raise HyperpingRateLimitError(
120
129
  "Rate limit exceeded",
121
130
  retry_after=retry_after,
122
131
  status_code=429,
123
- response_body={"raw": resp.text[:500]},
132
+ response_body=None,
124
133
  )
125
134
  if resp.status_code in (400, 422):
126
135
  raise HyperpingValidationError(
@@ -128,10 +137,14 @@ class McpTransport:
128
137
  status_code=resp.status_code,
129
138
  )
130
139
  if resp.status_code != 200:
140
+ # Drop the raw body for the same reason as the 429 path: the server
141
+ # may echo subscriber emails, webhook URLs, or other PII in free-
142
+ # form error text that the structured key-based redactor cannot
143
+ # match. The status code in the exception is enough for callers.
131
144
  raise HyperpingAPIError(
132
145
  f"MCP server returned HTTP {resp.status_code}",
133
146
  status_code=resp.status_code,
134
- response_body={"raw": resp.text[:500]},
147
+ response_body=None,
135
148
  )
136
149
 
137
150
  # HTTP 200. Parse the body so we classify JSON-RPC errors (including
@@ -145,7 +158,7 @@ class McpTransport:
145
158
  raise HyperpingAPIError(
146
159
  "MCP server returned 200 with non-JSON body",
147
160
  status_code=200,
148
- response_body={"raw": resp.text[:500]},
161
+ response_body=None,
149
162
  ) from None
150
163
 
151
164
  if isinstance(data, dict) and "error" in data:
@@ -293,10 +306,12 @@ class McpTransport:
293
306
  try:
294
307
  return json.loads(text)
295
308
  except json.JSONDecodeError as exc:
309
+ # Server-controlled ``text`` may carry PII; drop it instead of
310
+ # embedding the first 500 bytes into the exception.
296
311
  raise HyperpingAPIError(
297
312
  f"Failed to parse MCP tool response: {exc}",
298
313
  status_code=200,
299
- response_body={"raw": text[:500]},
314
+ response_body=None,
300
315
  ) from exc
301
316
 
302
317
  def close(self) -> None:
@@ -115,9 +115,23 @@ def parse_list(
115
115
  results.append(model_cls.model_validate(item)) # type: ignore[attr-defined]
116
116
  except (ValueError, ValidationError) as exc:
117
117
  skipped += 1
118
- # Log only the exception, not the raw item - it may contain
119
- # sensitive data (subscriber emails, custom auth headers).
120
- logger.warning("Failed to parse %s data: %s", label, exc)
118
+ # Log a structural summary only. Pydantic's full ValidationError
119
+ # string includes the offending input value, which can echo
120
+ # sensitive data (subscriber emails, custom auth headers, etc.).
121
+ if isinstance(exc, ValidationError):
122
+ locations = [".".join(str(p) for p in err.get("loc", ())) for err in exc.errors()]
123
+ logger.warning(
124
+ "Failed to parse %s data: %s at %s",
125
+ label,
126
+ type(exc).__name__,
127
+ locations,
128
+ )
129
+ else:
130
+ logger.warning(
131
+ "Failed to parse %s data: %s",
132
+ label,
133
+ type(exc).__name__,
134
+ )
121
135
 
122
136
  if skipped:
123
137
  logger.warning(