Hussien Haider commited on
Commit
4871da9
·
1 Parent(s): 04fc815
app/api/training.py CHANGED
@@ -18,6 +18,7 @@ from app.services.challenge_loader import (
18
  map_code_fixing_row_to_training,
19
  map_log_analysis_row_to_training,
20
  map_vuln_hunter_row_to_training,
 
21
  )
22
  from app.services.evaluator import (
23
  evaluate_training,
@@ -75,7 +76,7 @@ async def list_challenges(team_role: str = "blue", difficulty: Optional[str] = N
75
  if team_role == "blue":
76
  candidates = ["code-fixing", "log-analysis", "vulnerability-hunter"]
77
  else:
78
- candidates = ["crypto"]
79
 
80
  items: list[dict] = []
81
  per_type_cap = max(1, limit // max(1, len(candidates)))
@@ -189,6 +190,7 @@ _TYPE_TO_MAPPER = {
189
  "code-fixing": "map_code_fixing_row_to_training",
190
  "log-analysis": "map_log_analysis_row_to_training",
191
  "vulnerability-hunter": "map_vuln_hunter_row_to_training",
 
192
  }
193
 
194
 
@@ -235,6 +237,7 @@ _CANONICAL_CHALLENGE_TYPES: dict[str, str] = {
235
  "log_analysis": "log-analysis",
236
  "vulnerability-hunter": "vulnerability-hunter",
237
  "vulnerability_hunter": "vulnerability-hunter",
 
238
  }
239
 
240
 
@@ -258,6 +261,7 @@ def _tables_for_team(team_role: str) -> list[str]:
258
  if team_role == "red":
259
  return [
260
  "encryption_challenges",
 
261
  ]
262
  return [
263
  "code_fixing_challenges",
@@ -368,4 +372,5 @@ __all__ = [
368
  "map_code_fixing_row_to_training",
369
  "map_log_analysis_row_to_training",
370
  "map_vuln_hunter_row_to_training",
 
371
  ]
 
18
  map_code_fixing_row_to_training,
19
  map_log_analysis_row_to_training,
20
  map_vuln_hunter_row_to_training,
21
+ map_steganography_row_to_training,
22
  )
23
  from app.services.evaluator import (
24
  evaluate_training,
 
76
  if team_role == "blue":
77
  candidates = ["code-fixing", "log-analysis", "vulnerability-hunter"]
78
  else:
79
+ candidates = ["crypto", "steganography"]
80
 
81
  items: list[dict] = []
82
  per_type_cap = max(1, limit // max(1, len(candidates)))
 
190
  "code-fixing": "map_code_fixing_row_to_training",
191
  "log-analysis": "map_log_analysis_row_to_training",
192
  "vulnerability-hunter": "map_vuln_hunter_row_to_training",
193
+ "steganography": "map_steganography_row_to_training",
194
  }
195
 
196
 
 
237
  "log_analysis": "log-analysis",
238
  "vulnerability-hunter": "vulnerability-hunter",
239
  "vulnerability_hunter": "vulnerability-hunter",
240
+ "steganography": "steganography",
241
  }
242
 
243
 
 
261
  if team_role == "red":
262
  return [
263
  "encryption_challenges",
264
+ "steganography_challenges",
265
  ]
266
  return [
267
  "code_fixing_challenges",
 
372
  "map_code_fixing_row_to_training",
373
  "map_log_analysis_row_to_training",
374
  "map_vuln_hunter_row_to_training",
375
+ "map_steganography_row_to_training",
376
  ]
app/core/config.py CHANGED
@@ -71,6 +71,7 @@ TABLE_CANONICAL_MODULE: dict[str, str] = {
71
  "code_fixing_challenges": "code-fixing",
72
  "log_analysis_challenges": "log-analysis",
73
  "vulnerability_hunter_challenges": "vulnerability-hunter",
 
74
  }
75
 
76
 
 
71
  "code_fixing_challenges": "code-fixing",
72
  "log_analysis_challenges": "log-analysis",
73
  "vulnerability_hunter_challenges": "vulnerability-hunter",
74
+ "steganography_challenges": "steganography",
75
  }
76
 
77
 
app/core/module_router.py CHANGED
@@ -16,4 +16,6 @@ def challenge_type_for_module(module: str) -> str:
16
  """
17
  if module in WEB_EXPLOIT_MODULES:
18
  return "web"
 
 
19
  return "crypto"
 
16
  """
17
  if module in WEB_EXPLOIT_MODULES:
18
  return "web"
19
+ if module == "steganography":
20
+ return "steganography"
21
  return "crypto"
app/generators/__init__.py CHANGED
@@ -16,6 +16,7 @@ from . import crypto
16
  from . import code_fixing
17
  from . import log_analysis
18
  from . import vulnerability_hunter
 
19
 
20
  # The orchestrator in app.main iterates this registry. A new challenge
21
  # type is added by appending a tuple below (and a matching table in
@@ -26,6 +27,7 @@ REGISTRY = [
26
  ("code-fixing", code_fixing, ["blue"]),
27
  ("log-analysis", log_analysis, ["blue"]),
28
  ("vulnerability-hunter", vulnerability_hunter, ["blue"]),
 
29
  ]
30
 
31
  __all__ = [
@@ -33,5 +35,6 @@ __all__ = [
33
  "code_fixing",
34
  "log_analysis",
35
  "vulnerability_hunter",
 
36
  "REGISTRY",
37
  ]
 
16
  from . import code_fixing
17
  from . import log_analysis
18
  from . import vulnerability_hunter
19
+ from . import steganography
20
 
21
  # The orchestrator in app.main iterates this registry. A new challenge
22
  # type is added by appending a tuple below (and a matching table in
 
27
  ("code-fixing", code_fixing, ["blue"]),
28
  ("log-analysis", log_analysis, ["blue"]),
29
  ("vulnerability-hunter", vulnerability_hunter, ["blue"]),
30
+ ("steganography", steganography, ["red"]),
31
  ]
32
 
33
  __all__ = [
 
35
  "code_fixing",
36
  "log_analysis",
37
  "vulnerability_hunter",
38
+ "steganography",
39
  "REGISTRY",
40
  ]
app/generators/code_fixing.py CHANGED
@@ -772,6 +772,13 @@ CRITICAL REQUIREMENTS:
772
  7. difficulty must be EXACTLY one of these three Arabic words: "مبتدئ", "متوسط", or "قوي"
773
  8. hints must be a JSON array of exactly 3 objects: [{{"level": 1, "text": "...", "xp_cost": 20}}, {{"level": 2, "text": "...", "xp_cost": 30}}, {{"level": 3, "text": "...", "xp_cost": 50}}]. The first hint should be vague, the second more specific, the third should hint at the actual fix approach.
774
 
 
 
 
 
 
 
 
775
  Return ONLY this exact JSON structure (no other text):
776
  {{"title": "عنوان التحدي بالعربية", "story": "قصة التحدي بالعربية", "task_outline": "المطلوب من المتدرب بالعربية", "vulnerable_code": "الكود الكامل المصاب بالثغرة", "vulnerability_description": "شرح الثغرة بالعربية", "difficulty": "متوسط", "hints": [{{"level": 1, "text": "تلميح أول", "xp_cost": 20}}, {{"level": 2, "text": "تلميح ثاني", "xp_cost": 30}}, {{"level": 3, "text": "تلميح ثالث", "xp_cost": 50}}]}}
777
  """
@@ -1117,6 +1124,18 @@ async def _insert_to_db(row: dict) -> bool:
1117
  # like 'web-security' or 'systems-security'.
1118
  from app.core.config import normalize_row_module
1119
  row = normalize_row_module(TABLE_NAME, row)
 
 
 
 
 
 
 
 
 
 
 
 
1120
  url = f"{SUPABASE_URL}/rest/v1/{TABLE_NAME}"
1121
  headers = supabase_headers(content_type=True)
1122
  headers["Prefer"] = "return=representation"
 
772
  7. difficulty must be EXACTLY one of these three Arabic words: "مبتدئ", "متوسط", or "قوي"
773
  8. hints must be a JSON array of exactly 3 objects: [{{"level": 1, "text": "...", "xp_cost": 20}}, {{"level": 2, "text": "...", "xp_cost": 30}}, {{"level": 3, "text": "...", "xp_cost": 50}}]. The first hint should be vague, the second more specific, the third should hint at the actual fix approach.
774
 
775
+ UNIQUENESS RULES — ZERO TOLERANCE FOR DUPLICATES:
776
+ - Every challenge MUST be completely unique in title, story, code structure, and variable names.
777
+ - NEVER reuse the same application domain (e.g. if previous was a "bank API", make this one a "hospital system" or "IoT controller").
778
+ - NEVER reuse variable names, function names, or class names from common templates. Use creative, context-specific naming.
779
+ - The vulnerable_code MUST use a different programming pattern, different data structures, and different business logic each time.
780
+ - The title and story MUST describe a completely different scenario — not just a paraphrase.
781
+
782
  Return ONLY this exact JSON structure (no other text):
783
  {{"title": "عنوان التحدي بالعربية", "story": "قصة التحدي بالعربية", "task_outline": "المطلوب من المتدرب بالعربية", "vulnerable_code": "الكود الكامل المصاب بالثغرة", "vulnerability_description": "شرح الثغرة بالعربية", "difficulty": "متوسط", "hints": [{{"level": 1, "text": "تلميح أول", "xp_cost": 20}}, {{"level": 2, "text": "تلميح ثاني", "xp_cost": 30}}, {{"level": 3, "text": "تلميح ثالث", "xp_cost": 50}}]}}
784
  """
 
1124
  # like 'web-security' or 'systems-security'.
1125
  from app.core.config import normalize_row_module
1126
  row = normalize_row_module(TABLE_NAME, row)
1127
+ # --- Deduplication: skip if a similar challenge already exists ---
1128
+ try:
1129
+ from app.services.dedup import is_duplicate_code_fixing
1130
+ if is_duplicate_code_fixing(
1131
+ row.get("title", ""),
1132
+ row.get("vulnerable_code", ""),
1133
+ role_filter=row.get("team_role"),
1134
+ ):
1135
+ print(f" [dedup] Skipped duplicate: {row.get('title', '?')[:60]}")
1136
+ return False
1137
+ except Exception as _dedup_err:
1138
+ print(f" [dedup] Check failed (non-fatal): {_dedup_err}")
1139
  url = f"{SUPABASE_URL}/rest/v1/{TABLE_NAME}"
1140
  headers = supabase_headers(content_type=True)
1141
  headers["Prefer"] = "return=representation"
app/generators/crypto.py CHANGED
@@ -803,6 +803,12 @@ CRYPTO_SCENARIO_PROMPT = """أنت مصمم تحديات أمن سيبراني
803
  - اسم الملف الذي يحتوي المحتوى (مثال: intercept.txt، leak.bin، firmware.bin، contract.txt، payload.json): ضعه في `extra.filename` واستعمل نفس الاسم داخل story و task_outline بالضبط.
804
  - الـ hints: 3 تلميحات سقراطية قصيرة (جملة واحدة لكل تلميح) — توجّه الطالب للتفكير بدون إعطاء الجواب.
805
 
 
 
 
 
 
 
806
  أمثلة على عناوين مميزة (استلهم منها، لا تنسخها حرفياً):
807
  - "اعتراض بث مشفر من ضابط ألماني في الـ Afrika Korps"
808
  - "تسريب رسالة بين CFO و broker في تحقيق SEC"
@@ -1146,6 +1152,18 @@ def insert_to_db(challenge: Challenge, supabase_url: str = SUPABASE_URL,
1146
  # 'encryption-basics', 'hash-cracking' or 'rsa-aes'.
1147
  from app.core.config import normalize_row_module
1148
  payload = normalize_row_module("encryption_challenges", payload)
 
 
 
 
 
 
 
 
 
 
 
 
1149
  last_err: str = ""
1150
  for attempt in range(1, 4):
1151
  try:
 
803
  - اسم الملف الذي يحتوي المحتوى (مثال: intercept.txt، leak.bin، firmware.bin، contract.txt، payload.json): ضعه في `extra.filename` واستعمل نفس الاسم داخل story و task_outline بالضبط.
804
  - الـ hints: 3 تلميحات سقراطية قصيرة (جملة واحدة لكل تلميح) — توجّه الطالب للتفكير بدون إعطاء الجواب.
805
 
806
+ 禁止 التكرار — قواعد صارمة ضد التشابه:
807
+ - لا تستخدم نفس السياق (embassy, leak, ransomware, military, hospital) أكثر من مرة.
808
+ - غيّر الإعداد الجغرافي والزمني والمهني بالكامل عن أي تحدي سابق.
809
+ - العنوان والقصة يجب أن يكونا مختلفين جذرياً — لا تكتفي بتغيير كلمة واحدة.
810
+ - الـ plaintext يجب أن يكون محتوى مختلف تماماً (不同类型 من الملفات، محتوى مختلف).
811
+
812
  أمثلة على عناوين مميزة (استلهم منها، لا تنسخها حرفياً):
813
  - "اعتراض بث مشفر من ضابط ألماني في الـ Afrika Korps"
814
  - "تسريب رسالة بين CFO و broker في تحقيق SEC"
 
1152
  # 'encryption-basics', 'hash-cracking' or 'rsa-aes'.
1153
  from app.core.config import normalize_row_module
1154
  payload = normalize_row_module("encryption_challenges", payload)
1155
+ # --- Deduplication: skip if a similar challenge already exists ---
1156
+ try:
1157
+ from app.services.dedup import is_duplicate_encryption
1158
+ if is_duplicate_encryption(
1159
+ payload.get("title", ""),
1160
+ payload.get("story", ""),
1161
+ role_filter=payload.get("team_role"),
1162
+ ):
1163
+ print(f" [dedup] Skipped duplicate: {payload.get('title', '?')[:60]}")
1164
+ return False
1165
+ except Exception as _dedup_err:
1166
+ print(f" [dedup] Check failed (non-fatal): {_dedup_err}")
1167
  last_err: str = ""
1168
  for attempt in range(1, 4):
1169
  try:
app/generators/log_analysis.py CHANGED
@@ -692,6 +692,13 @@ CRITICAL REQUIREMENTS:
692
  8. difficulty must be EXACTLY "مبتدئ" or "متوسط" or "قوي"
693
  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}}]
694
 
 
 
 
 
 
 
 
695
  Return ONLY this JSON:
696
  {{"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}}]}}
697
  """
@@ -1036,6 +1043,18 @@ async def _insert_to_db(row: dict) -> bool:
1036
  # for consistency with the other 4 generators.
1037
  from app.core.config import normalize_row_module
1038
  row = normalize_row_module(TABLE_NAME, row)
 
 
 
 
 
 
 
 
 
 
 
 
1039
  url = f"{SUPABASE_URL}/rest/v1/{TABLE_NAME}"
1040
  headers = supabase_headers(content_type=True)
1041
  headers["Prefer"] = "return=representation"
 
692
  8. difficulty must be EXACTLY "مبتدئ" or "متوسط" or "قوي"
693
  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}}]
694
 
695
+ UNIQUENESS RULES — ZERO TOLERANCE FOR DUPLICATES:
696
+ - Every challenge MUST be completely unique in title, story, log content, and IP addresses.
697
+ - 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.
698
+ - Use DIFFERENT attacker IPs, different timestamps, different log formats each time.
699
+ - The log_content MUST contain different benign traffic patterns and different attack payloads each time.
700
+ - The title and story MUST describe a completely different investigation scenario.
701
+
702
  Return ONLY this JSON:
703
  {{"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}}]}}
704
  """
 
1043
  # for consistency with the other 4 generators.
1044
  from app.core.config import normalize_row_module
1045
  row = normalize_row_module(TABLE_NAME, row)
1046
+ # --- Deduplication: skip if a similar challenge already exists ---
1047
+ try:
1048
+ from app.services.dedup import is_duplicate_log_analysis
1049
+ if is_duplicate_log_analysis(
1050
+ row.get("title", ""),
1051
+ row.get("log_content", ""),
1052
+ role_filter=row.get("team_role"),
1053
+ ):
1054
+ print(f" [dedup] Skipped duplicate: {row.get('title', '?')[:60]}")
1055
+ return False
1056
+ except Exception as _dedup_err:
1057
+ print(f" [dedup] Check failed (non-fatal): {_dedup_err}")
1058
  url = f"{SUPABASE_URL}/rest/v1/{TABLE_NAME}"
1059
  headers = supabase_headers(content_type=True)
1060
  headers["Prefer"] = "return=representation"
app/generators/steganography.py ADDED
@@ -0,0 +1,660 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ steganography_generator.py
3
+ ==========================
4
+ Generates dynamic steganography (information hiding) challenges for the CyberArena pool.
5
+
6
+ Hiding methods:
7
+ 1. EOF: Append the flag bytes to the end of the image file.
8
+ 2. EXIF: Inject the flag into the JPEG Comment (COM) segment manually.
9
+ 3. ZIP: Append a zip archive containing flag.txt to the image file.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import asyncio
16
+ import base64
17
+ import hashlib
18
+ import json
19
+ import os
20
+ import random
21
+ import sys
22
+ import uuid
23
+ import zipfile
24
+ import io
25
+ from dataclasses import dataclass
26
+ from typing import Optional, Literal
27
+
28
+ import httpx
29
+
30
+ # Load .env early
31
+ try:
32
+ from app._env import load_app_env
33
+ load_app_env()
34
+ except Exception:
35
+ pass
36
+
37
+ from app.generators.crypto import _repair_json
38
+
39
+ # --------------------------------------------------------------------------- #
40
+ # 0. Constants
41
+ # --------------------------------------------------------------------------- #
42
+
43
+ ALLOWED_DIFFICULTIES = ("مبتدئ", "متوسط", "قوي")
44
+ ALLOWED_TEAMS = ("red",)
45
+
46
+ POOL_TARGET = 5
47
+ POOL_THRESHOLD = 2
48
+ POOL_BATCH = 3
49
+ IDLE_POLL_SECS = 60
50
+ WATCHER_POLL_SECS = 60
51
+
52
+ _AI_BACKOFF_UNTIL: dict[str, float] = {}
53
+ _POOL_LOCKS: dict[str, asyncio.Lock] = {}
54
+ _WATCHER_STARTED: set[tuple[str, str]] = set()
55
+
56
+ SUPABASE_URL = os.environ.get("SUPABASE_URL", "https://yevtnyokixocpihpdwqu.supabase.co")
57
+ SUPABASE_ANON_KEY = os.environ.get("SUPABASE_ANON_KEY", "")
58
+
59
+ # AI Configurations
60
+ MISTRAL_API_KEY = os.environ.get("MISTRAL_API_KEY", "")
61
+ MISTRAL_MODEL = os.environ.get("MISTRAL_MODEL", "mistral-large-latest")
62
+ MISTRAL_API_URL = os.environ.get("MISTRAL_API_URL", "https://api.mistral.ai/v1/chat/completions")
63
+
64
+ CLOUDFLARE_API_TOKEN = os.environ.get("CLOUDFLARE_API_TOKEN", "")
65
+ CLOUDFLARE_ACCOUNT_ID = os.environ.get("CLOUDFLARE_ACCOUNT_ID", "")
66
+ CLOUDFLARE_MODEL = os.environ.get("CLOUDFLARE_MODEL", "@cf/meta/llama-3.1-8b-instruct")
67
+
68
+ GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
69
+ GROQ_MODEL = os.environ.get("GROQ_MODEL", "llama-3.1-8b-instant")
70
+ GROQ_API_URL = "https://api.groq.com/openai/v1/chat/completions"
71
+
72
+ NVIDIA_API_KEY = os.environ.get("NVIDIA_API_KEY", "")
73
+ NVIDIA_MODEL = os.environ.get("NVIDIA_MODEL", "deepseek-ai/deepseek-v4-pro")
74
+ NVIDIA_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
75
+
76
+ # Curated Unsplash images for cyber themes
77
+ UNSPLASH_THEME_URLS = [
78
+ "https://images.unsplash.com/photo-1526374965328-7f61d4dc18c5?w=600&auto=format&fit=crop", # matrix/cyber
79
+ "https://images.unsplash.com/photo-1550751827-4bd374c3f58b?w=600&auto=format&fit=crop", # cybersecurity/servers
80
+ "https://images.unsplash.com/photo-1563986768609-322da13575f3?w=600&auto=format&fit=crop", # technology/abstract
81
+ "https://images.unsplash.com/photo-1510511459019-5dda7724fd87?w=600&auto=format&fit=crop", # hacker/code
82
+ "https://images.unsplash.com/photo-1601597111158-2fceff292cdc?w=600&auto=format&fit=crop", # network switch
83
+ "https://images.unsplash.com/photo-1526374865147-73cf412a95c5?w=600&auto=format&fit=crop", # cyber circuit
84
+ "https://images.unsplash.com/photo-1515879218367-8466d910aaa4?w=600&auto=format&fit=crop", # hacking terminal
85
+ "https://images.unsplash.com/photo-1517694712202-14dd9538aa97?w=600&auto=format&fit=crop", # laptop code
86
+ ]
87
+
88
+ XP_BY_DIFFICULTY = {"مبتدئ": 100, "متوسط": 150, "قوي": 200}
89
+
90
+ # --------------------------------------------------------------------------- #
91
+ # 1. Hiding Algorithms
92
+ # --------------------------------------------------------------------------- #
93
+
94
+ def generate_fallback_image_bytes(text: str = "APEX CyberArena") -> bytes:
95
+ """Generate a valid dark theme JPEG image using PIL (Pillow) or a tiny binary fallback."""
96
+ try:
97
+ from PIL import Image, ImageDraw
98
+ img = Image.new("RGB", (500, 500), color=(11, 11, 18))
99
+ draw = ImageDraw.Draw(img)
100
+ # Grid lines
101
+ for i in range(0, 500, 50):
102
+ draw.line([(i, 0), (i, 500)], fill=(0, 212, 170, 30), width=1)
103
+ draw.line([(0, i), (500, i)], fill=(255, 0, 85, 30), width=1)
104
+ draw.ellipse([(150, 150), (350, 350)], outline=(0, 212, 170), width=2)
105
+ draw.text((20, 20), text, fill=(255, 255, 255))
106
+ draw.text((20, 450), "Apex training artifact", fill=(120, 120, 120))
107
+
108
+ buf = io.BytesIO()
109
+ img.save(buf, format="JPEG")
110
+ return buf.getvalue()
111
+ except Exception:
112
+ # Minimal 1x1 valid JPEG bytes fallback
113
+ return (
114
+ b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x01\x00`\x00`\x00\x00"
115
+ b"\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t\x08"
116
+ b"\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a\x1f\x1e"
117
+ b"\x1d\x1a\x1c\x1c $.' \",#\x1c\x1c(7),01444\x1f'9=82<.342"
118
+ b"\xff\xc0\x00\x0b\x08\x00\x01\x00\x01\x01\x01\x11\x00\xff\xc4\x00"
119
+ b"\x1f\x00\x00\x01\x05\x01\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00"
120
+ b"\x00\x00\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\xff\xc4\x00"
121
+ b"\xb5\x10\x00\x02\x01\x03\x03\x02\x04\x03\x05\x05\x04\x04\x00\x00"
122
+ b"\x00\x00\x01\x02\x03\x00\x04\x11\x05\x12!1A\x06\x13"
123
+ b"Qa\x07\"q\x142\x81\x91\xa1\x08#B\xb1\xc1\x15R\xd1\xf0"
124
+ b"$3br\x82\x16\x17\x18\x19\x1a%&'()*456789:CDEFGHIJSTUVWXYZ"
125
+ b"cdefghijstuvwxyz\x83\x84\x85\x86\x87\x88\x89\x8a\x92\x93\x94\x95"
126
+ b"\x96\x97\x98\x99\x9a\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xb2\xb3"
127
+ b"\xb4\xb5\xb6\xb7\xb8\xb9\xba\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca"
128
+ b"\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xe2\xe3\xe4\xe5\xe6\xe7\xe8"
129
+ b"\xe9\xea\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xff\xda\x00\x0c\x03"
130
+ b"\x01\x00\x02\x11\x03\x11\x00?\x00\xed\xfc\xff\xd9"
131
+ )
132
+
133
+ async def fetch_random_unsplash_image() -> bytes:
134
+ """Fetch an image from Unsplash. Falls back to generating one if offline."""
135
+ url = random.choice(UNSPLASH_THEME_URLS)
136
+ try:
137
+ async with httpx.AsyncClient(timeout=10) as client:
138
+ r = await client.get(url)
139
+ if r.status_code == 200:
140
+ return r.content
141
+ except Exception as e:
142
+ print(f"[steganography] Unsplash fetch failed: {e}")
143
+ return generate_fallback_image_bytes()
144
+
145
+ def hide_flag_eof(image_bytes: bytes, flag: str) -> bytes:
146
+ """Hiding method 1: Append flag directly to EOF."""
147
+ return image_bytes + b"\n" + flag.encode("utf-8") + b"\n"
148
+
149
+ def hide_flag_exif(image_bytes: bytes, flag: str) -> bytes:
150
+ """Hiding method 2: Inject flag into standard JPEG comment segment manually."""
151
+ if image_bytes.startswith(b"\xff\xd8"):
152
+ comment_bytes = flag.encode("utf-8")
153
+ length = len(comment_bytes) + 2
154
+ length_bytes = length.to_bytes(2, byteorder='big')
155
+ # FF FE is the JPEG COM marker
156
+ com_segment = b"\xff\xfe" + length_bytes + comment_bytes
157
+ # Insert segment right after the SOI marker (FF D8)
158
+ return image_bytes[0:2] + com_segment + image_bytes[2:]
159
+ return hide_flag_eof(image_bytes, flag)
160
+
161
+ def hide_flag_zip(image_bytes: bytes, flag: str) -> bytes:
162
+ """Hiding method 3: Append a zip containing flag.txt to the end of the image."""
163
+ zip_buf = io.BytesIO()
164
+ with zipfile.ZipFile(zip_buf, 'w', zipfile.ZIP_DEFLATED) as zip_file:
165
+ zip_file.writestr("flag.txt", flag + "\n")
166
+ return image_bytes + zip_buf.getvalue()
167
+
168
+ def hide_flag(image_bytes: bytes, method: str, flag: str) -> bytes:
169
+ if method == "EOF":
170
+ return hide_flag_eof(image_bytes, flag)
171
+ elif method == "EXIF":
172
+ return hide_flag_exif(image_bytes, flag)
173
+ elif method == "ZIP":
174
+ return hide_flag_zip(image_bytes, flag)
175
+ raise ValueError(f"Unknown hiding method: {method}")
176
+
177
+ # --------------------------------------------------------------------------- #
178
+ # 2. Challenge Dataclasses
179
+ # --------------------------------------------------------------------------- #
180
+
181
+ @dataclass
182
+ class ScenarioSpec:
183
+ team_role: str
184
+ module: str
185
+ difficulty: str
186
+ title: str
187
+ story: str
188
+ task_outline: str
189
+ hiding_method: str
190
+ hints: list
191
+
192
+ @dataclass
193
+ class Challenge:
194
+ team_role: str
195
+ module: str
196
+ title: str
197
+ story: str
198
+ task_outline: str
199
+ files: dict
200
+ file_metadata: dict
201
+ command_outputs: dict
202
+ hints: list
203
+ tools_whitelist: list
204
+ flag_hash: str
205
+ flag_preview: str
206
+ difficulty: str
207
+ xp_reward: int
208
+ hiding_method: str
209
+ image_url: str
210
+ topic: str
211
+
212
+ def to_db_row(self) -> dict:
213
+ return {
214
+ "team_role": self.team_role,
215
+ "module": self.module,
216
+ "title": self.title,
217
+ "story": self.story,
218
+ "task_outline": self.task_outline,
219
+ "files": self.files,
220
+ "file_metadata": self.file_metadata,
221
+ "command_outputs": self.command_outputs,
222
+ "hints": self.hints,
223
+ "tools_whitelist": self.tools_whitelist,
224
+ "flag_hash": self.flag_hash,
225
+ "flag_preview": self.flag_preview,
226
+ "difficulty": self.difficulty,
227
+ "xp_reward": self.xp_reward,
228
+ "hiding_method": self.hiding_method,
229
+ "image_url": self.image_url,
230
+ "topic": self.topic,
231
+ }
232
+
233
+ # --------------------------------------------------------------------------- #
234
+ # 3. Curated Seeds
235
+ # --------------------------------------------------------------------------- #
236
+
237
+ CURATED_SEEDS = [
238
+ {
239
+ "difficulty": "مبتدئ",
240
+ "hiding_method": "EOF",
241
+ "title": "شفرة الملفات المتروكة",
242
+ "story": "اعترض فريق المراقبة الأمنية صورة مشبوهة باسم suspect.jpg تم رفعها إلى خادم غير مصرح به. يُعتقد أن المهاجم استغل ثغرة وقام بلصق العلم في نهاية ملف الصورة دون تعديل واصفاتها.",
243
+ "task_outline": "قم بفحص الملف suspect.jpg المتاح في ساندبوكس التدريب. ابحث عن نصوص نصية معلقة في نهاية الصورة. العلم مخفي في نهاية ملف الصورة.",
244
+ "hints": [
245
+ "الصورة هي ملف ثنائي، ولكن يمكنك قراءة النصوص المكتوبة بداخلها باستخدام أداة strings.",
246
+ "قم بتنفيذ strings suspect.jpg في الطرفية لمشاهدة جميع النصوص القابلة للقراءة."
247
+ ]
248
+ },
249
+ {
250
+ "difficulty": "متوسط",
251
+ "hiding_method": "ZIP",
252
+ "title": "الحقيبة المزدوجة",
253
+ "story": "عثر فريق الاستجابة للحوادث على خادم ويب مصاب. المهاجم قام بدمج أرشيف كامل مضغوط يحوي معلومات الاختراق داخل صورة التحدي challenge.jpg كتقنية للتمويه والتخفي.",
254
+ "task_outline": "افحص الملف challenge.jpg في ساندبوكس التدريب لتحديد ما إذا كان يحوي أرشيفاً مضغوطاً مدمجاً. استخرج الأرشيف لتحصل على ملف flag.txt.",
255
+ "hints": [
256
+ "أداة file يمكنها كشف البنية الحقيقية للملف وتحديد إذا ما كان هناك أرشيف مضغوط appended.",
257
+ "جرب استخدام أداة فك الضغط unzip challenge.jpg مباشرة لاستخراج الملفات المخفية."
258
+ ]
259
+ },
260
+ {
261
+ "difficulty": "قوي",
262
+ "hiding_method": "EXIF",
263
+ "title": "واصفات العميل الوصفية",
264
+ "story": "أرسل عميل سري صورة طبيعية باسم agent.jpg إلى مركز العمليات. تحليل واصفات الملف أظهر أن الصورة لا تحتوي على أي تعديل في البيانات الثنائية الملحقة، ولكن تم استغلال حقل التعليقات (Comment) في EXIF Metadata لنقل رسالة سرية.",
265
+ "task_outline": "افحص بيانات واصفات الملف agent.jpg واستخرج العلم المخفي داخل حقل التعليق (COM/EXIF metadata).",
266
+ "hints": [
267
+ "البيانات الوصفية للصور (Metadata) تخزن معلومات إضافية مثل الكاميرا أو تعليقات المبرمج.",
268
+ "أداة strings يمكنها طباعة التعليق من داخل البنية الرأسية للملف مباشرة، أو يمكنك قراءة واصفات الملف."
269
+ ]
270
+ }
271
+ ]
272
+
273
+ # --------------------------------------------------------------------------- #
274
+ # 4. Challenge Builder
275
+ # --------------------------------------------------------------------------- #
276
+
277
+ class ChallengeBuilder:
278
+ async def build(self, spec: ScenarioSpec, image_bytes: bytes) -> Challenge:
279
+ flag_preview = f"CyberArena{{{uuid.uuid4().hex[:16]}}}"
280
+ flag_hash = hashlib.sha256(flag_preview.encode()).hexdigest()
281
+
282
+ # Filename based on method
283
+ filename = "suspect.jpg"
284
+ if spec.hiding_method == "ZIP":
285
+ filename = "challenge.jpg"
286
+ elif spec.hiding_method == "EXIF":
287
+ filename = "agent.jpg"
288
+
289
+ file_bytes = hide_flag(image_bytes, spec.hiding_method, flag_preview)
290
+ files = {
291
+ filename: base64.b64encode(file_bytes).decode("utf-8")
292
+ }
293
+
294
+ # Command outputs metadata for UI cheat sheets / dashboard previews
295
+ command_outputs = {
296
+ "ls": {"stdout": filename, "stderr": "", "exit_code": 0},
297
+ f"file {filename}": {
298
+ "stdout": f"{filename}: JPEG image data, JFIF standard 1.01" + (
299
+ " (with appended Zip archive data)" if spec.hiding_method == "ZIP" else ""
300
+ ),
301
+ "stderr": "",
302
+ "exit_code": 0
303
+ }
304
+ }
305
+
306
+ return Challenge(
307
+ team_role=spec.team_role,
308
+ module="steganography",
309
+ title=spec.title,
310
+ story=spec.story,
311
+ task_outline=spec.task_outline,
312
+ files=files,
313
+ file_metadata={
314
+ filename: {"encoding": "binary", "size": len(file_bytes)}
315
+ },
316
+ command_outputs=command_outputs,
317
+ hints=spec.hints,
318
+ tools_whitelist=["cat", "ls", "file", "strings", "unzip"],
319
+ flag_hash=flag_hash,
320
+ flag_preview=flag_preview,
321
+ difficulty=spec.difficulty,
322
+ xp_reward=XP_BY_DIFFICULTY.get(spec.difficulty, 150),
323
+ hiding_method=spec.hiding_method,
324
+ image_url="",
325
+ topic="steganography"
326
+ )
327
+
328
+ # --------------------------------------------------------------------------- #
329
+ # 5. AI Generation Prompts
330
+ # --------------------------------------------------------------------------- #
331
+
332
+ STEGANOGRAPHY_PROMPT = """أنت كبير مصممي سيناريوهات الأمن السيبراني في منصة APEX.
333
+ مهمتك: تصميم سيناريو تدريبي للتحقيق الجنائي الرقمي وإخفاء المعلومات (Steganography) للفريق الأحمر.
334
+
335
+ الموضوع: إخفاء المعلومات في الوسائط (Steganography)
336
+ مستوى الصعوبة: {difficulty}
337
+ الطريقة المحددة للإخفاء: {hiding_method}
338
+
339
+ السيناريو يجب أن يكون بلغة عربية فصحى احترافية ومشوقة.
340
+ 1. العنوان: عنوان غامض ومبتكر باللغة العربية (مثل: "تسريب الظلال"، "الرسالة المشفرة للعميل"، "الملف الملتحم").
341
+ 2. القصة: قصة سياقية واقعية (3-4 جمل) عن حادثة أمنية، تسريب، أو عميل يقوم بنقل رسائل سرية مخفية في صورة.
342
+ 3. المهمة: اصف للمستخدم ما يجب القيام به باللغة العربية. تجنب ذكر الطريقة المباشرة (مثل "افتح unzip" أو "شغل strings") ولكن أشر لها بأسلوب استكشافي مشوق.
343
+
344
+ أرجع JSON فقط بدون أي نص قبله أو بعده وبدون كود التنسيق ```json:
345
+ {{
346
+ "title": "عنوان التحدي باللغة العربية",
347
+ "story": "قصة السياق الكاملة باللغة العربية الفصحى",
348
+ "task_outline": "تفاصيل المهمة باللغة العربية الفصحى",
349
+ "hints": [
350
+ "تلميح ذكي أول يساعد اللاعب خطوة بخطوة",
351
+ "تلميح ذكي ثان يوجه اللاعب للأداة المناسبة"
352
+ ]
353
+ }}"""
354
+
355
+ async def ai_generate_scenario_via_mistral(team_role: str, difficulty: str, hiding_method: str) -> ScenarioSpec:
356
+ if not MISTRAL_API_KEY:
357
+ raise RuntimeError("Mistral API Key missing")
358
+
359
+ prompt = STEGANOGRAPHY_PROMPT.format(difficulty=difficulty, hiding_method=hiding_method)
360
+ body = {
361
+ "model": MISTRAL_MODEL,
362
+ "messages": [
363
+ {"role": "system", "content": "You output valid JSON only. No prose, no markdown fences."},
364
+ {"role": "user", "content": prompt},
365
+ ],
366
+ "temperature": 0.7,
367
+ "response_format": {"type": "json_object"}
368
+ }
369
+ async with httpx.AsyncClient(timeout=30) as client:
370
+ r = await client.post(MISTRAL_API_URL, headers={"Authorization": f"Bearer {MISTRAL_API_KEY}"}, json=body)
371
+
372
+ if r.status_code != 200:
373
+ raise RuntimeError(f"Mistral HTTP {r.status_code}")
374
+
375
+ data = _repair_json(r.json()["choices"][0]["message"]["content"])
376
+ return ScenarioSpec(
377
+ team_role=team_role,
378
+ module="steganography",
379
+ difficulty=difficulty,
380
+ title=data["title"],
381
+ story=data["story"],
382
+ task_outline=data["task_outline"],
383
+ hiding_method=hiding_method,
384
+ hints=data.get("hints") or []
385
+ )
386
+
387
+ async def ai_generate_scenario_via_cloudflare(team_role: str, difficulty: str, hiding_method: str) -> ScenarioSpec:
388
+ if not CLOUDFLARE_API_TOKEN or not CLOUDFLARE_ACCOUNT_ID:
389
+ raise RuntimeError("Cloudflare settings missing")
390
+
391
+ url = f"https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/run/{CLOUDFLARE_MODEL}"
392
+ prompt = STEGANOGRAPHY_PROMPT.format(difficulty=difficulty, hiding_method=hiding_method)
393
+
394
+ body = {
395
+ "messages": [
396
+ {"role": "system", "content": "You are a cybersecurity training scenario generator. Output JSON only."},
397
+ {"role": "user", "content": prompt}
398
+ ]
399
+ }
400
+
401
+ async with httpx.AsyncClient(timeout=30) as client:
402
+ r = await client.post(url, headers={"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}, json=body)
403
+
404
+ if r.status_code != 200:
405
+ raise RuntimeError(f"Cloudflare HTTP {r.status_code}")
406
+
407
+ data = _repair_json(r.json()["result"]["response"])
408
+ return ScenarioSpec(
409
+ team_role=team_role,
410
+ module="steganography",
411
+ difficulty=difficulty,
412
+ title=data["title"],
413
+ story=data["story"],
414
+ task_outline=data["task_outline"],
415
+ hiding_method=hiding_method,
416
+ hints=data.get("hints") or []
417
+ )
418
+
419
+ async def ai_generate_scenario_via_groq(team_role: str, difficulty: str, hiding_method: str) -> ScenarioSpec:
420
+ if not GROQ_API_KEY:
421
+ raise RuntimeError("Groq key missing")
422
+
423
+ prompt = STEGANOGRAPHY_PROMPT.format(difficulty=difficulty, hiding_method=hiding_method)
424
+ body = {
425
+ "model": GROQ_MODEL,
426
+ "messages": [
427
+ {"role": "system", "content": "You output valid JSON only."},
428
+ {"role": "user", "content": prompt}
429
+ ],
430
+ "temperature": 0.7,
431
+ "response_format": {"type": "json_object"}
432
+ }
433
+
434
+ async with httpx.AsyncClient(timeout=30) as client:
435
+ r = await client.post(GROQ_API_URL, headers={"Authorization": f"Bearer {GROQ_API_KEY}"}, json=body)
436
+
437
+ if r.status_code != 200:
438
+ raise RuntimeError(f"Groq HTTP {r.status_code}")
439
+
440
+ data = _repair_json(r.json()["choices"][0]["message"]["content"])
441
+ return ScenarioSpec(
442
+ team_role=team_role,
443
+ module="steganography",
444
+ difficulty=difficulty,
445
+ title=data["title"],
446
+ story=data["story"],
447
+ task_outline=data["task_outline"],
448
+ hiding_method=hiding_method,
449
+ hints=data.get("hints") or []
450
+ )
451
+
452
+ # --------------------------------------------------------------------------- #
453
+ # 6. DB operations
454
+ # --------------------------------------------------------------------------- #
455
+
456
+ def get_pool_count(team_role: str, module: Optional[str] = None) -> int:
457
+ if not SUPABASE_ANON_KEY or not SUPABASE_URL:
458
+ return 0
459
+ url = f"{SUPABASE_URL}/rest/v1/steganography_challenges?select=id&team_role=eq.{team_role}"
460
+ try:
461
+ r = httpx.get(url, headers={
462
+ "apikey": SUPABASE_ANON_KEY,
463
+ "Authorization": f"Bearer {SUPABASE_ANON_KEY}"
464
+ }, timeout=10)
465
+ if r.status_code == 200:
466
+ return len(r.json())
467
+ except Exception as e:
468
+ print(f"[steganography] get_pool_count error: {e}")
469
+ return 0
470
+
471
+ def insert_to_db(challenge: Challenge) -> bool:
472
+ if not SUPABASE_ANON_KEY or not SUPABASE_URL:
473
+ return False
474
+
475
+ url = f"{SUPABASE_URL}/rest/v1/steganography_challenges"
476
+ headers = {
477
+ "apikey": SUPABASE_ANON_KEY,
478
+ "Authorization": f"Bearer {SUPABASE_ANON_KEY}",
479
+ "Content-Type": "application/json",
480
+ "Prefer": "return=minimal"
481
+ }
482
+ payload = challenge.to_db_row()
483
+
484
+ from app.core.config import normalize_row_module
485
+ payload = normalize_row_module("steganography_challenges", payload)
486
+
487
+ # Deduplication check
488
+ try:
489
+ from app.services.dedup import is_duplicate_steganography
490
+ if is_duplicate_steganography(payload.get("title", ""), payload.get("story", ""), payload.get("team_role")):
491
+ print(f" [dedup] Skipped duplicate: {payload.get('title')[:50]}")
492
+ return False
493
+ except Exception as e:
494
+ print(f" [dedup] Check error (non-fatal): {e}")
495
+
496
+ for attempt in range(3):
497
+ try:
498
+ with httpx.Client(timeout=20) as client:
499
+ r = client.post(url, headers=headers, json=payload)
500
+ if r.status_code in (200, 201, 204):
501
+ print(f"✓ Inserted stego: {challenge.title[:50]}")
502
+ return True
503
+ print(f"✗ Insert stego failed [{r.status_code}]: {r.text[:200]}")
504
+ return False
505
+ except Exception as e:
506
+ print(f"Attempt {attempt+1} failed: {e}")
507
+ time.sleep(2 ** attempt)
508
+ return False
509
+
510
+ # --------------------------------------------------------------------------- #
511
+ # 7. Refill and Watcher API
512
+ # --------------------------------------------------------------------------- #
513
+
514
+ def _get_pool_lock(team_role: str) -> asyncio.Lock:
515
+ if team_role not in _POOL_LOCKS:
516
+ _POOL_LOCKS[team_role] = asyncio.Lock()
517
+ return _POOL_LOCKS[team_role]
518
+
519
+ async def refill_pool(team_role: str, count: int = POOL_BATCH) -> int:
520
+ lock = _get_pool_lock(team_role)
521
+ async with lock:
522
+ current = get_pool_count(team_role)
523
+ gap = max(0, POOL_TARGET - current)
524
+ if gap == 0:
525
+ return 0
526
+ if count > gap:
527
+ count = gap
528
+
529
+ inserted = 0
530
+ methods = ["EOF", "ZIP", "EXIF"]
531
+ difficulties = ["مبتدئ", "متوسط", "قوي"]
532
+
533
+ for i in range(count):
534
+ method = methods[i % len(methods)]
535
+ difficulty = difficulties[i % len(difficulties)]
536
+ spec = None
537
+ source = "ai"
538
+
539
+ # AI Refill Loop
540
+ providers = [
541
+ ai_generate_scenario_via_mistral,
542
+ ai_generate_scenario_via_cloudflare,
543
+ ai_generate_scenario_via_groq
544
+ ]
545
+ for p in providers:
546
+ try:
547
+ spec = await p(team_role, difficulty, method)
548
+ print(f"[steganography] AI {p.__name__} OK: title={spec.title[:40]}")
549
+ break
550
+ except Exception as e:
551
+ print(f"[steganography] AI {p.__name__} failed: {e}")
552
+
553
+ # Fallback Seed
554
+ if spec is None:
555
+ source = "seed"
556
+ seed = random.choice([s for s in CURATED_SEEDS if s["hiding_method"] == method] or CURATED_SEEDS)
557
+ spec = ScenarioSpec(
558
+ team_role=team_role,
559
+ module="steganography",
560
+ difficulty=seed["difficulty"],
561
+ title=seed["title"] + f" #{random.randint(10, 99)}",
562
+ story=seed["story"],
563
+ task_outline=seed["task_outline"],
564
+ hiding_method=seed["hiding_method"],
565
+ hints=seed["hints"]
566
+ )
567
+
568
+ # Fetch stock photo and build
569
+ try:
570
+ img_bytes = await fetch_random_unsplash_image()
571
+ ch = await ChallengeBuilder().build(spec, img_bytes)
572
+ if insert_to_db(ch):
573
+ inserted += 1
574
+ except Exception as e:
575
+ print(f"[steganography] Build/insert failed: {e}")
576
+
577
+ await asyncio.sleep(0.3)
578
+
579
+ return inserted
580
+
581
+ async def start_pool_watcher(team_role: str) -> None:
582
+ key = ("steganography", team_role)
583
+ if key in _WATCHER_STARTED:
584
+ print(f"[steganography:{team_role}] watcher already running — skipping duplicate start.")
585
+ return
586
+ _WATCHER_STARTED.add(key)
587
+ try:
588
+ label = f"[steganography:{team_role}]"
589
+ print(f"{label} watcher started (target={POOL_TARGET}).")
590
+ while True:
591
+ try:
592
+ sleep_secs = IDLE_POLL_SECS
593
+ async with _get_pool_lock(team_role):
594
+ count = get_pool_count(team_role)
595
+ if count < POOL_TARGET:
596
+ needed = POOL_TARGET - count
597
+ print(f"{label} pool below target ({count}/{POOL_TARGET}) — refilling {needed}…")
598
+ added = await refill_pool(team_role, needed)
599
+ new_count = count + added
600
+ print(f"{label} refilled: {count} → {new_count} (target {POOL_TARGET}).")
601
+ sleep_secs = 5
602
+ await asyncio.sleep(sleep_secs)
603
+ except Exception as e:
604
+ import traceback
605
+ print(f"{label} watcher error: {e}")
606
+ traceback.print_exc()
607
+ await asyncio.sleep(10)
608
+ finally:
609
+ _WATCHER_STARTED.discard(key)
610
+
611
+ # --------------------------------------------------------------------------- #
612
+ # 8. CLI Parser
613
+ # --------------------------------------------------------------------------- #
614
+
615
+ def main():
616
+ try:
617
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
618
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
619
+ except Exception:
620
+ pass
621
+ p = argparse.ArgumentParser(description="Steganography challenge generator (RED TEAM ONLY)")
622
+ p.add_argument("--team", choices=("red",), default="red")
623
+ p.add_argument("--count", type=int, default=5)
624
+ p.add_argument("--refill", choices=("red",), help="Refill the team-wide pool")
625
+ p.add_argument("--dry-run", action="store_true", help="Build but don't insert")
626
+ args = p.parse_args()
627
+
628
+ if args.refill:
629
+ added = asyncio.run(refill_pool(args.refill, args.count))
630
+ print(f"Refilled {args.refill}: +{added}")
631
+ return
632
+
633
+ print(f"Building {args.count} challenges for RED team...")
634
+
635
+ async def test_generation():
636
+ methods = ["EOF", "ZIP", "EXIF"]
637
+ for i in range(args.count):
638
+ method = methods[i % len(methods)]
639
+ seed = CURATED_SEEDS[i % len(CURATED_SEEDS)]
640
+ spec = ScenarioSpec(
641
+ team_role="red",
642
+ module="steganography",
643
+ difficulty=seed["difficulty"],
644
+ title=seed["title"] + " (Test)",
645
+ story=seed["story"],
646
+ task_outline=seed["task_outline"],
647
+ hiding_method=method,
648
+ hints=seed["hints"]
649
+ )
650
+ img = generate_fallback_image_bytes()
651
+ ch = await ChallengeBuilder().build(spec, img)
652
+ print(f" • {ch.title} | {ch.difficulty} | hiding={ch.hiding_method} | flag={ch.flag_preview[:40]}...")
653
+ if not args.dry_run:
654
+ insert_to_db(ch)
655
+
656
+ asyncio.run(test_generation())
657
+
658
+ if __name__ == "__main__":
659
+ import time
660
+ main()
app/generators/vulnerability_hunter.py CHANGED
@@ -820,6 +820,14 @@ ABSOLUTE RULES — VIOLATIONS WILL BE REJECTED:
820
  The first hint should be vague, the second more specific, the third
821
  should reveal the canonical vulnerability name.
822
 
 
 
 
 
 
 
 
 
823
  Return ONLY this exact JSON structure (no other text):
824
  {{"title": "عنوان التحدي بالعربية", "story": "قصة التحدي بالعربية", "task_outline": "ادرس الكود التالي وحدد الثغرة الأمنية الوحيدة الموجودة فيه ثم اكتب اسمها بالإنجليزية (مفتاح قصير) في حقل الإجابة.", "vulnerable_code": "الكود الكامل المصاب بالثغرة", "vulnerability_type": "{vuln_type}", "vulnerability_class": "{vuln_class}", "vulnerability_description": "شرح الثغرة بالعربية", "difficulty": "{difficulty}", "hints": [{{"level": 1, "text": "...", "xp_cost": 10}}, {{"level": 2, "text": "...", "xp_cost": 20}}, {{"level": 3, "text": "...", "xp_cost": 40}}]}}
825
  """
@@ -1199,6 +1207,18 @@ async def _insert_to_db(row: dict) -> bool:
1199
  # values like 'web-security', 'secure-coding' or 'cryptography'.
1200
  from app.core.config import normalize_row_module
1201
  row = normalize_row_module(TABLE_NAME, row)
 
 
 
 
 
 
 
 
 
 
 
 
1202
  url = f"{SUPABASE_URL}/rest/v1/{TABLE_NAME}"
1203
  headers = supabase_headers(content_type=True)
1204
  headers["Prefer"] = "return=representation"
 
820
  The first hint should be vague, the second more specific, the third
821
  should reveal the canonical vulnerability name.
822
 
823
+ UNIQUENESS RULES — ZERO TOLERANCE FOR DUPLICATES:
824
+ - Every challenge MUST be completely unique in title, story, code structure, and variable names.
825
+ - NEVER reuse the same application domain (e.g. if previous was a "bank API", make this one a "hospital system", "IoT firmware", "game server", "scientific computing", etc.).
826
+ - NEVER reuse variable names, function names, class names, or file names from common templates. Use creative, context-specific naming.
827
+ - The vulnerable_code MUST use a different programming pattern, different data structures, and different business logic each time.
828
+ - The title and story MUST describe a completely different scenario — not just a paraphrase.
829
+ - Vary the code style: different imports, different module structure, different comments.
830
+
831
  Return ONLY this exact JSON structure (no other text):
832
  {{"title": "عنوان التحدي بالعربية", "story": "قصة التحدي بالعربية", "task_outline": "ادرس الكود التالي وحدد الثغرة الأمنية الوحيدة الموجودة فيه ثم اكتب اسمها بالإنجليزية (مفتاح قصير) في حقل الإجابة.", "vulnerable_code": "الكود الكامل المصاب بالثغرة", "vulnerability_type": "{vuln_type}", "vulnerability_class": "{vuln_class}", "vulnerability_description": "شرح الثغرة بالعربية", "difficulty": "{difficulty}", "hints": [{{"level": 1, "text": "...", "xp_cost": 10}}, {{"level": 2, "text": "...", "xp_cost": 20}}, {{"level": 3, "text": "...", "xp_cost": 40}}]}}
833
  """
 
1207
  # values like 'web-security', 'secure-coding' or 'cryptography'.
1208
  from app.core.config import normalize_row_module
1209
  row = normalize_row_module(TABLE_NAME, row)
1210
+ # --- Deduplication: skip if a similar challenge already exists ---
1211
+ try:
1212
+ from app.services.dedup import is_duplicate_vuln_hunter
1213
+ if is_duplicate_vuln_hunter(
1214
+ row.get("title", ""),
1215
+ row.get("vulnerable_code", ""),
1216
+ role_filter=row.get("team_role"),
1217
+ ):
1218
+ print(f" [dedup] Skipped duplicate: {row.get('title', '?')[:60]}")
1219
+ return False
1220
+ except Exception as _dedup_err:
1221
+ print(f" [dedup] Check failed (non-fatal): {_dedup_err}")
1222
  url = f"{SUPABASE_URL}/rest/v1/{TABLE_NAME}"
1223
  headers = supabase_headers(content_type=True)
1224
  headers["Prefer"] = "return=representation"
app/sandbox/terminal.py CHANGED
@@ -60,7 +60,8 @@ async def run_terminal_command(team_role: str, challenge_id: str, command: str)
60
 
61
  # Built-ins
62
  if tool in {"cat", "ls", "pwd", "echo", "whoami", "clear", "help",
63
- "sha256sum", "md5sum", "sha1sum", "base64", "xxd", "tr"}:
 
64
  result = shell_builtins(parts, workdir)
65
  if result is not None:
66
  return result
 
60
 
61
  # Built-ins
62
  if tool in {"cat", "ls", "pwd", "echo", "whoami", "clear", "help",
63
+ "sha256sum", "md5sum", "sha1sum", "base64", "xxd", "tr",
64
+ "file", "strings", "unzip", "unzip-file"}:
65
  result = shell_builtins(parts, workdir)
66
  if result is not None:
67
  return result
app/sandbox/tools.py CHANGED
@@ -110,6 +110,81 @@ def shell_builtins(args: list[str], workdir: str) -> Optional[dict]:
110
  src = src.replace(a, b)
111
  return {"stdout": src, "stderr": "", "exitCode": 0}
112
  return {"stdout": "", "stderr": "tr: no input", "exitCode": 1}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  except Exception as e:
114
  return {"stdout": "", "stderr": f"{tool}: {e}", "exitCode": 1}
115
  return None # not a builtin
 
110
  src = src.replace(a, b)
111
  return {"stdout": src, "stderr": "", "exitCode": 0}
112
  return {"stdout": "", "stderr": "tr: no input", "exitCode": 1}
113
+
114
+ if tool == "file":
115
+ if not rest:
116
+ return {"stdout": "", "stderr": "file: missing filename", "exitCode": 1}
117
+ fname = rest[0]
118
+ p = os.path.join(workdir, fname.lstrip("/\\").replace("..", "_"))
119
+ if not os.path.isfile(p):
120
+ return {"stdout": "", "stderr": f"file: {fname}: No such file", "exitCode": 1}
121
+ with open(p, "rb") as f:
122
+ header = f.read(8)
123
+ if header.startswith(b"\xff\xd8\xff"):
124
+ return {"stdout": f"{fname}: JPEG image data, JFIF standard 1.01", "stderr": "", "exitCode": 0}
125
+ if header.startswith(b"\x89PNG\r\n\x1a\n"):
126
+ return {"stdout": f"{fname}: PNG image data, 8-bit/color RGBA, non-interlaced", "stderr": "", "exitCode": 0}
127
+ if header.startswith(b"PK\x03\x04"):
128
+ return {"stdout": f"{fname}: Zip archive data, at least v2.0 to extract", "stderr": "", "exitCode": 0}
129
+
130
+ with open(p, "rb") as f:
131
+ content = f.read()
132
+ if b"PK\x03\x04" in content:
133
+ if content.startswith(b"\xff\xd8\xff"):
134
+ return {"stdout": f"{fname}: JPEG image data (with appended Zip archive data)", "stderr": "", "exitCode": 0}
135
+ return {"stdout": f"{fname}: Zip archive data (appended)", "stderr": "", "exitCode": 0}
136
+
137
+ try:
138
+ content.decode("utf-8")
139
+ return {"stdout": f"{fname}: ASCII text", "stderr": "", "exitCode": 0}
140
+ except UnicodeDecodeError:
141
+ return {"stdout": f"{fname}: data", "stderr": "", "exitCode": 0}
142
+
143
+ if tool == "strings":
144
+ if not rest:
145
+ return {"stdout": "", "stderr": "strings: missing filename", "exitCode": 1}
146
+ fname = rest[0]
147
+ p = os.path.join(workdir, fname.lstrip("/\\").replace("..", "_"))
148
+ if not os.path.isfile(p):
149
+ return {"stdout": "", "stderr": f"strings: {fname}: No such file", "exitCode": 1}
150
+ with open(p, "rb") as f:
151
+ data = f.read()
152
+ out = []
153
+ curr = []
154
+ for b in data:
155
+ if 32 <= b <= 126 or b == 10 or b == 13:
156
+ curr.append(chr(b))
157
+ else:
158
+ if len(curr) >= 4:
159
+ out.append("".join(curr).strip())
160
+ curr = []
161
+ if len(curr) >= 4:
162
+ out.append("".join(curr).strip())
163
+ out = [line for line in out if line]
164
+ return {"stdout": "\n".join(out[:1000]), "stderr": "", "exitCode": 0}
165
+
166
+ if tool in ("unzip", "unzip-file"):
167
+ if not rest:
168
+ return {"stdout": "", "stderr": "unzip: missing filename", "exitCode": 1}
169
+ fname = rest[0]
170
+ args_clean = [a for a in rest if not a.startswith("-")]
171
+ if not args_clean:
172
+ return {"stdout": "", "stderr": "unzip: missing filename", "exitCode": 1}
173
+ fname = args_clean[0]
174
+ p = os.path.join(workdir, fname.lstrip("/\\").replace("..", "_"))
175
+ if not os.path.isfile(p):
176
+ return {"stdout": "", "stderr": f"unzip: {fname}: No such file", "exitCode": 1}
177
+ import zipfile
178
+ try:
179
+ out_lines = [f"Archive: {fname}"]
180
+ with zipfile.ZipFile(p, 'r') as zip_ref:
181
+ zip_ref.extractall(workdir)
182
+ for info in zip_ref.infolist():
183
+ out_lines.append(f" extracting: {info.filename}")
184
+ return {"stdout": "\n".join(out_lines), "stderr": "", "exitCode": 0}
185
+ except Exception as e:
186
+ return {"stdout": "", "stderr": f"unzip: error: {e}", "exitCode": 1}
187
+
188
  except Exception as e:
189
  return {"stdout": "", "stderr": f"{tool}: {e}", "exitCode": 1}
190
  return None # not a builtin
app/services/challenge_loader.py CHANGED
@@ -141,3 +141,45 @@ def map_vuln_hunter_row_to_training(row: dict, team_role: str) -> dict:
141
  "xpReward": row.get("xp_reward", 150),
142
  "hints": row.get("hints") or [],
143
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  "xpReward": row.get("xp_reward", 150),
142
  "hints": row.get("hints") or [],
143
  }
144
+
145
+
146
+ def map_steganography_row_to_training(row: dict, team_role: str) -> dict:
147
+ """Map a row from ``steganography_challenges`` to TrainingData."""
148
+ files = row.get("files") or {}
149
+ file_meta = row.get("file_metadata") or {}
150
+ first_filename = next(iter(files), None)
151
+ code = ""
152
+ if first_filename:
153
+ try:
154
+ # Safely attempt to decode as UTF-8, but fall back or truncate for binaries
155
+ code = _b64.b64decode(files[first_filename]).decode("utf-8", errors="ignore")[:300]
156
+ except Exception:
157
+ code = ""
158
+
159
+ return {
160
+ "id": row.get("id"),
161
+ "scenarioId": row.get("id"),
162
+ "title": row.get("title", ""),
163
+ "story": row.get("story", ""),
164
+ "type": "steganography",
165
+ "topic": row.get("topic") or row.get("module", "steganography"),
166
+ "task": row.get("task_outline", ""),
167
+ "code": code,
168
+ "codeLanguage": _infer_code_language(first_filename, team_role),
169
+ "htmlPreview": None,
170
+ "logData": None,
171
+ "configData": None,
172
+ "vulnerabilityLocation": None,
173
+ "hints": row.get("hints") or [],
174
+ "expectedAnswer": row.get("flag_preview", ""), # CyberArena{...}
175
+ "expectedAnswerHash": row.get("flag_hash", ""), # server-side check
176
+ "explanation": "العلم مخفي داخل ملف الصورة المعطى. استخدم أدوات التحقيق مثل file أو strings أو unzip.",
177
+ "xpReward": row.get("xp_reward", 120),
178
+ "difficulty": row.get("difficulty", "متوسط"),
179
+ "files": files,
180
+ "fileMetadata": file_meta,
181
+ "commandOutputs": row.get("command_outputs") or {},
182
+ "toolsWhitelist": row.get("tools_whitelist") or [],
183
+ "challengeType": "steganography",
184
+ }
185
+
app/services/dedup.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Challenge deduplication utilities.
3
+
4
+ Before inserting a new challenge into any of the four tables,
5
+ we check whether a sufficiently similar challenge already exists
6
+ to prevent the pool from filling with near-duplicates.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import re
13
+ from difflib import SequenceMatcher
14
+ from typing import Dict, List, Optional, Set
15
+
16
+ import httpx
17
+
18
+ SUPABASE_URL = os.getenv("SUPABASE_URL", "")
19
+ SUPABASE_ANON_KEY = os.getenv("SUPABASE_ANON_KEY", "")
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Text normalisation helpers
23
+ # ---------------------------------------------------------------------------
24
+
25
+ _AR_STOP_WORDS: Set[str] = {
26
+ "في", "من", "على", "إلى", "عن", "مع", "هذا", "هذه", "التي", "الذي",
27
+ "أن", "إن", "لا", "ما", "لا", "كل", "بها", "به", "فيها", "فيه",
28
+ "أو", "ثم", "لكن", "بل", "حتى", "قد", "لقد", "كان", "يجب", "يتم",
29
+ "يمكن", "حيث", "كما", "أيضاً", "ذلك", "تلك", "اللذين", "اللتين",
30
+ }
31
+
32
+
33
+ def _normalise(text: str) -> str:
34
+ """Lower-case, strip punctuation, collapse whitespace."""
35
+ text = text.lower()
36
+ text = re.sub(r"[^\w\s]", " ", text)
37
+ text = re.sub(r"\s+", " ", text).strip()
38
+ return text
39
+
40
+
41
+ def _token_set(text: str) -> Set[str]:
42
+ """Return de-duplicated token set, Arabic stop-words removed."""
43
+ tokens = _normalise(text).split()
44
+ return {t for t in tokens if t not in _AR_STOP_WORDS and len(t) > 1}
45
+
46
+
47
+ def _title_similarity(a: str, b: str) -> float:
48
+ """
49
+ Title similarity = max of:
50
+ - token overlap (Jaccard-ish)
51
+ - SequenceMatcher ratio on normalised strings
52
+ """
53
+ sa, sb = _normalise(a), _normalise(b)
54
+ if not sa or not sb:
55
+ return 0.0
56
+ # SequenceMatcher on normalised strings
57
+ seq = SequenceMatcher(None, sa, sb).ratio()
58
+ # Token overlap
59
+ ta, tb = _token_set(a), _token_set(b)
60
+ if not ta or not tb:
61
+ return seq
62
+ jaccard = len(ta & tb) / max(len(ta | tb), 1)
63
+ return max(seq, jaccard)
64
+
65
+
66
+ def _code_similarity(a: str, b: str) -> float:
67
+ """
68
+ For code: use SequenceMatcher on stripped lines.
69
+ Code tends to have boilerplate; line-level matching works well.
70
+ """
71
+ def _strip(lines: str) -> List[str]:
72
+ return [ln.strip() for ln in lines.splitlines() if ln.strip()]
73
+ la, lb = _strip(a), _strip(b)
74
+ if not la or not lb:
75
+ return 0.0
76
+ return SequenceMatcher(None, la, lb).ratio()
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # DB fetch helpers
81
+ # ---------------------------------------------------------------------------
82
+
83
+ _HEADERS = {
84
+ "apikey": SUPABASE_ANON_KEY,
85
+ "Authorization": f"Bearer {SUPABASE_ANON_KEY}",
86
+ "Content-Type": "application/json",
87
+ }
88
+
89
+
90
+ def _fetch_existing_titles(table: str, role_filter: Optional[str] = None,
91
+ limit: int = 200) -> List[str]:
92
+ """Fetch recent titles from a challenge table."""
93
+ url = f"{SUPABASE_URL}/rest/v1/{table}"
94
+ params: Dict[str, str] = {
95
+ "select": "title",
96
+ "order": "created_at.desc",
97
+ "limit": str(limit),
98
+ }
99
+ if role_filter:
100
+ params["team_role"] = f"eq.{role_filter}"
101
+ try:
102
+ r = httpx.get(url, headers=_HEADERS, params=params, timeout=10)
103
+ r.raise_for_status()
104
+ return [row.get("title", "") for row in r.json()]
105
+ except Exception:
106
+ return []
107
+
108
+
109
+ def _fetch_existing_codes(table: str, code_col: str,
110
+ role_filter: Optional[str] = None,
111
+ limit: int = 100) -> List[str]:
112
+ """Fetch recent code/story/log_content from a challenge table."""
113
+ url = f"{SUPABASE_URL}/rest/v1/{table}"
114
+ params: Dict[str, str] = {
115
+ "select": f"title,{code_col}",
116
+ "order": "created_at.desc",
117
+ "limit": str(limit),
118
+ }
119
+ if role_filter:
120
+ params["team_role"] = f"eq.{role_filter}"
121
+ try:
122
+ r = httpx.get(url, headers=_HEADERS, params=params, timeout=10)
123
+ r.raise_for_status()
124
+ return [row.get(code_col, "") for row in r.json() if row.get(code_col)]
125
+ except Exception:
126
+ return []
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # Public API — one function per generator
131
+ # ---------------------------------------------------------------------------
132
+
133
+ def is_duplicate_encryption(title: str, story: str,
134
+ role_filter: Optional[str] = None) -> bool:
135
+ """Check if an encryption challenge is too similar to existing ones."""
136
+ existing_titles = _fetch_existing_titles("encryption_challenges", role_filter)
137
+ for t in existing_titles:
138
+ if _title_similarity(title, t) > 0.55:
139
+ return True
140
+
141
+ existing_stories = _fetch_existing_codes(
142
+ "encryption_challenges", "story", role_filter)
143
+ for s in existing_stories:
144
+ if _title_similarity(story, s) > 0.55:
145
+ return True
146
+ return False
147
+
148
+
149
+ def is_duplicate_code_fixing(title: str, vulnerable_code: str,
150
+ role_filter: Optional[str] = None) -> bool:
151
+ """Check if a code-fixing challenge is too similar to existing ones."""
152
+ existing_titles = _fetch_existing_titles("code_fixing_challenges", role_filter)
153
+ for t in existing_titles:
154
+ if _title_similarity(title, t) > 0.55:
155
+ return True
156
+
157
+ existing_codes = _fetch_existing_codes(
158
+ "code_fixing_challenges", "vulnerable_code", role_filter)
159
+ for c in existing_codes:
160
+ if _code_similarity(vulnerable_code, c) > 0.60:
161
+ return True
162
+ return False
163
+
164
+
165
+ def is_duplicate_log_analysis(title: str, log_content: str,
166
+ role_filter: Optional[str] = None) -> bool:
167
+ """Check if a log-analysis challenge is too similar to existing ones."""
168
+ existing_titles = _fetch_existing_titles("log_analysis_challenges", role_filter)
169
+ for t in existing_titles:
170
+ if _title_similarity(title, t) > 0.55:
171
+ return True
172
+
173
+ existing_logs = _fetch_existing_codes(
174
+ "log_analysis_challenges", "log_content", role_filter)
175
+ for lc in existing_logs:
176
+ if _code_similarity(log_content, lc) > 0.55:
177
+ return True
178
+ return False
179
+
180
+
181
+ def is_duplicate_vuln_hunter(title: str, vulnerable_code: str,
182
+ role_filter: Optional[str] = None) -> bool:
183
+ """Check if a vulnerability-hunter challenge is too similar to existing ones."""
184
+ existing_titles = _fetch_existing_titles(
185
+ "vulnerability_hunter_challenges", role_filter)
186
+ for t in existing_titles:
187
+ if _title_similarity(title, t) > 0.55:
188
+ return True
189
+
190
+ existing_codes = _fetch_existing_codes(
191
+ "vulnerability_hunter_challenges", "vulnerable_code", role_filter)
192
+ for c in existing_codes:
193
+ if _code_similarity(vulnerable_code, c) > 0.60:
194
+ return True
195
+ return False
196
+
197
+
198
+ def is_duplicate_steganography(title: str, story: str,
199
+ role_filter: Optional[str] = None) -> bool:
200
+ """Check if a steganography challenge is too similar to existing ones."""
201
+ existing_titles = _fetch_existing_titles("steganography_challenges", role_filter)
202
+ for t in existing_titles:
203
+ if _title_similarity(title, t) > 0.55:
204
+ return True
205
+
206
+ existing_stories = _fetch_existing_codes(
207
+ "steganography_challenges", "story", role_filter)
208
+ for s in existing_stories:
209
+ if _title_similarity(story, s) > 0.55:
210
+ return True
211
+ return False
app/services/scenario_service.py CHANGED
@@ -363,6 +363,8 @@ def map_scenario_to_list_item(scenario: dict) -> dict:
363
  module_out = "web"
364
  elif challenge_type == "crypto":
365
  module_out = "crypto"
 
 
366
  else:
367
  module_out = mod
368
 
@@ -377,6 +379,8 @@ def map_scenario_to_list_item(scenario: dict) -> dict:
377
  category = "Web Security"
378
  elif challenge_type == "crypto":
379
  category = "Cryptography"
 
 
380
 
381
  return {
382
  "id": scenario["id"],
 
363
  module_out = "web"
364
  elif challenge_type == "crypto":
365
  module_out = "crypto"
366
+ elif challenge_type == "steganography":
367
+ module_out = "steganography"
368
  else:
369
  module_out = mod
370
 
 
379
  category = "Web Security"
380
  elif challenge_type == "crypto":
381
  category = "Cryptography"
382
+ elif challenge_type == "steganography":
383
+ category = "Steganography"
384
 
385
  return {
386
  "id": scenario["id"],
app/services/supabase_service.py CHANGED
@@ -19,13 +19,15 @@ from app.core.config import SUPABASE_URL, SUPABASE_ANON_KEY
19
  def scenario_table(team_role: str = "", challenge_type: str = "") -> str:
20
  """Pick the right per-type table for a challenge lookup.
21
 
22
- ``challenge_type`` can be one of: ``"crypto"``,
23
  ``"code-fixing"``, ``"log-analysis"``, ``"vulnerability-hunter"``.
24
  If omitted we fall back to ``"encryption_challenges"`` for legacy
25
  behavior (Blue/Red crypto).
26
  """
27
  if challenge_type == "crypto":
28
  return "encryption_challenges"
 
 
29
  if challenge_type == "code-fixing":
30
  return "code_fixing_challenges"
31
  if challenge_type == "log-analysis":
@@ -74,17 +76,47 @@ async def fetch_scenario_by_id(
74
  ) -> Optional[dict]:
75
  if not SUPABASE_ANON_KEY or not SUPABASE_URL:
76
  return None
77
- table = scenario_table(team_role, challenge_type)
78
- url = f"{SUPABASE_URL}/rest/v1/{table}?id=eq.{scenario_id}&team_role=eq.{team_role}"
79
- try:
80
- async with httpx.AsyncClient() as client:
81
- resp = await client.get(url, headers=supabase_headers())
82
- if resp.status_code == 200:
83
- rows = resp.json()
84
- if rows:
85
- return rows[0]
86
- except Exception as e:
87
- print(f"Error fetching scenario {scenario_id}: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  return None
89
 
90
 
 
19
  def scenario_table(team_role: str = "", challenge_type: str = "") -> str:
20
  """Pick the right per-type table for a challenge lookup.
21
 
22
+ ``challenge_type`` can be one of: ``"crypto"``, ``"steganography"``,
23
  ``"code-fixing"``, ``"log-analysis"``, ``"vulnerability-hunter"``.
24
  If omitted we fall back to ``"encryption_challenges"`` for legacy
25
  behavior (Blue/Red crypto).
26
  """
27
  if challenge_type == "crypto":
28
  return "encryption_challenges"
29
+ if challenge_type == "steganography":
30
+ return "steganography_challenges"
31
  if challenge_type == "code-fixing":
32
  return "code_fixing_challenges"
33
  if challenge_type == "log-analysis":
 
76
  ) -> Optional[dict]:
77
  if not SUPABASE_ANON_KEY or not SUPABASE_URL:
78
  return None
79
+
80
+ async def _fetch(tbl: str) -> Optional[dict]:
81
+ url = f"{SUPABASE_URL}/rest/v1/{tbl}?id=eq.{scenario_id}&team_role=eq.{team_role}"
82
+ try:
83
+ async with httpx.AsyncClient() as client:
84
+ resp = await client.get(url, headers=supabase_headers())
85
+ if resp.status_code == 200:
86
+ rows = resp.json()
87
+ if rows:
88
+ return rows[0]
89
+ except Exception as e:
90
+ print(f"Error fetching scenario {scenario_id} from {tbl}: {e}")
91
+ return None
92
+
93
+ if challenge_type:
94
+ tbl = scenario_table(team_role, challenge_type)
95
+ return await _fetch(tbl)
96
+
97
+ # Search all tables for this team role
98
+ tables = []
99
+ if team_role == "red":
100
+ tables = ["encryption_challenges", "steganography_challenges"]
101
+ elif team_role == "blue":
102
+ tables = [
103
+ "code_fixing_challenges",
104
+ "log_analysis_challenges",
105
+ "vulnerability_hunter_challenges",
106
+ ]
107
+ else:
108
+ tables = [
109
+ "encryption_challenges",
110
+ "steganography_challenges",
111
+ "code_fixing_challenges",
112
+ "log_analysis_challenges",
113
+ "vulnerability_hunter_challenges",
114
+ ]
115
+
116
+ for tbl in tables:
117
+ row = await _fetch(tbl)
118
+ if row:
119
+ return row
120
  return None
121
 
122