Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 58 additions & 31 deletions src/ipsdk/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,20 @@ async def fetch_devices():


class ConnectionBase:
__slots__ = (
"_auth_lock",
"_auth_timestamp",
"_ttl_enabled",
"authenticated",
"client",
"client_id",
"client_secret",
"password",
"token",
"ttl",
"user",
)

client: httpx.Client | httpx.AsyncClient

@logging.trace
Expand Down Expand Up @@ -203,6 +217,7 @@ def __init__(
self._auth_lock: Any | None = None
self._auth_timestamp: float | None = None
self.ttl = ttl
self._ttl_enabled = ttl > 0 # Cache this check for performance

self.client = self.__init_client__(
base_url=self._make_base_url(host, port, base_path, use_tls),
Expand Down Expand Up @@ -238,13 +253,13 @@ def _make_base_url(
None
"""
if port == 0:
port = 443 if use_tls is True else 80
port = 443 if use_tls else 80

if port not in (None, 80, 443):
host = f"{host}:{port}"

base_path = "" if base_path is None else base_path
proto = "https" if use_tls is True else "http"
proto = "https" if use_tls else "http"

return urllib.parse.urlunsplit((proto, host, base_path, None, None))

Expand Down Expand Up @@ -313,7 +328,7 @@ def _validate_request_args(
method: HTTPMethod,
path: str,
params: dict[str, Any | None] | None = None,
json: str | bytes | dict | (list | None) = None,
json: str | bytes | dict | list | None = None,
) -> None:
"""
Validate request arguments to ensure they have correct types.
Expand All @@ -336,19 +351,19 @@ def _validate_request_args(
IpsdkError: If method is not HTTPMethod type, params is not dict,
json is not dict/list, or path is not string
"""
if isinstance(method, HTTPMethod) is False:
if not isinstance(method, HTTPMethod):
msg = "method must be of type `HTTPMethod`"
raise exceptions.IpsdkError(msg)

if all((params is not None, isinstance(params, dict) is False)):
if params is not None and not isinstance(params, dict):
msg = "params must be of type `dict`"
raise exceptions.IpsdkError(msg)

if all((json is not None, isinstance(json, (list, dict)) is False)):
if json is not None and not isinstance(json, (list, dict)):
msg = "json must be of type `dict` or `list`"
raise exceptions.IpsdkError(msg)

if isinstance(path, str) is False:
if not isinstance(path, str):
msg = "path must be of type `str`"
raise exceptions.IpsdkError(msg)

Expand Down Expand Up @@ -475,19 +490,25 @@ def _send_request(
RequestError: Network or connection errors occurred.
HTTPStatusError: Server returned an HTTP error status (4xx, 5xx).
"""
# Check if reauthentication is needed due to timeout
if self._needs_reauthentication():
logging.info("Forcing reauthentication due to timeout")
self.authenticated = False
self.token = None

if self.authenticated is False:
assert self._auth_lock is not None
# Check authentication status and handle TTL-based reauthentication
if self.authenticated is False or self._ttl_enabled:
if self._auth_lock is None:
msg = "Authentication lock not initialized"
raise exceptions.IpsdkError(msg)

with self._auth_lock:
if self.authenticated is False:
self.authenticate()
self.authenticated = True
self._auth_timestamp = time.time()
# Double-check pattern with TTL check inside lock
# to prevent race conditions
if self.authenticated is False or self._needs_reauthentication():
if self._needs_reauthentication():
logging.info("Forcing reauthentication due to timeout")
self.authenticated = False
self.token = None

if self.authenticated is False:
self.authenticate()
self.authenticated = True
self._auth_timestamp = time.time()

request = self._build_request(
method=method,
Expand Down Expand Up @@ -693,19 +714,25 @@ async def _send_request(
RequestError: Network or connection errors occurred.
HTTPStatusError: Server returned an HTTP error status (4xx, 5xx).
"""
# Check if reauthentication is needed due to timeout
if self._needs_reauthentication():
logging.info("Forcing reauthentication due to timeout")
self.authenticated = False
self.token = None

if self.authenticated is False:
assert self._auth_lock is not None
# Check authentication status and handle TTL-based reauthentication
if self.authenticated is False or self._ttl_enabled:
if self._auth_lock is None:
msg = "Authentication lock not initialized"
raise exceptions.IpsdkError(msg)

async with self._auth_lock:
if self.authenticated is False:
await self.authenticate()
self.authenticated = True
self._auth_timestamp = time.time()
# Double-check pattern with TTL check inside lock
# to prevent race conditions
if self.authenticated is False or self._needs_reauthentication():
if self._needs_reauthentication():
logging.info("Forcing reauthentication due to timeout")
self.authenticated = False
self.token = None

if self.authenticated is False:
await self.authenticate()
self.authenticated = True
self._auth_timestamp = time.time()

request = self._build_request(
method=method,
Expand Down
6 changes: 5 additions & 1 deletion src/ipsdk/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ def request(self) -> Any:
The httpx.Request object associated with this error, or None if
no httpx exception was provided during initialization.
"""
if self._exc is None:
return None
return self._exc.request

@property
Expand All @@ -140,7 +142,9 @@ def response(self) -> Any:
no httpx exception was provided or if the error occurred before
receiving a response.
"""
return self._exc.response
if self._exc is None:
return None
return getattr(self._exc, "response", None)


class RequestError(IpsdkError):
Expand Down
22 changes: 16 additions & 6 deletions src/ipsdk/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ async def get_devices():
print(f"Network error: {e}")
"""

from typing import TYPE_CHECKING
from typing import Any

import httpx
Expand Down Expand Up @@ -275,12 +276,21 @@ async def authenticate(self) -> None:
raise exceptions.RequestError(exc)


Gateway = type("Gateway", (AuthMixin, connection.Connection), {})
AsyncGateway = type("AsyncGateway", (AsyncAuthMixin, connection.AsyncConnection), {})
# Define dynamically created classes for runtime and type checking
if TYPE_CHECKING:
# For type checkers: provide explicit class definitions
class Gateway(AuthMixin, connection.Connection):
"""Synchronous Gateway client with authentication."""

# Type aliases for mypy
GatewayType = Gateway
AsyncGatewayType = AsyncGateway
class AsyncGateway(AsyncAuthMixin, connection.AsyncConnection):
"""Asynchronous Gateway client with authentication."""

else:
# For runtime: use dynamic type creation for flexibility
Gateway = type("Gateway", (AuthMixin, connection.Connection), {})
AsyncGateway = type(
"AsyncGateway", (AsyncAuthMixin, connection.AsyncConnection), {}
)


@logging.trace
Expand Down Expand Up @@ -335,7 +345,7 @@ def gateway_factory(
Returns:
An initialized connection instance
"""
factory = AsyncGateway if want_async is True else Gateway
factory = AsyncGateway if want_async else Gateway
return factory(
host=host,
port=port,
Expand Down
111 changes: 60 additions & 51 deletions src/ipsdk/heuristics.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ class Scanner:

_instance: Scanner | None = None
_initialized: bool = False
_default_patterns: dict[str, Pattern] | None = None
_default_redactions: dict[str, Callable[[str], str]] | None = None

def __new__(cls, _custom_patterns: dict[str, str | None] | None = None) -> Scanner:
"""Create or return the singleton instance.
Expand Down Expand Up @@ -73,10 +75,7 @@ def __init__(self, custom_patterns: dict[str, str | None] | None = None) -> None
"""
# Only initialize once due to Singleton pattern
if not self._initialized:
self._patterns: dict[str, Pattern] = {}
self._redaction_functions: dict[str, Callable[[str], str]] = {}

# Initialize default patterns
# Initialize default patterns (copies from class-level cache)
self._init_default_patterns()

# Add custom patterns if provided
Expand All @@ -91,60 +90,70 @@ def _init_default_patterns(self) -> None:
"""Initialize default sensitive data patterns.

Sets up regex patterns for common sensitive data types including API keys,
passwords, tokens, credit card numbers, and other PII.
passwords, tokens, credit card numbers, and other PII. Patterns are compiled
once at class level and reused across all instances for performance.

Returns:
None

Raises:
None
"""
# API Keys and tokens (various formats)
self.add_pattern(
"api_key",
r"(?i)\b(?:api[_-]?key|apikey)\s*[=:]\s*[\"']?([a-zA-Z0-9_\-]{16,})[\"']?",
)
self.add_pattern("bearer_token", r"(?i)\bbearer\s+([a-zA-Z0-9_\-\.]{20,})")
self.add_pattern(
"jwt_token",
r"\b(eyJ[a-zA-Z0-9_\-]+\.eyJ[a-zA-Z0-9_\-]+\.[a-zA-Z0-9_\-]+)\b",
)
self.add_pattern(
"access_token",
r"(?i)\b(?:access[_-]?token|accesstoken)\s*[=:]\s*[\"']?([a-zA-Z0-9_\-]{20,})[\"']?",
)

# Password patterns
self.add_pattern(
"password",
r"(?i)\b(?:password|passwd|pwd)\s*[=:]\s*[\"']?([^\s\"']{6,})[\"']?",
)
self.add_pattern(
"secret",
r"(?i)\b(?:secret|client_secret)\s*[=:]\s*[\"']?([a-zA-Z0-9_\-]{16,})[\"']?",
)

# URLs with authentication (check before email patterns)
self.add_pattern("auth_url", r"https?://[a-zA-Z0-9_\-]+:[a-zA-Z0-9_\-]+@[^\s]+")

# Basic email pattern (when used in sensitive contexts)
self.add_pattern(
"email_in_auth",
r"(?i)(?:username|user|email)\s*[=:]\s*[\"']?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})[\"']?",
)

# Database connection strings
self.add_pattern(
"db_connection",
r"(?i)\b(?:mongodb|mysql|postgresql|postgres)://[^\s]+:[^\s]+@[^\s]+",
)

# Private keys (basic detection)
self.add_pattern(
"private_key",
r"-----BEGIN (?:RSA )?PRIVATE KEY-----[\s\S]*?"
r"-----END (?:RSA )?PRIVATE KEY-----",
)
# Only compile patterns once at class level
if Scanner._default_patterns is None:
Scanner._default_patterns = {}
Scanner._default_redactions = {}

# Compile all default patterns once
patterns_to_compile = {
"api_key": (
r"(?i)\b(?:api[_-]?key|apikey)\s*[=:]\s*[\"']?"
r"([a-zA-Z0-9_\-]{16,})[\"']?"
),
"bearer_token": r"(?i)\bbearer\s+([a-zA-Z0-9_\-\.]{20,})",
"jwt_token": (
r"\b(eyJ[a-zA-Z0-9_\-]+\.eyJ[a-zA-Z0-9_\-]+"
r"\.[a-zA-Z0-9_\-]+)\b"
),
"access_token": (
r"(?i)\b(?:access[_-]?token|accesstoken)\s*[=:]\s*[\"']?"
r"([a-zA-Z0-9_\-]{20,})[\"']?"
),
"password": (
r"(?i)\b(?:password|passwd|pwd)\s*[=:]\s*[\"']?"
r"([^\s\"']{6,})[\"']?"
),
"secret": (
r"(?i)\b(?:secret|client_secret)\s*[=:]\s*[\"']?"
r"([a-zA-Z0-9_\-]{16,})[\"']?"
),
"auth_url": r"https?://[a-zA-Z0-9_\-]+:[a-zA-Z0-9_\-]+@[^\s]+",
"email_in_auth": (
r"(?i)(?:username|user|email)\s*[=:]\s*[\"']?"
r"([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})[\"']?"
),
"db_connection": (
r"(?i)\b(?:mongodb|mysql|postgresql|postgres)://"
r"[^\s]+:[^\s]+@[^\s]+"
),
"private_key": (
r"-----BEGIN (?:RSA )?PRIVATE KEY-----[\s\S]*?"
r"-----END (?:RSA )?PRIVATE KEY-----"
),
}

for name, pattern_str in patterns_to_compile.items():
compiled_pattern = re.compile(pattern_str)
Scanner._default_patterns[name] = compiled_pattern
# Use default argument to capture current value of name
# (avoid late-binding closure issue)
Scanner._default_redactions[name] = (
lambda _, n=name: f"[REDACTED_{n.upper()}]"
)

# Copy pre-compiled patterns to instance
self._patterns = Scanner._default_patterns.copy()
self._redaction_functions = Scanner._default_redactions.copy()

def add_pattern(
self,
Expand Down
6 changes: 5 additions & 1 deletion src/ipsdk/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,16 @@ class Request:
ValueError: If required parameters are missing or invalid
"""

__slots__ = ("headers", "json", "method", "params", "path")

@logging.trace
def __init__(
self,
method: str,
path: str,
params: dict[str, Any | None] | None = None,
headers: dict[str, str | None] | None = None,
json: str | bytes | dict | (list | None) = None,
json: str | bytes | dict | list | None = None,
) -> None:
self.method = method
self.path = path
Expand Down Expand Up @@ -130,6 +132,8 @@ class Response:
ValueError: If the httpx_response is None or invalid
"""

__slots__ = ("_response",)

@logging.trace
def __init__(self, httpx_response: httpx.Response) -> None:
if httpx_response is None:
Expand Down
Loading