qualora / services /security.py
Qualora QA Bot
final 4/4
9121aae
Raw
History Blame Contribute Delete
21.7 kB
"""
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."""
@wraps(f)
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)."""
@wraps(f)
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."""
@wraps(f)
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):
@wraps(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):
@wraps(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):
@wraps(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}")