austial-security 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.
@@ -0,0 +1,73 @@
1
+ """``austial.security`` -- baseline security primitives for Austial apps.
2
+
3
+ Ships:
4
+
5
+ - ``SecurityModule.for_root(config)`` -- DI wiring.
6
+ - ``SecureHeadersMiddleware`` -- CSP, HSTS, ``X-Content-Type-Options``,
7
+ ``Referrer-Policy``, ``X-Frame-Options``, ``Permissions-Policy``.
8
+ - ``RateLimitGuard`` + ``@RateLimit(limit, period_seconds)`` -- fixed-window
9
+ rate limiting keyed on ``(client_ip, route)`` by default, with a pluggable
10
+ ``RateLimitStore`` (in-memory reference impl included).
11
+ - ``IdempotencyKeyGuard`` -- ``Idempotency-Key`` replay/conflict handling for
12
+ ``POST``/``PUT``/``PATCH``/``DELETE``, backed by a pluggable
13
+ ``IdempotencyStore``.
14
+ - ``CsrfInterceptor`` -- double-submit cookie CSRF check for cookie-authenticated
15
+ flows; skipped when a bearer token is present.
16
+ - ``PIIRedactor`` -- masks email, phone, PAN, Aadhaar, passport, IBAN and
17
+ Luhn-verified card numbers.
18
+ - ``EnvelopeEncryptor`` -- AES-256-GCM app-level column encryption with an
19
+ embedded KEK version.
20
+
21
+ Aligned with OWASP ASVS V5 (validation), V6 (secrets/crypto), V8 (data protection),
22
+ V9 (transport), and OWASP API Top-10 API4 (unrestricted resource consumption),
23
+ API6 (unrestricted access to business flows), API8 (security misconfiguration).
24
+ """
25
+
26
+ from austial.security.config import SecurityConfig
27
+ from austial.security.csrf_interceptor import (
28
+ CSRF_COOKIE_NAME,
29
+ CSRF_HEADER_NAME,
30
+ CsrfInterceptor,
31
+ )
32
+ from austial.security.decorators import RATE_LIMIT_METADATA, RateLimit, get_rate_limit
33
+ from austial.security.envelope_encryptor import EnvelopeEncryptor
34
+ from austial.security.errors import (
35
+ EncryptionKeyError,
36
+ IdempotencyConflictError,
37
+ RateLimitExceededError,
38
+ )
39
+ from austial.security.idempotency_guard import IdempotencyKeyGuard
40
+ from austial.security.idempotency_store import (
41
+ IdempotencyRecord,
42
+ IdempotencyStore,
43
+ InMemoryIdempotencyStore,
44
+ )
45
+ from austial.security.pii_redactor import PIIRedactor
46
+ from austial.security.rate_limit_guard import RateLimitGuard
47
+ from austial.security.rate_limit_store import InMemoryRateLimitStore, RateLimitStore
48
+ from austial.security.secure_headers_middleware import SecureHeadersMiddleware
49
+ from austial.security.security_module import SecurityModule
50
+
51
+ __all__ = [
52
+ "CSRF_COOKIE_NAME",
53
+ "CSRF_HEADER_NAME",
54
+ "CsrfInterceptor",
55
+ "EncryptionKeyError",
56
+ "EnvelopeEncryptor",
57
+ "IdempotencyConflictError",
58
+ "IdempotencyKeyGuard",
59
+ "IdempotencyRecord",
60
+ "IdempotencyStore",
61
+ "InMemoryIdempotencyStore",
62
+ "InMemoryRateLimitStore",
63
+ "PIIRedactor",
64
+ "RATE_LIMIT_METADATA",
65
+ "RateLimit",
66
+ "RateLimitExceededError",
67
+ "RateLimitGuard",
68
+ "RateLimitStore",
69
+ "SecureHeadersMiddleware",
70
+ "SecurityConfig",
71
+ "SecurityModule",
72
+ "get_rate_limit",
73
+ ]
@@ -0,0 +1,31 @@
1
+ """``SecurityConfig`` -- configuration passed to ``SecurityModule.for_root(...)``.
2
+
3
+ Held in its own module so ``EnvelopeEncryptor``/``RateLimitGuard``/etc. can read
4
+ individual fields without importing the module wiring.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class SecurityConfig:
14
+ """Every field has a safe default; only ``encryption_keys`` + ``active_encryption_key``
15
+ must be supplied for the ``EnvelopeEncryptor`` to be usable."""
16
+
17
+ csp: str = "default-src 'self'; frame-ancestors 'none'; upgrade-insecure-requests"
18
+ hsts_max_age: int = 31536000
19
+ permissions_policy: str = "geolocation=(), microphone=(), camera=()"
20
+ encryption_keys: dict[str, bytes] = field(default_factory=dict)
21
+ active_encryption_key: str | None = None
22
+ default_rate_limit: tuple[int, int] = (60, 60)
23
+ idempotency_ttl_seconds: int = 86400
24
+ trusted_proxy_hops: int = 0
25
+ """How many reverse proxies you operate in front of the app, for client-IP resolution.
26
+
27
+ ``0`` (default): ``X-Forwarded-For`` is ignored and the socket peer is the client -- correct when
28
+ the app is exposed directly, and safe by default because the header is client-controlled. ``n > 0``:
29
+ the client is the ``n``-th entry counted from the *right* of ``X-Forwarded-For`` (each trusted proxy
30
+ appends the address it received the request from, so the right-most ``n`` entries are trustworthy
31
+ and everything to their left is attacker-controlled)."""
@@ -0,0 +1,94 @@
1
+ """``CsrfInterceptor`` -- double-submit cookie CSRF check for cookie-authenticated
2
+ flows.
3
+
4
+ The pattern:
5
+
6
+ - The server sets a random token in the ``csrf_token`` cookie at session start.
7
+ - Every state-changing request must echo the same token back in the
8
+ ``X-CSRF-Token`` header.
9
+ - The interceptor compares the two values in constant time and rejects on any
10
+ mismatch.
11
+
12
+ Bearer-token flows are exempt: an ``Authorization: Bearer ...`` header cannot
13
+ be attached cross-origin without the caller possessing the credential outright,
14
+ which defeats the CSRF threat model. Read-only methods (``GET``/``HEAD``/
15
+ ``OPTIONS``) are exempt as well; they should not carry side effects.
16
+
17
+ Raises ``ForbiddenException`` (HTTP 403) on a mismatch so a proxy or WAF can
18
+ distinguish CSRF failures from generic 401s.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hmac
24
+ from typing import Any
25
+
26
+ from austial.common.exceptions import ForbiddenException
27
+ from austial.common.guards.base import ExecutionContext
28
+ from austial.common.interceptors.base import CallHandler, NestInterceptor
29
+
30
+ CSRF_HEADER_NAME = "X-CSRF-Token"
31
+ CSRF_COOKIE_NAME = "csrf_token"
32
+ _SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
33
+
34
+
35
+ class CsrfInterceptor(NestInterceptor):
36
+ """Enforce the double-submit cookie invariant before the handler runs.
37
+
38
+ Product code sets the cookie itself (usually at login); this interceptor is
39
+ the read-side check."""
40
+
41
+ def __init__(
42
+ self,
43
+ *,
44
+ header_name: str = CSRF_HEADER_NAME,
45
+ cookie_name: str = CSRF_COOKIE_NAME,
46
+ ) -> None:
47
+ self._header = header_name
48
+ self._cookie = cookie_name
49
+
50
+ async def intercept(self, context: ExecutionContext, next: CallHandler) -> Any:
51
+ request = context.get_request()
52
+ method = getattr(request, "method", "GET").upper()
53
+
54
+ if method in _SAFE_METHODS:
55
+ return await next.handle()
56
+
57
+ # Bearer-authenticated request => skip CSRF (not the relevant threat model).
58
+ auth = _header(getattr(request, "headers", {}) or {}, "Authorization")
59
+ if auth and auth.lower().startswith("bearer "):
60
+ return await next.handle()
61
+
62
+ header_token = _header(getattr(request, "headers", {}) or {}, self._header)
63
+ cookie_token = _cookie(request, self._cookie)
64
+
65
+ if not header_token or not cookie_token:
66
+ raise ForbiddenException("CSRF token missing")
67
+
68
+ if not hmac.compare_digest(header_token, cookie_token):
69
+ raise ForbiddenException("CSRF token mismatch")
70
+
71
+ return await next.handle()
72
+
73
+
74
+ def _header(headers: Any, name: str) -> str | None:
75
+ try:
76
+ value = headers.get(name)
77
+ except AttributeError:
78
+ return None
79
+ if value:
80
+ return value
81
+ try:
82
+ return headers.get(name.lower())
83
+ except AttributeError:
84
+ return None
85
+
86
+
87
+ def _cookie(request: Any, name: str) -> str | None:
88
+ cookies = getattr(request, "cookies", None)
89
+ if cookies is None:
90
+ return None
91
+ try:
92
+ return cookies.get(name)
93
+ except AttributeError:
94
+ return None
@@ -0,0 +1,40 @@
1
+ """Decorators: ``@RateLimit(limit, period_seconds)``.
2
+
3
+ Attaches per-route metadata that ``RateLimitGuard`` reads to override
4
+ ``SecurityConfig.default_rate_limit`` for that handler.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+
11
+ from austial.core.metadata import get_metadata, set_metadata
12
+
13
+ RATE_LIMIT_METADATA = "austial:security:rate_limit"
14
+
15
+
16
+ def RateLimit(limit: int, period_seconds: int) -> Callable: # noqa: N802 - decorator naming
17
+ """Override the rate-limit budget for a single handler or controller.
18
+
19
+ ``limit`` is the maximum number of requests inside a fixed window of
20
+ ``period_seconds`` seconds. Raises ``ValueError`` at decoration time on a
21
+ non-positive value so misconfiguration surfaces at import."""
22
+
23
+ if limit <= 0:
24
+ raise ValueError("RateLimit limit must be positive")
25
+ if period_seconds <= 0:
26
+ raise ValueError("RateLimit period_seconds must be positive")
27
+
28
+ def decorator(target):
29
+ set_metadata(RATE_LIMIT_METADATA, (limit, period_seconds))(target)
30
+ return target
31
+
32
+ return decorator
33
+
34
+
35
+ def get_rate_limit(target, default: tuple[int, int]) -> tuple[int, int]:
36
+ """Return the rate-limit tuple attached to ``target`` (or ``default``)."""
37
+ value = get_metadata(RATE_LIMIT_METADATA, target, None)
38
+ if value is None:
39
+ return default
40
+ return value
@@ -0,0 +1,93 @@
1
+ """``EnvelopeEncryptor`` -- app-level column encryption with AES-256-GCM.
2
+
3
+ The wire format is a URL-safe base64 encoding of::
4
+
5
+ b"v1:<key_version>:" || nonce (12 bytes) || ciphertext || tag (16 bytes)
6
+
7
+ The key version prefix lets a rotation swap the active KEK without a bulk
8
+ re-encrypt: old rows keep decrypting with their embedded version, new writes
9
+ use ``SecurityConfig.active_encryption_key``.
10
+
11
+ Callers pass a ``dict[str, bytes]`` of ``version -> 32-byte key``. A key that
12
+ is not exactly 32 bytes is rejected at construction (``EncryptionKeyError``).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import os
19
+
20
+ from cryptography.exceptions import InvalidTag
21
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
22
+
23
+ from austial.common.decorators.injectable import Injectable
24
+ from austial.security.errors import EncryptionKeyError
25
+
26
+ _VERSION_PREFIX = b"v1:"
27
+
28
+
29
+ @Injectable()
30
+ class EnvelopeEncryptor:
31
+ """Symmetric app-level encryption for individual column values.
32
+
33
+ ``encrypt`` writes with ``active_key``. ``decrypt`` reads whichever version
34
+ the ciphertext advertises so historical rows stay readable across a key
35
+ rotation."""
36
+
37
+ def __init__(self, keys: dict[str, bytes], active_key: str | None) -> None:
38
+ if not keys:
39
+ raise EncryptionKeyError("At least one encryption key must be supplied")
40
+ if active_key is None:
41
+ raise EncryptionKeyError("active_encryption_key must be supplied")
42
+ if active_key not in keys:
43
+ raise EncryptionKeyError(f"active_encryption_key '{active_key}' is not present in keys")
44
+ for version, material in keys.items():
45
+ if not isinstance(material, (bytes, bytearray)) or len(material) != 32:
46
+ raise EncryptionKeyError(f"Key '{version}' must be exactly 32 bytes (AES-256)")
47
+ self._keys: dict[str, bytes] = {v: bytes(m) for v, m in keys.items()}
48
+ self._active_key = active_key
49
+
50
+ def encrypt(self, plaintext: str) -> str:
51
+ if not isinstance(plaintext, str):
52
+ raise TypeError("plaintext must be str")
53
+ aesgcm = AESGCM(self._keys[self._active_key])
54
+ nonce = os.urandom(12)
55
+ ciphertext_with_tag = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
56
+ header = _VERSION_PREFIX + self._active_key.encode("ascii") + b":"
57
+ blob = header + nonce + ciphertext_with_tag
58
+ return base64.urlsafe_b64encode(blob).decode("ascii")
59
+
60
+ def decrypt(self, ciphertext: str) -> str:
61
+ try:
62
+ blob = base64.urlsafe_b64decode(ciphertext.encode("ascii"))
63
+ except (ValueError, TypeError) as exc:
64
+ raise EncryptionKeyError("Ciphertext is not valid base64") from exc
65
+
66
+ if not blob.startswith(_VERSION_PREFIX):
67
+ raise EncryptionKeyError("Ciphertext is missing the version prefix")
68
+ remainder = blob[len(_VERSION_PREFIX) :]
69
+ try:
70
+ version_end = remainder.index(b":")
71
+ except ValueError as exc:
72
+ raise EncryptionKeyError("Ciphertext version delimiter not found") from exc
73
+
74
+ version = remainder[:version_end].decode("ascii", errors="replace")
75
+ payload = remainder[version_end + 1 :]
76
+ if len(payload) < 12 + 16: # nonce + minimum-sized tag
77
+ raise EncryptionKeyError("Ciphertext payload is truncated")
78
+
79
+ key = self._keys.get(version)
80
+ if key is None:
81
+ raise EncryptionKeyError(f"Unknown encryption key version '{version}'")
82
+
83
+ nonce, ciphertext_bytes = payload[:12], payload[12:]
84
+ aesgcm = AESGCM(key)
85
+ try:
86
+ plaintext = aesgcm.decrypt(nonce, ciphertext_bytes, None)
87
+ except InvalidTag as exc:
88
+ raise EncryptionKeyError("Ciphertext failed authentication") from exc
89
+ return plaintext.decode("utf-8")
90
+
91
+ @property
92
+ def active_key(self) -> str:
93
+ return self._active_key
@@ -0,0 +1,31 @@
1
+ """Security-specific exceptions.
2
+
3
+ Extend Austial's typed HTTP exceptions so ``AllExceptionsFilter`` maps them to
4
+ the correct status codes without a translation layer.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from austial.common.exceptions import ConflictException, TooManyRequestsException
10
+
11
+
12
+ class RateLimitExceededError(TooManyRequestsException):
13
+ """Raised by ``RateLimitGuard`` when a caller exceeds their allowance
14
+ inside the current fixed window (HTTP 429)."""
15
+
16
+ def __init__(self, message: str = "Rate limit exceeded", *, retry_after: int | None = None):
17
+ super().__init__(message)
18
+ self.retry_after = retry_after
19
+
20
+
21
+ class IdempotencyConflictError(ConflictException):
22
+ """Raised by ``IdempotencyKeyGuard`` when the same ``Idempotency-Key`` is
23
+ replayed with a different request body inside the TTL (HTTP 409)."""
24
+
25
+ def __init__(self, message: str = "Idempotency key reused with different request"):
26
+ super().__init__(message)
27
+
28
+
29
+ class EncryptionKeyError(ValueError):
30
+ """Raised by ``EnvelopeEncryptor`` when the active KEK is missing, a key
31
+ length is wrong, or a stored ciphertext references an unknown key version."""
@@ -0,0 +1,124 @@
1
+ """``IdempotencyKeyGuard`` -- enforces ``Idempotency-Key`` on write methods.
2
+
3
+ For ``POST``/``PUT``/``PATCH``/``DELETE`` the guard reads the header
4
+ ``Idempotency-Key``:
5
+
6
+ - Missing header: guard is a no-op (allow through). Product code can require it
7
+ by decorating the route with a companion validator.
8
+ - Header present, no cached record: allow through; the framework's response
9
+ interceptor stores the outcome (product code calls
10
+ ``store.put(IdempotencyRecord(...))`` from a middleware/interceptor once the
11
+ handler returns).
12
+ - Header present, cached record with the same body hash: replay the cached
13
+ response by short-circuiting the pipeline. This is done by attaching the
14
+ record to ``request.state.idempotent_replay``; the response interceptor is
15
+ expected to render it.
16
+ - Header present, cached record with a *different* body hash: 409 conflict.
17
+
18
+ Read-only methods (``GET``/``HEAD``/``OPTIONS``) are allowed unconditionally --
19
+ they are already idempotent by definition and rarely mutate state.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import json
26
+ from typing import Any
27
+
28
+ from austial.common.decorators.injectable import Injectable
29
+ from austial.common.guards.base import CanActivate, ExecutionContext
30
+ from austial.security.errors import IdempotencyConflictError
31
+ from austial.security.idempotency_store import IdempotencyStore
32
+
33
+ _WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
34
+
35
+
36
+ @Injectable()
37
+ class IdempotencyKeyGuard(CanActivate):
38
+ def __init__(self, store: IdempotencyStore) -> None:
39
+ self._store = store
40
+
41
+ async def can_activate(self, context: ExecutionContext) -> bool:
42
+ request = context.get_request()
43
+ method = getattr(request, "method", "GET").upper()
44
+ if method not in _WRITE_METHODS:
45
+ return True
46
+
47
+ headers = getattr(request, "headers", {}) or {}
48
+ key = _header(headers, "Idempotency-Key")
49
+ if not key:
50
+ return True
51
+
52
+ request_hash = await self._hash_request(request, method)
53
+ cached = self._store.get(key)
54
+ if cached is None:
55
+ # cache miss: allow through; product code stores after handler runs
56
+ request.state.idempotency_key = key
57
+ request.state.idempotency_request_hash = request_hash
58
+ return True
59
+
60
+ if cached.request_hash != request_hash:
61
+ raise IdempotencyConflictError(
62
+ f"Idempotency-Key '{key}' has already been used with a different request payload"
63
+ )
64
+
65
+ # cache hit: signal replay
66
+ request.state.idempotent_replay = cached
67
+ return True
68
+
69
+ async def _hash_request(self, request: Any, method: str) -> str:
70
+ path = getattr(getattr(request, "url", None), "path", "") or getattr(request, "path", "/")
71
+ body_bytes = await _body(request)
72
+ digest = hashlib.sha256()
73
+ digest.update(method.encode("ascii"))
74
+ digest.update(b"\0")
75
+ digest.update(path.encode("utf-8"))
76
+ digest.update(b"\0")
77
+ digest.update(body_bytes)
78
+ return digest.hexdigest()
79
+
80
+
81
+ def _header(headers: Any, name: str) -> str | None:
82
+ try:
83
+ value = headers.get(name)
84
+ except AttributeError:
85
+ return None
86
+ if value:
87
+ return value
88
+ try:
89
+ return headers.get(name.lower())
90
+ except AttributeError:
91
+ return None
92
+
93
+
94
+ async def _body(request: Any) -> bytes:
95
+ """Read the request body. Starlette exposes ``await request.body()``; a test
96
+ double may attach ``body_bytes`` directly or provide a dict."""
97
+ # cached body written by prior guards/middleware
98
+ cached = getattr(getattr(request, "state", None), "raw_body", None)
99
+ if isinstance(cached, (bytes, bytearray)):
100
+ return bytes(cached)
101
+
102
+ body_attr = getattr(request, "body", None)
103
+ if callable(body_attr):
104
+ result = body_attr()
105
+ if hasattr(result, "__await__"):
106
+ result = await result
107
+ if isinstance(result, (bytes, bytearray)):
108
+ return bytes(result)
109
+ if isinstance(result, str):
110
+ return result.encode("utf-8")
111
+ if isinstance(body_attr, (bytes, bytearray)):
112
+ return bytes(body_attr)
113
+ if isinstance(body_attr, str):
114
+ return body_attr.encode("utf-8")
115
+
116
+ body_bytes = getattr(request, "body_bytes", None)
117
+ if isinstance(body_bytes, (bytes, bytearray)):
118
+ return bytes(body_bytes)
119
+
120
+ payload = getattr(request, "json_body", None)
121
+ if payload is not None:
122
+ return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
123
+
124
+ return b""
@@ -0,0 +1,70 @@
1
+ """``IdempotencyStore`` protocol + ``InMemoryIdempotencyStore`` reference impl.
2
+
3
+ An idempotency record captures the response produced for a mutation so the
4
+ guard can replay it verbatim if the client retries with the same
5
+ ``Idempotency-Key``. Records expire after ``ttl_seconds``; a replay with a
6
+ different request body while the record is still alive is a conflict.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import threading
12
+ import time
13
+ from dataclasses import dataclass
14
+ from typing import Any, Protocol, runtime_checkable
15
+
16
+ from austial.common.decorators.injectable import Injectable
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class IdempotencyRecord:
21
+ """Snapshot of a response bound to an ``Idempotency-Key``."""
22
+
23
+ key: str
24
+ request_hash: str
25
+ status_code: int
26
+ body: Any
27
+ stored_at: float
28
+
29
+
30
+ @runtime_checkable
31
+ class IdempotencyStore(Protocol):
32
+ """Pluggable backend for ``IdempotencyKeyGuard``."""
33
+
34
+ def get(self, key: str) -> IdempotencyRecord | None:
35
+ """Return the record for ``key`` if it is still within TTL, else ``None``."""
36
+ ...
37
+
38
+ def put(self, record: IdempotencyRecord) -> None:
39
+ """Persist a record (overwriting any earlier record for the same key)."""
40
+ ...
41
+
42
+
43
+ @Injectable()
44
+ class InMemoryIdempotencyStore:
45
+ """Thread-safe, process-local record cache with lazy TTL expiry."""
46
+
47
+ def __init__(self, ttl_seconds: int = 86400) -> None:
48
+ self._lock = threading.Lock()
49
+ self._ttl = ttl_seconds
50
+ self._records: dict[str, IdempotencyRecord] = {}
51
+
52
+ def get(self, key: str) -> IdempotencyRecord | None:
53
+ now = time.time()
54
+ with self._lock:
55
+ record = self._records.get(key)
56
+ if record is None:
57
+ return None
58
+ if now - record.stored_at > self._ttl:
59
+ self._records.pop(key, None)
60
+ return None
61
+ return record
62
+
63
+ def put(self, record: IdempotencyRecord) -> None:
64
+ with self._lock:
65
+ self._records[record.key] = record
66
+
67
+ def reset(self) -> None:
68
+ """Clear all records. Useful in tests."""
69
+ with self._lock:
70
+ self._records.clear()
@@ -0,0 +1,132 @@
1
+ """``PIIRedactor`` -- masks common personally-identifying data before it hits logs.
2
+
3
+ Recognises:
4
+
5
+ - Email addresses (RFC-5322-ish, pragmatic subset).
6
+ - International phone numbers (``+`` optional, 7-15 digits, common separators).
7
+ - Indian PAN (``AAAAA9999A``), Aadhaar (12 digits, spaces optional; Verhoeff
8
+ check-digit heuristic keeps the false-positive rate low).
9
+ - Passport numbers (Indian format ``[A-PR-WY]\\d{7}``).
10
+ - IBAN (2 letters + 2 digits + up to 30 alphanumerics with a mod-97 check).
11
+ - Credit card numbers (13-19 digits, spaces/dashes tolerated, Luhn-verified).
12
+
13
+ Redaction preserves the first and last character for emails and the last four
14
+ digits for cards so operators can still correlate incidents. Values that fail
15
+ their per-pattern check (Luhn, mod-97) are left untouched to avoid destroying
16
+ useful log data.
17
+
18
+ Instances are safe to share across threads; no state is mutated after init.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from typing import Any
25
+
26
+ from austial.common.decorators.injectable import Injectable
27
+
28
+ _EMAIL_RE = re.compile(r"(?P<user>[A-Za-z0-9._%+-]+)@(?P<host>[A-Za-z0-9.-]+\.[A-Za-z]{2,})")
29
+ _PHONE_RE = re.compile(r"(?<![A-Za-z0-9])(?P<phone>\+?\d[\d\s().-]{6,}\d)(?![A-Za-z0-9])")
30
+ _PAN_RE = re.compile(r"\b[A-Z]{5}[0-9]{4}[A-Z]\b")
31
+ _AADHAAR_RE = re.compile(r"(?<!\d)\d{4}[\s-]?\d{4}[\s-]?\d{4}(?!\d)")
32
+ _PASSPORT_RE = re.compile(r"\b[A-PR-WY][0-9]{7}\b")
33
+ _IBAN_RE = re.compile(r"\b[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}\b")
34
+ _CARD_RE = re.compile(r"(?<!\d)(?:\d[ -]?){12,18}\d(?!\d)")
35
+
36
+
37
+ @Injectable()
38
+ class PIIRedactor:
39
+ """Redact PII patterns from strings or nested dict/list structures.
40
+
41
+ ``redact`` accepts a string (returns a string) or a mapping (returns a new
42
+ mapping with the same shape, values redacted recursively). Non-string
43
+ scalar values pass through unchanged."""
44
+
45
+ _placeholder = "[REDACTED]"
46
+
47
+ def redact(self, record: dict | list | str | Any) -> Any:
48
+ if isinstance(record, str):
49
+ return self._redact_str(record)
50
+ if isinstance(record, dict):
51
+ return {k: self.redact(v) for k, v in record.items()}
52
+ if isinstance(record, (list, tuple)):
53
+ result = [self.redact(v) for v in record]
54
+ return type(record)(result) if isinstance(record, tuple) else result
55
+ return record
56
+
57
+ # ------------------------------------------------------------------
58
+
59
+ def _redact_str(self, text: str) -> str:
60
+ # Order matters: longer/more-specific patterns first so a card's leading
61
+ # digits cannot be swallowed by the 12-digit Aadhaar regex, and IBAN's
62
+ # alphanumerics cannot be nibbled by PAN.
63
+ text = _EMAIL_RE.sub(_mask_email, text)
64
+ text = _PAN_RE.sub(self._placeholder, text)
65
+ text = _PASSPORT_RE.sub(self._placeholder, text)
66
+ text = _IBAN_RE.sub(lambda m: self._placeholder if _iban_ok(m.group(0)) else m.group(0), text)
67
+ text = _CARD_RE.sub(_mask_card, text)
68
+ text = _AADHAAR_RE.sub(lambda m: self._placeholder if _digits_only(m.group(0), 12) else m.group(0), text)
69
+ text = _PHONE_RE.sub(_mask_phone, text)
70
+ return text
71
+
72
+
73
+ # --- helpers ------------------------------------------------------------
74
+
75
+
76
+ def _mask_email(match: re.Match[str]) -> str:
77
+ user = match.group("user")
78
+ host = match.group("host")
79
+ masked_user = user[0] + "***" + user[-1] if len(user) >= 2 else "***"
80
+ return f"{masked_user}@{host}"
81
+
82
+
83
+ def _mask_phone(match: re.Match[str]) -> str:
84
+ phone = match.group("phone")
85
+ digits = re.sub(r"\D", "", phone)
86
+ if not 7 <= len(digits) <= 15:
87
+ return phone
88
+ return "[REDACTED-PHONE]"
89
+
90
+
91
+ def _mask_card(match: re.Match[str]) -> str:
92
+ raw = match.group(0)
93
+ digits = re.sub(r"\D", "", raw)
94
+ if not 13 <= len(digits) <= 19:
95
+ return raw
96
+ if not _luhn_ok(digits):
97
+ return raw
98
+ return f"[REDACTED-CARD-{digits[-4:]}]"
99
+
100
+
101
+ def _luhn_ok(digits: str) -> bool:
102
+ total = 0
103
+ reverse = digits[::-1]
104
+ for i, ch in enumerate(reverse):
105
+ d = ord(ch) - 48
106
+ if i % 2 == 1:
107
+ d *= 2
108
+ if d > 9:
109
+ d -= 9
110
+ total += d
111
+ return total % 10 == 0
112
+
113
+
114
+ def _digits_only(text: str, expected: int) -> bool:
115
+ return len(re.sub(r"\D", "", text)) == expected
116
+
117
+
118
+ def _iban_ok(value: str) -> bool:
119
+ value = value.upper()
120
+ rearranged = value[4:] + value[:4]
121
+ digits = []
122
+ for ch in rearranged:
123
+ if ch.isalpha():
124
+ digits.append(str(ord(ch) - 55))
125
+ elif ch.isdigit():
126
+ digits.append(ch)
127
+ else:
128
+ return False
129
+ try:
130
+ return int("".join(digits)) % 97 == 1
131
+ except ValueError:
132
+ return False
@@ -0,0 +1,82 @@
1
+ """``RateLimitGuard`` -- fixed-window rate limiter keyed on ``(client_ip, route)``.
2
+
3
+ Reads the per-route budget from ``@RateLimit(limit, period_seconds)`` metadata
4
+ if present, otherwise falls back to ``SecurityConfig.default_rate_limit``. On a
5
+ breach raises ``RateLimitExceededError`` (HTTP 429) carrying a ``Retry-After``
6
+ hint that the framework's exception filter can surface on the response.
7
+
8
+ The default key is ``f"{client_ip}:{method}:{route}"``. Override
9
+ ``build_key(request)`` in a subclass to use a session id, api key, or tenant id
10
+ where those are stronger identifiers. The client IP honours ``X-Forwarded-For`` only
11
+ when ``SecurityConfig.trusted_proxy_hops`` says how many proxies you operate.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ from austial.common.decorators.injectable import Injectable
19
+ from austial.common.guards.base import CanActivate, ExecutionContext
20
+ from austial.security.config import SecurityConfig
21
+ from austial.security.decorators import get_rate_limit
22
+ from austial.security.errors import RateLimitExceededError
23
+ from austial.security.rate_limit_store import RateLimitStore
24
+
25
+
26
+ @Injectable()
27
+ class RateLimitGuard(CanActivate):
28
+ def __init__(self, store: RateLimitStore, config: SecurityConfig) -> None:
29
+ self._store = store
30
+ self._config = config
31
+
32
+ async def can_activate(self, context: ExecutionContext) -> bool:
33
+ handler = context.get_handler()
34
+ controller = context.get_class()
35
+
36
+ # handler-level metadata wins over class-level, both win over the default
37
+ limit, period = get_rate_limit(handler, get_rate_limit(controller, self._config.default_rate_limit))
38
+ request = context.get_request()
39
+ key = self.build_key(request)
40
+
41
+ count, reset_at = self._store.hit(key, period)
42
+ if count > limit:
43
+ import time
44
+
45
+ retry_after = max(1, reset_at - int(time.time()))
46
+ raise RateLimitExceededError(
47
+ f"Rate limit exceeded: {limit} requests per {period}s",
48
+ retry_after=retry_after,
49
+ )
50
+ return True
51
+
52
+ def build_key(self, request: Any) -> str:
53
+ """Return the counter key for ``request``. Override to key on session,
54
+ api key or tenant. Default is ``f"{client_ip}:{method}:{path}"``, where the client IP is the
55
+ socket peer, or (behind ``SecurityConfig.trusted_proxy_hops`` proxies) the trusted
56
+ ``X-Forwarded-For`` entry."""
57
+ client_ip = self._client_ip(request)
58
+ method = getattr(request, "method", "GET")
59
+ path = getattr(getattr(request, "url", None), "path", "") or getattr(request, "path", "/")
60
+ return f"{client_ip}:{method}:{path}"
61
+
62
+ def _client_ip(self, request: Any) -> str:
63
+ hops = self._config.trusted_proxy_hops
64
+ if hops > 0:
65
+ headers = getattr(request, "headers", {}) or {}
66
+ forwarded = None
67
+ try:
68
+ forwarded = headers.get("X-Forwarded-For") or headers.get("x-forwarded-for")
69
+ except AttributeError: # pragma: no cover - dict-like headers
70
+ forwarded = None
71
+ if forwarded:
72
+ entries = [e.strip() for e in forwarded.split(",") if e.strip()]
73
+ # The n-th from the right is the address our outermost trusted proxy saw. If the chain is
74
+ # shorter than the configured hop count the header is not from our proxy: ignore it.
75
+ if len(entries) >= hops:
76
+ return entries[-hops]
77
+ client = getattr(request, "client", None)
78
+ if client is not None:
79
+ host = getattr(client, "host", None)
80
+ if host:
81
+ return host
82
+ return "unknown"
@@ -0,0 +1,64 @@
1
+ """``RateLimitStore`` protocol + ``InMemoryRateLimitStore`` reference impl.
2
+
3
+ The store is the pluggable backend for ``RateLimitGuard``. A production system
4
+ supplies a Redis-backed implementation; the in-memory default keeps tests and
5
+ single-process deployments honest.
6
+
7
+ The store's contract is a fixed-window counter: given a ``key`` and a ``period``
8
+ in seconds, ``hit`` returns ``(count, reset_at_epoch)`` for the current window,
9
+ incrementing the count as a side effect. Windows are aligned to wall-clock
10
+ seconds so a fleet of workers converges on the same buckets.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import threading
16
+ import time
17
+ from typing import Protocol, runtime_checkable
18
+
19
+ from austial.common.decorators.injectable import Injectable
20
+
21
+
22
+ @runtime_checkable
23
+ class RateLimitStore(Protocol):
24
+ """Pluggable backend for the ``RateLimitGuard``.
25
+
26
+ Implementations must be safe to call from multiple asyncio tasks (and,
27
+ for a shared backend, from multiple workers)."""
28
+
29
+ def hit(self, key: str, period_seconds: int) -> tuple[int, int]:
30
+ """Increment the counter for ``key`` inside its current window and
31
+ return ``(count, reset_at_epoch)``."""
32
+ ...
33
+
34
+
35
+ @Injectable()
36
+ class InMemoryRateLimitStore:
37
+ """Thread-safe, process-local fixed-window counter.
38
+
39
+ Not shared across workers -- swap for a Redis-backed store in a
40
+ multi-process deployment. Windows are aligned to wall-clock seconds
41
+ (``epoch // period``)."""
42
+
43
+ def __init__(self) -> None:
44
+ self._lock = threading.Lock()
45
+ # key -> (window_start_epoch, count)
46
+ self._counters: dict[str, tuple[int, int]] = {}
47
+
48
+ def hit(self, key: str, period_seconds: int) -> tuple[int, int]:
49
+ now = int(time.time())
50
+ window_start = (now // period_seconds) * period_seconds
51
+ reset_at = window_start + period_seconds
52
+ with self._lock:
53
+ existing = self._counters.get(key)
54
+ if existing is None or existing[0] != window_start:
55
+ self._counters[key] = (window_start, 1)
56
+ return 1, reset_at
57
+ count = existing[1] + 1
58
+ self._counters[key] = (window_start, count)
59
+ return count, reset_at
60
+
61
+ def reset(self) -> None:
62
+ """Clear all counters. Useful in tests."""
63
+ with self._lock:
64
+ self._counters.clear()
@@ -0,0 +1,49 @@
1
+ """``SecureHeadersMiddleware`` -- sets baseline security response headers.
2
+
3
+ Applied module-wide via ``consumer.apply(SecureHeadersMiddleware).for_routes("*")``.
4
+ Reads its policy from ``SecurityConfig``; product code overrides the CSP string
5
+ (or any other header) by passing a customised config to ``SecurityModule.for_root``.
6
+
7
+ Headers set on every response:
8
+
9
+ - ``Content-Security-Policy`` -- from ``config.csp``.
10
+ - ``Strict-Transport-Security`` -- ``max-age=<hsts_max_age>; includeSubDomains``.
11
+ - ``X-Content-Type-Options: nosniff``.
12
+ - ``Referrer-Policy: strict-origin-when-cross-origin``.
13
+ - ``X-Frame-Options: DENY``.
14
+ - ``Permissions-Policy`` -- from ``config.permissions_policy``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Awaitable, Callable
20
+
21
+ from starlette.requests import Request
22
+ from starlette.responses import Response
23
+
24
+ from austial.common.middleware.base import NestMiddleware
25
+ from austial.security.config import SecurityConfig
26
+
27
+
28
+ class SecureHeadersMiddleware(NestMiddleware):
29
+ """Constructor takes a ``SecurityConfig`` (supplied by the DI container).
30
+
31
+ Middleware is instantiated per binding; the config is snapshotted at
32
+ startup so the header set is stable for the process lifetime."""
33
+
34
+ def __init__(self, config: SecurityConfig | None = None) -> None:
35
+ self._config = config or SecurityConfig()
36
+
37
+ async def use(self, request: Request, call_next: Callable[[], Awaitable[Response]]) -> Response:
38
+ response = await call_next()
39
+ headers = response.headers
40
+ headers.setdefault("Content-Security-Policy", self._config.csp)
41
+ headers.setdefault(
42
+ "Strict-Transport-Security",
43
+ f"max-age={self._config.hsts_max_age}; includeSubDomains",
44
+ )
45
+ headers.setdefault("X-Content-Type-Options", "nosniff")
46
+ headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
47
+ headers.setdefault("X-Frame-Options", "DENY")
48
+ headers.setdefault("Permissions-Policy", self._config.permissions_policy)
49
+ return response
@@ -0,0 +1,91 @@
1
+ """``SecurityModule.for_root(...)`` -- wires the security primitives into the DI
2
+ container.
3
+
4
+ Provides ``SecurityConfig``, ``SecureHeadersMiddleware``, ``RateLimitGuard``,
5
+ ``IdempotencyKeyGuard``, ``CsrfInterceptor``, ``PIIRedactor``, and (when
6
+ encryption keys are configured) ``EnvelopeEncryptor``. Registered ``@Global``
7
+ by default so any module can inject them.
8
+
9
+ Callers may override the store implementations by passing a class or a
10
+ pre-constructed instance for ``rate_limit_store`` / ``idempotency_store``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from austial.common.decorators.modules import Module
18
+ from austial.core.dynamic_module import DynamicModule
19
+ from austial.security.config import SecurityConfig
20
+ from austial.security.csrf_interceptor import CsrfInterceptor
21
+ from austial.security.envelope_encryptor import EnvelopeEncryptor
22
+ from austial.security.idempotency_guard import IdempotencyKeyGuard
23
+ from austial.security.idempotency_store import IdempotencyStore, InMemoryIdempotencyStore
24
+ from austial.security.pii_redactor import PIIRedactor
25
+ from austial.security.rate_limit_guard import RateLimitGuard
26
+ from austial.security.rate_limit_store import InMemoryRateLimitStore, RateLimitStore
27
+ from austial.security.secure_headers_middleware import SecureHeadersMiddleware
28
+
29
+
30
+ @Module()
31
+ class SecurityModule:
32
+ """Import via ``SecurityModule.for_root(config, ...)`` in ``AppModule``'s
33
+ ``imports=[...]``. Registered ``@Global`` by default."""
34
+
35
+ @staticmethod
36
+ def for_root(
37
+ config: SecurityConfig | None = None,
38
+ *,
39
+ rate_limit_store: type | RateLimitStore | None = None,
40
+ idempotency_store: type | IdempotencyStore | None = None,
41
+ is_global: bool = True,
42
+ ) -> DynamicModule:
43
+ config = config or SecurityConfig()
44
+
45
+ rl_store: RateLimitStore = (
46
+ rate_limit_store if isinstance(rate_limit_store, RateLimitStore) else InMemoryRateLimitStore()
47
+ )
48
+ idem_store: IdempotencyStore = (
49
+ idempotency_store
50
+ if isinstance(idempotency_store, IdempotencyStore)
51
+ else InMemoryIdempotencyStore(ttl_seconds=config.idempotency_ttl_seconds)
52
+ )
53
+
54
+ secure_headers = SecureHeadersMiddleware(config)
55
+ redactor = PIIRedactor()
56
+ csrf = CsrfInterceptor()
57
+
58
+ providers: list[Any] = [
59
+ {"provide": SecurityConfig, "useValue": config},
60
+ {"provide": RateLimitStore, "useValue": rl_store},
61
+ {"provide": IdempotencyStore, "useValue": idem_store},
62
+ {"provide": SecureHeadersMiddleware, "useValue": secure_headers},
63
+ {"provide": CsrfInterceptor, "useValue": csrf},
64
+ {"provide": PIIRedactor, "useValue": redactor},
65
+ RateLimitGuard,
66
+ IdempotencyKeyGuard,
67
+ ]
68
+ exports: list[Any] = [
69
+ SecurityConfig,
70
+ RateLimitStore,
71
+ IdempotencyStore,
72
+ SecureHeadersMiddleware,
73
+ CsrfInterceptor,
74
+ PIIRedactor,
75
+ RateLimitGuard,
76
+ IdempotencyKeyGuard,
77
+ ]
78
+
79
+ if config.active_encryption_key is not None and config.encryption_keys:
80
+ encryptor = EnvelopeEncryptor(
81
+ keys=config.encryption_keys,
82
+ active_key=config.active_encryption_key,
83
+ )
84
+ providers.append({"provide": EnvelopeEncryptor, "useValue": encryptor})
85
+ exports.append(EnvelopeEncryptor)
86
+
87
+ return DynamicModule(
88
+ module=SecurityModule,
89
+ providers=providers,
90
+ exports=exports,
91
+ )
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.5
2
+ Name: austial-security
3
+ Version: 0.2.0
4
+ Summary: Security primitives -- secure headers, rate limiting, idempotency, CSRF, PII redaction, envelope encryption.
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: austial-common
7
+ Requires-Dist: austial-core
8
+ Requires-Dist: cryptography>=43.0.0
@@ -0,0 +1,16 @@
1
+ austial/security/__init__.py,sha256=fzWdK6rMjqYxfJ1oN56F1xv2CRuGIJfqVvlXLC_kdlk,2749
2
+ austial/security/config.py,sha256=sWS3hyDu-b2lAnC-SCFCg1NDJLZ-bT78AiuRHrlbQQ0,1520
3
+ austial/security/csrf_interceptor.py,sha256=QMDjtLn1HO67-L1KHXGtT0P-Jz-z9UA5AJ6k6Re_xj4,3065
4
+ austial/security/decorators.py,sha256=H2f-8mQ-vF_2xwxyhFIaU7pieRbxOa7Hc5tuyo7cIqc,1363
5
+ austial/security/envelope_encryptor.py,sha256=oZInRv5z0_VpT2bbUTwcg69RX2gWvczzNx5LWuLd2AA,3879
6
+ austial/security/errors.py,sha256=d4AtPmaZVrD9zKxKli5iyqM3sGf0fSFP8VYRrr7gKJc,1170
7
+ austial/security/idempotency_guard.py,sha256=fzATyf1vqMM3XVS01pSMjvyGGJo6cO7x85ofK3OY_6o,4555
8
+ austial/security/idempotency_store.py,sha256=k4VO76Xxe92gdzoWnDGf2zp-gqUG1AuV22xRpdTaRKc,2155
9
+ austial/security/pii_redactor.py,sha256=H_fpk6XdIEf78cuxVvArDxg4r3UqeEi7FMyXsaK1tY8,4671
10
+ austial/security/rate_limit_guard.py,sha256=Ap11WCLo_SvtBgvnkv73M-8F3KgeKHzV-AAO-Qz9EWQ,3712
11
+ austial/security/rate_limit_store.py,sha256=MlyoW5Mm7NATAhC9e46R1i9pCVXi5eAe-VHcyFge2ZE,2338
12
+ austial/security/secure_headers_middleware.py,sha256=SLcwfif-f2M4sk488o-ziH9jpgeQnbnlPWAx_RNmWT4,2055
13
+ austial/security/security_module.py,sha256=jQozxNyVq1bXgZfGuFw-mQkoWMHzFEC9m8-xyTPetHI,3584
14
+ austial_security-0.2.0.dist-info/METADATA,sha256=SW3uZvjBdffFBAzROCRjCH7biEhRxNmqPLy4MM7_fAo,296
15
+ austial_security-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
16
+ austial_security-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any