""" log_analysis_generator.py ========================= Generates fully-realized log-analysis challenges for the CyberArena Blue Team pool. Architecture: main.py watcher (polls log_analysis_challenges count) | v refill_pool(team_role, count) --> ai_generate_challenge() | | | v | AI generates log content | | | v | _upload_log_to_storage() -> Supabase Storage bucket | | | v | _validate_and_build() -> DB row v insert_to_db() <----- Challenge row | v public.log_analysis_challenges Public API (used by main.py): - POOL_TARGET = 5 - POOL_THRESHOLD = 2 - POOL_BATCH = 3 - get_pool_count(team_role) -> int - async refill_pool(team_role, count) -> int - async start_pool_watcher(team_role) CLI: python log_analysis_generator.py --team blue --seed-only python log_analysis_generator.py --team blue --ai --count 1 """ from __future__ import annotations import argparse import asyncio import json import os import random import re import sys import time import uuid from typing import Optional # Load .env early try: from dotenv import load_dotenv _env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") if os.path.exists(_env_path): load_dotenv(_env_path) except ImportError: pass import httpx try: from app.services.dedup import fetch_existing_titles except ImportError: def fetch_existing_titles(table: str, role_filter: Optional[str] = None, limit: int = 50) -> list[str]: return [] # --------------------------------------------------------------------------- # # 0. Constants # --------------------------------------------------------------------------- # ALLOWED_LOG_TYPES = ("apache", "nginx", "syslog", "auth", "firewall", "waf", "iis") ALLOWED_DIFFICULTIES = ("مبتدئ", "متوسط", "قوي") ALLOWED_TEAMS = ("blue",) # Attack types per log type ATTACK_BY_LOG_TYPE = { "apache": ["sqli", "xss", "path-traversal", "webshell", "brute-force"], "nginx": ["sqli", "xss", "path-traversal", "webshell", "dos"], "syslog": ["privilege-escalation", "c2", "lateral-movement", "malware"], "auth": ["brute-force", "credential-stuffing", "lateral-movement"], "firewall":["port-scan", "exfiltration", "c2", "dos"], "waf": ["sqli", "xss", "rce", "webshell"], "iis": ["sqli", "xss", "rce"], } ATTACK_DESCRIPTIONS_AR = { "sqli": "حقن استعلامات SQL خبيثة", "xss": "هجوم برمجة عبر المواقع", "path-traversal": "تجاوز المسار للوصول لملفات حساسة", "webshell": "رفع شل ويب للوصول للخادم", "brute-force": "محاولات تخمين كلمات مرور متكررة", "credential-stuffing": "استخدام كلمات مرور مسرّبة", "dos": "هجوم حجب الخدمة", "privilege-escalation": "تصعيد الصلاحيات", "c2": "اتصال خادم قيادة وتحكم (C2)", "lateral-movement": "حركة جانبية داخل الشبكة", "malware": "تنفيذ برمجية خبيثة", "port-scan": "مسح منافذ الشبكة", "exfiltration": "تسريب بيانات خارج الشبكة", "rce": "تنفيذ أوامر عن بُعد", } # Module names for each log type MODULE_BY_LOG_TYPE = { "apache": "log-analysis", "nginx": "log-analysis", "syslog": "log-analysis", "auth": "log-analysis", "firewall":"log-analysis", "waf": "log-analysis", "iis": "log-analysis", } # Rotation: cycles through (log_type, attack_type) pairs LOG_ROTATION = [] for log_type, attacks in ATTACK_BY_LOG_TYPE.items(): for attack in attacks: LOG_ROTATION.append((log_type, attack)) # Pool constants POOL_TARGET = 5 POOL_THRESHOLD = 2 POOL_BATCH = 3 from app.core.config import ( SUPABASE_URL, SUPABASE_ANON_KEY, CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_MODEL, CLOUDFLARE_URL, GROQ_API_KEY, GROQ_MODEL, GROQ_API_URL, NVIDIA_API_KEY, NVIDIA_MODEL, NVIDIA_URL, MISTRAL_API_KEY, MISTRAL_MODEL, MISTRAL_API_URL, ) STORAGE_BUCKET = "log-analysis-files" CLOUDFLARE_MODEL_FALLBACKS = [ "@cf/qwen/qwen2.5-coder-32b-instruct", "@cf/meta/llama-3.3-70b-instruct-fp8-fast", "@cf/meta/llama-3.1-70b-instruct", "@cf/mistralai/mistral-small-3.1-24b-instruct", "@cf/openai/gpt-oss-120b", "@cf/openai/gpt-oss-20b", "@cf/meta/llama-3.1-8b-instruct", ] TABLE_NAME = "log_analysis_challenges" # Per-team backoff tracker _AI_BACKOFF_UNTIL: dict[str, float] = {} # Concurrency control _POOL_LOCKS: dict[str, asyncio.Lock] = {} _WATCHER_STARTED: set[str] = set() def _get_pool_lock(team_role: str) -> asyncio.Lock: if team_role not in _POOL_LOCKS: _POOL_LOCKS[team_role] = asyncio.Lock() return _POOL_LOCKS[team_role] # --------------------------------------------------------------------------- # # 1. Helpers # --------------------------------------------------------------------------- # def supabase_headers(content_type: bool = False) -> dict: headers = { "apikey": SUPABASE_ANON_KEY, "Authorization": f"Bearer {SUPABASE_ANON_KEY}", } if content_type: headers["Content-Type"] = "application/json" return headers def _extract_string_value(text: str, key: str) -> Optional[str]: """Extract a JSON string value for a given key, handling multi-line content and embedded quotes.""" pattern = rf'"{re.escape(key)}"\s*:\s*"((?:[^"\\]|\\.)*)"' match = re.search(pattern, text, re.DOTALL) if match: value = match.group(1) value = value.replace('\\n', '\n').replace('\\t', '\t').replace('\\"', '"').replace('\\\\', '\\') return value return None def _find_matching_bracket(text: str, open_idx: int, open_ch: str, close_ch: str) -> Optional[int]: """Find the matching closing bracket, accounting for nested brackets and strings.""" depth = 0 i = open_idx in_string = False escape_next = False while i < len(text): c = text[i] if escape_next: escape_next = False i += 1 continue if c == '\\' and in_string: escape_next = True i += 1 continue if c == '"': in_string = not in_string elif not in_string: if c == open_ch: depth += 1 elif c == close_ch: depth -= 1 if depth == 0: return i i += 1 return None def parse_json_safe(raw) -> dict: """Robustly parse LLM JSON output, tolerating markdown fences, trailing commas, etc.""" if raw is None or raw == "": raise ValueError("Empty response from model") if isinstance(raw, dict): return raw if not isinstance(raw, str): raise ValueError(f"Expected str or dict, got {type(raw).__name__}") cleaned = raw.strip() fence_match = re.search(r"```(?:json)?\s*([\s\S]*?)```", cleaned, re.IGNORECASE) if fence_match: cleaned = fence_match.group(1).strip() start = cleaned.find("{") if start == -1: raise ValueError(f"No JSON object found. Raw: {cleaned[:200]}") end = _find_matching_bracket(cleaned, start, "{", "}") if end is None: end = cleaned.rfind("}") if end == -1 or end <= start: raise ValueError(f"Unbalanced JSON braces. Raw: {cleaned[:200]}") cleaned = cleaned[start:end + 1] cleaned = re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]", "", cleaned) try: return json.loads(cleaned, strict=False) except json.JSONDecodeError: pass fixed = cleaned fixed = re.sub(r',\s*([}\]])', r'\1', fixed) fixed = re.sub(r'(? tuple[str, str]: return LOG_ROTATION[i % len(LOG_ROTATION)] # --------------------------------------------------------------------------- # # 1b. Supabase Storage helpers # --------------------------------------------------------------------------- # async def _upload_log_to_storage(storage_path: str, content: str) -> bool: """Upload log content to Supabase Storage bucket. Returns True on success.""" if not SUPABASE_URL or not SUPABASE_ANON_KEY: print(" [storage] Missing SUPABASE_URL or SUPABASE_ANON_KEY") return False url = f"{SUPABASE_URL}/storage/v1/object/{STORAGE_BUCKET}/{storage_path}" headers = { "apikey": SUPABASE_ANON_KEY, "Authorization": f"Bearer {SUPABASE_ANON_KEY}", "Content-Type": "text/plain", "x-upsert": "true", # overwrite if exists } try: async with httpx.AsyncClient(timeout=60) as client: resp = await client.post(url, content=content.encode("utf-8"), headers=headers) if resp.status_code in (200, 201): return True # Try PUT (some Storage versions use PUT for upload) async with httpx.AsyncClient(timeout=60) as client: resp = await client.put(url, content=content.encode("utf-8"), headers=headers) if resp.status_code in (200, 201): return True print(f" [storage] Upload failed: {resp.status_code} {resp.text[:200]}") return False except Exception as e: print(f" [storage] Upload exception: {type(e).__name__}: {e}") return False def _public_url_for(storage_path: str) -> str: return f"{SUPABASE_URL}/storage/v1/object/public/{STORAGE_BUCKET}/{storage_path}" # --------------------------------------------------------------------------- # # 2. Curated Seeds (hand-crafted, upload-ready) # --------------------------------------------------------------------------- # def _build_seeds() -> list[dict]: """Return a list of curated seed challenges. Each is a complete challenge row with pre-built log content (the log text is uploaded to Storage on first use).""" seeds = [ # --- 1. auth / brute-force (مبتدئ) --- { "log_type": "auth", "attack_type": "brute-force", "difficulty": "مبتدئ", "xp_reward": 100, "title": "محاولات دخول مشبوهة على SSH", "story": "شركة تقنية اكتشفت ارتفاعاً غير طبيعي في فشل محاولات الدخول على خوادم SSH. راجع السجل وحدد المهاجم.", "task_outline": "افتح ملف auth.log وحدد: (1) عنوان IP المهاجم، (2) اسم المستخدم المستهدف، (3) الطابع الزمني لأول محاولة فاشلة.", "log_content": _seed_auth_brute_force(), "expected_attack_type": "brute-force", "expected_attacker_ip": "185.220.101.45", "expected_timestamp": "Mar 12 03:14:22", "expected_ioc": "root", "vulnerability_description": "هجوم brute-force على SSH يستهدف حساب root من IP 185.220.101.45. يجب حظر الـ IP وتطبيق fail2ban.", "hints": [ {"level": 1, "text": "ابحث عن سطور 'Failed password' المتكررة", "xp_cost": 15}, {"level": 2, "text": "استخدم grep 'Failed password' auth.log | awk '{print $11}' | sort | uniq -c | sort -rn", "xp_cost": 25}, {"level": 3, "text": "الـ IP المهاجم هو 185.220.101.45 والهدف حساب root", "xp_cost": 40}, ], }, # --- 2. apache / sqli (متوسط) --- { "log_type": "apache", "attack_type": "sqli", "difficulty": "متوسط", "xp_reward": 150, "title": "حقن SQL على موقع التجارة الإلكترونية", "story": "فريق الـ SOC رصد ارتفاعاً في استعلامات بطيئة على خادم MySQL. راجع سجلات Apache واكتشف المهاجم.", "task_outline": "افتح access.log وحدد: (1) عنوان IP المهاجم، (2) نوع payload الـ SQLi، (3) الطابع الزمني للهجوم.", "log_content": _seed_apache_sqli(), "expected_attack_type": "sqli", "expected_attacker_ip": "203.0.113.42", "expected_timestamp": "15/Dec/2024:03:42:18", "expected_ioc": "UNION SELECT", "vulnerability_description": "حقن UNION-based SQLi من IP 203.0.113.42 على endpoint /products/search. الثغرة في دالة البحث عن المنتجات.", "hints": [ {"level": 1, "text": "ابحث عن طلبات GET طويلة على /products/search", "xp_cost": 20}, {"level": 2, "text": "كلمات مفتاحية للبحث: 'UNION', 'SELECT', '%27'", "xp_cost": 30}, {"level": 3, "text": "IP المهاجم 203.0.113.42 والـ payload يحتوي على UNION SELECT", "xp_cost": 50}, ], }, # --- 3. nginx / webshell (متوسط) --- { "log_type": "nginx", "attack_type": "webshell", "difficulty": "متوسط", "xp_reward": 150, "title": "رفع WebShell على خادم Nginx", "story": "تنبيه من IDS يشير إلى نشاط POST مشبوه على endpoint غير معروف. راجع سجلات Nginx وحقق.", "task_outline": "افتح nginx-access.log وحدد: (1) الـ IP المهاجم، (2) اسم ملف الـ shell المرفوع، (3) توقيت الـ POST الأول.", "log_content": _seed_nginx_webshell(), "expected_attack_type": "webshell", "expected_attacker_ip": "198.51.100.77", "expected_timestamp": "20/Nov/2024:14:08:33", "expected_ioc": "shell.php", "vulnerability_description": "رفع webshell (shell.php) عبر ثغرة file upload في endpoint /uploads. المهاجم رفع PHP web shell ثم نفّذ أوامر نظام.", "hints": [ {"level": 1, "text": "ابحث عن طلبات POST ناجحة على /uploads", "xp_cost": 20}, {"level": 2, "text": "لاحقة الملف: .php على endpoint رفع", "xp_cost": 30}, {"level": 3, "text": "الـ shell اسمها shell.php من IP 198.51.100.77", "xp_cost": 50}, ], }, # --- 4. syslog / c2 (قوي) --- { "log_type": "syslog", "attack_type": "c2", "difficulty": "قوي", "xp_reward": 200, "title": "اتصال خادم C2 مشبوه", "story": "محلل الشبكة رصد اتصالات DNS غير اعتيادية من خادم داخلي. تحقق من syslog لتأكيد الاختراق.", "task_outline": "افتح syslog.log وحدد: (1) اسم النطاق المشبوه، (2) العملية التي تقوم بالاتصال، (3) الـ IP الداخلي المصاب.", "log_content": _seed_syslog_c2(), "expected_attack_type": "c2", "expected_attacker_ip": "10.0.5.42", "expected_timestamp": "Jan 15 02:33:17", "expected_ioc": "evil-c2-server.xyz", "vulnerability_description": "Malware ينشئ قناة C2 عبر DNS tunneling إلى evil-c2-server.xyz من الخادم الداخلي 10.0.5.42.", "hints": [ {"level": 1, "text": "ابحث عن طلبات DNS متكررة لنطاق غير معروف", "xp_cost": 25}, {"level": 2, "text": "النطاق يبدو مثل DGA: حروف عشوائية + .xyz/.top", "xp_cost": 40}, {"level": 3, "text": "evil-c2-server.xyz من الخادم 10.0.5.42", "xp_cost": 60}, ], }, # --- 5. firewall / exfiltration (قوي) --- { "log_type": "firewall", "attack_type": "exfiltration", "difficulty": "قوي", "xp_reward": 200, "title": "تسريب بيانات خارج الشبكة", "story": "حجم البيانات الصادرة على منفذ 443 أعلى من المعتاد بـ 10 أضعاف. تحقق من جدار الحماية.", "task_outline": "افتح firewall.log وحدد: (1) الـ IP الداخلي المُسرّب، (2) الـ IP الخارجي المستقبل، (3) حجم البيانات التقريبي.", "log_content": _seed_firewall_exfil(), "expected_attack_type": "exfiltration", "expected_attacker_ip": "10.0.3.118", "expected_timestamp": "2024-11-08T01:15:00", "expected_ioc": "2.3GB", "vulnerability_description": "تسريب قاعدة بيانات (2.3GB) مشفرة على منفذ 443 إلى IP خارجي. الـ IP الداخلي 10.0.3.118 مصاب ببرمجية خبيثة.", "hints": [ {"level": 1, "text": "ابحث عن sessions TCP طويلة بحجم بايتات عالي", "xp_cost": 25}, {"level": 2, "text": "حجم > 1GB على منفذ HTTPS (443) في وقت قصير", "xp_cost": 40}, {"level": 3, "text": "الـ IP الداخلي 10.0.3.118 سرّب 2.3GB", "xp_cost": 60}, ], }, # --- 6. waf / xss (مبتدئ) --- { "log_type": "waf", "attack_type": "xss", "difficulty": "مبتدئ", "xp_reward": 100, "title": "هجوم XSS على نموذج التعليقات", "story": "WAF سجل محاولات حقن سكريبت في حقل التعليقات. راجع السجل وحدد المهاجم.", "task_outline": "افتح waf.log وحدد: (1) الـ IP المهاجم، (2) الـ XSS payload المستخدم، (3) عدد المحاولات.", "log_content": _seed_waf_xss(), "expected_attack_type": "xss", "expected_attacker_ip": "192.0.2.88", "expected_timestamp": "2024-10-22T10:14:55", "expected_ioc": ".", "hints": [ {"level": 1, "text": "ابحث عن rule 'XSS Attack' أو 'Cross-Site Scripting'", "xp_cost": 15}, {"level": 2, "text": "الـ payload يحتوي على