"""
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 يحتوي على