Spaces:
Sleeping
Sleeping
| """ | |
| 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'(?<!")(\b[A-Za-z_][A-Za-z0-9_]*\b)(\s*:)', r'"\1"\2', fixed) | |
| fixed = re.sub(r"'([^'\\]*(?:\\.[^'\\]*)*)'", lambda m: '"' + m.group(1).replace('"', '\\"') + '"', fixed) | |
| try: | |
| return json.loads(fixed, strict=False) | |
| except json.JSONDecodeError: | |
| pass | |
| result: dict = {} | |
| for key in ("title", "story", "task_outline", "log_content", | |
| "vulnerability_description", "difficulty", | |
| "expected_attack_type", "expected_attacker_ip", "expected_timestamp", "expected_ioc"): | |
| val = _extract_string_value(cleaned, key) | |
| if val is not None: | |
| result[key] = val | |
| hints_key_idx = cleaned.find('"hints"') | |
| if hints_key_idx != -1: | |
| bracket_start = cleaned.find("[", hints_key_idx) | |
| if bracket_start != -1: | |
| bracket_end = _find_matching_bracket(cleaned, bracket_start, "[", "]") | |
| if bracket_end is not None: | |
| hints_str = cleaned[bracket_start:bracket_end + 1] | |
| try: | |
| result["hints"] = json.loads(hints_str, strict=False) | |
| except json.JSONDecodeError: | |
| hints_str = re.sub(r',\s*([}\]])', r'\1', hints_str) | |
| try: | |
| result["hints"] = json.loads(hints_str, strict=False) | |
| except Exception: | |
| result["hints"] = [] | |
| if not result: | |
| preview = raw[:500].replace("\n", " ") | |
| raise ValueError(f"Could not extract any fields from model output. Raw preview: {preview}") | |
| return result | |
| def _pick_rotation_slot(i: int) -> 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": "<script>alert", | |
| "vulnerability_description": "محاولات XSS متعددة من 192.0.2.88 على endpoint /comments. الـ payload يحتوي على <script>alert(1)</script>.", | |
| "hints": [ | |
| {"level": 1, "text": "ابحث عن rule 'XSS Attack' أو 'Cross-Site Scripting'", "xp_cost": 15}, | |
| {"level": 2, "text": "الـ payload يحتوي على <script>", "xp_cost": 25}, | |
| {"level": 3, "text": "IP المهاجم 192.0.2.88 والـ payload <script>alert", "xp_cost": 40}, | |
| ], | |
| }, | |
| ] | |
| return seeds | |
| def _seed_auth_brute_force() -> str: | |
| """SSH auth.log with 50+ failed attempts from a single IP targeting root.""" | |
| lines = [ | |
| "Mar 12 03:10:01 webserver sshd[12847]: Accepted publickey for admin from 10.0.1.5 port 51234 ssh2: ED25519 SHA256:abc123", | |
| "Mar 12 03:10:15 webserver sshd[12850]: Failed password for invalid user admin from 185.220.101.45 port 33421 ssh2", | |
| "Mar 12 03:12:33 webserver sshd[12851]: Failed password for invalid user test from 185.220.101.45 port 33422 ssh2", | |
| "Mar 12 03:12:38 webserver sshd[12852]: Failed password for invalid user guest from 185.220.101.45 port 33423 ssh2", | |
| "Mar 12 03:13:05 webserver sshd[12853]: Failed password for invalid user oracle from 185.220.101.45 port 33424 ssh2", | |
| "Mar 12 03:13:09 webserver sshd[12854]: Failed password for invalid user postgres from 185.220.101.45 port 33425 ssh2", | |
| "Mar 12 03:13:12 webserver sshd[12855]: Failed password for invalid user nagios from 185.220.101.45 port 33426 ssh2", | |
| "Mar 12 03:13:15 webserver sshd[12856]: Failed password for invalid user www from 185.220.101.45 port 33427 ssh2", | |
| "Mar 12 03:13:19 webserver sshd[12857]: Failed password for invalid user www-data from 185.220.101.45 port 33428 ssh2", | |
| "Mar 12 03:13:22 webserver sshd[12858]: Failed password for invalid user apache from 185.220.101.45 port 33429 ssh2", | |
| "Mar 12 03:13:26 webserver sshd[12859]: Failed password for invalid user git from 185.220.101.45 port 33430 ssh2", | |
| "Mar 12 03:13:30 webserver sshd[12860]: Failed password for invalid user jenkins from 185.220.101.45 port 33431 ssh2", | |
| "Mar 12 03:13:35 webserver sshd[12861]: Failed password for invalid user deploy from 185.220.101.45 port 33432 ssh2", | |
| "Mar 12 03:13:39 webserver sshd[12862]: Failed password for invalid user ubuntu from 185.220.101.45 port 33433 ssh2", | |
| "Mar 12 03:13:44 webserver sshd[12863]: Failed password for invalid user pi from 185.220.101.45 port 33434 ssh2", | |
| "Mar 12 03:13:49 webserver sshd[12864]: Failed password for invalid user vagrant from 185.220.101.45 port 33435 ssh2", | |
| "Mar 12 03:13:55 webserver sshd[12865]: Failed password for invalid user ec2-user from 185.220.101.45 port 33436 ssh2", | |
| "Mar 12 03:14:01 webserver sshd[12866]: Failed password for invalid user centos from 185.220.101.45 port 33437 ssh2", | |
| "Mar 12 03:14:08 webserver sshd[12867]: Failed password for invalid user mysql from 185.220.101.45 port 33438 ssh2", | |
| "Mar 12 03:14:15 webserver sshd[12868]: Failed password for invalid user redis from 185.220.101.45 port 33439 ssh2", | |
| "Mar 12 03:14:22 webserver sshd[12869]: Failed password for root from 185.220.101.45 port 33440 ssh2", | |
| "Mar 12 03:14:28 webserver sshd[12870]: Failed password for root from 185.220.101.45 port 33441 ssh2", | |
| "Mar 12 03:14:35 webserver sshd[12871]: Failed password for root from 185.220.101.45 port 33442 ssh2", | |
| "Mar 12 03:14:42 webserver sshd[12872]: Failed password for root from 185.220.101.45 port 33443 ssh2", | |
| "Mar 12 03:14:50 webserver sshd[12873]: Failed password for root from 185.220.101.45 port 33444 ssh2", | |
| "Mar 12 03:14:58 webserver sshd[12874]: Failed password for root from 185.220.101.45 port 33445 ssh2", | |
| "Mar 12 03:15:06 webserver sshd[12875]: Failed password for root from 185.220.101.45 port 33446 ssh2", | |
| "Mar 12 03:15:15 webserver sshd[12876]: Failed password for root from 185.220.101.45 port 33447 ssh2", | |
| "Mar 12 03:15:25 webserver sshd[12877]: Failed password for root from 185.220.101.45 port 33448 ssh2", | |
| "Mar 12 03:15:35 webserver sshd[12878]: Failed password for root from 185.220.101.45 port 33449 ssh2", | |
| "Mar 12 03:15:46 webserver sshd[12879]: Failed password for root from 185.220.101.45 port 33450 ssh2", | |
| "Mar 12 03:15:57 webserver sshd[12880]: Failed password for root from 185.220.101.45 port 33451 ssh2", | |
| "Mar 12 03:16:09 webserver sshd[12881]: Failed password for root from 185.220.101.45 port 33452 ssh2", | |
| "Mar 12 03:16:22 webserver sshd[12882]: Failed password for root from 185.220.101.45 port 33453 ssh2", | |
| "Mar 12 03:16:35 webserver sshd[12883]: Failed password for root from 185.220.101.45 port 33454 ssh2", | |
| "Mar 12 03:16:49 webserver sshd[12884]: Failed password for root from 185.220.101.45 port 33455 ssh2", | |
| "Mar 12 03:17:03 webserver sshd[12885]: Failed password for root from 185.220.101.45 port 33456 ssh2", | |
| "Mar 12 03:17:18 webserver sshd[12886]: Failed password for root from 185.220.101.45 port 33457 ssh2", | |
| "Mar 12 03:17:33 webserver sshd[12887]: Failed password for root from 185.220.101.45 port 33458 ssh2", | |
| "Mar 12 03:17:49 webserver sshd[12888]: Failed password for root from 185.220.101.45 port 33459 ssh2", | |
| "Mar 12 03:18:05 webserver sshd[12889]: Failed password for root from 185.220.101.45 port 33460 ssh2", | |
| "Mar 12 03:18:22 webserver sshd[12890]: Failed password for root from 185.220.101.45 port 33461 ssh2", | |
| "Mar 12 03:18:39 webserver sshd[12891]: Failed password for root from 185.220.101.45 port 33462 ssh2", | |
| "Mar 12 03:18:57 webserver sshd[12892]: Failed password for root from 185.220.101.45 port 33463 ssh2", | |
| "Mar 12 03:19:15 webserver sshd[12893]: Failed password for root from 185.220.101.45 port 33464 ssh2", | |
| "Mar 12 03:19:34 webserver sshd[12894]: Failed password for root from 185.220.101.45 port 33465 ssh2", | |
| "Mar 12 03:19:53 webserver sshd[12895]: Failed password for root from 185.220.101.45 port 33466 ssh2", | |
| "Mar 12 03:20:13 webserver sshd[12896]: Failed password for root from 185.220.101.45 port 33467 ssh2", | |
| "Mar 12 03:20:33 webserver sshd[12897]: Failed password for root from 185.220.101.45 port 33468 ssh2", | |
| "Mar 12 03:20:54 webserver sshd[12898]: Failed password for root from 185.220.101.45 port 33469 ssh2", | |
| "Mar 12 03:21:15 webserver sshd[12899]: Failed password for root from 185.220.101.45 port 33470 ssh2", | |
| "Mar 12 03:21:37 webserver sshd[12900]: Failed password for root from 185.220.101.45 port 33471 ssh2", | |
| "Mar 12 03:22:00 webserver sshd[12901]: Failed password for root from 185.220.101.45 port 33472 ssh2", | |
| "Mar 12 03:22:23 webserver sshd[12902]: Connection closed by invalid user [preauth]", | |
| "Mar 12 03:22:30 webserver sshd[12849]: Accepted publickey for admin from 10.0.1.5 port 51235 ssh2: ED25519 SHA256:abc123", | |
| "Mar 12 03:23:01 webserver CRON[12903]: pam_unix(cron:session): session opened for user root by (uid=0)", | |
| "Mar 12 03:23:02 webserver CRON[12904]: (root) CMD (/usr/local/bin/backup.sh)", | |
| ] | |
| return "\n".join(lines) + "\n" | |
| def _seed_apache_sqli() -> str: | |
| """Apache access.log with UNION-based SQLi from one IP.""" | |
| base_lines = [ | |
| '10.0.1.5 - - [15/Dec/2024:03:30:00 +0000] "GET / HTTP/1.1" 200 4521 "-" "Mozilla/5.0"', | |
| '10.0.1.5 - - [15/Dec/2024:03:30:15 +0000] "GET /products HTTP/1.1" 200 12450 "-" "Mozilla/5.0"', | |
| '10.0.1.6 - - [15/Dec/2024:03:31:00 +0000] "GET /about HTTP/1.1" 200 3210 "-" "Mozilla/5.0"', | |
| ] | |
| # Inject 5 normal search requests from various IPs | |
| for i in range(5): | |
| base_lines.append( | |
| f'10.0.1.{10+i} - - [15/Dec/2024:03:35:0{i} +0000] "GET /products/search?q=shoes HTTP/1.1" 200 3200 "-" "Mozilla/5.0"' | |
| ) | |
| # Attacker probes | |
| attacker = "203.0.113.42" | |
| base_lines.extend([ | |
| f'{attacker} - - [15/Dec/2024:03:40:01 +0000] "GET /products/search?q=\' HTTP/1.1" 500 1024 "-" "sqlmap/1.5"', | |
| f'{attacker} - - [15/Dec/2024:03:40:15 +0000] "GET /products/search?q=shoes\' HTTP/1.1" 500 1024 "-" "sqlmap/1.5"', | |
| f'{attacker} - - [15/Dec/2024:03:40:30 +0000] "GET /products/search?q=shoes%27%20OR%201=1-- HTTP/1.1" 500 1024 "-" "sqlmap/1.5"', | |
| f'{attacker} - - [15/Dec/2024:03:41:00 +0000] "GET /products/search?q=shoes%27%20UNION%20SELECT%20NULL-- HTTP/1.1" 500 1024 "-" "sqlmap/1.5"', | |
| f'{attacker} - - [15/Dec/2024:03:41:30 +0000] "GET /products/search?q=shoes%27%20UNION%20SELECT%20NULL,NULL-- HTTP/1.1" 500 1024 "-" "sqlmap/1.5"', | |
| f'{attacker} - - [15/Dec/2024:03:42:18 +0000] "GET /products/search?q=shoes%27%20UNION%20SELECT%20username,password%20FROM%20users-- HTTP/1.1" 200 8500 "-" "sqlmap/1.5"', | |
| f'{attacker} - - [15/Dec/2024:03:42:55 +0000] "GET /products/search?q=shoes%27%20UNION%20SELECT%20NULL,NULL,NULL,NULL,NULL-- HTTP/1.1" 200 9200 "-" "sqlmap/1.5"', | |
| f'{attacker} - - [15/Dec/2024:03:43:30 +0000] "GET /products/search?q=admin%27-- HTTP/1.1" 200 1500 "-" "sqlmap/1.5"', | |
| f'{attacker} - - [15/Dec/2024:03:44:00 +0000] "GET /products HTTP/1.1" 200 12450 "-" "sqlmap/1.5"', | |
| f'{attacker} - - [15/Dec/2024:03:44:15 +0000] "GET /admin HTTP/1.1" 404 412 "-" "sqlmap/1.5"', | |
| ]) | |
| # Some normal traffic after | |
| for i in range(8): | |
| base_lines.append( | |
| f'10.0.2.{i+1} - - [15/Dec/2024:03:5{i}:00 +0000] "GET /products HTTP/1.1" 200 12450 "-" "Mozilla/5.0"' | |
| ) | |
| return "\n".join(base_lines) + "\n" | |
| def _seed_nginx_webshell() -> str: | |
| """Nginx access.log with webshell upload + command execution.""" | |
| base = [ | |
| '10.0.1.5 - - [20/Nov/2024:14:00:00 +0000] "GET / HTTP/1.1" 200 4521 "-" "Mozilla/5.0"', | |
| '10.0.1.5 - - [20/Nov/2024:14:00:30 +0000] "GET /upload HTTP/1.1" 200 2100 "-" "Mozilla/5.0"', | |
| '10.0.1.6 - - [20/Nov/2024:14:01:00 +0000] "GET /products HTTP/1.1" 200 3200 "-" "Mozilla/5.0"', | |
| ] | |
| attacker = "198.51.100.77" | |
| base.extend([ | |
| f'{attacker} - - [20/Nov/2024:14:05:01 +0000] "GET /upload HTTP/1.1" 200 2100 "-" "Mozilla/5.0"', | |
| f'{attacker} - - [20/Nov/2024:14:05:30 +0000] "GET /upload HTTP/1.1" 200 2100 "-" "Mozilla/5.0"', | |
| f'{attacker} - - [20/Nov/2024:14:07:00 +0000] "GET /upload?file=test.txt HTTP/1.1" 200 2100 "-" "Mozilla/5.0"', | |
| f'{attacker} - - [20/Nov/2024:14:08:33 +0000] "POST /upload HTTP/1.1" 201 124 "-" "curl/7.68.0"', | |
| f'{attacker} - - [20/Nov/2024:14:09:01 +0000] "GET /uploads/shell.php HTTP/1.1" 200 12 "-" "curl/7.68.0"', | |
| f'{attacker} - - [20/Nov/2024:14:09:15 +0000] "POST /uploads/shell.php HTTP/1.1" 200 45 "-" "curl/7.68.0"', | |
| f'{attacker} - - [20/Nov/2024:14:09:16 +0000] "POST /uploads/shell.php?cmd=id HTTP/1.1" 200 87 "-" "curl/7.68.0"', | |
| f'{attacker} - - [20/Nov/2024:14:09:30 +0000] "POST /uploads/shell.php?cmd=cat%20/etc/passwd HTTP/1.1" 200 1820 "-" "curl/7.68.0"', | |
| f'{attacker} - - [20/Nov/2024:14:10:00 +0000] "POST /uploads/shell.php?cmd=whoami HTTP/1.1" 200 8 "-" "curl/7.68.0"', | |
| f'{attacker} - - [20/Nov/2024:14:10:30 +0000] "POST /uploads/shell.php?cmd=uname%20-a HTTP/1.1" 200 215 "-" "curl/7.68.0"', | |
| f'{attacker} - - [20/Nov/2024:14:11:00 +0000] "GET /admin HTTP/1.1" 403 162 "-" "Mozilla/5.0"', | |
| f'{attacker} - - [20/Nov/2024:14:11:30 +0000] "POST /uploads/shell.php?cmd=chmod%20777%20/etc/shadow HTTP/1.1" 200 0 "-" "curl/7.68.0"', | |
| ]) | |
| return "\n".join(base) + "\n" | |
| def _seed_syslog_c2() -> str: | |
| """Syslog showing C2 beaconing over DNS.""" | |
| lines = [] | |
| # Normal syslog noise | |
| for i in range(20): | |
| lines.append(f"Jan 15 02:{i:02d}:00 server01 kernel: [12345.{i}] TCP: peer closed connection") | |
| lines.extend([ | |
| "Jan 15 02:30:00 server01 named[1523]: client @0x7f8b8c001234 10.0.5.42#54321: query: evil-c2-server.xyz IN A +E(0)K (10.0.0.1)", | |
| "Jan 15 02:30:15 server01 named[1523]: client @0x7f8b8c001234 10.0.5.42#54322: query: evil-c2-server.xyz IN A +E(0)K (10.0.0.1)", | |
| "Jan 15 02:30:30 server01 named[1523]: client @0x7f8b8c001234 10.0.5.42#54323: query: evil-c2-server.xyz IN A +E(0)K (10.0.0.1)", | |
| "Jan 15 02:32:17 server01 sshd[3421]: Accepted password for ops from 10.0.5.42 port 51234 ssh2", | |
| "Jan 15 02:33:17 server01 named[1523]: client @0x7f8b8c001234 10.0.5.42#54324: query: x8f3k2.evil-c2-server.xyz IN A +E(0)K (10.0.0.1)", | |
| "Jan 15 02:33:30 server01 kernel: [98765.1] audit: type=1400 audit(1705306417.123:42): apparmor=\"DENIED\" operation=\"open\" profile=\"/usr/sbin/named\" name=\"/tmp/.cache.bin\" pid=3421", | |
| "Jan 15 02:33:45 server01 named[1523]: client @0x7f8b8c001234 10.0.5.42#54325: query: x9a2m7.evil-c2-server.xyz IN A +E(0)K (10.0.0.1)", | |
| "Jan 15 02:34:00 server01 cron[8990]: (root) CMD (/usr/local/bin/heartbeat.sh)", | |
| "Jan 15 02:34:15 server01 named[1523]: client @0x7f8b8c001234 10.0.5.42#54326: query: x1b9n3.evil-c2-server.xyz IN A +E(0)K (10.0.0.1)", | |
| "Jan 15 02:35:00 server01 sshd[3500]: Failed password for root from 10.0.5.42 port 51240 ssh2", | |
| "Jan 15 02:35:30 server01 named[1523]: client @0x7f8b8c001234 10.0.5.42#54327: query: x5p2q8.evil-c2-server.xyz IN A +E(0)K (10.0.0.1)", | |
| "Jan 15 02:36:00 server01 named[1523]: client @0x7f8b8c001234 10.0.5.42#54328: query: evil-c2-server.xyz IN TXT +E(0)K (10.0.0.1)", | |
| "Jan 15 02:36:15 server01 kernel: [98770.5] TCP: request_sock_TCP: Established connection from 10.0.5.42:4444 to 198.51.100.99:443", | |
| "Jan 15 02:36:45 server01 named[1523]: client @0x7f8b8c001234 10.0.5.42#54329: query: x7k4t6.evil-c2-server.xyz IN A +E(0)K (10.0.0.1)", | |
| ]) | |
| return "\n".join(lines) + "\n" | |
| def _seed_firewall_exfil() -> str: | |
| """Firewall log showing large outbound transfer.""" | |
| lines = [ | |
| "# Firewall Log - NetFilter", | |
| "# Format: timestamp action src_ip:src_port -> dst_ip:dst_port proto bytes", | |
| ] | |
| # Normal traffic | |
| for i in range(15): | |
| lines.append(f"2024-11-08T00:30:{i:02d}Z ALLOW 10.0.3.{10+i}:443 -> 142.250.190.78:443 TCP 12450") | |
| # Exfiltration | |
| lines.extend([ | |
| "2024-11-08T01:14:30Z ALLOW 10.0.3.118:51234 -> 198.51.100.200:443 TCP 14500", | |
| "2024-11-08T01:14:45Z ALLOW 10.0.3.118:51234 -> 198.51.100.200:443 TCP 245000000", | |
| "2024-11-08T01:14:55Z ALLOW 10.0.3.118:51234 -> 198.51.100.200:443 TCP 512000000", | |
| "2024-11-08T01:15:00Z ALLOW 10.0.3.118:51234 -> 198.51.100.200:443 TCP 1024000000", | |
| "2024-11-08T01:15:15Z ALLOW 10.0.3.118:51234 -> 198.51.100.200:443 TCP 2048000000", | |
| "2024-11-08T01:15:30Z ALLOW 10.0.3.118:51234 -> 198.51.100.200:443 TCP 512000000", | |
| "2024-11-08T01:15:45Z CLOSE 10.0.3.118:51234 -> 198.51.100.200:443 TCP 0", | |
| "2024-11-08T01:16:00Z ALLOW 10.0.3.118:51235 -> 198.51.100.200:8443 TCP 14500", | |
| "2024-11-08T01:16:15Z ALLOW 10.0.3.118:51235 -> 198.51.100.200:8443 TCP 14500", | |
| ]) | |
| return "\n".join(lines) + "\n" | |
| def _seed_waf_xss() -> str: | |
| """WAF log with XSS attempts.""" | |
| lines = [ | |
| "# ModSecurity WAF Audit Log", | |
| "# Format: timestamp [rule_id] src_ip method path status attack_type", | |
| ] | |
| # Normal requests | |
| for i in range(10): | |
| lines.append(f"2024-10-22T10:0{i}:00Z [200] 10.0.1.{10+i} GET /comments 200 OK") | |
| # XSS attempts from one IP | |
| attacker = "192.0.2.88" | |
| lines.extend([ | |
| f"2024-10-22T10:14:25Z [941100] {attacker} POST /comments 200 XSS Attack", | |
| f"2024-10-22T10:14:30Z [941100] {attacker} POST /comments 200 XSS Attack", | |
| f"2024-10-22T10:14:35Z [941100] {attacker} POST /comments 200 XSS Attack", | |
| f"2024-10-22T10:14:40Z [941110] {attacker} POST /comments 200 XSS Filter - Category 1: Script Tag", | |
| f"2024-10-22T10:14:45Z [941100] {attacker} POST /comments 200 XSS Attack", | |
| f"2024-10-22T10:14:50Z [941110] {attacker} POST /comments 200 XSS Filter - Category 1: Script Tag", | |
| f"2024-10-22T10:14:55Z [941100] {attacker} POST /comments 200 XSS Attack", | |
| f"2024-10-22T10:15:00Z [941100] {attacker} POST /comments 200 XSS Attack", | |
| f"2024-10-22T10:15:05Z [941100] {attacker} POST /comments 200 XSS Attack", | |
| f"2024-10-22T10:15:10Z [941110] {attacker} POST /comments 200 XSS Filter - Category 1: Script Tag", | |
| f"2024-10-22T10:15:15Z [941100] {attacker} POST /comments 200 XSS Attack", | |
| f"2024-10-22T10:15:20Z [941100] {attacker} GET /comments 200 OK", | |
| f"2024-10-22T10:15:30Z [941100] {attacker} POST /comments 200 XSS Attack", | |
| ]) | |
| return "\n".join(lines) + "\n" | |
| # --------------------------------------------------------------------------- # | |
| # 3. AI Generation (3-tier: Cloudflare -> Groq -> NVIDIA) | |
| # --------------------------------------------------------------------------- # | |
| SYSTEM_PROMPT = """You are a SOC analyst and log forensics expert. Generate a realistic log-analysis challenge for blue-team training. | |
| Generate a {log_type} server log file that contains a clear {attack_type} attack. The log must include: | |
| - 20-50 normal/benign log lines | |
| - 5-20 attack log lines from a single attacker IP | |
| - Realistic timestamps and formatting matching {log_type} log conventions | |
| CRITICAL REQUIREMENTS: | |
| 1. Respond with ONLY valid JSON. No markdown, no code fences, no explanation. | |
| 2. "log_content" MUST be a complete log file (50-200 lines), with realistic timestamps, no placeholders. | |
| 3. "expected_attack_type" must be one of: sqli, xss, brute-force, webshell, c2, exfiltration, dos, lateral-movement, privilege-escalation, port-scan, rce | |
| 4. "expected_attacker_ip" must be the IP that performs the attack. | |
| 5. "expected_timestamp" must be the timestamp of the FIRST attack line. | |
| 6. "expected_ioc" must be a short indicator of compromise (URL fragment, hash, command, etc). | |
| 7. All text fields (title, story, task_outline, vulnerability_description) MUST be in Arabic. | |
| 8. difficulty must be EXACTLY "مبتدئ" or "متوسط" or "قوي" | |
| 9. hints must be 3 objects: [{{"level":1,"text":"hint 1","xp_cost":20}}, {{"level":2,"text":"hint 2","xp_cost":30}}, {{"level":3,"text":"hint 3","xp_cost":50}}] | |
| UNIQUENESS RULES — ZERO TOLERANCE FOR DUPLICATES: | |
| - Every challenge MUST be completely unique in title, story, log content, and IP addresses. | |
| - NEVER reuse the same server type or application domain. Vary between: web server, database, mail server, DNS, FTP, SSH, API gateway, container orchestrator, IoT device, etc. | |
| - Use DIFFERENT attacker IPs, different timestamps, different log formats each time. | |
| - The log_content MUST contain different benign traffic patterns and different attack payloads each time. | |
| - The title and story MUST describe a completely different investigation scenario. | |
| PREVIOUS RECENT TITLES TO AVOID (DO NOT REUSE ANY OF THESE OR SIMILAR CONCEPTS): | |
| {blacklisted_titles} | |
| Return ONLY this JSON: | |
| {{"title":"...","story":"...","task_outline":"...","log_content":"...","vulnerability_description":"...","expected_attack_type":"...","expected_attacker_ip":"...","expected_timestamp":"...","expected_ioc":"...","difficulty":"متوسط","hints":[{{"level":1,"text":"...","xp_cost":20}},{{"level":2,"text":"...","xp_cost":30}},{{"level":3,"text":"...","xp_cost":50}}]}} | |
| """ | |
| async def _post_with_json_fallback(client, url: str, payload: dict, headers: dict, provider_name: str) -> Optional[httpx.Response]: | |
| """POST with response_format. If 4xx, retry without.""" | |
| resp = await client.post(url, json=payload, headers=headers) | |
| if resp.status_code in (400, 404, 422) and "response_format" in payload: | |
| print(f" [{provider_name}] response_format not supported ({resp.status_code}), retrying without...") | |
| payload2 = {k: v for k, v in payload.items() if k != "response_format"} | |
| resp = await client.post(url, json=payload2, headers=headers) | |
| return resp | |
| async def _try_mistral(prompt: str, system: str, model: str) -> Optional[str]: | |
| """Try Mistral API (mistral-large-latest by default). | |
| Per the user's request, Mistral is the PRIMARY tier for challenge | |
| generation — it runs first in the orchestrator. Only on a hard | |
| failure (429 / timeout / 5xx / parse) do we fall through to | |
| Cloudflare, then Groq, then NVIDIA/DeepSeek, then seed. | |
| """ | |
| if not MISTRAL_API_KEY: | |
| return None | |
| try: | |
| headers = { | |
| "Content-Type": "application/json", | |
| "Authorization": f"Bearer {MISTRAL_API_KEY}", | |
| } | |
| messages = [] | |
| if system: | |
| messages.append({"role": "system", "content": system}) | |
| messages.append({"role": "user", "content": prompt}) | |
| payload = { | |
| "model": model or MISTRAL_MODEL, | |
| "messages": messages, | |
| "temperature": 0.7, | |
| "max_tokens": 2500, | |
| "response_format": {"type": "json_object"}, | |
| } | |
| async with httpx.AsyncClient(timeout=30) as client: | |
| resp = await _post_with_json_fallback(client, MISTRAL_API_URL, payload, headers, "mistral") | |
| if resp.status_code == 200: | |
| data = resp.json() | |
| content = data.get("choices", [{}])[0].get("message", {}).get("content") | |
| if content: | |
| return content | |
| print(f" [mistral] 200 but empty content: {str(data)[:200]}") | |
| return None | |
| if resp.status_code == 429: | |
| print(f" [mistral] 429 rate-limited") | |
| else: | |
| print(f" [mistral] error {resp.status_code}: {resp.text[:200]}") | |
| return None | |
| except Exception as e: | |
| print(f" [mistral] exception: {type(e).__name__}: {e}") | |
| return None | |
| async def _try_cloudflare(prompt: str, system: str) -> Optional[str]: | |
| """Try Cloudflare Workers AI. | |
| Speed-first strategy: | |
| - Race 3 FAST models in parallel (qwen-coder-32b + gpt-oss-20b + llama-3.1-8b) | |
| - 25s timeout each, max_tokens=2500 | |
| - First valid response wins | |
| - Skip 70B/120B entirely (too slow for our 15s target) | |
| If all 3 fail, returns None and caller falls back to Groq/NVIDIA/seed. | |
| """ | |
| if not (CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID): | |
| return None | |
| # Fast models only (skip 70B/120B - too slow for 15s target) | |
| fast_models = [ | |
| "@cf/qwen/qwen2.5-coder-32b-instruct", # 32B, smart | |
| "@cf/openai/gpt-oss-20b", # 20B, OpenAI compat | |
| "@cf/meta/llama-3.1-8b-instruct", # 8B, fastest | |
| ] | |
| if CLOUDFLARE_MODEL and CLOUDFLARE_MODEL not in fast_models: | |
| fast_models.insert(0, CLOUDFLARE_MODEL) | |
| messages = [] | |
| if system: | |
| messages.append({"role": "system", "content": system}) | |
| messages.append({"role": "user", "content": prompt}) | |
| payload_base = {"messages": messages, "temperature": 0.7, "max_tokens": 2500} | |
| async def _call_one(model_name: str) -> Optional[str]: | |
| url = f"https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/run/{model_name}" | |
| headers = { | |
| "Content-Type": "application/json", | |
| "Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}", | |
| } | |
| payload = {**payload_base, "response_format": {"type": "json_object"}} | |
| try: | |
| async with httpx.AsyncClient(timeout=15) as client: | |
| resp = await _post_with_json_fallback(client, url, payload, headers, f"cf/{model_name.split('/')[-1]}") | |
| if resp.status_code == 200: | |
| result = resp.json() | |
| if result.get("success") and result.get("result", {}).get("response"): | |
| return result["result"]["response"] | |
| except asyncio.CancelledError: | |
| raise | |
| except Exception as e: | |
| print(f" [cf/{model_name.split('/')[-1]}] {type(e).__name__}: {e}") | |
| return None | |
| async def _race_one(model_name: str): | |
| return model_name, await _call_one(model_name) | |
| # True race: cancel losers as soon as one returns a valid response | |
| tasks = [asyncio.create_task(_race_one(m)) for m in fast_models] | |
| try: | |
| for fut in asyncio.as_completed(tasks, timeout=8): | |
| try: | |
| model_name, response = await fut | |
| if response: | |
| for t in tasks: | |
| if not t.done(): | |
| t.cancel() | |
| print(f" [cf/race] winner: {model_name.split('/')[-1]}") | |
| return response | |
| except asyncio.CancelledError: | |
| continue | |
| except Exception: | |
| continue | |
| except asyncio.TimeoutError: | |
| pass | |
| finally: | |
| for t in tasks: | |
| if not t.done(): | |
| t.cancel() | |
| return None | |
| async def _try_groq(prompt: str, system: str, model: str) -> Optional[str]: | |
| if not GROQ_API_KEY: | |
| return None | |
| try: | |
| messages = [] | |
| if system: | |
| messages.append({"role": "system", "content": system}) | |
| messages.append({"role": "user", "content": prompt}) | |
| payload = { | |
| "model": model or GROQ_MODEL, | |
| "messages": messages, | |
| "temperature": 0.7, | |
| "max_tokens": 2500, | |
| "response_format": {"type": "json_object"}, | |
| } | |
| async with httpx.AsyncClient(timeout=25) as client: | |
| resp = await _post_with_json_fallback(client, GROQ_API_URL, payload, { | |
| "Content-Type": "application/json", | |
| "Authorization": f"Bearer {GROQ_API_KEY}", | |
| }, "groq") | |
| if resp.status_code == 200: | |
| data = resp.json() | |
| content = data.get("choices", [{}])[0].get("message", {}).get("content") | |
| if content: | |
| return content | |
| return None | |
| except Exception as e: | |
| print(f" [groq] {type(e).__name__}: {e}") | |
| return None | |
| async def _try_nvidia(prompt: str, system: str, model: str) -> Optional[str]: | |
| if not NVIDIA_API_KEY: | |
| return None | |
| try: | |
| messages = [] | |
| if system: | |
| messages.append({"role": "system", "content": system}) | |
| messages.append({"role": "user", "content": prompt}) | |
| payload = { | |
| "model": model or NVIDIA_MODEL, | |
| "messages": messages, | |
| "temperature": 0.7, | |
| "max_tokens": 2500, | |
| "response_format": {"type": "json_object"}, | |
| } | |
| async with httpx.AsyncClient(timeout=25) as client: | |
| resp = await _post_with_json_fallback(client, NVIDIA_API_URL, payload, { | |
| "Content-Type": "application/json", | |
| "Authorization": f"Bearer {NVIDIA_API_KEY}", | |
| }, "deepseek") | |
| if resp.status_code == 200: | |
| data = resp.json() | |
| content = data.get("choices", [{}])[0].get("message", {}).get("content") | |
| if content: | |
| return content | |
| return None | |
| except Exception as e: | |
| print(f" [deepseek] {type(e).__name__}: {e}") | |
| return None | |
| async def _call_ai(prompt: str, system: str = "") -> Optional[str]: | |
| """Try Mistral -> Cloudflare -> Groq -> NVIDIA. Returns first success.""" | |
| print(f" [ai] Trying Mistral...") | |
| r = await _try_mistral(prompt, system, "") | |
| if r: | |
| return r | |
| print(f" [ai] Trying Cloudflare...") | |
| r = await _try_cloudflare(prompt, system) | |
| if r: | |
| return r | |
| print(f" [ai] Trying Groq...") | |
| r = await _try_groq(prompt, system, "") | |
| if r: | |
| return r | |
| print(f" [ai] Trying NVIDIA/DeepSeek...") | |
| r = await _try_nvidia(prompt, system, "") | |
| if r: | |
| return r | |
| return None | |
| def _try_parse_ai_output(raw) -> Optional[dict]: | |
| if not raw: | |
| return None | |
| if isinstance(raw, dict): | |
| return raw | |
| try: | |
| return parse_json_safe(raw) | |
| except Exception as e: | |
| print(f" [ai] parse_json_safe failed: {e}") | |
| return None | |
| def _validate_and_build(data: dict, log_type: str, attack_type: str) -> Optional[dict]: | |
| """Validate AI response and build a row dict.""" | |
| required = ["title", "story", "task_outline", "log_content", "expected_attack_type", "expected_attacker_ip", "vulnerability_description", "difficulty"] | |
| for field in required: | |
| if not data.get(field): | |
| print(f" [ai] Missing field: {field}") | |
| return None | |
| log_content = data.get("log_content", "") | |
| if len(log_content) < 200: | |
| print(f" [ai] Rejected: log_content too short ({len(log_content)} chars)") | |
| return None | |
| line_count = log_content.count('\n') + 1 | |
| if line_count < 20: | |
| print(f" [ai] Rejected: log_content has only {line_count} lines") | |
| return None | |
| if data["difficulty"] not in ALLOWED_DIFFICULTIES: | |
| data["difficulty"] = "متوسط" | |
| hints = data.get("hints") or [] | |
| if isinstance(hints, str): | |
| try: | |
| hints = json.loads(hints) | |
| except Exception: | |
| hints = [] | |
| if not isinstance(hints, list): | |
| hints = [] | |
| return { | |
| "team_role": "blue", | |
| "log_type": log_type, | |
| "module": MODULE_BY_LOG_TYPE.get(log_type, "forensics"), | |
| "title": data["title"], | |
| "story": data["story"], | |
| "task_outline": data["task_outline"], | |
| "log_content": log_content, | |
| "expected_attack_type": data["expected_attack_type"], | |
| "expected_attacker_ip": data.get("expected_attacker_ip", ""), | |
| "expected_timestamp": data.get("expected_timestamp", ""), | |
| "expected_ioc": data.get("expected_ioc", ""), | |
| "vulnerability_description": data.get("vulnerability_description", ATTACK_DESCRIPTIONS_AR.get(attack_type, attack_type)), | |
| "hints": hints, | |
| "difficulty": data["difficulty"], | |
| "xp_reward": data.get("xp_reward") or (200 if data["difficulty"] == "قوي" else 150 if data["difficulty"] == "متوسط" else 100), | |
| } | |
| async def ai_generate_challenge(log_type: str, attack_type: str, blacklisted_titles: str = "") -> Optional[dict]: | |
| """Use AI to generate a log-analysis challenge. 3 rounds of fallback.""" | |
| system = SYSTEM_PROMPT.format(log_type=log_type, attack_type=attack_type, blacklisted_titles=blacklisted_titles) | |
| base_prompt = f"ولّد سجل {log_type} يحتوي على هجوم {attack_type} وفق الهيكل المطلوب." | |
| # Round 1 | |
| raw = await _call_ai(base_prompt, system) | |
| data = _try_parse_ai_output(raw) | |
| built = _validate_and_build(data, log_type, attack_type) if data else None | |
| if built: | |
| return built | |
| # Round 2: stricter | |
| retry_prompt = ( | |
| f"CRITICAL: Return ONLY valid JSON, no markdown. Generate a {log_type} log with {attack_type} attack. " | |
| f"Required keys: title, story, task_outline, log_content (50-200 line log file), vulnerability_description, " | |
| f"expected_attack_type, expected_attacker_ip, expected_timestamp, expected_ioc, difficulty, hints." | |
| ) | |
| raw2 = await _call_ai(retry_prompt, system) | |
| data2 = _try_parse_ai_output(raw2) | |
| built2 = _validate_and_build(data2, log_type, attack_type) if data2 else None | |
| if built2: | |
| return built2 | |
| # Round 3: minimal | |
| minimal = f"Output JSON only. {{log_type={log_type}, attack={attack_type}}}. Required: title, story, task_outline, log_content (50+ line log), vulnerability_description, expected_attack_type, expected_attacker_ip, expected_timestamp, expected_ioc, difficulty (مبتدئ|متوسط|قوي), hints (3 objects with level,text,xp_cost). Arabic in all text fields." | |
| raw3 = await _call_ai(minimal, system) | |
| data3 = _try_parse_ai_output(raw3) | |
| built3 = _validate_and_build(data3, log_type, attack_type) if data3 else None | |
| if built3: | |
| return built3 | |
| print(f" [ai] All rounds exhausted for {log_type}/{attack_type}") | |
| return None | |
| # --------------------------------------------------------------------------- # | |
| # 4. DB & Storage Operations | |
| # --------------------------------------------------------------------------- # | |
| async def get_pool_count(team_role: str) -> int: | |
| if not SUPABASE_ANON_KEY or not SUPABASE_URL: | |
| return 0 | |
| url = f"{SUPABASE_URL}/rest/v1/{TABLE_NAME}?select=id&team_role=eq.{team_role}" | |
| try: | |
| async with httpx.AsyncClient() as client: | |
| resp = await client.get(url, headers=supabase_headers()) | |
| if resp.status_code == 200: | |
| return len(resp.json()) | |
| except Exception as e: | |
| print(f"[log-analysis] Error checking pool count: {e}") | |
| return 0 | |
| async def _insert_to_db(row: dict) -> bool: | |
| if not SUPABASE_ANON_KEY or not SUPABASE_URL: | |
| return False | |
| from app.core.config import normalize_row_module | |
| row = normalize_row_module(TABLE_NAME, row) | |
| from app.services.insert_guard import atomic_insert | |
| from app.services.dedup import is_duplicate_log_analysis | |
| team_role = row.get("team_role", "blue") | |
| return await atomic_insert( | |
| table=TABLE_NAME, | |
| team_role=team_role, | |
| row=row, | |
| dedup_func=is_duplicate_log_analysis, | |
| dedup_args=[ | |
| row.get("title", ""), | |
| row.get("log_content", ""), | |
| row.get("task_outline", ""), | |
| ], | |
| dedup_kwargs={"role_filter": team_role}, | |
| ) | |
| async def _delete_challenge(challenge_id: str): | |
| if not SUPABASE_ANON_KEY or not SUPABASE_URL: | |
| return | |
| url = f"{SUPABASE_URL}/rest/v1/{TABLE_NAME}?id=eq.{challenge_id}" | |
| try: | |
| async with httpx.AsyncClient() as client: | |
| await client.delete(url, headers=supabase_headers()) | |
| except Exception as e: | |
| print(f" DB delete error: {e}") | |
| async def _delete_storage_file(storage_path: str): | |
| if not SUPABASE_ANON_KEY or not SUPABASE_URL or not storage_path: | |
| return | |
| url = f"{SUPABASE_URL}/storage/v1/object/{STORAGE_BUCKET}/{storage_path}" | |
| headers = { | |
| "apikey": SUPABASE_ANON_KEY, | |
| "Authorization": f"Bearer {SUPABASE_ANON_KEY}", | |
| } | |
| try: | |
| async with httpx.AsyncClient(timeout=30) as client: | |
| await client.delete(url, headers=headers) | |
| except Exception as e: | |
| print(f" Storage delete error: {e}") | |
| # --------------------------------------------------------------------------- # | |
| # 5. Pool refill & watcher | |
| # --------------------------------------------------------------------------- # | |
| def _seed_for(log_type: str, attack_type: str) -> Optional[dict]: | |
| """Find a matching seed for the given log_type + attack_type.""" | |
| for seed in _build_seeds(): | |
| if seed["log_type"] == log_type and seed["attack_type"] == attack_type: | |
| return seed | |
| # Fallback: any seed for the log type | |
| for seed in _build_seeds(): | |
| if seed["log_type"] == log_type: | |
| return seed | |
| return None | |
| async def _seed_to_row(seed: dict) -> dict: | |
| """Convert a seed dict to a DB row (upload log to Storage, return row).""" | |
| challenge_id = str(uuid.uuid4()) | |
| storage_path = f"blue/log-analysis/{challenge_id}.log" | |
| log_content = seed["log_content"] | |
| file_size = len(log_content.encode("utf-8")) | |
| uploaded = await _upload_log_to_storage(storage_path, log_content) | |
| if not uploaded: | |
| # Fallback: store content inline in storage_path field with a marker prefix | |
| storage_path = f"inline://{storage_path}" | |
| return { | |
| "id": challenge_id, | |
| "team_role": "blue", | |
| "log_type": seed["log_type"], | |
| "module": MODULE_BY_LOG_TYPE.get(seed["log_type"], "forensics"), | |
| "title": seed["title"], | |
| "story": seed["story"], | |
| "task_outline": seed["task_outline"], | |
| "storage_path": storage_path, | |
| "file_size_bytes": file_size, | |
| "log_metadata": { | |
| "log_type": seed["log_type"], | |
| "line_count": log_content.count("\n") + 1, | |
| "source": "seed", | |
| }, | |
| "expected_attack_type": seed["expected_attack_type"], | |
| "expected_attacker_ip": seed.get("expected_attacker_ip"), | |
| "expected_timestamp": seed.get("expected_timestamp"), | |
| "expected_ioc": seed.get("expected_ioc"), | |
| "vulnerability_description": seed["vulnerability_description"], | |
| "hints": seed.get("hints", []), | |
| "difficulty": seed["difficulty"], | |
| "xp_reward": seed.get("xp_reward", 150), | |
| } | |
| async def _ai_to_row(built: dict, team_role: str = "blue", log_type: str = "") -> dict: | |
| """Convert AI-built dict to DB row (upload log to Storage). | |
| Note: `built` contains the raw AI JSON, which includes `log_content` (the | |
| full log file). We must NOT send that to the DB — it's stored in | |
| Supabase Storage instead. We pop it and upload it, then build a clean row | |
| that only has columns present in the log_analysis_challenges table. | |
| """ | |
| log_content = built.pop("log_content", "") | |
| challenge_id = str(uuid.uuid4()) | |
| storage_path = f"{team_role}/log-analysis/{challenge_id}.log" | |
| file_size = len(log_content.encode("utf-8")) if log_content else 0 | |
| uploaded = False | |
| if log_content: | |
| uploaded = await _upload_log_to_storage(storage_path, log_content) | |
| if not log_content or not uploaded: | |
| # Keep row, mark storage_path as inline marker | |
| storage_path = f"inline://{storage_path}" | |
| return { | |
| "id": challenge_id, | |
| "team_role": team_role, | |
| "log_type": log_type or built.get("log_type", "auth"), | |
| "module": MODULE_BY_LOG_TYPE.get(log_type or built.get("log_type", "auth"), "forensics"), | |
| "title": built.get("title", ""), | |
| "story": built.get("story", ""), | |
| "task_outline": built.get("task_outline", ""), | |
| "storage_path": storage_path, | |
| "file_size_bytes": file_size, | |
| "log_metadata": { | |
| "log_type": log_type or built.get("log_type", "auth"), | |
| "line_count": log_content.count("\n") + 1 if log_content else 0, | |
| "source": "ai", | |
| }, | |
| "expected_attack_type": built.get("expected_attack_type", ""), | |
| "expected_attacker_ip": built.get("expected_attacker_ip"), | |
| "expected_timestamp": built.get("expected_timestamp"), | |
| "expected_ioc": built.get("expected_ioc"), | |
| "vulnerability_description": built.get("vulnerability_description", ""), | |
| "hints": built.get("hints", []), | |
| "difficulty": built.get("difficulty", "متوسط"), | |
| "xp_reward": built.get("xp_reward", 150), | |
| } | |
| async def _refill_pool_inner(team_role: str, count: int) -> int: | |
| """Unlocked inner — caller must hold _get_pool_lock(team_role).""" | |
| if _AI_BACKOFF_UNTIL.get(team_role, 0) > time.time(): | |
| print(f"[log-analysis] {team_role} in AI backoff, using seeds only") | |
| return await _refill_with_seeds_only(team_role, count) | |
| base_count = await get_pool_count(team_role) | |
| needed = max(0, POOL_TARGET - base_count) | |
| target = min(count, needed) | |
| if target <= 0: | |
| return 0 | |
| recent_titles = [] | |
| try: | |
| from app.services.dedup import fetch_existing_titles | |
| recent_titles = fetch_existing_titles("log_analysis_challenges", team_role, limit=30) | |
| except Exception as e: | |
| print(f"[log-analysis] Failed to fetch recent titles: {e}") | |
| blacklisted_titles_str = "\n".join([f"- {t}" for t in recent_titles]) if recent_titles else "لا يوجد عناوين سابقة حالياً." | |
| async def _gen_one(i: int) -> bool: | |
| log_type, attack_type = _pick_rotation_slot(base_count + i) | |
| built = None | |
| try: | |
| built = await ai_generate_challenge(log_type, attack_type, blacklisted_titles=blacklisted_titles_str) | |
| except Exception as e: | |
| print(f" [log-analysis] AI exception: {e}") | |
| if built: | |
| row = await _ai_to_row(built, team_role=team_role, log_type=log_type) | |
| if await _insert_to_db(row): | |
| print(f" [+] Inserted AI: {row['title']}") | |
| return True | |
| # Fallback to seed | |
| seed = _seed_for(log_type, attack_type) | |
| if seed: | |
| rand_tag = uuid.uuid4().hex[:6] | |
| seed = dict(seed) | |
| seed["title"] = f"{seed.get('title', 'تحليل السجلات')} - رمز {rand_tag}" | |
| seed["story"] = seed.get("story", "") + f" [معرف التدقيق: {rand_tag}]" | |
| row = await _seed_to_row(seed) | |
| if await _insert_to_db(row): | |
| print(f" [+] Inserted seed: {row['title']}") | |
| return True | |
| return False | |
| results = [] | |
| for i in range(target): | |
| results.append(await _gen_one(i)) | |
| await asyncio.sleep(2.0) | |
| return sum(1 for r in results if r) | |
| async def refill_pool(team_role: str, count: int) -> int: | |
| async with _get_pool_lock(team_role): | |
| return await _refill_pool_inner(team_role, count) | |
| async def _refill_with_seeds_only(team_role: str, count: int) -> int: | |
| inserted = 0 | |
| seeds = _build_seeds() | |
| for i in range(count): | |
| seed = seeds[i % len(seeds)] | |
| row = await _seed_to_row(seed) | |
| if await _insert_to_db(row): | |
| print(f" [+] Inserted seed (backoff): {row['title']}") | |
| inserted += 1 | |
| return inserted | |
| async def start_pool_watcher(team_role: str): | |
| """Background watcher — keeps the pool at POOL_TARGET (5) for {team_role}.""" | |
| if team_role not in ALLOWED_TEAMS: | |
| return | |
| if team_role in _WATCHER_STARTED: | |
| print(f"[log-analysis:{team_role}] watcher already running — skipping duplicate start.") | |
| return | |
| _WATCHER_STARTED.add(team_role) | |
| print(f"[log-analysis] Pool watcher started for '{team_role}' (target={POOL_TARGET}, threshold={POOL_THRESHOLD}, batch={POOL_BATCH})") | |
| try: | |
| while True: | |
| try: | |
| sleep_secs = 30 | |
| async with _get_pool_lock(team_role): | |
| count = await get_pool_count(team_role) | |
| if count <= POOL_THRESHOLD: | |
| print(f"[log-analysis] Pool at {count}/{POOL_TARGET} (≤ {POOL_THRESHOLD}), refilling {POOL_BATCH}…") | |
| added = await _refill_pool_inner(team_role, POOL_BATCH) | |
| print(f"[log-analysis] Refill done: +{added} (now {count + added}/{POOL_TARGET})") | |
| sleep_secs = 2 | |
| await asyncio.sleep(sleep_secs) | |
| except Exception as e: | |
| print(f"[log-analysis] Watcher error: {e}") | |
| await asyncio.sleep(10) | |
| finally: | |
| _WATCHER_STARTED.discard(team_role) | |
| # --------------------------------------------------------------------------- # | |
| # 6. CLI | |
| # --------------------------------------------------------------------------- # | |
| def main(): | |
| parser = argparse.ArgumentParser(description="Log Analysis Challenge Generator") | |
| parser.add_argument("--team", default="blue", choices=["blue"]) | |
| parser.add_argument("--seed-only", action="store_true", help="Only insert seeds, no AI") | |
| parser.add_argument("--ai", action="store_true", help="Generate via AI") | |
| parser.add_argument("--count", type=int, default=1) | |
| args = parser.parse_args() | |
| async def _run(): | |
| if args.seed_only: | |
| seeds = _build_seeds() | |
| for seed in seeds[:args.count]: | |
| row = await _seed_to_row(seed) | |
| ok = await _insert_to_db(row) | |
| print(f" {'[+]' if ok else '[-]'} Seed: {row['title']}") | |
| elif args.ai: | |
| for i in range(args.count): | |
| log_type, attack_type = _pick_rotation_slot(i) | |
| print(f" Generating: {log_type} / {attack_type}") | |
| built = await ai_generate_challenge(log_type, attack_type) | |
| if built: | |
| row = await _ai_to_row(built) | |
| ok = await _insert_to_db(row) | |
| print(f" {'[+]' if ok else '[-]'} AI: {row['title']}") | |
| else: | |
| seed = _seed_for(log_type, attack_type) | |
| if seed: | |
| row = await _seed_to_row(seed) | |
| ok = await _insert_to_db(row) | |
| print(f" {'[+]' if ok else '[-]'} Seed fallback: {row['title']}") | |
| asyncio.run(_run()) | |
| if __name__ == "__main__": | |
| main() | |