SessionArmor 0.1.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,29 @@
1
+ """
2
+ SessionArmor — defense-grade Django middleware.
3
+
4
+ Session security, audit logging, and configurable request gates with hooks.
5
+
6
+ Three middleware classes, one base class:
7
+
8
+ SessionSecurityMiddleware
9
+ Fingerprint binding, absolute/idle timeouts, claim drift detection.
10
+
11
+ AuditMiddleware
12
+ Privacy-preserving audit trail for every authenticated request.
13
+
14
+ GateMiddleware
15
+ Base class for building cacheable, hookable conditional gates
16
+ (compliance, onboarding, feature flags, maintenance, etc.).
17
+ """
18
+
19
+ from .audit import AuditMiddleware
20
+ from .gate import GateMiddleware
21
+ from .session import SessionSecurityMiddleware
22
+
23
+ __all__ = [
24
+ 'GateMiddleware',
25
+ 'SessionSecurityMiddleware',
26
+ 'AuditMiddleware',
27
+ ]
28
+
29
+ __version__ = '0.1.0'
session_armor/audit.py ADDED
@@ -0,0 +1,163 @@
1
+ """
2
+ AuditMiddleware — security audit trail for Django requests.
3
+
4
+ Logs every authenticated request with security-relevant context for
5
+ forensic analysis, anomaly detection, and compliance reporting.
6
+
7
+ Every log entry captures **WHO** did **WHAT** from **WHERE**, enabling:
8
+ - Post-incident forensics
9
+ - Real-time anomaly detection (via log aggregation / SIEM)
10
+ - Compliance reporting (NIST 800-53 AU-3, AU-12; SOC 2)
11
+
12
+ Never logs request bodies, tokens, or credentials.
13
+
14
+ Hooks (override in subclass)::
15
+
16
+ get_user_identity(request) → dict
17
+ get_extra_fields(request, response) → dict
18
+ before_log(request, response, entry) → dict | None
19
+ get_log_level(status_code) → int
20
+ format_entry(entry) → str
21
+
22
+ Usage::
23
+
24
+ # Generic
25
+ MIDDLEWARE = [..., 'session_armor.audit.AuditMiddleware', ...]
26
+
27
+ # Customized (e.g. Auth0-aware)
28
+ class MyAudit(AuditMiddleware):
29
+ def get_user_identity(self, request):
30
+ return {
31
+ 'uid': request.user.sub,
32
+ 'tenant': request.user.organization_uuid,
33
+ }
34
+ """
35
+
36
+ import logging
37
+ import time
38
+ import uuid
39
+
40
+ from .utils import get_client_ip, hash_value, is_authenticated, sanitize_for_log
41
+
42
+ logger = logging.getLogger('security.audit')
43
+
44
+
45
+ class AuditMiddleware:
46
+ """
47
+ Audit logging for authenticated requests.
48
+
49
+ Captures:
50
+ Request ID, user identity, method, path, status, hashed IP,
51
+ truncated user-agent, and request duration.
52
+
53
+ Does NOT capture:
54
+ Request/response bodies, tokens, passwords, query parameters.
55
+ """
56
+
57
+ # ── Configuration ────────────────────────────────────────────────────
58
+ logger_name: str = 'security.audit'
59
+ exempt_paths: tuple[str, ...] = ()
60
+
61
+ # ── Django middleware protocol ────────────────────────────────────────
62
+
63
+ def __init__(self, get_response):
64
+ self.get_response = get_response
65
+ self._logger = logging.getLogger(self.logger_name)
66
+
67
+ def __call__(self, request):
68
+ if self._is_exempt(request):
69
+ return self.get_response(request)
70
+
71
+ # Attach request ID for correlation
72
+ request_id = str(uuid.uuid4())[:8]
73
+ request.META['X_REQUEST_ID'] = request_id
74
+ start = time.time()
75
+
76
+ response = self.get_response(request)
77
+
78
+ duration_ms = round((time.time() - start) * 1000)
79
+
80
+ # Only audit authenticated requests
81
+ if is_authenticated(request):
82
+ self._emit(request, response, request_id, duration_ms)
83
+
84
+ return response
85
+
86
+ # ── HOOKS (override in subclass) ─────────────────────────────────────
87
+
88
+ def get_user_identity(self, request) -> dict:
89
+ """
90
+ Extract user identity fields for the audit log entry.
91
+
92
+ Override to match your auth system's user model.
93
+ Default returns ``{'uid': user.pk}``.
94
+ """
95
+ user = request.user
96
+ return {
97
+ 'uid': getattr(user, 'sub', None) or str(getattr(user, 'pk', '')),
98
+ }
99
+
100
+ def get_extra_fields(self, request, response) -> dict:
101
+ """Return additional fields to include in the audit entry."""
102
+ return {}
103
+
104
+ def before_log(self, request, response, entry: dict) -> dict | None:
105
+ """
106
+ Called before the entry is logged.
107
+
108
+ Return the (optionally modified) entry, or ``None`` to suppress
109
+ logging for this request.
110
+ """
111
+ return entry
112
+
113
+ def get_log_level(self, status_code: int) -> int:
114
+ """Return the ``logging`` level for a given response status code."""
115
+ if status_code >= 500:
116
+ return logging.ERROR
117
+ if status_code in (401, 403):
118
+ return logging.WARNING
119
+ if status_code >= 400:
120
+ return logging.INFO
121
+ return logging.DEBUG
122
+
123
+ def format_entry(self, entry: dict) -> str:
124
+ """
125
+ Format the audit entry for the log message.
126
+
127
+ Override to produce structured JSON, CEF, or other formats.
128
+ """
129
+ return f"AUDIT {entry}"
130
+
131
+ # ── INTERNALS ────────────────────────────────────────────────────────
132
+
133
+ def _emit(self, request, response, request_id: str, duration_ms: int):
134
+ ip = get_client_ip(request)
135
+
136
+ entry = {
137
+ 'rid': request_id,
138
+ **self.get_user_identity(request),
139
+ 'method': request.method,
140
+ 'path': sanitize_for_log(request.path, max_length=500),
141
+ 'status': response.status_code,
142
+ 'ip_hash': hash_value(ip, length=16),
143
+ 'ua': sanitize_for_log(
144
+ request.META.get('HTTP_USER_AGENT', ''), max_length=80
145
+ ),
146
+ 'ms': duration_ms,
147
+ **self.get_extra_fields(request, response),
148
+ }
149
+
150
+ entry = self.before_log(request, response, entry)
151
+ if entry is None:
152
+ return
153
+
154
+ level = self.get_log_level(response.status_code)
155
+ message = self.format_entry(entry)
156
+
157
+ if response.status_code in (401, 403):
158
+ self._logger.log(level, message.replace('AUDIT', 'AUDIT_DENIED', 1))
159
+ else:
160
+ self._logger.log(level, message)
161
+
162
+ def _is_exempt(self, request) -> bool:
163
+ return any(request.path.startswith(p) for p in self.exempt_paths)
session_armor/gate.py ADDED
@@ -0,0 +1,213 @@
1
+ """
2
+ GateMiddleware — configurable conditional gate for Django middleware stacks.
3
+
4
+ A gate checks a condition on each request (with session caching) and either
5
+ passes the request through or rejects it. Subclass this to build:
6
+
7
+ - Compliance gates (legal document acceptance)
8
+ - Onboarding gates (step completion)
9
+ - Feature gates (entitlement / feature-flag checks)
10
+ - Maintenance gates (scheduled downtime)
11
+ - Any "must satisfy X before proceeding" pattern
12
+
13
+ Follows the before/after hooks pattern for maximum customizability::
14
+
15
+ class ComplianceGate(GateMiddleware):
16
+ gate_id = 'compliance'
17
+ cache_ttl_setting = 'COMPLIANCE_CACHE_TTL'
18
+ default_cache_ttl = 3600
19
+
20
+ def check(self, request) -> bool:
21
+ return user_accepted_terms(request)
22
+
23
+ def on_reject(self, request):
24
+ return redirect('/accept-terms/')
25
+
26
+ Hooks execute in this order::
27
+
28
+ before_check → check → after_check → on_pass / on_reject
29
+ ↓ (exception)
30
+ on_error
31
+ """
32
+
33
+ import logging
34
+ import time
35
+
36
+ from django.conf import settings
37
+
38
+ from .utils import is_authenticated
39
+
40
+ logger = logging.getLogger('session_armor.gate')
41
+
42
+
43
+ class GateMiddleware:
44
+ """
45
+ Base class for conditional gate middleware with session caching and hooks.
46
+
47
+ Configuration (set as class attributes on your subclass):
48
+
49
+ gate_id Unique ID used for session-key namespacing.
50
+ cache_ttl_setting Name of a Django setting that holds the TTL override.
51
+ default_cache_ttl Fallback TTL in seconds (default 300).
52
+ exempt_paths Tuple of path prefixes that bypass this gate.
53
+ requires_auth Only check authenticated requests (default True).
54
+ fail_open Pass through on errors (default False).
55
+
56
+ Hooks (override any combination in your subclass):
57
+
58
+ before_check(request) → bool | None
59
+ check(request) → bool **required**
60
+ after_check(request, passed: bool)
61
+ on_reject(request) → HttpResponse
62
+ on_pass(request)
63
+ on_error(request, error) → bool
64
+ get_rejection_context(request) → dict
65
+ """
66
+
67
+ # ── Configuration (override in subclass) ─────────────────────────────
68
+ gate_id: str = ''
69
+ cache_ttl_setting: str = ''
70
+ default_cache_ttl: int = 300
71
+ exempt_paths: tuple[str, ...] = ()
72
+ requires_auth: bool = True
73
+ fail_open: bool = False
74
+
75
+ # ── Django middleware protocol ────────────────────────────────────────
76
+
77
+ def __init__(self, get_response):
78
+ self.get_response = get_response
79
+ if not self.gate_id:
80
+ self.gate_id = self.__class__.__name__.lower()
81
+
82
+ def __call__(self, request):
83
+ # Exempt paths bypass the gate entirely
84
+ if self._is_exempt(request):
85
+ return self.get_response(request)
86
+
87
+ # Auth guard
88
+ if self.requires_auth and not is_authenticated(request):
89
+ return self.get_response(request)
90
+
91
+ # ── before_check hook ──
92
+ should_check = self.before_check(request)
93
+ if should_check is False:
94
+ return self.get_response(request)
95
+
96
+ now = time.time()
97
+ prefix = self._cache_prefix
98
+ ttl = self._get_cache_ttl()
99
+
100
+ # ── Session-cache fast path ──
101
+ checked_at = request.session.get(f'{prefix}_at', 0)
102
+ if (now - checked_at) < ttl and f'{prefix}_result' in request.session:
103
+ passed = request.session[f'{prefix}_result']
104
+ if not passed:
105
+ return self.on_reject(request)
106
+ self.on_pass(request)
107
+ return self.get_response(request)
108
+
109
+ # ── Run the check ──
110
+ try:
111
+ passed = self.check(request)
112
+ except Exception as exc:
113
+ passed = self.on_error(request, exc)
114
+
115
+ # ── after_check hook ──
116
+ self.after_check(request, passed)
117
+
118
+ # ── Cache the result ──
119
+ request.session[f'{prefix}_at'] = now
120
+ request.session[f'{prefix}_result'] = passed
121
+
122
+ if not passed:
123
+ ctx = self.get_rejection_context(request)
124
+ if ctx:
125
+ request.session[f'{prefix}_context'] = ctx
126
+ return self.on_reject(request)
127
+
128
+ self.on_pass(request)
129
+ return self.get_response(request)
130
+
131
+ # ── HOOKS (override in subclass) ─────────────────────────────────────
132
+
133
+ def before_check(self, request) -> bool | None:
134
+ """Called before ``check()``. Return ``False`` to skip the gate."""
135
+ return True
136
+
137
+ def check(self, request) -> bool:
138
+ """
139
+ The main condition.
140
+
141
+ Return ``True`` to let the request through, ``False`` to reject it.
142
+ **Must** be overridden in every subclass.
143
+ """
144
+ raise NotImplementedError(
145
+ f"{self.__class__.__name__} must implement check()"
146
+ )
147
+
148
+ def after_check(self, request, passed: bool) -> None:
149
+ """Called after ``check()`` with the result. Logging, metrics, side effects."""
150
+ pass
151
+
152
+ def on_reject(self, request):
153
+ """Called when the gate rejects. Return an ``HttpResponse``."""
154
+ from django.http import HttpResponseForbidden
155
+ return HttpResponseForbidden("Access denied")
156
+
157
+ def on_pass(self, request) -> None:
158
+ """Called when the gate passes. Side effects only (no return value)."""
159
+ pass
160
+
161
+ def on_error(self, request, error: Exception) -> bool:
162
+ """
163
+ Called when ``check()`` raises an exception.
164
+
165
+ Return ``True`` to pass through (fail-open), ``False`` to reject.
166
+ """
167
+ logger.error(
168
+ "%s check error: %s", self.__class__.__name__, error,
169
+ exc_info=True,
170
+ )
171
+ return self.fail_open
172
+
173
+ def get_rejection_context(self, request) -> dict:
174
+ """
175
+ Return extra data to store in session when the gate rejects.
176
+
177
+ Accessible later via ``cls.get_context(request)`` from views.
178
+ """
179
+ return {}
180
+
181
+ # ── CLASS UTILITIES (call from views) ────────────────────────────────
182
+
183
+ @classmethod
184
+ def invalidate(cls, request):
185
+ """
186
+ Clear cached gate state, forcing a re-check on the next request.
187
+
188
+ Call this from views when the underlying condition changes
189
+ (e.g. user just accepted terms, completed an onboarding step).
190
+ """
191
+ prefix = f'_gate_{cls.gate_id or cls.__name__.lower()}'
192
+ for suffix in ('_at', '_result', '_context'):
193
+ request.session.pop(f'{prefix}{suffix}', None)
194
+
195
+ @classmethod
196
+ def get_context(cls, request) -> dict:
197
+ """Read the cached rejection context from the session."""
198
+ prefix = f'_gate_{cls.gate_id or cls.__name__.lower()}'
199
+ return request.session.get(f'{prefix}_context', {})
200
+
201
+ # ── INTERNALS ────────────────────────────────────────────────────────
202
+
203
+ @property
204
+ def _cache_prefix(self) -> str:
205
+ return f'_gate_{self.gate_id}'
206
+
207
+ def _get_cache_ttl(self) -> int:
208
+ if self.cache_ttl_setting:
209
+ return getattr(settings, self.cache_ttl_setting, self.default_cache_ttl)
210
+ return self.default_cache_ttl
211
+
212
+ def _is_exempt(self, request) -> bool:
213
+ return any(request.path.startswith(p) for p in self.exempt_paths)
session_armor/py.typed ADDED
File without changes
@@ -0,0 +1,263 @@
1
+ """
2
+ SessionSecurityMiddleware — defense-grade session protection.
3
+
4
+ Enforces:
5
+ - Client fingerprint binding (IP + User-Agent hash)
6
+ - Absolute session timeout (force re-auth after N hours)
7
+ - Idle session timeout (force re-auth after inactivity)
8
+ - JWT claim drift detection (critical claims must not change mid-session)
9
+
10
+ All behavior is customizable via hooks so different auth systems
11
+ (Auth0, WorkOS, Django allauth, etc.) can plug in seamlessly.
12
+
13
+ References:
14
+ NIST SP 800-53 AC-12 (Session Termination)
15
+ NIST SP 800-53 SC-23 (Session Authenticity)
16
+ OWASP Session Management Cheat Sheet
17
+
18
+ Hooks (override in subclass)::
19
+
20
+ get_fingerprint_parts(request) → list[str]
21
+ get_critical_claims(user) → list[str]
22
+ get_login_url(request) → str
23
+ on_violation(request, reason) → HttpResponse | None
24
+ on_timeout(request, kind) → None
25
+ on_session_init(request) → None
26
+
27
+ Usage::
28
+
29
+ # Generic (works with any Django auth)
30
+ MIDDLEWARE = [
31
+ ...
32
+ 'session_armor.session.SessionSecurityMiddleware',
33
+ ...
34
+ ]
35
+
36
+ # Customized
37
+ class MySessionSecurity(SessionSecurityMiddleware):
38
+ def get_critical_claims(self, user):
39
+ return [user.sub, user.tenant_id]
40
+
41
+ def get_login_url(self, request):
42
+ return reverse('my_login')
43
+ """
44
+
45
+ import hashlib
46
+ import logging
47
+ import time
48
+
49
+ from django.conf import settings
50
+ from django.shortcuts import redirect
51
+
52
+ from .utils import get_client_ip, is_authenticated
53
+
54
+ logger = logging.getLogger('session_armor.session')
55
+
56
+
57
+ class SessionSecurityMiddleware:
58
+ """
59
+ Session binding, timeouts, and anomaly detection.
60
+
61
+ Must run **after** your authentication middleware (needs ``request.user``).
62
+
63
+ Settings (read from Django settings with secure defaults):
64
+
65
+ SESSION_ABSOLUTE_TIMEOUT int — seconds (default 28800 / 8 h)
66
+ SESSION_IDLE_TIMEOUT int — seconds (default 1800 / 30 min)
67
+ SESSION_BIND_IP bool (default True)
68
+ SESSION_BIND_USER_AGENT bool (default True)
69
+ SESSION_DETECT_CLAIM_DRIFT bool (default True)
70
+
71
+ Session keys (all prefixed ``_ss_``):
72
+
73
+ _ss_fingerprint Client fingerprint hash
74
+ _ss_auth_time Absolute session start (epoch)
75
+ _ss_last_active Last activity timestamp (epoch)
76
+ _ss_claims_hash Hash of critical JWT claims
77
+ """
78
+
79
+ # ── Configuration ────────────────────────────────────────────────────
80
+ exempt_paths: tuple[str, ...] = ()
81
+
82
+ # ── Django middleware protocol ────────────────────────────────────────
83
+
84
+ def __init__(self, get_response):
85
+ self.get_response = get_response
86
+
87
+ def __call__(self, request):
88
+ if self._is_exempt(request):
89
+ return self.get_response(request)
90
+
91
+ if not is_authenticated(request):
92
+ return self.get_response(request)
93
+
94
+ now = time.time()
95
+ user = request.user
96
+
97
+ # ── Binding check ──
98
+ violation = self._check_binding(request)
99
+ if violation:
100
+ logger.warning(
101
+ "Session binding violation (%s) for user %s, path=%s",
102
+ violation, getattr(user, 'sub', '?'), request.path,
103
+ )
104
+ return self._terminate(request, reason=violation)
105
+
106
+ # ── Absolute timeout ──
107
+ auth_time = request.session.get('_ss_auth_time')
108
+ timeout = getattr(settings, 'SESSION_ABSOLUTE_TIMEOUT', 28800)
109
+ if auth_time and (now - auth_time) > timeout:
110
+ logger.info("Absolute timeout for user %s", getattr(user, 'sub', '?'))
111
+ self.on_timeout(request, 'absolute')
112
+ return self._terminate(request, reason='absolute_timeout')
113
+
114
+ # ── Idle timeout ──
115
+ last_active = request.session.get('_ss_last_active')
116
+ idle_timeout = getattr(settings, 'SESSION_IDLE_TIMEOUT', 1800)
117
+ if last_active and (now - last_active) > idle_timeout:
118
+ logger.info("Idle timeout for user %s", getattr(user, 'sub', '?'))
119
+ self.on_timeout(request, 'idle')
120
+ return self._terminate(request, reason='idle_timeout')
121
+
122
+ # ── Claim drift ──
123
+ if getattr(settings, 'SESSION_DETECT_CLAIM_DRIFT', True):
124
+ drift = self._check_claim_drift(request)
125
+ if drift:
126
+ logger.warning(
127
+ "JWT claim drift for user %s: %s",
128
+ getattr(user, 'sub', '?'), drift,
129
+ )
130
+ return self._terminate(request, reason='claim_drift')
131
+
132
+ # ── Initialize on first authenticated request ──
133
+ if '_ss_auth_time' not in request.session:
134
+ self._init_session(request, now)
135
+ self.on_session_init(request)
136
+
137
+ # ── Update last-active ──
138
+ request.session['_ss_last_active'] = now
139
+
140
+ return self.get_response(request)
141
+
142
+ # ── HOOKS (override in subclass) ─────────────────────────────────────
143
+
144
+ def get_fingerprint_parts(self, request) -> list[str]:
145
+ """
146
+ Return the parts that make up the client fingerprint.
147
+
148
+ Override to add custom binding factors (client certs, device ID, etc.).
149
+ Each part is joined with ``|`` and SHA-256 hashed.
150
+ """
151
+ parts = []
152
+ if getattr(settings, 'SESSION_BIND_IP', True):
153
+ parts.append(get_client_ip(request))
154
+ if getattr(settings, 'SESSION_BIND_USER_AGENT', True):
155
+ parts.append(request.META.get('HTTP_USER_AGENT', ''))
156
+ return parts
157
+
158
+ def get_critical_claims(self, user) -> list[str]:
159
+ """
160
+ Return claim values that must not change mid-session.
161
+
162
+ Override to match your auth system's user model. The values are
163
+ hashed together; if the hash changes, the session is terminated.
164
+
165
+ Default extracts ``sub`` and ``email`` (works for most OIDC setups).
166
+ """
167
+ return [
168
+ getattr(user, 'sub', '') or '',
169
+ getattr(user, 'email', '') or '',
170
+ ]
171
+
172
+ def get_login_url(self, request) -> str:
173
+ """
174
+ Return the URL to redirect to when a session is terminated.
175
+
176
+ Override to implement platform-specific or tenant-specific routing.
177
+ Default uses Django's ``LOGIN_URL`` setting.
178
+ """
179
+ return getattr(settings, 'LOGIN_URL', '/')
180
+
181
+ def on_violation(self, request, reason: str):
182
+ """
183
+ Called on security violation, before session flush.
184
+
185
+ Return an ``HttpResponse`` to override the default redirect.
186
+ Return ``None`` to use the default behavior (flush → login redirect).
187
+ """
188
+ return None
189
+
190
+ def on_timeout(self, request, kind: str) -> None:
191
+ """
192
+ Called on absolute or idle timeout, before session flush.
193
+
194
+ ``kind`` is ``'absolute'`` or ``'idle'``.
195
+ Use for logging, metrics, or pre-flush cleanup.
196
+ """
197
+ pass
198
+
199
+ def on_session_init(self, request) -> None:
200
+ """
201
+ Called when session security metadata is initialized for a new session.
202
+
203
+ Use to set additional session values at auth time.
204
+ """
205
+ pass
206
+
207
+ # ── INTERNALS ────────────────────────────────────────────────────────
208
+
209
+ def _check_binding(self, request) -> str | None:
210
+ stored = request.session.get('_ss_fingerprint')
211
+ if not stored:
212
+ return None # First request — will be initialized
213
+
214
+ parts = self.get_fingerprint_parts(request)
215
+ current = hashlib.sha256('|'.join(parts).encode('utf-8')).hexdigest()
216
+ if current != stored:
217
+ return 'fingerprint_mismatch'
218
+ return None
219
+
220
+ def _check_claim_drift(self, request) -> str | None:
221
+ stored = request.session.get('_ss_claims_hash')
222
+ if not stored:
223
+ return None
224
+
225
+ current = self._hash_claims(request.user)
226
+ if current != stored:
227
+ return 'critical_claims_changed'
228
+ return None
229
+
230
+ def _hash_claims(self, user) -> str:
231
+ parts = self.get_critical_claims(user)
232
+ raw = '|'.join(str(p) for p in parts)
233
+ return hashlib.sha256(raw.encode('utf-8')).hexdigest()
234
+
235
+ def _init_session(self, request, now: float):
236
+ # Rotate session ID to prevent fixation attacks (OWASP A7)
237
+ request.session.cycle_key()
238
+
239
+ parts = self.get_fingerprint_parts(request)
240
+ request.session['_ss_fingerprint'] = hashlib.sha256(
241
+ '|'.join(parts).encode('utf-8')
242
+ ).hexdigest()
243
+ request.session['_ss_auth_time'] = now
244
+ request.session['_ss_last_active'] = now
245
+ request.session['_ss_claims_hash'] = self._hash_claims(request.user)
246
+ logger.debug(
247
+ "Session security initialized for user %s",
248
+ getattr(request.user, 'sub', '?'),
249
+ )
250
+
251
+ def _terminate(self, request, reason: str):
252
+ # Get login URL BEFORE flushing (user info may be needed)
253
+ custom = self.on_violation(request, reason)
254
+ login_url = self.get_login_url(request)
255
+
256
+ request.session.flush()
257
+
258
+ if custom is not None:
259
+ return custom
260
+ return redirect(login_url)
261
+
262
+ def _is_exempt(self, request) -> bool:
263
+ return any(request.path.startswith(p) for p in self.exempt_paths)
session_armor/utils.py ADDED
@@ -0,0 +1,93 @@
1
+ """
2
+ Shared utilities for SessionArmor middleware.
3
+ """
4
+
5
+ import hashlib
6
+ import re
7
+
8
+ from django.conf import settings
9
+
10
+ # Characters that can corrupt structured log parsing (newlines, ANSI escapes,
11
+ # null bytes, control characters).
12
+ _LOG_UNSAFE_RE = re.compile(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\x1b\[\]\r\n]')
13
+
14
+
15
+ def hash_value(value: str, length: int = 0) -> str:
16
+ """SHA-256 hash of a string. Optionally truncate to ``length`` chars."""
17
+ h = hashlib.sha256(value.encode('utf-8')).hexdigest()
18
+ return h[:length] if length else h
19
+
20
+
21
+ def get_client_ip(request) -> str:
22
+ """
23
+ Extract the originating client IP with proxy-depth awareness.
24
+
25
+ When ``SESSION_ARMOR_TRUSTED_PROXY_DEPTH`` is set in Django settings,
26
+ only that many rightmost ``X-Forwarded-For`` entries are trusted.
27
+ The client IP is the entry just before the trusted proxies.
28
+
29
+ If the header has fewer entries than the proxy depth (i.e. the request
30
+ didn't traverse enough proxies), we fall back to ``REMOTE_ADDR`` to
31
+ avoid trusting a spoofed header.
32
+
33
+ Examples with ``SESSION_ARMOR_TRUSTED_PROXY_DEPTH = 1``::
34
+
35
+ X-Forwarded-For: spoofed, real, proxy1
36
+ → returns "real"
37
+
38
+ X-Forwarded-For: real, proxy1
39
+ → returns "real"
40
+
41
+ X-Forwarded-For: spoofed_only
42
+ → returns REMOTE_ADDR (not enough hops)
43
+ """
44
+ depth = getattr(settings, 'SESSION_ARMOR_TRUSTED_PROXY_DEPTH', 0)
45
+ forwarded = request.META.get('HTTP_X_FORWARDED_FOR', '')
46
+
47
+ if not forwarded:
48
+ return request.META.get('REMOTE_ADDR', '')
49
+
50
+ parts = [p.strip() for p in forwarded.split(',')]
51
+
52
+ if depth and depth > 0:
53
+ # The client is at index -(depth + 1) from the right.
54
+ # If the header is shorter than depth + 1, it's spoofed — use REMOTE_ADDR.
55
+ client_index = len(parts) - depth - 1
56
+ if client_index < 0:
57
+ return request.META.get('REMOTE_ADDR', '')
58
+ return parts[client_index]
59
+
60
+ # No proxy depth configured — legacy behavior (leftmost, less safe).
61
+ return parts[0]
62
+
63
+
64
+ def get_client_fingerprint(request, parts: list[str] | None = None) -> str:
65
+ """
66
+ Build a privacy-preserving fingerprint from client connection properties.
67
+
68
+ By default hashes IP + User-Agent. Pass *parts* to override.
69
+ """
70
+ if parts is None:
71
+ parts = [
72
+ get_client_ip(request),
73
+ request.META.get('HTTP_USER_AGENT', ''),
74
+ ]
75
+ raw = '|'.join(parts)
76
+ return hash_value(raw)
77
+
78
+
79
+ def is_authenticated(request) -> bool:
80
+ """Check whether the request has an authenticated user."""
81
+ user = getattr(request, 'user', None)
82
+ return bool(user and getattr(user, 'is_authenticated', False))
83
+
84
+
85
+ def sanitize_for_log(value: str, max_length: int = 200) -> str:
86
+ """
87
+ Sanitize a user-controlled string for safe inclusion in log entries.
88
+
89
+ Strips control characters, ANSI escape sequences, null bytes, and
90
+ truncates to ``max_length`` to prevent log injection / log flooding.
91
+ """
92
+ clean = _LOG_UNSAFE_RE.sub('', value)
93
+ return clean[:max_length]
@@ -0,0 +1,174 @@
1
+ Metadata-Version: 2.4
2
+ Name: SessionArmor
3
+ Version: 0.1.0
4
+ Summary: Defense-grade Django middleware: session security, audit logging, and configurable request gates with hooks.
5
+ Project-URL: Homepage, https://github.com/Tunet-xyz/session_armor
6
+ Project-URL: Documentation, https://github.com/Tunet-xyz/session_armor#readme
7
+ Project-URL: Repository, https://github.com/Tunet-xyz/session_armor
8
+ Project-URL: Issues, https://github.com/Tunet-xyz/session_armor/issues
9
+ Project-URL: Changelog, https://github.com/Tunet-xyz/session_armor/blob/main/CHANGELOG.md
10
+ Author-email: Alex C <alex@coded.uk>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: audit,django,gates,middleware,nist,owasp,security,session
14
+ Classifier: Framework :: Django
15
+ Classifier: Framework :: Django :: 4.2
16
+ Classifier: Framework :: Django :: 5.0
17
+ Classifier: Intended Audience :: Developers
18
+ Classifier: License :: OSI Approved :: MIT License
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Security
25
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
+ Requires-Python: >=3.10
27
+ Requires-Dist: django>=4.2
28
+ Provides-Extra: dev
29
+ Requires-Dist: django-stubs>=4.2.0; extra == 'dev'
30
+ Requires-Dist: mypy>=1.0.0; extra == 'dev'
31
+ Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
32
+ Requires-Dist: pytest-django>=4.5; extra == 'dev'
33
+ Requires-Dist: pytest>=8.0; extra == 'dev'
34
+ Requires-Dist: ruff>=0.4; extra == 'dev'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # SessionArmor
38
+
39
+ [![Tests](https://github.com/Tunet-xyz/session_armor/actions/workflows/tests.yaml/badge.svg)](https://github.com/Tunet-xyz/session_armor/actions/workflows/tests.yaml)
40
+ [![PyPI version](https://badge.fury.io/py/SessionArmor.svg)](https://pypi.org/project/SessionArmor/)
41
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
42
+ [![Django 4.2+](https://img.shields.io/badge/django-4.2+-green.svg)](https://www.djangoproject.com/)
43
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
44
+
45
+ **Defense-grade Django middleware: session security, audit logging, and configurable request gates with hooks.**
46
+
47
+ Part of the [Tunet](https://github.com/Tunet-xyz) ecosystem — alongside [SwapLayer](https://github.com/Tunet-xyz/swap_layer) and [InfraGlyph](https://github.com/Tunet-xyz/infra_glyph).
48
+
49
+ ---
50
+
51
+ ## What It Does
52
+
53
+ SessionArmor provides three drop-in Django middleware classes that harden your application's session layer:
54
+
55
+ | Middleware | Purpose |
56
+ |-----------|---------|
57
+ | `SessionSecurityMiddleware` | Fingerprint binding, absolute/idle timeouts, claim drift detection |
58
+ | `AuditMiddleware` | Privacy-preserving security audit trail for every authenticated request |
59
+ | `GateMiddleware` | Base class for building cacheable conditional gates (compliance, onboarding, feature flags) |
60
+
61
+ All three are **hookable** — override methods to customize behavior without touching internals.
62
+
63
+ ## References
64
+
65
+ - [NIST SP 800-53 AC-12](https://csrc.nist.gov/publications/detail/sp/800-53/rev-5/final) (Session Termination)
66
+ - [NIST SP 800-53 SC-23](https://csrc.nist.gov/publications/detail/sp/800-53/rev-5/final) (Session Authenticity)
67
+ - [NIST SP 800-53 AU-3/AU-12](https://csrc.nist.gov/publications/detail/sp/800-53/rev-5/final) (Audit Content/Generation)
68
+ - [OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)
69
+
70
+ ---
71
+
72
+ ## Installation
73
+
74
+ ```bash
75
+ pip install SessionArmor
76
+ ```
77
+
78
+ ## Quick Start
79
+
80
+ ```python
81
+ # settings.py
82
+ MIDDLEWARE = [
83
+ 'django.middleware.security.SecurityMiddleware',
84
+ 'django.contrib.sessions.middleware.SessionMiddleware',
85
+ # ... your auth middleware ...
86
+ 'session_armor.session.SessionSecurityMiddleware',
87
+ 'session_armor.audit.AuditMiddleware',
88
+ # ... rest of your middleware ...
89
+ ]
90
+
91
+ # Optional settings (shown with defaults)
92
+ SESSION_ABSOLUTE_TIMEOUT = 28800 # 8 hours
93
+ SESSION_IDLE_TIMEOUT = 1800 # 30 minutes
94
+ SESSION_BIND_IP = True
95
+ SESSION_BIND_USER_AGENT = True
96
+ SESSION_DETECT_CLAIM_DRIFT = True
97
+ SESSION_ARMOR_TRUSTED_PROXY_DEPTH = 1 # Number of trusted reverse proxies
98
+ ```
99
+
100
+ ## Building a Gate
101
+
102
+ ```python
103
+ from session_armor import GateMiddleware
104
+ from django.shortcuts import redirect
105
+
106
+ class ComplianceGate(GateMiddleware):
107
+ gate_id = 'compliance'
108
+ cache_ttl_setting = 'COMPLIANCE_CACHE_TTL'
109
+ default_cache_ttl = 3600
110
+ fail_open = False # Block access if check fails
111
+
112
+ def check(self, request) -> bool:
113
+ return user_accepted_current_terms(request)
114
+
115
+ def on_reject(self, request):
116
+ return redirect('/accept-terms/')
117
+ ```
118
+
119
+ ## Customizing Session Security
120
+
121
+ ```python
122
+ from session_armor import SessionSecurityMiddleware
123
+
124
+ class MySessionSecurity(SessionSecurityMiddleware):
125
+ exempt_paths = ('/health/', '/static/')
126
+
127
+ def get_critical_claims(self, user):
128
+ # Auth0 / OIDC claims that must not change mid-session
129
+ return [user.sub, user.organization_uuid, user.platform]
130
+
131
+ def get_login_url(self, request):
132
+ platform = getattr(request.user, 'platform', '')
133
+ return f'/{platform}/login/' if platform else '/login/'
134
+ ```
135
+
136
+ ## Customizing Audit Logging
137
+
138
+ ```python
139
+ from session_armor import AuditMiddleware
140
+
141
+ class MyAudit(AuditMiddleware):
142
+ exempt_paths = ('/health/', '/static/', '/favicon.ico')
143
+
144
+ def get_user_identity(self, request):
145
+ return {
146
+ 'uid': request.user.sub,
147
+ 'platform': request.user.platform,
148
+ 'org': request.user.organization_uuid,
149
+ }
150
+ ```
151
+
152
+ ---
153
+
154
+ ## Development
155
+
156
+ ```bash
157
+ # Clone and install
158
+ git clone https://github.com/Tunet-xyz/session_armor.git
159
+ cd session_armor
160
+ pip install -e ".[dev]"
161
+
162
+ # Run tests
163
+ pytest
164
+
165
+ # Lint
166
+ ruff check src/session_armor tests
167
+
168
+ # Type check
169
+ mypy src/session_armor
170
+ ```
171
+
172
+ ## License
173
+
174
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,10 @@
1
+ session_armor/__init__.py,sha256=7TpN2B6RmBAtkDFKFQs3q9AMq0tiywVYzBwf2mcus1w,768
2
+ session_armor/audit.py,sha256=ZFG0O5Vlkzy7j4bP95qU9dmuLo7j-1HN5Fw1enZ9OgM,5594
3
+ session_armor/gate.py,sha256=vWFj9pyQcdBNCnrFtgayL2R5_OBHzsHRVe8rz4yIH7Q,7677
4
+ session_armor/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ session_armor/session.py,sha256=n0kXie7RTiXhRstR9ncUo_j5BJ3xnEzIsFf4TbYHN2k,9590
6
+ session_armor/utils.py,sha256=7tRlsX-5M7bzj-7QqF6NbNvx4DHzUL2ShgqNeV82akA,3050
7
+ sessionarmor-0.1.0.dist-info/METADATA,sha256=BIMpHqRuNyHY3Aum2uam-CEIh7rlaB8UlTX0Xz9Z8GQ,6024
8
+ sessionarmor-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ sessionarmor-0.1.0.dist-info/licenses/LICENSE,sha256=Y0CO8lj8gytK0m9xyNBIteUiW0n9A0k5tBaVXaB2beE,1068
10
+ sessionarmor-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 Alex C
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.