Some checks failed
CI / build (push) Failing after 3s
PHASE 2.3 (AUDIT-2026-Q3.md):
Task 1 — Wire-in Wave 3 (1 router mounted, 2 deferred):
- app.routers.unified_scanner_router mounted at /api/v2/scanner/* (2 routes:
POST /api/v2/scanner/token/scan, POST /api/v2/scanner/wallet/scan).
Refactored prefix from /api/v2 -> /api/v2/scanner to avoid future conflicts
with the v1 /api/v1/scanner/ stub.
- app.routers.unified_wallet_scanner DEFERRED (no router APIRouter attribute;
library module consumed by unified_scanner_router via get_wallet_scanner()).
- app.routers.admin_extensions DEFERRED (DORMANT per audit; 25 routes at
/api/v1/admin/* would shadow /api/v1/admin/alerts_webhook).
Task 2 — Archive 136 dead-code files to app/_archive/legacy_2026_07/:
- 73 routers in app/routers/ (reach graph showed zero reach into mount.py).
- 63 flat app/*.py (domain modules never imported by live code).
- 1 file RESTORED post-archive: app/routers/x402_bridge_health.py (caught by
tests/unit/test_bridge_health.py which directly imports it; reach graph
considered tests/ only as transitive reach — to be patched in next cycle).
Forced-LIVE (NOT archived per user directive):
- app/ai_pipeline_v3.py (3 importers in audit window, importers themselves DEAD)
- app/splade_bm25.py (LIVE via app.rag_service)
- app/wallet_manager_v2.py (LIVE via x402_enforcement, x402_tools, sweep_all, sweep_now)
- app/crypto_embeddings.py (NOT in audit ARCHIVE list; heavy import graph)
Verification (forward-import closure from mount.py + main.py + factory.py + lifespan.py):
- imports = 348 app.* modules
- reached = 194 files reachable from roots
- archive set = audit_dead (186) - reached - forced_live (4) - test_live (1) = 136
- Net delta: 136 files moved, 44,932 LOC reduction, 293->295 active routes (+2 from Wave 3)
pyproject.toml updates:
- setuptools.packages.find: added exclude for app._archive*
- ruff.extend-exclude: added "app/_archive/"
- mypy.exclude: added "app/_archive/"
Smoke test: pytest tests/ — 817 passed, 3 pre-existing failures unchanged
(0 new failures; 0 routes lost; all 4 forced-LIVE files still importable).
Restoration: git mv app/_archive/legacy_2026_07/<name>.py <original-path>
and add the import to app/mount.py ROUTER_MODULES.
Refs: AUDIT-2026-Q3.md /home/dev/pry/rmi-final-deadcode-2026-07-06.md
746 lines
24 KiB
Python
746 lines
24 KiB
Python
"""
|
|
RMI Security Defense System - Advanced Threat Protection
|
|
===========================================================
|
|
Enterprise-grade security layer for the RugMunch Intelligence Platform.
|
|
|
|
Features:
|
|
• Bot Detection - behavioral analysis, fingerprinting, challenge-response
|
|
• Anomaly Detection - statistical anomaly detection on requests
|
|
• Rate Limiting - adaptive rate limits per user/IP/endpoint
|
|
• IP Reputation - threat intelligence integration, blocklists
|
|
• Request Fingerprinting - identify automated tools, scrapers
|
|
• Geo-blocking - country-based access control
|
|
• Honeypot Endpoints - trap bad actors
|
|
• DDoS Protection - request flood detection, circuit breaker
|
|
• Vulnerability Scanning - automated security scanning
|
|
• Compliance Logging - GDPR/SOC2 audit trails
|
|
|
|
Integrations:
|
|
- AbuseIPDB for IP reputation
|
|
- Cloudflare Turnstile for bot challenges
|
|
- Custom ML model for behavioral analysis
|
|
- Redis for real-time state tracking
|
|
|
|
Author: RMI Security Team
|
|
Date: 2026-05-31
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import secrets
|
|
import time
|
|
from dataclasses import asdict, dataclass, field
|
|
from datetime import UTC, datetime
|
|
from enum import StrEnum
|
|
from typing import Any, ClassVar
|
|
|
|
logger = logging.getLogger("rmi_security_defense")
|
|
|
|
|
|
# ── Enums ─────────────────────────────────────────────────────
|
|
|
|
|
|
class ThreatLevel(StrEnum):
|
|
LOW = "low" # Suspicious but not malicious
|
|
MEDIUM = "medium" # Likely automated, rate limit
|
|
HIGH = "high" # Confirmed bot/malicious, block
|
|
CRITICAL = "critical" # Active attack, immediate ban
|
|
|
|
|
|
class BotType(StrEnum):
|
|
UNKNOWN = "unknown"
|
|
SCRAPER = "scraper"
|
|
SPAMMER = "spammer"
|
|
BOTNET = "botnet"
|
|
CREDENTIAL_STUFFING = "credential_stuffing"
|
|
DDOS = "ddos"
|
|
EXPLOIT_SCANNER = "exploit_scanner"
|
|
AI_AGENT = "ai_agent" # Legitimate AI agent
|
|
HUMAN = "human" # Verified human
|
|
|
|
|
|
class SecurityAction(StrEnum):
|
|
ALLOW = "allow"
|
|
CHALLENGE = "challenge" # CAPTCHA/Turnstile
|
|
RATE_LIMIT = "rate_limit" # Slow down
|
|
BLOCK = "block" # Temporary block
|
|
BAN = "ban" # Permanent ban
|
|
HONEYPOT = "honeypot" # Feed fake data
|
|
|
|
|
|
# ── Data Models ─────────────────────────────────────────────
|
|
|
|
|
|
@dataclass
|
|
class RequestFingerprint:
|
|
"""Fingerprint of an incoming request for analysis."""
|
|
|
|
fingerprint_id: str
|
|
ip_address: str
|
|
user_agent: str
|
|
accept_language: str
|
|
accept_encoding: str
|
|
accept_header: str
|
|
dnt: str
|
|
connection: str
|
|
sec_ch_ua: str
|
|
sec_ch_ua_mobile: str
|
|
sec_ch_ua_platform: str
|
|
viewport_width: int = 0
|
|
viewport_height: int = 0
|
|
screen_width: int = 0
|
|
screen_height: int = 0
|
|
color_depth: int = 0
|
|
timezone: str = ""
|
|
canvas_hash: str = "" # Canvas fingerprinting hash
|
|
webgl_hash: str = "" # WebGL fingerprinting hash
|
|
fonts: list[str] = field(default_factory=list)
|
|
plugins: list[str] = field(default_factory=list)
|
|
timestamp: str = ""
|
|
|
|
def to_dict(self) -> dict:
|
|
return asdict(self)
|
|
|
|
def is_suspicious(self) -> bool:
|
|
"""Quick heuristic check for suspicious fingerprint."""
|
|
# No JS fingerprinting data = likely bot
|
|
if not self.canvas_hash and not self.webgl_hash:
|
|
return True
|
|
# Common bot user agents
|
|
bot_ua_patterns = [
|
|
"bot",
|
|
"crawler",
|
|
"spider",
|
|
"scraper",
|
|
"curl",
|
|
"wget",
|
|
"python-requests",
|
|
"httpie",
|
|
"postman",
|
|
"insomnia",
|
|
]
|
|
ua_lower = self.user_agent.lower()
|
|
if any(p in ua_lower for p in bot_ua_patterns):
|
|
return True
|
|
# Missing standard headers
|
|
return bool(not self.accept_language or not self.accept_header)
|
|
|
|
|
|
@dataclass
|
|
class ThreatAssessment:
|
|
"""Result of threat analysis on a request."""
|
|
|
|
assessment_id: str
|
|
ip_address: str
|
|
fingerprint_id: str
|
|
threat_level: str
|
|
bot_type: str
|
|
action: str
|
|
confidence: float = 0.0
|
|
reasons: list[str] = field(default_factory=list)
|
|
timestamp: str = ""
|
|
|
|
def to_dict(self) -> dict:
|
|
return asdict(self)
|
|
|
|
|
|
@dataclass
|
|
class SecurityEvent:
|
|
"""Security event for audit logging."""
|
|
|
|
event_id: str
|
|
timestamp: str
|
|
event_type: str
|
|
ip_address: str
|
|
user_agent: str
|
|
path: str
|
|
method: str
|
|
threat_level: str
|
|
action_taken: str
|
|
details: dict[str, Any] = field(default_factory=dict)
|
|
|
|
def to_dict(self) -> dict:
|
|
return asdict(self)
|
|
|
|
|
|
# ── Bot Detection Engine ────────────────────────────────────
|
|
|
|
|
|
class BotDetectionEngine:
|
|
"""
|
|
Multi-layer bot detection using behavioral analysis,
|
|
fingerprinting, and heuristics.
|
|
"""
|
|
|
|
# Known good bot patterns (allow these)
|
|
GOOD_BOTS: ClassVar[list] =[
|
|
"googlebot",
|
|
"bingbot",
|
|
"duckduckbot",
|
|
"slurp",
|
|
"baiduspider",
|
|
"yandexbot",
|
|
"facebookexternalhit",
|
|
"twitterbot",
|
|
"linkedinbot",
|
|
]
|
|
|
|
# Known bad patterns (immediate block)
|
|
BAD_PATTERNS: ClassVar[list] =[
|
|
"sqlmap",
|
|
"nikto",
|
|
"nmap",
|
|
"masscan",
|
|
"zgrab",
|
|
"gobuster",
|
|
"dirbuster",
|
|
"wfuzz",
|
|
"burp",
|
|
"metasploit",
|
|
"nessus",
|
|
"openvas",
|
|
]
|
|
|
|
@staticmethod
|
|
async def analyze_request(
|
|
ip: str,
|
|
user_agent: str,
|
|
path: str,
|
|
method: str,
|
|
headers: dict[str, str],
|
|
body_size: int = 0,
|
|
) -> ThreatAssessment:
|
|
"""Analyze a request for bot/malicious activity."""
|
|
reasons = []
|
|
confidence = 0.0
|
|
threat_level = ThreatLevel.LOW.value
|
|
bot_type = BotType.UNKNOWN.value
|
|
action = SecurityAction.ALLOW.value
|
|
|
|
ua_lower = user_agent.lower()
|
|
|
|
# Check for known good bots
|
|
if any(gb in ua_lower for gb in BotDetectionEngine.GOOD_BOTS):
|
|
return ThreatAssessment(
|
|
assessment_id=f"asm_{int(time.time())}_{secrets.token_hex(4)}",
|
|
ip_address=ip,
|
|
fingerprint_id="",
|
|
threat_level=ThreatLevel.LOW.value,
|
|
bot_type=BotType.HUMAN.value, # Treat as legitimate
|
|
action=SecurityAction.ALLOW.value,
|
|
confidence=0.9,
|
|
reasons=["Known good bot"],
|
|
timestamp=datetime.now(UTC).isoformat(),
|
|
)
|
|
|
|
# Check for known bad patterns
|
|
if any(bp in ua_lower for bp in BotDetectionEngine.BAD_PATTERNS):
|
|
reasons.append("Known attack tool signature")
|
|
confidence += 0.95
|
|
threat_level = ThreatLevel.CRITICAL.value
|
|
bot_type = BotType.EXPLOIT_SCANNER.value
|
|
action = SecurityAction.BAN.value
|
|
|
|
# Check for missing standard headers
|
|
if not headers.get("accept-language"):
|
|
reasons.append("Missing Accept-Language header")
|
|
confidence += 0.3
|
|
|
|
if not headers.get("accept"):
|
|
reasons.append("Missing Accept header")
|
|
confidence += 0.3
|
|
|
|
# Check for suspicious request patterns
|
|
if path.endswith((".env", ".git", ".sql", ".bak", ".zip", ".tar.gz")):
|
|
reasons.append("Suspicious file access pattern")
|
|
confidence += 0.4
|
|
threat_level = max(threat_level, ThreatLevel.HIGH.value)
|
|
bot_type = BotType.EXPLOIT_SCANNER.value
|
|
action = SecurityAction.BLOCK.value
|
|
|
|
# Check for common exploit paths
|
|
exploit_paths = [
|
|
"/wp-admin",
|
|
"/wp-login",
|
|
"/administrator",
|
|
"/phpmyadmin",
|
|
"/.git/",
|
|
"/.env",
|
|
"/config.php",
|
|
"/robots.txt",
|
|
"/xmlrpc.php",
|
|
"/api/v1/users",
|
|
"/api/admin",
|
|
"/debug",
|
|
"/console",
|
|
]
|
|
if any(path.startswith(ep) for ep in exploit_paths):
|
|
reasons.append("Access to sensitive endpoint")
|
|
confidence += 0.3
|
|
|
|
# Check body size anomalies
|
|
if body_size > 10 * 1024 * 1024: # 10MB
|
|
reasons.append("Unusually large request body")
|
|
confidence += 0.2
|
|
|
|
# Check request rate
|
|
rate_check = await BotDetectionEngine._check_request_rate(ip)
|
|
if rate_check["excessive"]:
|
|
reasons.append(f"Excessive request rate: {rate_check['rpm']} RPM")
|
|
confidence += min(rate_check["rpm"] / 1000, 0.5)
|
|
threat_level = max(threat_level, ThreatLevel.MEDIUM.value)
|
|
bot_type = BotType.DDOS.value if rate_check["rpm"] > 1000 else BotType.SCRAPER.value
|
|
action = SecurityAction.RATE_LIMIT.value
|
|
|
|
# Check IP reputation
|
|
reputation = await BotDetectionEngine._check_ip_reputation(ip)
|
|
if reputation["score"] > 50:
|
|
reasons.append(f"IP reputation score: {reputation['score']}")
|
|
confidence += reputation["score"] / 200
|
|
threat_level = max(threat_level, ThreatLevel.HIGH.value)
|
|
action = SecurityAction.BLOCK.value
|
|
|
|
# Final assessment
|
|
if confidence >= 0.8:
|
|
threat_level = ThreatLevel.CRITICAL.value
|
|
action = SecurityAction.BAN.value
|
|
elif confidence >= 0.6:
|
|
threat_level = ThreatLevel.HIGH.value
|
|
action = SecurityAction.BLOCK.value
|
|
elif confidence >= 0.4:
|
|
threat_level = ThreatLevel.MEDIUM.value
|
|
action = SecurityAction.RATE_LIMIT.value
|
|
elif confidence >= 0.2:
|
|
threat_level = ThreatLevel.LOW.value
|
|
action = SecurityAction.CHALLENGE.value
|
|
|
|
return ThreatAssessment(
|
|
assessment_id=f"asm_{int(time.time())}_{secrets.token_hex(4)}",
|
|
ip_address=ip,
|
|
fingerprint_id="",
|
|
threat_level=threat_level,
|
|
bot_type=bot_type,
|
|
action=action,
|
|
confidence=min(confidence, 1.0),
|
|
reasons=reasons,
|
|
timestamp=datetime.now(UTC).isoformat(),
|
|
)
|
|
|
|
@staticmethod
|
|
async def _check_request_rate(ip: str) -> dict[str, Any]:
|
|
"""Check request rate for an IP."""
|
|
try:
|
|
import redis.asyncio as redis_lib
|
|
|
|
r = redis_lib.Redis(
|
|
host=os.getenv("REDIS_HOST", "localhost"),
|
|
port=int(os.getenv("REDIS_PORT", "6379")),
|
|
password=os.getenv("REDIS_PASSWORD", ""),
|
|
decode_responses=True,
|
|
)
|
|
|
|
key = f"req_rate:{ip}"
|
|
now = int(time.time())
|
|
|
|
# Add current request
|
|
await r.zadd(key, {str(now): now})
|
|
# Remove requests older than 60 seconds
|
|
await r.zremrangebyscore(key, 0, now - 60)
|
|
# Set expiry
|
|
await r.expire(key, 60)
|
|
|
|
# Count requests in last 60 seconds
|
|
count = await r.zcard(key)
|
|
|
|
return {
|
|
"rpm": count,
|
|
"excessive": count > 120, # 120 RPM = 2 RPS
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Rate check error: {e}")
|
|
return {"rpm": 0, "excessive": False}
|
|
|
|
@staticmethod
|
|
async def _check_ip_reputation(ip: str) -> dict[str, Any]:
|
|
"""Check IP reputation using AbuseIPDB or local cache."""
|
|
try:
|
|
import redis.asyncio as redis_lib
|
|
|
|
r = redis_lib.Redis(
|
|
host=os.getenv("REDIS_HOST", "localhost"),
|
|
port=int(os.getenv("REDIS_PORT", "6379")),
|
|
password=os.getenv("REDIS_PASSWORD", ""),
|
|
decode_responses=True,
|
|
)
|
|
|
|
# Check local cache
|
|
cached = await r.get(f"ip_reputation:{ip}")
|
|
if cached:
|
|
return json.loads(cached)
|
|
|
|
# Default: unknown reputation
|
|
result = {"score": 0, "reports": 0, "source": "default"}
|
|
|
|
# Cache for 1 hour
|
|
await r.setex(f"ip_reputation:{ip}", 3600, json.dumps(result))
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(f"IP reputation check error: {e}")
|
|
return {"score": 0, "reports": 0}
|
|
|
|
|
|
# ── Anomaly Detection ───────────────────────────────────────
|
|
|
|
|
|
class AnomalyDetector:
|
|
"""
|
|
Statistical anomaly detection for API requests.
|
|
Uses rolling averages and standard deviations.
|
|
"""
|
|
|
|
@staticmethod
|
|
async def detect_anomalies(
|
|
ip: str,
|
|
user_id: str,
|
|
endpoint: str,
|
|
request_size: int,
|
|
response_time_ms: float,
|
|
) -> list[dict[str, Any]]:
|
|
"""Detect anomalies in request patterns."""
|
|
anomalies = []
|
|
|
|
try:
|
|
import redis.asyncio as redis_lib
|
|
|
|
r = redis_lib.Redis(
|
|
host=os.getenv("REDIS_HOST", "localhost"),
|
|
port=int(os.getenv("REDIS_PORT", "6379")),
|
|
password=os.getenv("REDIS_PASSWORD", ""),
|
|
decode_responses=True,
|
|
)
|
|
|
|
# Track response times for endpoint
|
|
rt_key = f"anomaly:rt:{endpoint}"
|
|
await r.lpush(rt_key, response_time_ms)
|
|
await r.ltrim(rt_key, 0, 999) # Keep last 1000
|
|
|
|
# Calculate rolling stats
|
|
times = await r.lrange(rt_key, 0, -1)
|
|
if len(times) >= 10:
|
|
times = [float(t) for t in times]
|
|
mean = sum(times) / len(times)
|
|
variance = sum((t - mean) ** 2 for t in times) / len(times)
|
|
std_dev = variance**0.5
|
|
|
|
# Check if current is anomalous (3 sigma)
|
|
if std_dev > 0 and abs(response_time_ms - mean) > 3 * std_dev:
|
|
anomalies.append(
|
|
{
|
|
"type": "response_time_spike",
|
|
"severity": "medium",
|
|
"details": {
|
|
"current": response_time_ms,
|
|
"mean": round(mean, 2),
|
|
"std_dev": round(std_dev, 2),
|
|
},
|
|
}
|
|
)
|
|
|
|
# Track request sizes
|
|
size_key = f"anomaly:size:{endpoint}"
|
|
await r.lpush(size_key, request_size)
|
|
await r.ltrim(size_key, 0, 999)
|
|
|
|
sizes = await r.lrange(size_key, 0, -1)
|
|
if len(sizes) >= 10:
|
|
sizes = [int(s) for s in sizes]
|
|
mean_size = sum(sizes) / len(sizes)
|
|
if request_size > mean_size * 10: # 10x average
|
|
anomalies.append(
|
|
{
|
|
"type": "request_size_spike",
|
|
"severity": "low",
|
|
"details": {
|
|
"current": request_size,
|
|
"mean": round(mean_size, 2),
|
|
},
|
|
}
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Anomaly detection error: {e}")
|
|
|
|
return anomalies
|
|
|
|
|
|
# ── Honeypot System ─────────────────────────────────────────
|
|
|
|
|
|
class HoneypotSystem:
|
|
"""
|
|
Honeypot endpoints that trap bad actors.
|
|
Returns fake data and logs attacker behavior.
|
|
"""
|
|
|
|
HONEYPOT_ENDPOINTS: ClassVar[list] =[
|
|
"/admin/config.php",
|
|
"/api/v1/admin/backup",
|
|
"/.env",
|
|
"/wp-config.php",
|
|
"/config/database.yml",
|
|
"/phpmyadmin",
|
|
"/api/internal/debug",
|
|
"/console",
|
|
"/actuator",
|
|
"/api/v1/keys",
|
|
]
|
|
|
|
@staticmethod
|
|
def is_honeypot(path: str) -> bool:
|
|
"""Check if path is a honeypot endpoint."""
|
|
return any(path.startswith(ep) or path == ep for ep in HoneypotSystem.HONEYPOT_ENDPOINTS)
|
|
|
|
@staticmethod
|
|
async def handle_honeypot(request: Any) -> dict[str, Any]:
|
|
"""Handle honeypot request - log and return fake data."""
|
|
ip = request.client.host if request.client else ""
|
|
ua = request.headers.get("user-agent", "")
|
|
path = str(request.url.path)
|
|
|
|
# Log the attack
|
|
event = SecurityEvent(
|
|
event_id=f"sec_{int(time.time())}_{secrets.token_hex(4)}",
|
|
timestamp=datetime.now(UTC).isoformat(),
|
|
event_type="honeypot_triggered",
|
|
ip_address=ip,
|
|
user_agent=ua,
|
|
path=path,
|
|
method=request.method,
|
|
threat_level=ThreatLevel.HIGH.value,
|
|
action_taken=SecurityAction.BAN.value,
|
|
details={"honeypot_endpoint": path},
|
|
)
|
|
|
|
await BotDetectionEngine._log_security_event(event)
|
|
|
|
# Auto-ban the IP
|
|
from app.admin_backend import SecurityManager
|
|
|
|
await SecurityManager.block_ip(ip, f"Honeypot triggered: {path}", 168) # 7 days
|
|
|
|
# Return fake data to keep attacker engaged
|
|
fake_responses = {
|
|
"/admin/config.php": {
|
|
"db_host": "localhost",
|
|
"db_user": "admin",
|
|
"db_pass": "fake_password_123",
|
|
},
|
|
"/.env": {
|
|
"APP_KEY": "base64:fakekey123",
|
|
"DB_PASSWORD": "fakepass456",
|
|
"API_SECRET": "sk_fake_789",
|
|
},
|
|
"/api/v1/keys": {"api_keys": [{"key": "ak_live_fake123", "scope": "admin"}]},
|
|
}
|
|
|
|
return fake_responses.get(path, {"status": "ok", "data": "sensitive_data_here"})
|
|
|
|
@staticmethod
|
|
async def _log_security_event(event: SecurityEvent):
|
|
"""Log security event to Redis and file."""
|
|
try:
|
|
import redis.asyncio as redis_lib
|
|
|
|
r = redis_lib.Redis(
|
|
host=os.getenv("REDIS_HOST", "localhost"),
|
|
port=int(os.getenv("REDIS_PORT", "6379")),
|
|
password=os.getenv("REDIS_PASSWORD", ""),
|
|
decode_responses=True,
|
|
)
|
|
|
|
await r.lpush("security_events", json.dumps(event.to_dict()))
|
|
await r.ltrim("security_events", 0, 9999)
|
|
|
|
# Also log to file
|
|
log_file = f"/var/log/rmi/security_{datetime.now().strftime('%Y-%m')}.jsonl"
|
|
os.makedirs(os.path.dirname(log_file), exist_ok=True)
|
|
with open(log_file, "a") as f:
|
|
f.write(json.dumps(event.to_dict()) + "\n")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Security event log error: {e}")
|
|
|
|
|
|
# ── Geo-blocking ────────────────────────────────────────────
|
|
|
|
|
|
class GeoBlocker:
|
|
"""Country-based access control."""
|
|
|
|
BLOCKED_COUNTRIES: set[str] = set() # ISO country codes # noqa: RUF012
|
|
ALLOWED_COUNTRIES: set[str] = set() # If set, only allow these # noqa: RUF012
|
|
|
|
@staticmethod
|
|
async def check_country(ip: str) -> dict[str, Any]:
|
|
"""Check if IP country is allowed."""
|
|
# In production, use GeoIP2 or similar
|
|
# For now, return permissive
|
|
return {
|
|
"allowed": True,
|
|
"country": "unknown",
|
|
"country_code": "XX",
|
|
"blocked": False,
|
|
}
|
|
|
|
|
|
# ── DDoS Protection ─────────────────────────────────────────
|
|
|
|
|
|
class DDoSProtector:
|
|
"""
|
|
DDoS protection using circuit breaker pattern
|
|
and request flood detection.
|
|
"""
|
|
|
|
CIRCUIT_THRESHOLD = 1000 # requests per minute
|
|
CIRCUIT_DURATION = 300 # 5 minute circuit break
|
|
|
|
@staticmethod
|
|
async def check_circuit(endpoint: str) -> dict[str, Any]:
|
|
"""Check if circuit breaker is open for endpoint."""
|
|
try:
|
|
import redis.asyncio as redis_lib
|
|
|
|
r = redis_lib.Redis(
|
|
host=os.getenv("REDIS_HOST", "localhost"),
|
|
port=int(os.getenv("REDIS_PORT", "6379")),
|
|
password=os.getenv("REDIS_PASSWORD", ""),
|
|
decode_responses=True,
|
|
)
|
|
|
|
# Check if circuit is open
|
|
circuit_key = f"circuit:{endpoint}"
|
|
is_open = await r.get(circuit_key)
|
|
if is_open:
|
|
return {"allowed": False, "reason": "circuit_open", "retry_after": int(is_open)}
|
|
|
|
# Check request rate for endpoint
|
|
rate_key = f"endpoint_rate:{endpoint}"
|
|
now = int(time.time())
|
|
await r.zadd(rate_key, {str(now): now})
|
|
await r.zremrangebyscore(rate_key, 0, now - 60)
|
|
await r.expire(rate_key, 60)
|
|
|
|
count = await r.zcard(rate_key)
|
|
if count > DDoSProtector.CIRCUIT_THRESHOLD:
|
|
# Open circuit
|
|
await r.setex(
|
|
circuit_key,
|
|
DDoSProtector.CIRCUIT_DURATION,
|
|
str(now + DDoSProtector.CIRCUIT_DURATION),
|
|
)
|
|
return {
|
|
"allowed": False,
|
|
"reason": "circuit_opened",
|
|
"retry_after": DDoSProtector.CIRCUIT_DURATION,
|
|
}
|
|
|
|
return {"allowed": True, "current_rpm": count}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Circuit check error: {e}")
|
|
return {"allowed": True} # Fail open
|
|
|
|
@staticmethod
|
|
async def close_circuit(endpoint: str):
|
|
"""Manually close a circuit breaker."""
|
|
try:
|
|
import redis.asyncio as redis_lib
|
|
|
|
r = redis_lib.Redis(
|
|
host=os.getenv("REDIS_HOST", "localhost"),
|
|
port=int(os.getenv("REDIS_PORT", "6379")),
|
|
password=os.getenv("REDIS_PASSWORD", ""),
|
|
decode_responses=True,
|
|
)
|
|
await r.delete(f"circuit:{endpoint}")
|
|
except Exception as e:
|
|
logger.error(f"Circuit close error: {e}")
|
|
|
|
|
|
# ── Security Middleware Helper ──────────────────────────────
|
|
|
|
|
|
async def security_middleware_check(request: Any) -> ThreatAssessment | None:
|
|
"""
|
|
Run full security check on request.
|
|
Returns ThreatAssessment if action is needed, None if safe.
|
|
"""
|
|
ip = request.client.host if request.client else ""
|
|
path = str(request.url.path)
|
|
method = request.method
|
|
ua = request.headers.get("user-agent", "")
|
|
|
|
# Skip checks for health endpoints
|
|
if path in ["/health", "/api/v1/health", "/ping"]:
|
|
return None
|
|
|
|
# Check honeypot
|
|
if HoneypotSystem.is_honeypot(path):
|
|
await HoneypotSystem.handle_honeypot(request)
|
|
return ThreatAssessment(
|
|
assessment_id=f"asm_{int(time.time())}",
|
|
ip_address=ip,
|
|
fingerprint_id="",
|
|
threat_level=ThreatLevel.CRITICAL.value,
|
|
bot_type=BotType.EXPLOIT_SCANNER.value,
|
|
action=SecurityAction.BAN.value,
|
|
confidence=1.0,
|
|
reasons=["Honeypot triggered"],
|
|
timestamp=datetime.now(UTC).isoformat(),
|
|
)
|
|
|
|
# Check DDoS circuit
|
|
circuit = await DDoSProtector.check_circuit(path)
|
|
if not circuit["allowed"]:
|
|
return ThreatAssessment(
|
|
assessment_id=f"asm_{int(time.time())}",
|
|
ip_address=ip,
|
|
fingerprint_id="",
|
|
threat_level=ThreatLevel.HIGH.value,
|
|
bot_type=BotType.DDOS.value,
|
|
action=SecurityAction.BLOCK.value,
|
|
confidence=0.9,
|
|
reasons=[f"Circuit breaker: {circuit['reason']}"],
|
|
timestamp=datetime.now(UTC).isoformat(),
|
|
)
|
|
|
|
# Run bot detection
|
|
headers = dict(request.headers)
|
|
assessment = await BotDetectionEngine.analyze_request(
|
|
ip=ip,
|
|
user_agent=ua,
|
|
path=path,
|
|
method=method,
|
|
headers=headers,
|
|
)
|
|
|
|
# Log if not clean
|
|
if assessment.action != SecurityAction.ALLOW.value:
|
|
event = SecurityEvent(
|
|
event_id=f"sec_{int(time.time())}_{secrets.token_hex(4)}",
|
|
timestamp=datetime.now(UTC).isoformat(),
|
|
event_type="threat_detected",
|
|
ip_address=ip,
|
|
user_agent=ua,
|
|
path=path,
|
|
method=method,
|
|
threat_level=assessment.threat_level,
|
|
action_taken=assessment.action,
|
|
details={"confidence": assessment.confidence, "reasons": assessment.reasons},
|
|
)
|
|
await HoneypotSystem._log_security_event(event)
|
|
|
|
return assessment if assessment.action != SecurityAction.ALLOW.value else None
|