File size: 16,043 Bytes
80a4a65
 
 
 
 
 
 
 
 
 
 
3c7b4e4
4c41470
80a4a65
3c7b4e4
80a4a65
 
 
 
 
 
 
4871da9
cb16781
80a4a65
 
 
 
 
 
cb16781
80a4a65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
04fc815
80a4a65
 
 
 
 
 
 
 
 
 
 
 
 
cb16781
80a4a65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
04fc815
 
80a4a65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4871da9
cb16781
80a4a65
 
 
 
 
 
 
 
 
 
 
 
04fc815
80a4a65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4871da9
cb16781
 
 
80a4a65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4871da9
cb16781
80a4a65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
04fc815
80a4a65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3c7b4e4
 
 
80a4a65
 
 
 
 
 
 
 
3c7b4e4
 
 
80a4a65
 
 
 
 
 
 
 
3c7b4e4
 
 
80a4a65
 
 
 
 
 
 
 
3c7b4e4
 
 
80a4a65
 
 
cb16781
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80a4a65
 
 
 
 
 
 
4871da9
cb16781
80a4a65
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
"""``/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                                       #
# --------------------------------------------------------------------------- #

@router.get("/api/training/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                            #
# --------------------------------------------------------------------------- #

@router.post("/api/training/solved")
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   #
# --------------------------------------------------------------------------- #

@router.post("/api/training/generate")
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                         #
# --------------------------------------------------------------------------- #

@router.post("/api/training/evaluate")
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                                            #
# --------------------------------------------------------------------------- #

@router.post("/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                                        #
# --------------------------------------------------------------------------- #

@router.post("/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                                #
# --------------------------------------------------------------------------- #

@router.post("/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 = ""


@router.post("/api/training/evaluate-web-exploit")
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",
]