Coverage for src/taipanstack/security/decorators.py: 100%
126 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 15:01 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 15:01 +0000
1"""
2Security decorators for robust Python applications.
4Provides decorators for input validation, exception handling,
5timeout control, and other security patterns. Compatible with
6any Python framework (Flask, FastAPI, Django, etc.).
7"""
9import functools
10import inspect
11import math
12import signal
13import sys
14import threading
15from collections.abc import Callable
16from types import FrameType
17from typing import ParamSpec, TypeVar
19from taipanstack.security.guards import SecurityError
21P = ParamSpec("P")
22R = TypeVar("R")
23T = TypeVar("T")
26class OperationTimeoutError(Exception):
27 """Raised when a function exceeds its timeout limit."""
29 def __init__(self, seconds: float, func_name: str = "function") -> None:
30 """Initialize OperationTimeoutError.
32 Args:
33 seconds: The timeout that was exceeded.
34 func_name: Name of the function that timed out.
36 """
37 self.seconds = seconds
38 self.func_name = func_name
39 super().__init__(f"{func_name} timed out after {seconds} seconds")
42class ValidationError(Exception):
43 """Raised when input validation fails."""
45 def __init__(
46 self,
47 message: str,
48 param_name: str | None = None,
49 value: object = None,
50 ) -> None:
51 """Initialize ValidationError.
53 Args:
54 message: Description of the validation failure.
55 param_name: Name of the parameter that failed.
56 value: The invalid value (sanitized).
58 """
59 self.param_name = param_name
60 self.value = value
61 super().__init__(message)
64def validate_inputs(
65 **validators: Callable[[object], object],
66) -> Callable[[Callable[P, R]], Callable[P, R]]:
67 """Decorator to validate function inputs.
69 Validates function arguments using provided validator functions.
70 Validators should raise ValueError or ValidationError on invalid input.
72 Args:
73 **validators: Mapping of parameter names to validator functions.
75 Returns:
76 Decorated function with input validation.
78 Example:
79 >>> from taipanstack.security.validators import validate_email, validate_port
80 >>> @validate_inputs(email=validate_email, port=validate_port)
81 ... def connect(email: str, port: int) -> None:
82 ... pass
83 >>> connect(email="invalid", port=8080)
84 ValidationError: Invalid email format: invalid
86 """
88 def decorator(func: Callable[P, R]) -> Callable[P, R]:
89 sig = inspect.signature(func)
91 @functools.wraps(func)
92 def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
93 # Bind and apply defaults on each call
94 bound = sig.bind(*args, **kwargs)
95 bound.apply_defaults()
97 # Validate each parameter that has a validator
98 for param_name, validator in validators.items():
99 if param_name in bound.arguments:
100 value = bound.arguments[param_name]
101 try:
102 # Call validator - it should raise on invalid input
103 validated = validator(value)
104 # Update to validated value if returned
105 if validated is not None:
106 bound.arguments[param_name] = validated
107 except (ValueError, TypeError) as e:
108 raise ValidationError(
109 str(e),
110 param_name=param_name,
111 value=repr(value)[:100],
112 ) from e
114 # Call original function with validated arguments
115 return func(*bound.args, **bound.kwargs)
117 return wrapper
119 return decorator
122def guard_exceptions(
123 *,
124 catch: tuple[type[Exception], ...] = (Exception,),
125 reraise_as: type[Exception] | None = None,
126 default: T | None = None,
127 log_errors: bool = True,
128) -> Callable[[Callable[P, R]], Callable[P, R | T | None]]:
129 """Decorator to safely handle exceptions.
131 Catches exceptions and optionally re-raises as a different type
132 or returns a default value.
134 Args:
135 catch: Exception types to catch.
136 reraise_as: Exception type to re-raise as (None = don't reraise).
137 default: Default value to return if exception caught and not reraised.
138 log_errors: Whether to log caught exceptions.
140 Returns:
141 Decorated function with exception handling.
143 Example:
144 >>> @guard_exceptions(catch=(IOError,), reraise_as=SecurityError)
145 ... def read_file(path: str) -> str:
146 ... return open(path).read()
147 >>> read_file("/nonexistent")
148 SecurityError: [guard_exceptions] ...
150 """
152 def decorator(func: Callable[P, R]) -> Callable[P, R | T | None]:
153 @functools.wraps(func)
154 def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | T | None:
155 try:
156 return func(*args, **kwargs)
157 except catch as e:
158 if log_errors:
159 import logging
161 logging.getLogger("taipanstack.security").warning(
162 "Exception caught in %s: %s",
163 func.__name__,
164 str(e),
165 )
167 if reraise_as is not None:
168 if reraise_as == SecurityError:
169 raise SecurityError(
170 str(e),
171 guard_name="guard_exceptions",
172 ) from e
173 raise reraise_as(str(e)) from e
175 return default
177 return wrapper
179 return decorator
182def timeout(
183 seconds: float,
184 *,
185 use_signal: bool = True,
186) -> Callable[[Callable[P, R]], Callable[P, R]]:
187 """Decorator to limit function execution time.
189 Uses signal-based timeout on Unix or thread-based on Windows.
190 Signal-based is more reliable but only works in main thread.
192 Args:
193 seconds: Maximum execution time in seconds.
194 use_signal: Use signal-based timeout (Unix only, main thread only).
196 Returns:
197 Decorated function with timeout.
199 Example:
200 >>> @timeout(5.0)
201 ... def slow_operation() -> str:
202 ... import time
203 ... time.sleep(10)
204 ... return "done"
205 >>> slow_operation()
206 TimeoutError: slow_operation timed out after 5.0 seconds
208 """
209 # Security Enhancement: explicitly validate bounds using math.isfinite()
210 # and check for non-negative limits to prevent silent NaN propagation,
211 # unhandled ValueError exceptions from threading/asyncio primitives,
212 # or unexpected infinite blocking behaviors.
213 if (
214 not isinstance(seconds, (int, float))
215 or not math.isfinite(seconds)
216 or seconds < 0
217 ):
218 raise ValueError("timeout must be a finite non-negative number")
220 def decorator(func: Callable[P, R]) -> Callable[P, R]:
221 @functools.wraps(func)
222 def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
223 # Determine if we can use signals
224 can_use_signal = (
225 use_signal
226 and sys.platform != "win32"
227 and threading.current_thread() is threading.main_thread()
228 )
230 if can_use_signal:
231 return _timeout_with_signal(
232 func,
233 seconds,
234 args,
235 dict(kwargs),
236 )
237 return _timeout_with_thread(
238 func,
239 seconds,
240 args,
241 dict(kwargs),
242 )
244 return wrapper
246 return decorator
249def _timeout_with_signal(
250 func: Callable[..., R],
251 seconds: float,
252 args: tuple[object, ...],
253 kwargs: dict[str, object],
254) -> R:
255 """Implement timeout using Unix signals."""
257 def handler(_signum: int, _frame: FrameType | None) -> None:
258 raise OperationTimeoutError(seconds, func.__name__)
260 # Set up signal handler
261 old_handler = signal.signal(signal.SIGALRM, handler)
262 signal.setitimer(signal.ITIMER_REAL, seconds)
264 try:
265 return func(*args, **kwargs)
266 finally:
267 # Restore old handler and cancel alarm
268 signal.setitimer(signal.ITIMER_REAL, 0)
269 signal.signal(signal.SIGALRM, old_handler)
272def _timeout_with_thread(
273 func: Callable[..., R],
274 seconds: float,
275 args: tuple[object, ...],
276 kwargs: dict[str, object],
277) -> R:
278 """Implement timeout using a separate thread."""
279 result: list[R] = []
280 exception: list[BaseException] = []
282 def target() -> None:
283 try:
284 result.append(func(*args, **kwargs))
285 except BaseException as e:
286 exception.append(e)
288 thread = threading.Thread(target=target)
289 thread.daemon = True
290 thread.start()
291 thread.join(timeout=seconds)
293 if thread.is_alive():
294 # Thread still running - timeout occurred
295 raise OperationTimeoutError(seconds, func.__name__)
297 if exception:
298 raise exception[0]
300 return result[0]
303def deprecated(
304 message: str = "",
305 *,
306 removal_version: str | None = None,
307) -> Callable[[Callable[P, R]], Callable[P, R]]:
308 """Mark a function as deprecated.
310 Emits a warning when the decorated function is called.
312 Args:
313 message: Additional deprecation message.
314 removal_version: Version when function will be removed.
316 Returns:
317 Decorated function that warns on use.
319 Example:
320 >>> @deprecated("Use new_function instead", removal_version="2.0")
321 ... def old_function() -> None:
322 ... pass
324 """
326 def decorator(func: Callable[P, R]) -> Callable[P, R]:
327 @functools.wraps(func)
328 def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
329 import warnings
331 msg = f"{func.__name__} is deprecated."
332 if removal_version:
333 msg += f" Will be removed in version {removal_version}."
334 if message:
335 msg += f" {message}"
337 warnings.warn(msg, DeprecationWarning, stacklevel=2)
338 return func(*args, **kwargs)
340 return wrapper
342 return decorator
345def require_type(
346 **type_hints: type,
347) -> Callable[[Callable[P, R]], Callable[P, R]]:
348 """Decorator to enforce runtime type checking.
350 Validates that arguments match specified types at runtime.
352 Args:
353 **type_hints: Mapping of parameter names to expected types.
355 Returns:
356 Decorated function with type checking.
358 Example:
359 >>> @require_type(name=str, count=int)
360 ... def greet(name: str, count: int) -> None:
361 ... print(f"Hello {name}" * count)
362 >>> greet(name=123, count=2)
363 TypeError: Parameter 'name' expected str, got int
365 """
367 def decorator(func: Callable[P, R]) -> Callable[P, R]:
368 sig = inspect.signature(func)
370 @functools.wraps(func)
371 def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
372 bound = sig.bind(*args, **kwargs)
373 bound.apply_defaults()
375 for param_name, expected_type in type_hints.items():
376 if param_name in bound.arguments:
377 value = bound.arguments[param_name]
378 if not isinstance(value, expected_type):
379 raise TypeError(
380 f"Parameter '{param_name}' expected "
381 f"{expected_type.__name__}, got {type(value).__name__}",
382 )
384 return func(*bound.args, **bound.kwargs)
386 return wrapper
388 return decorator