Spaces:
Sleeping
Sleeping
| """``/api/training/*`` — list, generate, evaluate, solve endpoints. | |
| Per-type generation / mapping logic lives in | |
| :mod:`app.services.scenario_service` and | |
| :mod:`app.services.challenge_loader`. Per-type evaluation lives in | |
| :mod:`app.services.evaluator`. This router is a thin HTTP shell. | |
| """ | |
| from typing import Optional | |
| import random | |
| from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException | |
| from pydantic import BaseModel | |
| from app.core.auth import get_current_user | |
| from app.core.constants import CYBER_SECURITY_TOPICS | |
| from app.core.module_router import challenge_type_for_module | |
| from app.services.challenge_loader import ( | |
| map_encryption_row_to_training, | |
| map_code_fixing_row_to_training, | |
| map_log_analysis_row_to_training, | |
| map_vuln_hunter_row_to_training, | |
| map_steganography_row_to_training, | |
| map_web_exploit_row_to_training, | |
| ) | |
| from app.services.evaluator import ( | |
| evaluate_training, | |
| evaluate_code_fix, | |
| evaluate_log_analysis, | |
| evaluate_vuln_hunter, | |
| evaluate_web_exploit, | |
| ) | |
| from app.services.scenario_service import ( | |
| attach_scenario_metadata, | |
| handle_background_replacement, | |
| map_scenario_to_list_item, | |
| ) | |
| from app.services.supabase_service import ( | |
| fetch_scenario_by_id, | |
| fetch_random_scenario_from_supabase, | |
| fetch_all_scenarios_for_type, | |
| get_supabase_scenario_count, | |
| scenario_table, | |
| ) | |
| from app.types import ( | |
| EvaluateRequest, | |
| TrainingRequest, | |
| SolvedRequest, | |
| CodeFixEvaluateRequest, | |
| LogAnalysisEvaluateRequest, | |
| VulnHunterEvaluateRequest, | |
| ) | |
| router = APIRouter() | |
| # --------------------------------------------------------------------------- # | |
| # /api/training/list — dashboard list # | |
| # --------------------------------------------------------------------------- # | |
| async def list_challenges(team_role: str = "blue", difficulty: Optional[str] = None, limit: int = 100): | |
| """List cached challenges for a team. | |
| For each challenge type the team has access to, fetch all rows | |
| (capped at ``limit``) and map them to the lightweight list shape | |
| the dashboard renders. Blue team gets code-fixing / log-analysis | |
| / vulnerability-hunter; red team gets crypto. | |
| Note: the previous implementation called | |
| :func:`fetch_random_scenario_from_supabase` with an empty | |
| ``module`` argument, which built a ``module=eq.`` PostgREST | |
| filter that never matched (modules in the DB are never empty). | |
| That made the dashboard report zero challenges even when the pool | |
| was full. This version uses | |
| :func:`fetch_all_scenarios_for_type` instead. | |
| """ | |
| candidates: list[str] = [] | |
| if team_role == "blue": | |
| candidates = ["code-fixing", "log-analysis", "vulnerability-hunter"] | |
| else: | |
| candidates = ["crypto", "steganography", "web-exploitation"] | |
| items: list[dict] = [] | |
| per_type_cap = max(1, limit // max(1, len(candidates))) | |
| for ctype in candidates: | |
| try: | |
| rows = await fetch_all_scenarios_for_type( | |
| team_role, challenge_type=ctype, limit=per_type_cap | |
| ) | |
| if not rows: | |
| continue | |
| for row in rows: | |
| row["_challenge_type"] = ctype | |
| items.append(map_scenario_to_list_item(row)) | |
| if len(items) >= limit: | |
| break | |
| except Exception as e: | |
| print(f"[list_challenges] {ctype}: {e}") | |
| continue | |
| if len(items) >= limit: | |
| break | |
| return {"items": items, "total": len(items)} | |
| # --------------------------------------------------------------------------- # | |
| # /api/training/solved — consume one from the pool # | |
| # --------------------------------------------------------------------------- # | |
| async def solve_challenge(req: SolvedRequest, background_tasks: BackgroundTasks): | |
| ctype = challenge_type_for_module(req.module or "") | |
| background_tasks.add_task( | |
| handle_background_replacement, | |
| req.scenarioId, | |
| req.teamRole or "red", | |
| req.module or "", | |
| req.path or "cryptography", | |
| req.category or "encryption", | |
| req.difficulty or "متوسط", | |
| ) | |
| return {"status": "consumed", "challenge_type": ctype} | |
| # --------------------------------------------------------------------------- # | |
| # /api/training/generate — hydrate a cached scenario into a full challenge # | |
| # --------------------------------------------------------------------------- # | |
| async def generate_training(req: TrainingRequest, background_tasks: BackgroundTasks): | |
| module = req.module | |
| path = req.path | |
| category = req.category | |
| challenge_id = req.challengeId | |
| team_role = req.teamRole or "red" | |
| if not module or not path or not category: | |
| raise HTTPException(status_code=400, detail="module / path / category are required") | |
| # The ``module`` field on the request is the **challenge type** (one of | |
| # the five canonical values) — see AGENTS.md "Challenge Type vs Module". | |
| # We use it to (a) look the row up in the right table and (b) pin | |
| # training.type to the canonical value. | |
| challenge_type = _normalize_challenge_type(module) | |
| # 1) Try to grab a cached scenario for this (type, team). When | |
| # ``challenge_id`` is given we look in every per-type table until | |
| # we find the row; otherwise we pick a random row from the table | |
| # that matches the requested type. | |
| scenario: Optional[dict] = None | |
| if challenge_id: | |
| scenario = await _fetch_scenario_by_id_typed( | |
| team_role, challenge_id, challenge_type | |
| ) | |
| if not scenario: | |
| scenario = await _fetch_random_scenario_typed( | |
| team_role, challenge_type | |
| ) | |
| if not scenario: | |
| raise HTTPException( | |
| status_code=404, | |
| detail=( | |
| f"لا يوجد سيناريو في البركة لهذه الوحدة ({module}). " | |
| "سيناريو جديد سيُولَّد قريباً." | |
| ), | |
| ) | |
| # 2) Project the raw DB row straight into the TrainingData shape. | |
| # The row mappers in app.services.challenge_loader are the | |
| # single source of truth for "DB row → front-end payload" — they | |
| # include vulnerable_code / log_url / htmlPreview / etc. for | |
| # each type, so no further AI round-trip is needed. | |
| training_data = _project_row_to_training(scenario, team_role, challenge_type) | |
| # 3) For legacy callers / fronts that still expect a ``type`` | |
| # alias and a stable id, run the metadata attach. It is now a | |
| # pure pass-through because the mapper already set every field. | |
| training_data = attach_scenario_metadata( | |
| training_data, scenario, challenge_type=challenge_type | |
| ) | |
| return {"training": training_data} | |
| # --------------------------------------------------------------------------- # | |
| # Row → TrainingData projection # | |
| # --------------------------------------------------------------------------- # | |
| _TYPE_TO_MAPPER = { | |
| "crypto": "map_encryption_row_to_training", | |
| "code-fixing": "map_code_fixing_row_to_training", | |
| "log-analysis": "map_log_analysis_row_to_training", | |
| "vulnerability-hunter": "map_vuln_hunter_row_to_training", | |
| "steganography": "map_steganography_row_to_training", | |
| "web-exploitation": "map_web_exploit_row_to_training", | |
| } | |
| def _project_row_to_training( | |
| scenario: dict, team_role: str, challenge_type: str | |
| ) -> dict: | |
| """Run the per-type mapper against a raw DB row. | |
| Falls back to a minimal "best-effort" shape when the challenge | |
| type is unknown so that the front-end at least gets a | |
| ``type``/``id`` pair to render a generic error. This should | |
| never fire in practice because the candidate list in | |
| ``list_challenges`` only returns the four canonical types. | |
| """ | |
| mapper_name = _TYPE_TO_MAPPER.get(challenge_type) | |
| if mapper_name is None: | |
| return { | |
| "id": str(scenario.get("id", "")), | |
| "scenarioId": str(scenario.get("id", "")), | |
| "title": scenario.get("title", ""), | |
| "story": scenario.get("story", ""), | |
| "task": scenario.get("task_outline", ""), | |
| "type": challenge_type, | |
| "difficulty": scenario.get("difficulty", "متوسط"), | |
| "xpReward": scenario.get("xp_reward", 150), | |
| "hints": scenario.get("hints") or [], | |
| } | |
| mapper = globals()[mapper_name] | |
| return mapper(scenario, team_role) | |
| # --------------------------------------------------------------------------- # | |
| # Helpers used only by /api/training/generate # | |
| # --------------------------------------------------------------------------- # | |
| # Canonical challenge types — these are the ONLY values that may appear | |
| # in ``training["type"]`` and they map 1:1 to a per-type Supabase table. | |
| _CANONICAL_CHALLENGE_TYPES: dict[str, str] = { | |
| "crypto": "crypto", | |
| "encryption": "crypto", | |
| "code-fixing": "code-fixing", | |
| "code_fixing": "code-fixing", | |
| "log-analysis": "log-analysis", | |
| "log_analysis": "log-analysis", | |
| "vulnerability-hunter": "vulnerability-hunter", | |
| "vulnerability_hunter": "vulnerability-hunter", | |
| "steganography": "steganography", | |
| "web-exploitation": "web-exploitation", | |
| "web_exploitation": "web-exploitation", | |
| "web": "web-exploitation", | |
| } | |
| def _normalize_challenge_type(module: str) -> str: | |
| """Resolve any module alias to its canonical challenge type. | |
| Unknown values fall through to ``"crypto"`` (the legacy default) so | |
| that the red-team code path keeps working. The trainer endpoint | |
| never raises on bad input — it logs and best-efforts. | |
| """ | |
| key = (module or "").strip().lower() | |
| return _CANONICAL_CHALLENGE_TYPES.get(key, "crypto") | |
| def _tables_for_team(team_role: str) -> list[str]: | |
| """All per-type tables that can hold a row for ``team_role``. | |
| Used to look up a row by id without knowing its type up front. The | |
| list is short and stable so a sequential scan is fine. | |
| """ | |
| if team_role == "red": | |
| return [ | |
| "encryption_challenges", | |
| "steganography_challenges", | |
| "web_exploitation_challenges", | |
| ] | |
| return [ | |
| "code_fixing_challenges", | |
| "log_analysis_challenges", | |
| "vulnerability_hunter_challenges", | |
| ] | |
| async def _fetch_scenario_by_id_typed( | |
| team_role: str, scenario_id: str, hint_type: str | |
| ) -> Optional[dict]: | |
| """Look up a scenario by id, scoped to the team. | |
| Tries the hint table first (single round-trip), then falls back to | |
| every other table the team owns. This replaces the old behaviour | |
| where ``fetch_scenario_by_id`` always looked in | |
| ``encryption_challenges`` — which would silently return ``None`` | |
| for code-fixing / log-analysis / vuln-hunter rows. | |
| """ | |
| if not scenario_id: | |
| return None | |
| tables = [scenario_table(team_role, hint_type)] + [ | |
| t for t in _tables_for_team(team_role) | |
| if t != scenario_table(team_role, hint_type) | |
| ] | |
| for table in tables: | |
| row = await _fetch_scenario_in_table(team_role, scenario_id, table) | |
| if row: | |
| return row | |
| return None | |
| async def _fetch_scenario_in_table( | |
| team_role: str, scenario_id: str, table: str | |
| ) -> Optional[dict]: | |
| import httpx | |
| from app.services.supabase_service import supabase_headers | |
| from app.core.config import SUPABASE_URL, SUPABASE_ANON_KEY | |
| if not SUPABASE_ANON_KEY or not SUPABASE_URL: | |
| return None | |
| url = ( | |
| f"{SUPABASE_URL}/rest/v1/{table}" | |
| f"?id=eq.{scenario_id}&team_role=eq.{team_role}&limit=1" | |
| ) | |
| try: | |
| async with httpx.AsyncClient() as client: | |
| resp = await client.get(url, headers=supabase_headers()) | |
| if resp.status_code == 200: | |
| rows = resp.json() | |
| if rows: | |
| return rows[0] | |
| except Exception as e: | |
| print(f"[generate_training] {table} lookup failed: {e}") | |
| return None | |
| async def _fetch_random_scenario_typed( | |
| team_role: str, challenge_type: str | |
| ) -> Optional[dict]: | |
| """Pick a random row from the table that matches ``challenge_type``.""" | |
| from app.services.supabase_service import fetch_all_scenarios_for_type | |
| rows = await fetch_all_scenarios_for_type(team_role, challenge_type, limit=50) | |
| if rows: | |
| return random.choice(rows) | |
| return None | |
| # --------------------------------------------------------------------------- # | |
| # /api/training/evaluate — generic / legacy red+blue # | |
| # --------------------------------------------------------------------------- # | |
| async def post_evaluate_training(req: EvaluateRequest, background_tasks: BackgroundTasks, user: dict = Depends(get_current_user)): | |
| # IDOR FIX: Override userId from JWT | |
| req.userId = user["user_id"] | |
| return await evaluate_training(req, background_tasks) | |
| # --------------------------------------------------------------------------- # | |
| # /api/training/evaluate-code-fix # | |
| # --------------------------------------------------------------------------- # | |
| async def post_evaluate_code_fix(req: CodeFixEvaluateRequest, background_tasks: BackgroundTasks, user: dict = Depends(get_current_user)): | |
| # IDOR FIX: Override userId from JWT | |
| req.userId = user["user_id"] | |
| return await evaluate_code_fix(req, background_tasks) | |
| # --------------------------------------------------------------------------- # | |
| # /api/training/evaluate-log-analysis # | |
| # --------------------------------------------------------------------------- # | |
| async def post_evaluate_log_analysis(req: LogAnalysisEvaluateRequest, background_tasks: BackgroundTasks, user: dict = Depends(get_current_user)): | |
| # IDOR FIX: Override userId from JWT | |
| req.userId = user["user_id"] | |
| return await evaluate_log_analysis(req, background_tasks) | |
| # --------------------------------------------------------------------------- # | |
| # /api/training/evaluate-vulnerability-hunter # | |
| # --------------------------------------------------------------------------- # | |
| async def post_evaluate_vuln_hunter(req: VulnHunterEvaluateRequest, background_tasks: BackgroundTasks, user: dict = Depends(get_current_user)): | |
| # IDOR FIX: Override userId from JWT | |
| req.userId = user["user_id"] | |
| return await evaluate_vuln_hunter(req, background_tasks) | |
| # --------------------------------------------------------------------------- # | |
| # /api/training/evaluate-web-exploit # | |
| # --------------------------------------------------------------------------- # | |
| class WebExploitEvaluateRequest(BaseModel): | |
| userId: str = "" | |
| teamRole: str = "red" | |
| challengeId: str = "" | |
| payload: str = "" | |
| async def post_evaluate_web_exploit(req: WebExploitEvaluateRequest, background_tasks: BackgroundTasks, user: dict = Depends(get_current_user)): | |
| # IDOR FIX: Override userId from JWT | |
| req.userId = user["user_id"] | |
| return await evaluate_web_exploit(req, background_tasks) | |
| # Re-export the row mappers so 1v1 can keep importing them from a | |
| # stable location without taking a hard dependency on the service layer. | |
| __all__ = [ | |
| "map_encryption_row_to_training", | |
| "map_code_fixing_row_to_training", | |
| "map_log_analysis_row_to_training", | |
| "map_vuln_hunter_row_to_training", | |
| "map_steganography_row_to_training", | |
| "map_web_exploit_row_to_training", | |
| ] | |