Spaces:
Sleeping
Sleeping
| """ | |
| security.py β Qualora Security & Compliance | |
| ============================================ | |
| Consolidated: auth.py + limits.py + alerts.py | |
| Strictly hardened for Vercel (Serverless) & Enterprise MVC standards. | |
| Features: | |
| - XSS-immune HTTP-Only JWTs + CSRF protection. | |
| - Sliding-window rate limiting with OOM-safe garbage collection. | |
| - Context-aware Webhook dispatching (prevents lambda thread-freezing). | |
| """ | |
| import os | |
| import hmac | |
| import secrets | |
| import time | |
| import random | |
| import logging | |
| import threading | |
| from functools import wraps | |
| from datetime import datetime, timedelta, timezone | |
| from typing import Optional, Dict, Any | |
| from collections import deque | |
| import requests | |
| import jwt | |
| from flask import request, jsonify | |
| from werkzeug.security import generate_password_hash, check_password_hash | |
| from bson import ObjectId | |
| from bson.errors import InvalidId | |
| # Centralized core imports | |
| from core import ( | |
| JWT_SECRET, JWT_EXPIRATION_SECONDS, DEBUG, IS_PRODUCTION, | |
| AUTH_RATE_LIMIT_PER_MIN, AUDIT_RATE_LIMIT_PER_MIN, | |
| ABUSE_BLOCK_THRESHOLD, ABUSE_BLOCK_DURATION_SEC, | |
| get_db, infer_f1_score | |
| ) | |
| logger = logging.getLogger(__name__) | |
| # Keyword-based mapping for compliance flag severity classification. | |
| # Edit these lists to tune which LLM `compliance_flags` should escalate. | |
| _COMPLIANCE_FLAG_SEVERITY_KEYWORDS = { | |
| "critical": [ | |
| "legal", "privacy", "confidential", "pci", "ssn", "gdpr", | |
| "breach", "data leak", "fraud", "criminal", "safety" | |
| ], | |
| "warning": [ | |
| "escalate", "escalation", "supervisor", "manager", "sensitive", | |
| "policy_violation", "policy", "sla" | |
| ], | |
| "info": [ | |
| "note", "format", "minor", "typo", "cosmetic" | |
| ] | |
| } | |
| # Allow runtime override of the keyword lists via environment variables | |
| # Use comma-separated lists, e.g. COMPLIANCE_FLAGS_CRITICAL="privacy,breach" | |
| def _load_compliance_flag_severity_keywords_from_env(): | |
| try: | |
| for sev, env_name in ( | |
| ("critical", "COMPLIANCE_FLAGS_CRITICAL"), | |
| ("warning", "COMPLIANCE_FLAGS_WARNING"), | |
| ("info", "COMPLIANCE_FLAGS_INFO"), | |
| ): | |
| raw = os.environ.get(env_name, "").strip() | |
| if not raw: | |
| continue | |
| parts = [p.strip() for p in raw.split(",") if p.strip()] | |
| if parts: | |
| _COMPLIANCE_FLAG_SEVERITY_KEYWORDS[sev] = parts | |
| logger.debug("Loaded compliance flag severity overrides from env (if any).") | |
| except Exception as e: | |
| logger.warning("Failed to load compliance flag overrides from env: %s", e) | |
| # Load overrides at import time so the runtime behavior is adjustable via env vars | |
| _load_compliance_flag_severity_keywords_from_env() | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # AUTHENTICATION & AUTHORIZATION | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def hash_password(password: str) -> str: | |
| """Hash a plaintext password using Werkzeug's secure hashing (Vercel-compatible).""" | |
| return generate_password_hash(password) | |
| def verify_password(hashed: str, password: str) -> bool: | |
| """Verify a plaintext password against a Werkzeug hash.""" | |
| return check_password_hash(hashed, password) | |
| def create_token(user_id: str, org_id: str, role: str, email: str) -> str: | |
| """Create a signed JWT token with user identity, org isolation, and role.""" | |
| payload = { | |
| "user_id": str(user_id), | |
| "org_id": str(org_id), | |
| "role": role, | |
| "email": email, | |
| "iat": datetime.now(timezone.utc), | |
| "exp": datetime.now(timezone.utc) + timedelta(seconds=JWT_EXPIRATION_SECONDS), | |
| "iss": "qualora_enterprise" | |
| } | |
| return jwt.encode(payload, JWT_SECRET, algorithm="HS256") | |
| def decode_token(token: str) -> Optional[Dict[str, Any]]: | |
| """Decode and verify a JWT token, enforcing issuer.""" | |
| if not token or not isinstance(token, str): | |
| return None | |
| try: | |
| return jwt.decode(token, JWT_SECRET, algorithms=["HS256"], issuer="qualora_enterprise") | |
| except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, Exception): | |
| return None | |
| def generate_csrf_token() -> str: | |
| return secrets.token_urlsafe(32) | |
| def set_auth_cookies(response, token: str, csrf_token: str): | |
| """Set secure cookies on the response (access_token is HTTP-Only for XSS immunity).""" | |
| response.set_cookie( | |
| "access_token", | |
| token, | |
| httponly=True, | |
| secure=IS_PRODUCTION, | |
| samesite="Strict", | |
| max_age=JWT_EXPIRATION_SECONDS, | |
| path="/", | |
| ) | |
| # Mirror cookie: NOT Http-Only. Used by auth-init.js to sync state & prevent redirect loops. | |
| response.set_cookie( | |
| "qualora_logged_in", | |
| "true", | |
| httponly=False, | |
| secure=IS_PRODUCTION, | |
| samesite="Strict", | |
| max_age=JWT_EXPIRATION_SECONDS, | |
| path="/", | |
| ) | |
| response.set_cookie( | |
| "csrf_token", | |
| csrf_token, | |
| httponly=False, # JS must read this to construct headers | |
| secure=IS_PRODUCTION, | |
| samesite="Strict", | |
| max_age=JWT_EXPIRATION_SECONDS, | |
| path="/", | |
| ) | |
| def clear_auth_cookies(response): | |
| response.delete_cookie( | |
| "access_token", | |
| path="/", | |
| httponly=True, | |
| secure=IS_PRODUCTION, | |
| samesite="Strict", | |
| ) | |
| response.delete_cookie( | |
| "qualora_logged_in", | |
| path="/", | |
| httponly=False, | |
| secure=IS_PRODUCTION, | |
| samesite="Strict", | |
| ) | |
| response.delete_cookie( | |
| "csrf_token", | |
| path="/", | |
| httponly=False, | |
| secure=IS_PRODUCTION, | |
| samesite="Strict", | |
| ) | |
| def csrf_protect(f): | |
| """Validate CSRF token on state-changing requests using constant-time comparison.""" | |
| def wrapper(*args, **kwargs): | |
| if request.method in ['GET', 'HEAD', 'OPTIONS']: | |
| return f(*args, **kwargs) | |
| csrf_header = request.headers.get('X-CSRF-Token', '').strip() | |
| csrf_cookie = request.cookies.get('csrf_token', '').strip() | |
| if not csrf_header or not csrf_cookie: | |
| return jsonify({'error': 'CSRF token missing'}), 403 | |
| if not hmac.compare_digest(csrf_header, csrf_cookie): | |
| return jsonify({'error': 'CSRF token mismatch'}), 403 | |
| return f(*args, **kwargs) | |
| return wrapper | |
| def _safe_user_doc(doc: Dict[str, Any]) -> Dict[str, Any]: | |
| """Remove sensitive fields from user document before sending to client.""" | |
| doc.pop("password", None) | |
| doc.pop("reset_token", None) | |
| doc.pop("reset_token_expires", None) | |
| for key in ("_id", "org_id", "user_id"): | |
| if key in doc and doc[key] is not None: | |
| doc[key] = str(doc[key]) | |
| return doc | |
| def safe_oid(oid: Any) -> Optional[ObjectId]: | |
| if isinstance(oid, ObjectId): | |
| return oid | |
| try: | |
| return ObjectId(str(oid)) | |
| except (InvalidId, TypeError, ValueError): | |
| return None | |
| def require_auth(f): | |
| """Require valid JWT token in the access_token cookie (Bearer fallback strictly removed).""" | |
| def wrapper(*args, **kwargs): | |
| token = request.cookies.get("access_token") | |
| if not token: | |
| return jsonify({"error": "Unauthorized: missing HTTP-Only token"}), 401 | |
| payload = decode_token(token) | |
| if payload is None: | |
| return jsonify({"error": "Unauthorized: invalid or expired token"}), 401 | |
| request.user = payload | |
| return f(*args, **kwargs) | |
| return wrapper | |
| def optional_auth(f): | |
| """Attempt JWT auth via cookie, but allow guest passthrough if missing.""" | |
| def wrapper(*args, **kwargs): | |
| request.user = None | |
| token = request.cookies.get("access_token") | |
| if token: | |
| payload = decode_token(token) | |
| if payload is not None: | |
| request.user = payload | |
| return f(*args, **kwargs) | |
| return wrapper | |
| def require_role(allowed_roles: list): | |
| """Require user to have one of the allowed roles (RBAC).""" | |
| def decorator(f): | |
| def wrapper(*args, **kwargs): | |
| if not hasattr(request, 'user') or request.user is None: | |
| return jsonify({"error": "Unauthorized"}), 401 | |
| if request.user.get('role') not in allowed_roles: | |
| return jsonify({"error": f"Forbidden: requires one of {allowed_roles}"}), 403 | |
| return f(*args, **kwargs) | |
| return wrapper | |
| return decorator | |
| def ensure_org_isolation(org_id_from_request: str) -> Optional[ObjectId]: | |
| """Verify requested org_id matches the authenticated user's org_id (IDOR prevention).""" | |
| if not hasattr(request, 'user') or not request.user: | |
| return None | |
| req_org_id = str(org_id_from_request) | |
| if str(request.user.get('org_id')) != req_org_id: | |
| return None | |
| return safe_oid(req_org_id) | |
| AUTH_RATE_LIMIT = AUTH_RATE_LIMIT_PER_MIN | |
| AUDIT_RATE_LIMIT = AUDIT_RATE_LIMIT_PER_MIN | |
| ABUSE_THRESHOLD = ABUSE_BLOCK_THRESHOLD | |
| ABUSE_BLOCK_SECS = ABUSE_BLOCK_DURATION_SEC | |
| WINDOW_SECONDS = 60 | |
| # Thread-safe Standard Dicts (Not defaultdicts) to allow GC and prevent OOM leaks | |
| _lock = threading.RLock() | |
| _ip_windows: dict = {} | |
| _org_windows: dict = {} | |
| _failed_auth: dict = {} | |
| _blocked_ips: dict = {} | |
| _stats_lock = threading.Lock() | |
| _rate_limit_hits = {"auth": 0, "audit": 0} | |
| _abuse_blocks = 0 | |
| def _now() -> float: | |
| return time.monotonic() | |
| def _lazy_garbage_collect(): | |
| """Probabilistic memory sweep to purge empty dictionary keys and prevent OOM.""" | |
| if random.random() > 0.05: | |
| return | |
| with _lock: | |
| now = _now() | |
| window_cutoff = now - WINDOW_SECONDS | |
| abuse_cutoff = now - ABUSE_BLOCK_SECS | |
| stale_ips = [ip for ip, dq in _ip_windows.items() if not dq or dq[-1] < window_cutoff] | |
| for ip in stale_ips: _ip_windows.pop(ip, None) | |
| stale_orgs = [org for org, dq in _org_windows.items() if not dq or dq[-1] < window_cutoff] | |
| for org in stale_orgs: _org_windows.pop(org, None) | |
| stale_fails = [ip for ip, fails in _failed_auth.items() if not fails or fails[-1] < abuse_cutoff] | |
| for ip in stale_fails: _failed_auth.pop(ip, None) | |
| stale_blocks = [ip for ip, unblock_at in _blocked_ips.items() if unblock_at < now] | |
| for ip in stale_blocks: _blocked_ips.pop(ip, None) | |
| def _purge_window(dq: deque, cutoff: float): | |
| while dq and dq[0] < cutoff: | |
| dq.popleft() | |
| def get_remote_ip() -> str: | |
| """Extract real IP securely, preventing X-Forwarded-For spoofing.""" | |
| real_ip = request.headers.get("x-real-ip") | |
| if real_ip: | |
| return real_ip.strip() | |
| fwd = request.headers.get("X-Forwarded-For", "") | |
| if fwd: | |
| return fwd.split(",")[-1].strip() | |
| return request.remote_addr or "unknown" | |
| def is_ip_blocked(ip: str) -> bool: | |
| with _lock: | |
| unblock_at = _blocked_ips.get(ip) | |
| if unblock_at is None: | |
| return False | |
| if _now() >= unblock_at: | |
| _blocked_ips.pop(ip, None) | |
| return False | |
| return True | |
| def record_failed_auth(ip: str): | |
| global _abuse_blocks | |
| with _lock: | |
| cutoff = _now() - ABUSE_BLOCK_SECS | |
| fails = _failed_auth.get(ip, []) | |
| fails = [t for t in fails if t > cutoff] | |
| fails.append(_now()) | |
| _failed_auth[ip] = fails | |
| if len(_failed_auth[ip]) >= ABUSE_THRESHOLD: | |
| _blocked_ips[ip] = _now() + ABUSE_BLOCK_SECS | |
| logger.warning("[RateLimit] IP %s blocked for %ds (too many failed auths)", ip, ABUSE_BLOCK_SECS) | |
| with _stats_lock: | |
| _abuse_blocks += 1 | |
| def record_success_auth(ip: str): | |
| with _lock: | |
| _failed_auth.pop(ip, None) | |
| def check_ip_rate(ip: str, limit: int = AUTH_RATE_LIMIT) -> bool: | |
| with _lock: | |
| dq = _ip_windows.setdefault(ip, deque()) | |
| cutoff = _now() - WINDOW_SECONDS | |
| _purge_window(dq, cutoff) | |
| if len(dq) >= limit: | |
| return False | |
| dq.append(_now()) | |
| return True | |
| def check_org_rate(org_id: str, limit: int = AUDIT_RATE_LIMIT) -> bool: | |
| if not org_id: | |
| return True | |
| with _lock: | |
| dq = _org_windows.setdefault(org_id, deque()) | |
| cutoff = _now() - WINDOW_SECONDS | |
| _purge_window(dq, cutoff) | |
| if len(dq) >= limit: | |
| return False | |
| dq.append(_now()) | |
| return True | |
| def auth_rate_limit(f): | |
| def wrapper(*args, **kwargs): | |
| _lazy_garbage_collect() | |
| ip = get_remote_ip() | |
| if is_ip_blocked(ip): | |
| logger.warning("[RateLimit] Blocked IP %s attempted access", ip) | |
| with _stats_lock: | |
| _rate_limit_hits["auth"] += 1 | |
| return jsonify({"error": "Too many failed attempts β try again later"}), 429 | |
| if not check_ip_rate(ip, AUTH_RATE_LIMIT): | |
| logger.warning("[RateLimit] IP %s exceeded auth rate limit", ip) | |
| with _stats_lock: | |
| _rate_limit_hits["auth"] += 1 | |
| return jsonify({"error": "Rate limit exceeded β slow down"}), 429 | |
| return f(*args, **kwargs) | |
| return wrapper | |
| def audit_rate_limit(f): | |
| def wrapper(*args, **kwargs): | |
| _lazy_garbage_collect() | |
| org_id = "" | |
| user = getattr(request, "user", None) | |
| if user: | |
| org_id = user.get("org_id", "") | |
| if not org_id: | |
| org_id = request.args.get("org_id", "") or (request.json or {}).get("org_id", "") | |
| if not check_org_rate(org_id, AUDIT_RATE_LIMIT): | |
| logger.warning("[RateLimit] Org %s exceeded audit rate limit", org_id) | |
| with _stats_lock: | |
| _rate_limit_hits["audit"] += 1 | |
| return jsonify({"error": "Audit rate limit exceeded for your organisation"}), 429 | |
| return f(*args, **kwargs) | |
| return wrapper | |
| def get_rate_limit_status() -> dict: | |
| with _lock: | |
| with _stats_lock: | |
| return { | |
| "auth_limits_tracked": len(_ip_windows), | |
| "audit_limits_tracked": len(_org_windows), | |
| "blocked_ips": list(_blocked_ips.keys()), | |
| "total_auth_limit_hits": _rate_limit_hits["auth"], | |
| "total_audit_limit_hits": _rate_limit_hits["audit"], | |
| "total_abuse_blocks": _abuse_blocks, | |
| } | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # COMPLIANCE ALERTS | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _safe_to_oid(id_val: any) -> ObjectId | str: | |
| if isinstance(id_val, ObjectId): | |
| return id_val | |
| try: | |
| return ObjectId(str(id_val)) | |
| except (InvalidId, TypeError, ValueError): | |
| return str(id_val) | |
| def evaluate_and_save_alerts(audit_result: dict, audit_id: str, org_id: str) -> list: | |
| """Evaluate audit results and dynamically dispatch webhooks without blocking Vercel.""" | |
| triggered = [] | |
| # ββ Rule 1: Compliance Risk | |
| if audit_result.get("compliance_risk") == "Red": | |
| triggered.append({ | |
| "alert_type": "compliance_breach", "severity": "critical", | |
| "message": "Critical compliance breach detected.", | |
| "triggered_by": ["compliance_risk=Red"] | |
| }) | |
| # ββ Rule 2: Low Agent Performance (using mathematical inferencer) | |
| f1 = infer_f1_score(audit_result) | |
| if f1 < 0.6: | |
| triggered.append({ | |
| "alert_type": "low_performance", "severity": "warning", | |
| "message": f"Agent F1 score is dangerously low: {f1:.2f}", | |
| "triggered_by": [f"f1_score={f1:.2f}"] | |
| }) | |
| # ββ Rule 3: Systematic Failures | |
| evals = audit_result.get("success_evaluation", {}) | |
| failures = [k for k, v in evals.items() if isinstance(v, dict) and v.get("pass") is False] | |
| if len(failures) >= 3: | |
| triggered.append({ | |
| "alert_type": "systematic_failure", "severity": "critical", | |
| "message": f"Systematic failure across {len(failures)} criteria.", | |
| "triggered_by": failures | |
| }) | |
| # ββ Rule 4: Explicit compliance flags from the LLM output | |
| # The LLM may return fine-grained `compliance_flags` describing specific | |
| # policy violations (e.g. "privacy breach", "escalation required"). Treat | |
| # those as alertable items β map certain keywords to critical severity. | |
| try: | |
| flags = audit_result.get("compliance_flags", []) or [] | |
| # Normalize to a list of strings | |
| if isinstance(flags, str): | |
| flags = [flags] | |
| elif isinstance(flags, dict): | |
| # dictionary form -> take truthy keys | |
| flags = [k for k, v in flags.items() if v] | |
| if flags: | |
| logger.info("Compliance flags present: %s", flags) | |
| seen = set() | |
| for flag in flags: | |
| if not flag: | |
| continue | |
| fstr = str(flag).strip() | |
| if not fstr or fstr in seen: | |
| continue | |
| seen.add(fstr) | |
| raw = fstr.lower() | |
| severity = "warning" | |
| # Escalate to critical for high-risk keywords | |
| if any(k in raw for k in ("legal", "privacy", "confidential", "pci", "ssn", "gdpr", "breach", "criminal", "fraud", "data leak", "safety")): | |
| severity = "critical" | |
| triggered.append({ | |
| "alert_type": "compliance_flag", | |
| "severity": severity, | |
| "message": f"Compliance flag detected: {fstr}", | |
| "triggered_by": [f"compliance_flag:{fstr}"] | |
| }) | |
| except Exception as _err: | |
| logger.warning("Failed to evaluate compliance_flags for alerts: %s", _err) | |
| if not triggered: | |
| return [] | |
| # ββ Persist to DB | |
| db = get_db() | |
| if db is not None: | |
| try: | |
| now = datetime.now(timezone.utc) | |
| alert_ids = [] | |
| for alert in triggered: | |
| doc = {"org_id": _safe_to_oid(org_id), "audit_id": _safe_to_oid(audit_id), "acknowledged": False, "created_at": now, **alert} | |
| res = db.alerts.insert_one(doc) | |
| alert_ids.append(res.inserted_id) | |
| db.audits.update_one({"_id": _safe_to_oid(audit_id), "org_id": _safe_to_oid(org_id)}, {"$set": {"alerts_triggered": alert_ids}}) | |
| logger.info(f"Saved {len(alert_ids)} alerts for audit {audit_id}") | |
| except Exception as e: | |
| logger.error("Failed to save alerts to database: %s", str(e)) | |
| # ββ Context-aware Dispatch | |
| webhook_url = os.environ.get("WEBHOOK_URL", "").strip() | |
| slack_url = os.environ.get("SLACK_URL", "").strip() | |
| discord_url = os.environ.get("DISCORD_URL", "").strip() | |
| active_urls = [url for url in [webhook_url, slack_url, discord_url] if url] | |
| if active_urls: | |
| if os.environ.get("VERCEL_ENV"): | |
| # SERVERLESS: Must wait for IO before lambda freezes | |
| from concurrent.futures import ThreadPoolExecutor, wait | |
| with ThreadPoolExecutor(max_workers=len(active_urls)) as executor: | |
| futures = [executor.submit(_fire_webhook, url, triggered, audit_id, org_id) for url in active_urls] | |
| wait(futures, timeout=6.0) | |
| else: | |
| # SERVER: Fire-and-forget | |
| for url in active_urls: | |
| threading.Thread(target=_fire_webhook, args=(url, triggered, audit_id, org_id), daemon=True).start() | |
| return triggered | |
| def _fire_webhook(url: str, alerts: list, audit_id: str, org_id: str) -> None: | |
| """Format and transmit payloads to external webhooks securely.""" | |
| try: | |
| is_slack = "hooks.slack.com" in url | |
| is_discord = "discord.com/api/webhooks" in url | |
| payload = { | |
| "source": "qualora", | |
| "org_id": str(org_id), | |
| "audit_id": str(audit_id), | |
| "alerts": alerts, | |
| "timestamp": datetime.now(timezone.utc).isoformat() | |
| } | |
| if is_slack or is_discord: | |
| txt = f"π¨ *Qualora Alert Triggered*\nOrg: `{org_id}` | Audit: `{audit_id}`\n" | |
| for alert in alerts: | |
| emoji = "π΄" if alert['severity'] == "critical" else "π " | |
| txt += f"{emoji} **{alert['alert_type']}** ({alert['severity']}): {alert['message']}\n" | |
| payload = {"text": txt} | |
| if is_discord: | |
| payload["content"] = txt | |
| payload.pop("text", None) | |
| response = requests.post(url, json=payload, timeout=5) | |
| if response.status_code not in (200, 204): | |
| logger.warning(f"Webhook {url} returned {response.status_code}: {response.text[:100]}") | |
| except requests.exceptions.Timeout: | |
| logger.warning(f"Webhook {url} timed out.") | |
| except Exception as e: | |
| logger.error(f"Failed to fire webhook {url}: {e}") |