Spaces:
Sleeping
Sleeping
Hussien Haider commited on
Commit ·
4871da9
1
Parent(s): 04fc815
- app/api/training.py +6 -1
- app/core/config.py +1 -0
- app/core/module_router.py +2 -0
- app/generators/__init__.py +3 -0
- app/generators/code_fixing.py +19 -0
- app/generators/crypto.py +18 -0
- app/generators/log_analysis.py +19 -0
- app/generators/steganography.py +660 -0
- app/generators/vulnerability_hunter.py +20 -0
- app/sandbox/terminal.py +2 -1
- app/sandbox/tools.py +75 -0
- app/services/challenge_loader.py +42 -0
- app/services/dedup.py +211 -0
- app/services/scenario_service.py +4 -0
- app/services/supabase_service.py +44 -12
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 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|