CyberArena / app /generators /steganography.py
Hussien Haider
H
8d0f2a3
Raw
History Blame Contribute Delete
36.4 kB
"""
steganography_generator.py
==========================
Generates dynamic steganography (information hiding) challenges for the CyberArena pool.
Hiding methods:
1. EOF: Append the flag bytes to the end of the image file.
2. EXIF: Inject the flag into the JPEG Comment (COM) segment manually.
3. ZIP: Append a zip archive containing flag.txt to the image file.
"""
from __future__ import annotations
import argparse
import asyncio
import base64
import hashlib
import json
import random
import sys
import uuid
import zipfile
import io
from dataclasses import dataclass
from typing import Optional, Literal
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 []
# Load .env early
try:
from app._env import load_app_env
load_app_env()
except Exception:
pass
from app.generators.crypto import _repair_json
from app.core.config import (
SUPABASE_URL, SUPABASE_ANON_KEY,
CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_MODEL,
GROQ_API_KEY, GROQ_MODEL,
NVIDIA_API_KEY, NVIDIA_MODEL,
MISTRAL_API_KEY, MISTRAL_MODEL, MISTRAL_API_URL,
)
# --------------------------------------------------------------------------- #
# 0. Constants
# --------------------------------------------------------------------------- #
ALLOWED_DIFFICULTIES = ("مبتدئ", "متوسط", "قوي")
ALLOWED_TEAMS = ("red",)
POOL_TARGET = 5
POOL_THRESHOLD = 2
POOL_BATCH = 3
IDLE_POLL_SECS = 60
WATCHER_POLL_SECS = 60
_AI_BACKOFF_UNTIL: dict[str, float] = {}
_POOL_LOCKS: dict[str, asyncio.Lock] = {}
_WATCHER_STARTED: set[tuple[str, str]] = set()
# Curated Unsplash images for cyber themes
UNSPLASH_THEME_URLS = [
"https://images.unsplash.com/photo-1526374965328-7f61d4dc18c5?w=600&auto=format&fit=crop", # matrix/cyber
"https://images.unsplash.com/photo-1550751827-4bd374c3f58b?w=600&auto=format&fit=crop", # cybersecurity/servers
"https://images.unsplash.com/photo-1563986768609-322da13575f3?w=600&auto=format&fit=crop", # technology/abstract
"https://images.unsplash.com/photo-1510511459019-5dda7724fd87?w=600&auto=format&fit=crop", # hacker/code
"https://images.unsplash.com/photo-1601597111158-2fceff292cdc?w=600&auto=format&fit=crop", # network switch
"https://images.unsplash.com/photo-1526374865147-73cf412a95c5?w=600&auto=format&fit=crop", # cyber circuit
"https://images.unsplash.com/photo-1515879218367-8466d910aaa4?w=600&auto=format&fit=crop", # hacking terminal
"https://images.unsplash.com/photo-1517694712202-14dd9538aa97?w=600&auto=format&fit=crop", # laptop code
]
XP_BY_DIFFICULTY = {"مبتدئ": 100, "متوسط": 150, "قوي": 200}
# --------------------------------------------------------------------------- #
# 1. Hiding Algorithms
# --------------------------------------------------------------------------- #
def generate_fallback_image_bytes(text: str = "APEX CyberArena") -> bytes:
"""Generate a valid dark theme JPEG image using PIL (Pillow) or a tiny binary fallback."""
try:
from PIL import Image, ImageDraw
# Random dark background color to ensure variation
bg_color = (random.randint(5, 20), random.randint(5, 20), random.randint(15, 30))
img = Image.new("RGB", (500, 500), color=bg_color)
draw = ImageDraw.Draw(img)
# Draw dynamic cyber grid lines
grid_color = (random.randint(0, 50), random.randint(150, 255), random.randint(150, 255), 40)
grid_spacing = random.choice([30, 40, 50, 60])
for i in range(0, 500, grid_spacing):
draw.line([(i, 0), (i, 500)], fill=grid_color, width=1)
draw.line([(0, i), (500, i)], fill=grid_color, width=1)
# Draw dynamic abstract geometric patterns
for _ in range(random.randint(3, 8)):
shape_color = (random.randint(50, 255), random.randint(100, 255), random.randint(100, 255))
x0 = random.randint(50, 350)
y0 = random.randint(50, 350)
x1 = x0 + random.randint(50, 150)
y1 = y0 + random.randint(50, 150)
draw.ellipse([x0, y0, x1, y1], outline=shape_color, width=random.choice([1, 2, 3]))
draw.text((30, 30), text, fill=(255, 255, 255))
draw.text((30, 450), f"Security Artifact ID: {uuid.uuid4().hex[:8]}", fill=(120, 120, 120))
buf = io.BytesIO()
img.save(buf, format="JPEG")
return buf.getvalue()
except Exception:
# Minimal 1x1 valid JPEG bytes fallback + random string to guarantee different hash
base_jpeg = (
b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x01\x00`\x00`\x00\x00"
b"\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t\x08"
b"\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a\x1f\x1e"
b"\x1d\x1a\x1c\x1c $.' \",#\x1c\x1c(7),01444\x1f'9=82<.342"
b"\xff\xc0\x00\x0b\x08\x00\x01\x00\x01\x01\x01\x11\x00\xff\xc4\x00"
b"\x1f\x00\x00\x01\x05\x01\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00"
b"\x00\x00\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\xff\xc4\x00"
b"\xb5\x10\x00\x02\x01\x03\x03\x02\x04\x03\x05\x05\x04\x04\x00\x00"
b"\x00\x00\x01\x02\x03\x00\x04\x11\x05\x12!1A\x06\x13"
b"Qa\x07\"q\x142\x81\x91\xa1\x08#B\xb1\xc1\x15R\xd1\xf0"
b"$3br\x82\x16\x17\x18\x19\x1a%&'()*456789:CDEFGHIJSTUVWXYZ"
b"cdefghijstuvwxyz\x83\x84\x85\x86\x87\x88\x89\x8a\x92\x93\x94\x95"
b"\x96\x97\x98\x99\x9a\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xb2\xb3"
b"\xb4\xb5\xb6\xb7\xb8\xb9\xba\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca"
b"\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xe2\xe3\xe4\xe5\xe6\xe7\xe8"
b"\xe9\xea\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xff\xda\x00\x0c\x03"
b"\x01\x00\x02\x11\x03\x11\x00?\x00\xed\xfc\xff\xd9"
)
return base_jpeg + f"\n# ID: {uuid.uuid4().hex}\n".encode("utf-8")
def generate_host_file_bytes(filename: str) -> bytes:
"""Generate dynamic dummy bytes for various stego host carriers (.pdf, .zip, .docx, .png)."""
ext = filename.split(".")[-1].lower()
unique_id = uuid.uuid4().hex[:10]
if ext in ("jpg", "jpeg", "jfif"):
return generate_fallback_image_bytes(f"CyberArena Stock Asset: {unique_id}")
elif ext == "png":
# PNG signature + simple dynamic chunk representation
return b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15c4\x00\x00\x00\nIDATx\x9cc`\x00\x00\x00\x02\x00\x01H\xaf\xa4q\x00\x00\x00\x00IEND\xaeB`\x82" + f"\n# ID: {unique_id}".encode()
elif ext == "pdf":
return (
f"%PDF-1.4\n"
f"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n"
f"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n"
f"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >> endobj\n"
f"4 0 obj << /Length 55 >> stream\n"
f"BT /F1 24 Tf 100 700 Td (APEX Confidential Artifact ID: {unique_id}) Tj ET\n"
f"endstream\n"
f"endobj\n"
f"xref\n"
f"0 5\n"
f"0000000000 65535 f\n"
f"0000000009 00000 n\n"
f"0000000058 00000 n\n"
f"0000000115 00000 n\n"
f"0000000218 00000 n\n"
f"trailer << /Size 5 /Root 1 0 R >>\n"
f"startxref\n"
f"312\n"
f"%%EOF\n"
).encode("utf-8")
elif ext == "zip":
zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, 'w', zipfile.ZIP_DEFLATED) as zip_file:
zip_file.writestr("decoy.txt", f"Decoy file ID: {uuid.uuid4().hex}\n")
return zip_buf.getvalue()
elif ext in ("docx", "xlsx"):
zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, 'w', zipfile.ZIP_DEFLATED) as zip_file:
zip_file.writestr("[Content_Types].xml", f'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/></Types> <!-- ID: {unique_id} -->')
return zip_buf.getvalue()
else:
# Fallback binary text representation
return f"APEX Analysis Report {uuid.uuid4().hex}\n==================================\nThis is a secure system log.\n".encode("utf-8")
async def fetch_random_unsplash_image() -> bytes:
"""Fetch an image from Unsplash. Falls back to generating one if offline."""
url = random.choice(UNSPLASH_THEME_URLS)
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(url)
if r.status_code == 200:
return r.content
except Exception as e:
print(f"[steganography] Unsplash fetch failed: {e}")
return generate_fallback_image_bytes()
def hide_flag_eof(image_bytes: bytes, flag: str) -> bytes:
"""Hiding method 1: Append flag directly to EOF."""
return image_bytes + b"\n" + flag.encode("utf-8") + b"\n"
def hide_flag_exif(image_bytes: bytes, flag: str) -> bytes:
"""Hiding method 2: Inject flag into standard JPEG comment segment manually."""
if image_bytes.startswith(b"\xff\xd8"):
comment_bytes = flag.encode("utf-8")
length = len(comment_bytes) + 2
length_bytes = length.to_bytes(2, byteorder='big')
# FF FE is the JPEG COM marker
com_segment = b"\xff\xfe" + length_bytes + comment_bytes
# Insert segment right after the SOI marker (FF D8)
return image_bytes[0:2] + com_segment + image_bytes[2:]
return hide_flag_eof(image_bytes, flag)
def hide_flag_zip(image_bytes: bytes, flag: str) -> bytes:
"""Hiding method 3: Append a zip containing flag.txt to the end of the image."""
zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, 'w', zipfile.ZIP_DEFLATED) as zip_file:
zip_file.writestr("flag.txt", flag + "\n")
return image_bytes + zip_buf.getvalue()
def hide_flag(image_bytes: bytes, method: str, flag: str) -> bytes:
if method == "EOF":
return hide_flag_eof(image_bytes, flag)
elif method == "EXIF":
return hide_flag_exif(image_bytes, flag)
elif method == "ZIP":
return hide_flag_zip(image_bytes, flag)
raise ValueError(f"Unknown hiding method: {method}")
def generate_random_filename(hiding_method: str) -> str:
"""Generates fully randomized, realistic cybersecurity/corporate filenames."""
prefixes = [
"suspect", "evidence", "leak", "backup", "invoice", "report", "confidential",
"intercept", "transfer", "log", "archive", "secret", "dump", "agent_report",
"network_capture", "shadow_copy", "credentials", "kernel_dump", "key_log",
"system_config", "traffic_log", "threat_intel", "payload", "exfiltration",
"covert_data", "unauthorized_access", "encrypted_db", "device_firmware",
"chat_history", "user_profile", "auth_logs", "database_dump", "cctv_still",
"blueprint", "memo", "contract", "financials", "audit_trail"
]
nouns = [
"data", "file", "record", "payload", "evidence", "intel", "capture", "doc",
"archive", "leak", "image", "packet", "db", "backup", "log", "firmware",
"receipt", "scan", "capture", "export", "attachment", "snapshot", "dump"
]
if hiding_method == "EXIF":
ext = random.choice(["jpg", "jpeg", "jfif"])
else:
ext = random.choice([
"jpg", "jpeg", "png", "pdf", "zip", "docx", "xlsx", "txt", "bin", "dat",
"conf", "db", "tar.gz", "rar", "sys", "bak"
])
pattern = random.choice([
f"{random.choice(prefixes)}_{random.choice(nouns)}",
f"{random.choice(prefixes)}_{random.randint(1000, 9999)}",
f"{random.choice(prefixes)}",
f"{random.choice(nouns)}_{random.choice(prefixes)}_{random.randint(10, 99)}"
])
return f"{pattern}.{ext}"
# --------------------------------------------------------------------------- #
# 2. Challenge Dataclasses
# --------------------------------------------------------------------------- #
@dataclass
class ScenarioSpec:
team_role: str
module: str
difficulty: str
title: str
story: str
task_outline: str
hiding_method: str
hints: list
@dataclass
class Challenge:
team_role: str
module: str
title: str
story: str
task_outline: str
files: dict
file_metadata: dict
command_outputs: dict
hints: list
tools_whitelist: list
flag_hash: str
flag_preview: str
difficulty: str
xp_reward: int
hiding_method: str
image_url: str
topic: str
def to_db_row(self) -> dict:
return {
"team_role": self.team_role,
"module": self.module,
"title": self.title,
"story": self.story,
"task_outline": self.task_outline,
"files": self.files,
"file_metadata": self.file_metadata,
"command_outputs": self.command_outputs,
"hints": self.hints,
"tools_whitelist": self.tools_whitelist,
"flag_hash": self.flag_hash,
"flag_preview": self.flag_preview,
"difficulty": self.difficulty,
"xp_reward": self.xp_reward,
"hiding_method": self.hiding_method,
"image_url": self.image_url,
"topic": self.topic,
}
# --------------------------------------------------------------------------- #
# 3. Curated Seeds
# --------------------------------------------------------------------------- #
CURATED_SEEDS = [
{
"difficulty": "مبتدئ",
"hiding_method": "EOF",
"title": "شفرة الملفات المتروكة",
"story": "اعترض فريق المراقبة الأمنية صورة مشبوهة باسم suspect.jpg تم رفعها إلى خادم غير مصرح به. يُعتقد أن المهاجم استغل ثغرة وقام بلصق العلم في نهاية ملف الصورة دون تعديل واصفاتها.",
"task_outline": "قم بفحص الملف suspect.jpg المرفق. ابحث عن أي نصوص سرية مخفية في نهاية بنية الصورة الثنائية للحصول على العلم (Flag).",
"hints": [
"الملفات الثنائية مثل الصور قد تحتوي أحياناً على نصوص مخفية في نهايتها. هل جربت البحث عن أدوات تقوم باستخراج النصوص المقروءة من الملفات الثنائية؟",
"هناك أداة شائعة في أنظمة لينكس تستخدم لعرض السلاسل النصية المقروءة داخل أي ملف ثنائي. فحص مخرجات هذه الأداة لملف الصورة قد يكشف لك العلم."
]
},
{
"difficulty": "متوسط",
"hiding_method": "ZIP",
"title": "الحقيبة المزدوجة",
"story": "عثر فريق الاستجابة للحوادث على خادم ويب مصاب. المهاجم قام بدمج أرشيف كامل مضغوط يحوي معلومات الاختراق داخل صورة التحدي challenge.jpg كتقنية للتمويه والتخفي.",
"task_outline": "افحص الملف challenge.jpg المرفق لتحديد ما إذا كان يحوي أرشيفاً مضغوطاً مدمجاً في بنيته، ثم استخرج الأرشيف لتجد ملف العلم flag.txt.",
"hints": [
"الامتداد الظاهري للملف ليس دائماً حقيقياً. قد تقوم بعض البرمجيات بدمج أنواع مختلفة من الملفات معاً (مثل دمج أرشيف مضغوط مع صورة). كيف يمكنك التحقق من البنية الحقيقية للملف؟",
"إذا كانت بنية الملف تحتوي على أرشيف مضغوط ملحق بها، فربما تفيدك أدوات فك الضغط المعتادة لاستخراج الملفات المدمجة بداخلها."
]
},
{
"difficulty": "قوي",
"hiding_method": "EXIF",
"title": "واصفات العميل الوصفية",
"story": "أرسل عميل سري صورة طبيعية باسم agent.jpg إلى مركز العمليات. تحليل واصفات الملف أظهر أن الصورة لا تحتوي على أي تعديل في البيانات الثنائية الملحقة، ولكن تم استغلال حقل التعليقات (Comment) في EXIF Metadata لنقل رسالة سرية.",
"task_outline": "افحص بيانات واصفات الملف agent.jpg واستخرج العلم المخفي داخل حقول البيانات الوصفية المدمجة في الصورة (EXIF Metadata).",
"hints": [
"الصور الرقمية تحمل معها بيانات وصفية (Metadata) تصف الكاميرا والتعليقات والبيانات الجغرافية. هل فكرت في فحص هذه البيانات الوصفية للملف؟",
"يمكنك قراءة التعليقات والبيانات الوصفية باستخدام أدوات عرض النصوص أو أدوات فحص بيانات EXIF. ابحث عن حقل التعليق (Comment) المدمج في رأس الملف."
]
}
]
# --------------------------------------------------------------------------- #
# 4. Challenge Builder
# --------------------------------------------------------------------------- #
def _align_filename_in_story(text: str, filename: str) -> str:
import re
# Replace any word resembling a file name with the new filename
text = re.sub(
r"[a-zA-Z0-9_\-]+\.(jpg|jpeg|png|pdf|zip|docx|xlsx|txt|bin|dat|conf|db|tar\.gz|rar|sys|bak|jfif)",
filename,
text
)
return text
class ChallengeBuilder:
async def build(self, spec: ScenarioSpec, image_bytes: bytes) -> Challenge:
flag_preview = f"CyberArena{{{uuid.uuid4().hex[:16]}}}"
flag_hash = hashlib.sha256(flag_preview.encode()).hexdigest()
# Generate fully randomized filename
filename = generate_random_filename(spec.hiding_method)
spec.story = _align_filename_in_story(spec.story, filename)
spec.task_outline = _align_filename_in_story(spec.task_outline, filename)
# Decide dynamic host bytes
ext = filename.split(".")[-1].lower()
if ext in ("jpg", "jpeg", "png", "jfif") and image_bytes:
host_bytes = image_bytes
else:
host_bytes = generate_host_file_bytes(filename)
file_bytes = hide_flag(host_bytes, spec.hiding_method, flag_preview)
files = {
filename: base64.b64encode(file_bytes).decode("utf-8")
}
# Generate realistic 'file' command outputs
file_desc = "JPEG image data, JFIF standard 1.01"
if ext == "png":
file_desc = "PNG image data, 1 x 1, 8-bit/color RGBA"
elif ext == "jfif":
file_desc = "JPEG image data, JFIF standard 1.01"
elif ext == "pdf":
file_desc = "PDF document, version 1.4"
elif ext == "zip":
file_desc = "Zip archive data, at least v2.0 to extract"
elif ext in ("docx", "xlsx"):
file_desc = "Microsoft Word 2007+ Document (Zip archive)"
elif ext == "txt":
file_desc = "ASCII text"
elif ext in ("bin", "dat", "bak"):
file_desc = "data"
elif ext == "conf":
file_desc = "ASCII text, with CRLF line terminators"
elif ext == "db":
file_desc = "SQLite 3.x database"
elif ext == "tar.gz":
file_desc = "gzip compressed data, from Unix"
elif ext == "rar":
file_desc = "RAR archive data, v5.0"
elif ext == "sys":
file_desc = "PE32 system driver (native) Intel 80386"
if spec.hiding_method == "ZIP" and ext in ("jpg", "jpeg", "png", "pdf", "jfif"):
file_desc += " (with appended Zip archive data)"
# Command outputs metadata for UI cheat sheets / dashboard previews
command_outputs = {
"ls": {"stdout": filename, "stderr": "", "exit_code": 0},
f"file {filename}": {
"stdout": f"{filename}: {file_desc}",
"stderr": "",
"exit_code": 0
}
}
return Challenge(
team_role=spec.team_role,
module="steganography",
title=spec.title,
story=spec.story,
task_outline=spec.task_outline,
files=files,
file_metadata={
filename: {"encoding": "binary", "size": len(file_bytes)}
},
command_outputs=command_outputs,
hints=spec.hints,
tools_whitelist=["cat", "ls", "file", "strings", "unzip"],
flag_hash=flag_hash,
flag_preview=flag_preview,
difficulty=spec.difficulty,
xp_reward=XP_BY_DIFFICULTY.get(spec.difficulty, 150),
hiding_method=spec.hiding_method,
image_url="",
topic="steganography"
)
# --------------------------------------------------------------------------- #
# 5. AI Generation Prompts
# --------------------------------------------------------------------------- #
STEGANOGRAPHY_PROMPT = """أنت كبير مصممي سيناريوهات الأمن السيبراني في منصة APEX.
مهمتك: تصميم سيناريو تدريبي للتحقيق الجنائي الرقمي وإخفاء المعلومات (Steganography) للفريق الأحمر.
الموضوع: إخفاء المعلومات في الوسائط (Steganography)
مستوى الصعوبة: {difficulty}
الطريقة المحددة للإخفاء: {hiding_method}
تنبيه هام جداً لمنع التكرار (قاعدة صارمة ضد التشابه):
يجب أن تتجنب تماماً تكرار أي من العناوين أو الأفكار أو السياقات المستعملة مؤخراً.
العناوين التي تم توليدها مؤخراً ويُمنع استخدامها أو تكرار أي فكرة منها هي:
{blacklisted_titles}
السيناريو يجب أن يكون بلغة عربية فصحى احترافية ومشوقة.
1. العنوان: عنوان غامض ومبتكر باللغة العربية (مثل: "تسريب الظلال"، "الرسالة المشفرة للعميل"، "الملف الملتحم").
2. القصة: قصة سياقية واقعية (3-4 جمل) عن حادثة أمنية، تسريب، أو عميل يقوم بنقل رسائل سرية مخفية في صورة.
3. المهمة: اصف للمستخدم ما يجب القيام به باللغة العربية. تجنب ذكر "ساندبوكس التدريب" أو أي أدوات صريحة، وركز على أن الملف يتم تحميله محلياً على جهاز المستخدم.
4. التلميحات (hints): يجب ألا تحتوي على حل مباشر أو أوامر طرفية صريحة (مثل run unzip or strings). بدلاً من ذلك، يجب أن تقدم توجيهات عامة ومساعدة غير مباشرة (Socratic hints) توجه المستخدم نحو التفكير في كيفية فحص الملف (مثل الإشارة لوجود بيانات وصفية أو إمكانية دمج الأرشيفات في الملفات وكيفية استخراجها).
أرجع JSON فقط بدون أي نص قبله أو بعده وبدون كود التنسيق ```json:
{{
"title": "عنوان التحدي باللغة العربية",
"story": "قصة السياق الكاملة باللغة العربية الفصحى",
"task_outline": "تفاصيل المهمة باللغة العربية الفصحى",
"hints": [
"تلميح مساعد ذكي أول (غير مباشر)",
"تلميح مساعد ذكي ثان (غير مباشر)"
]
}}"""
async def ai_generate_scenario_via_mistral(team_role: str, difficulty: str, hiding_method: str, blacklisted_titles: str = "") -> ScenarioSpec:
if not MISTRAL_API_KEY:
raise RuntimeError("Mistral API Key missing")
prompt = STEGANOGRAPHY_PROMPT.format(difficulty=difficulty, hiding_method=hiding_method, blacklisted_titles=blacklisted_titles)
body = {
"model": MISTRAL_MODEL,
"messages": [
{"role": "system", "content": "You output valid JSON only. No prose, no markdown fences."},
{"role": "user", "content": prompt},
],
"temperature": 0.7,
"response_format": {"type": "json_object"}
}
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(MISTRAL_API_URL, headers={"Authorization": f"Bearer {MISTRAL_API_KEY}"}, json=body)
if r.status_code != 200:
raise RuntimeError(f"Mistral HTTP {r.status_code}")
data = _repair_json(r.json()["choices"][0]["message"]["content"])
return ScenarioSpec(
team_role=team_role,
module="steganography",
difficulty=difficulty,
title=data["title"],
story=data["story"],
task_outline=data["task_outline"],
hiding_method=hiding_method,
hints=data.get("hints") or []
)
async def ai_generate_scenario_via_cloudflare(team_role: str, difficulty: str, hiding_method: str, blacklisted_titles: str = "") -> ScenarioSpec:
if not CLOUDFLARE_API_TOKEN or not CLOUDFLARE_ACCOUNT_ID:
raise RuntimeError("Cloudflare settings missing")
url = f"https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/run/{CLOUDFLARE_MODEL}"
prompt = STEGANOGRAPHY_PROMPT.format(difficulty=difficulty, hiding_method=hiding_method, blacklisted_titles=blacklisted_titles)
body = {
"messages": [
{"role": "system", "content": "You are a cybersecurity training scenario generator. Output JSON only."},
{"role": "user", "content": prompt}
]
}
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(url, headers={"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}, json=body)
if r.status_code != 200:
raise RuntimeError(f"Cloudflare HTTP {r.status_code}")
data = _repair_json(r.json()["result"]["response"])
return ScenarioSpec(
team_role=team_role,
module="steganography",
difficulty=difficulty,
title=data["title"],
story=data["story"],
task_outline=data["task_outline"],
hiding_method=hiding_method,
hints=data.get("hints") or []
)
async def ai_generate_scenario_via_groq(team_role: str, difficulty: str, hiding_method: str, blacklisted_titles: str = "") -> ScenarioSpec:
if not GROQ_API_KEY:
raise RuntimeError("Groq key missing")
prompt = STEGANOGRAPHY_PROMPT.format(difficulty=difficulty, hiding_method=hiding_method, blacklisted_titles=blacklisted_titles)
body = {
"model": GROQ_MODEL,
"messages": [
{"role": "system", "content": "You output valid JSON only."},
{"role": "user", "content": prompt}
],
"temperature": 0.7,
"response_format": {"type": "json_object"}
}
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(GROQ_API_URL, headers={"Authorization": f"Bearer {GROQ_API_KEY}"}, json=body)
if r.status_code != 200:
raise RuntimeError(f"Groq HTTP {r.status_code}")
data = _repair_json(r.json()["choices"][0]["message"]["content"])
return ScenarioSpec(
team_role=team_role,
module="steganography",
difficulty=difficulty,
title=data["title"],
story=data["story"],
task_outline=data["task_outline"],
hiding_method=hiding_method,
hints=data.get("hints") or []
)
# --------------------------------------------------------------------------- #
# 6. DB operations
# --------------------------------------------------------------------------- #
def get_pool_count(team_role: str, module: Optional[str] = None) -> int:
if not SUPABASE_ANON_KEY or not SUPABASE_URL:
return 0
url = f"{SUPABASE_URL}/rest/v1/steganography_challenges?select=id&team_role=eq.{team_role}"
try:
r = httpx.get(url, headers={
"apikey": SUPABASE_ANON_KEY,
"Authorization": f"Bearer {SUPABASE_ANON_KEY}"
}, timeout=10)
if r.status_code == 200:
return len(r.json())
except Exception as e:
print(f"[steganography] get_pool_count error: {e}")
return 0
async def insert_to_db(challenge: Challenge) -> bool:
if not SUPABASE_ANON_KEY or not SUPABASE_URL:
return False
from app.core.config import normalize_row_module
payload = challenge.to_db_row()
payload = normalize_row_module("steganography_challenges", payload)
team_role = payload.get("team_role", "red")
from app.services.insert_guard import atomic_insert
from app.services.dedup import is_duplicate_steganography
return await atomic_insert(
table="steganography_challenges",
team_role=team_role,
row=payload,
dedup_func=is_duplicate_steganography,
dedup_args=[
payload.get("title", ""),
payload.get("story", ""),
payload.get("task_outline", ""),
],
dedup_kwargs={"role_filter": team_role},
)
# --------------------------------------------------------------------------- #
# 7. Refill and Watcher API
# --------------------------------------------------------------------------- #
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]
async def _refill_pool_inner(team_role: str, count: int) -> int:
"""Unlocked inner — caller must hold _get_pool_lock(team_role)."""
current = get_pool_count(team_role)
gap = max(0, POOL_TARGET - current)
if gap == 0:
return 0
if count > gap:
count = gap
inserted = 0
methods = ["EOF", "ZIP", "EXIF"]
difficulties = ["مبتدئ", "متوسط", "قوي"]
recent_titles = []
try:
recent_titles = fetch_existing_titles("steganography_challenges", team_role, limit=30)
except Exception as e:
print(f"[steganography] Failed to fetch recent titles: {e}")
blacklisted_titles_str = "\n".join([f"- {t}" for t in recent_titles]) if recent_titles else "لا يوجد عناوين سابقة حالياً."
for i in range(count):
method = methods[i % len(methods)]
difficulty = difficulties[i % len(difficulties)]
spec = None
source = "ai"
providers = [
ai_generate_scenario_via_mistral,
ai_generate_scenario_via_cloudflare,
ai_generate_scenario_via_groq
]
for p in providers:
try:
spec = await p(team_role, difficulty, method, blacklisted_titles=blacklisted_titles_str)
print(f"[steganography] AI {p.__name__} OK: title={spec.title[:40]}")
break
except Exception as e:
print(f"[steganography] AI {p.__name__} failed: {e}")
if spec is None:
source = "seed"
seed = random.choice([s for s in CURATED_SEEDS if s["hiding_method"] == method] or CURATED_SEEDS)
rand_tag = uuid.uuid4().hex[:6]
spec = ScenarioSpec(
team_role=team_role,
module="steganography",
difficulty=seed["difficulty"],
title=f"{seed['title']} - رمز {rand_tag}",
story=seed["story"] + f" [معرف التدقيق: {rand_tag}]",
task_outline=seed["task_outline"],
hiding_method=seed["hiding_method"],
hints=seed["hints"]
)
try:
img_bytes = await fetch_random_unsplash_image()
ch = await ChallengeBuilder().build(spec, img_bytes)
if await insert_to_db(ch):
inserted += 1
except Exception as e:
print(f"[steganography] Build/insert failed: {e}")
await asyncio.sleep(0.3)
return inserted
async def refill_pool(team_role: str, count: int = POOL_BATCH) -> int:
async with _get_pool_lock(team_role):
return await _refill_pool_inner(team_role, count)
async def start_pool_watcher(team_role: str) -> None:
key = ("steganography", team_role)
if key in _WATCHER_STARTED:
print(f"[steganography:{team_role}] watcher already running — skipping duplicate start.")
return
_WATCHER_STARTED.add(key)
try:
label = f"[steganography:{team_role}]"
print(f"{label} watcher started (target={POOL_TARGET}).")
while True:
try:
sleep_secs = IDLE_POLL_SECS
async with _get_pool_lock(team_role):
count = get_pool_count(team_role)
if count < POOL_TARGET:
needed = POOL_TARGET - count
print(f"{label} pool below target ({count}/{POOL_TARGET}) — refilling {needed}…")
added = await _refill_pool_inner(team_role, needed)
new_count = count + added
print(f"{label} refilled: {count}{new_count} (target {POOL_TARGET}).")
sleep_secs = 5
await asyncio.sleep(sleep_secs)
except Exception as e:
import traceback
print(f"{label} watcher error: {e}")
traceback.print_exc()
await asyncio.sleep(10)
finally:
_WATCHER_STARTED.discard(key)
# --------------------------------------------------------------------------- #
# 8. CLI Parser
# --------------------------------------------------------------------------- #
def main():
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
p = argparse.ArgumentParser(description="Steganography challenge generator (RED TEAM ONLY)")
p.add_argument("--team", choices=("red",), default="red")
p.add_argument("--count", type=int, default=5)
p.add_argument("--refill", choices=("red",), help="Refill the team-wide pool")
p.add_argument("--dry-run", action="store_true", help="Build but don't insert")
args = p.parse_args()
if args.refill:
added = asyncio.run(refill_pool(args.refill, args.count))
print(f"Refilled {args.refill}: +{added}")
return
print(f"Building {args.count} challenges for RED team...")
async def test_generation():
methods = ["EOF", "ZIP", "EXIF"]
for i in range(args.count):
method = methods[i % len(methods)]
seed = CURATED_SEEDS[i % len(CURATED_SEEDS)]
spec = ScenarioSpec(
team_role="red",
module="steganography",
difficulty=seed["difficulty"],
title=seed["title"] + " (Test)",
story=seed["story"],
task_outline=seed["task_outline"],
hiding_method=method,
hints=seed["hints"]
)
img = generate_fallback_image_bytes()
ch = await ChallengeBuilder().build(spec, img)
print(f" • {ch.title} | {ch.difficulty} | hiding={ch.hiding_method} | flag={ch.flag_preview[:40]}...")
if not args.dry_run:
await insert_to_db(ch)
asyncio.run(test_generation())
if __name__ == "__main__":
import time
main()