Hussien Haider commited on
Commit
80a4a65
·
1 Parent(s): f532bd2
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +5 -0
  2. README.md +164 -10
  3. app/__init__.py +15 -0
  4. app/_env.py +172 -0
  5. app/api/__init__.py +24 -0
  6. app/api/auth.py +23 -0
  7. app/api/certificates.py +34 -0
  8. app/api/leaderboard.py +48 -0
  9. onevone_router.py → app/api/onevone.py +20 -15
  10. app/api/terminal.py +79 -0
  11. app/api/training.py +387 -0
  12. app/api/xp.py +26 -0
  13. app/core/__init__.py +68 -0
  14. app/core/config.py +113 -0
  15. app/core/constants.py +153 -0
  16. app/core/module_router.py +19 -0
  17. app/core/security.py +61 -0
  18. app/core/text.py +42 -0
  19. app/generators/__init__.py +40 -0
  20. code_fixing_generator.py → app/generators/code_fixing.py +79 -6
  21. crypto_generator.py → app/generators/crypto.py +131 -4
  22. log_analysis_generator.py → app/generators/log_analysis.py +77 -5
  23. vulnerability_hunter_generator.py → app/generators/vulnerability_hunter.py +78 -5
  24. web_exploitation_generator.py → app/generators/web.py +113 -4
  25. app/main.py +117 -0
  26. app/sandbox/__init__.py +19 -0
  27. app/sandbox/terminal.py +103 -0
  28. app/sandbox/tools.py +115 -0
  29. app/sandbox/workdir.py +72 -0
  30. app/services/__init__.py +119 -0
  31. app/services/certificate_service.py +464 -0
  32. app/services/challenge_loader.py +206 -0
  33. app/services/completion_service.py +64 -0
  34. app/services/evaluator.py +715 -0
  35. app/services/file_storage.py +68 -0
  36. app/services/scenario_service.py +491 -0
  37. app/services/supabase_service.py +183 -0
  38. app/types.py +122 -0
  39. apply_migration_005.py +0 -169
  40. apply_migration_009.py +0 -208
  41. challenge_files/blue_android-ios_1780374909_app_logs.txt +0 -3
  42. challenge_files/blue_binary-analysis_1780074195_MalwareX.exe +0 -1
  43. challenge_files/blue_binary-analysis_1780439585_MalwareX.exe +0 -16
  44. challenge_files/blue_cloud-config_1780424632_docker-compose.yml +0 -1
  45. challenge_files/blue_csrf_1780074134_index.html +0 -1
  46. challenge_files/blue_dns-poisoning_1780073949_dns-logs.txt +0 -3
  47. challenge_files/blue_encryption-basics_1780073989_encrypted_message.txt +0 -1
  48. challenge_files/blue_encryption-basics_1780074215_hashes.txt +0 -1
  49. challenge_files/blue_encryption-basics_1780430362_encryption_logs.txt +0 -3
  50. challenge_files/blue_encryption-basics_1780439473_file.enc +0 -1
.gitignore CHANGED
@@ -18,3 +18,8 @@ __pycache__/
18
  Thumbs.db
19
  .vscode/
20
  .idea/
 
 
 
 
 
 
18
  Thumbs.db
19
  .vscode/
20
  .idea/
21
+
22
+ # No local file storage — defensive ignore in case anything slips in
23
+ challenge_files/
24
+ /tmp/
25
+ /tempfiles/
README.md CHANGED
@@ -1,10 +1,164 @@
1
- ---
2
- title: CyberArena
3
- emoji: 🏆
4
- colorFrom: yellow
5
- colorTo: yellow
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CyberArena — Backend
2
+
3
+ > FastAPI backend for the CyberArena cybersecurity training platform.
4
+ > Single source of truth: this directory. The old `Apex/backend/` has
5
+ > been removed.
6
+
7
+ ## Quick start
8
+
9
+ ```bash
10
+ cd CyberArena
11
+ pip install -r requirements.txt
12
+ python main.py # serves on http://localhost:8090
13
+ ```
14
+
15
+ The server loads `.env` automatically from this folder.
16
+
17
+ ## Project layout
18
+
19
+ ```
20
+ CyberArena/
21
+ ├── main.py ← 5-line uvicorn entry
22
+ ├── requirements.txt
23
+ ├── Dockerfile
24
+ ├── start.cmd
25
+
26
+ ├── app/ ← all real code
27
+ │ ├── main.py ← FastAPI app, middleware, startup
28
+ │ ├── types.py ← Pydantic request models
29
+ │ │
30
+ │ ├── core/ ← constants + pure helpers (no I/O)
31
+ │ │ ├── config.py ← env-loaded handles (Supabase, AI URLs, keys)
32
+ │ │ ├── constants.py ← topic catalog, fallback HTML, cert config
33
+ │ │ ├── security.py ← normalize_vuln_key, ip/ioc/time matchers
34
+ │ │ ├── text.py ← parse_json_safe (LLM JSON parser)
35
+ │ │ └── module_router.py ← module → challenge_type dispatcher
36
+ │ │
37
+ │ ├── services/ ← business logic / I/O
38
+ │ │ ├── supabase_service.py ← PostgREST client (CRUD on pool tables)
39
+ │ │ ├── challenge_loader.py ← row → TrainingData mappers (per type)
40
+ │ │ ├── scenario_service.py ← Groq scenario + challenge generation
41
+ │ │ ├── evaluator.py ← /evaluate handlers (5 types)
42
+ │ │ ├── completion_service.py ← user_completions idempotent tracker
43
+ │ │ ├── certificate_service.py ← PDF build + cert issue/verify/download
44
+ │ │ └── file_storage.py ← write downloadable files to disk
45
+ │ │
46
+ │ ├── generators/ ← per-type AI pool generators
47
+ │ │ ├── __init__.py ← REGISTRY: list of (slug, module, teams)
48
+ │ │ ├── crypto.py ← owns encryption_challenges
49
+ │ │ ├── web.py ← owns web_exploitation_challenges
50
+ │ │ ├── code_fixing.py ← owns code_fixing_challenges
51
+ │ │ ├── log_analysis.py ← owns log_analysis_challenges
52
+ │ │ └── vulnerability_hunter.py ← owns vulnerability_hunter_challenges
53
+ │ │
54
+ │ ├── sandbox/ ← in-process OS simulator sandbox
55
+ │ │ ├── workdir.py ← per-challenge tempdir + safe_join
56
+ │ │ ├── tools.py ← built-in cat/ls/sha256sum/base64/…
57
+ │ │ └── terminal.py ← shlex parse + whitelist subprocess run
58
+ │ │
59
+ │ └── api/ ← FastAPI routers (HTTP shell only)
60
+ │ ├── auth.py ← /api/auth
61
+ │ ├── xp.py ← /api/xp
62
+ │ ├── leaderboard.py ← /api/leaderboard
63
+ │ ├── certificates.py ← /api/certificates*
64
+ │ ├── training.py ← /api/training/*
65
+ │ ├── terminal.py ← /api/training/terminal*
66
+ │ └── onevone.py ← /api/onevone/* (head-to-head matches)
67
+
68
+ ├── db/ ← all database concerns
69
+ │ ├── schema/ ← numbered, idempotent SQL migrations
70
+ │ │ ├── 000_extensions.sql
71
+ │ │ ├── 001_users.sql
72
+ │ │ ├── 002_encryption_challenges.sql
73
+ │ │ ├── 003_web_exploitation_challenges.sql
74
+ │ │ ├── 004_code_fixing_challenges.sql
75
+ │ │ ├── 005_log_analysis_challenges.sql
76
+ │ │ ├── 006_vulnerability_hunter_challenges.sql
77
+ │ │ ├── 007_onevone.sql
78
+ │ │ ├── 008_certificates.sql
79
+ │ │ └── 009_user_completions.sql
80
+ │ ├── seed/ ← per-type example JSON (template)
81
+ │ │ ├── README.md
82
+ │ │ ├── crypto.example.json
83
+ │ │ ├── web.example.json
84
+ │ │ ├── code_fixing.example.json
85
+ │ │ ├── log_analysis.example.json
86
+ │ │ └── vulnerability_hunter.example.json
87
+ │ ├── apply.py ← apply every schema/*.sql in order
88
+ │ └── README.md
89
+
90
+ ├── scripts/ ← ops utilities
91
+ │ ├── check_state.py ← one-page DB health report
92
+ │ └── seed_pools.py ← pool size + status (read-only by default)
93
+
94
+ ├── .env ← secrets (never commit)
95
+ └── README.md
96
+
97
+ > **No local file storage.** AI-generated challenge files are uploaded
98
+ > to the `challenge-files` Supabase Storage bucket (see
99
+ > `db/schema/010_challenge_files_bucket.sql`) and the public URL is
100
+ > returned in the training payload. The backend never writes to its
101
+ > own disk.
102
+ ```
103
+
104
+ ## API surface
105
+
106
+ The FastAPI app exposes **28 routes** under `/api/`. The full list
107
+ lives in `app/main.py`; the most important are:
108
+
109
+ | Method | Path | Notes |
110
+ |--------|-------------------------------------|-------|
111
+ | POST | `/api/auth` | proxied to Supabase `apex-auth` edge function |
112
+ | POST | `/api/xp` | proxied to Supabase `apex-xp` edge function |
113
+ | GET | `/api/leaderboard` | top users by XP |
114
+ | GET | `/api/training/list` | dashboard cards |
115
+ | POST | `/api/training/generate` | hydrate cached scenario into a full challenge |
116
+ | POST | `/api/training/evaluate` | legacy red+blue AI evaluator |
117
+ | POST | `/api/training/evaluate-web` | 3-layer web exploitation validator |
118
+ | POST | `/api/training/evaluate-code-fix` | AI code-fix grader |
119
+ | POST | `/api/training/evaluate-log-analysis` | 4-field exact match + Mistral feedback |
120
+ | POST | `/api/training/evaluate-vulnerability-hunter` | exact canonical-key match |
121
+ | POST | `/api/training/terminal*` | OS simulator sandbox |
122
+ | POST | `/api/certificates` | list / issue |
123
+ | GET | `/api/certificates/{id}/pdf` | streamed PDF |
124
+ | GET | `/api/certificates/verify/{code}` | public QR landing |
125
+ | GET | `/api/certificates/progress` | (completions, required) |
126
+ | POST | `/api/onevone/rooms` | 1v1 mode (see `app/api/onevone.py`) |
127
+
128
+ ## Pool architecture
129
+
130
+ Each challenge type owns its own table + its own generator. The
131
+ orchestrator in `app/main.py::populate_pool_background` walks
132
+ `app.generators.REGISTRY` and starts one watcher per (type, team).
133
+
134
+ Pool model: `POOL_TARGET / POOL_THRESHOLD / POOL_BATCH` are module
135
+ constants inside each generator. The watcher refills the table
136
+ when the count drops to `POOL_THRESHOLD` and inserts `POOL_BATCH` new
137
+ rows in parallel via `asyncio.gather`.
138
+
139
+ See `../AGENTS.md` "Pool Architecture" for the full contract.
140
+
141
+ ## Database
142
+
143
+ Apply the migrations to Supabase with:
144
+
145
+ ```bash
146
+ python db/apply.py # needs SUPABASE_SERVICE_KEY in .env
147
+ ```
148
+
149
+ Each `.sql` file is idempotent (`CREATE … IF NOT EXISTS`, `DROP
150
+ POLICY IF EXISTS + CREATE`). See `db/README.md` for details.
151
+
152
+ ## Running tests
153
+
154
+ There is no formal test suite yet. Use:
155
+
156
+ ```bash
157
+ python -c "from app.main import app; print(len(app.routes), 'routes')"
158
+ python scripts/check_state.py # requires SUPABASE_URL
159
+ ```
160
+
161
+ ## Deployment
162
+
163
+ The provided `Dockerfile` pins `python:3.11-slim` and starts the
164
+ server on `PORT` (default 7860, matches Hugging Face Spaces).
app/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CyberArena backend package.
2
+
3
+ This package organises the FastAPI backend into focused subpackages:
4
+
5
+ app.core → constants, helpers (no I/O)
6
+ app.services → business logic that talks to Supabase / AI providers
7
+ app.generators → per-challenge-type AI pool generators
8
+ app.sandbox → in-process terminal sandbox for the OS simulator
9
+ app.api → FastAPI routers mounted under /api/...
10
+
11
+ The actual FastAPI ``app`` is built in :mod:`app.main` and re-exported as
12
+ ``app.main:app`` so the top-level ``main.py`` stays a 5-line entry.
13
+ """
14
+
15
+ __all__ = ["main"]
app/_env.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Centralised .env loader.
2
+
3
+ This module is imported **first** by every other module that touches
4
+ ``os.environ`` (currently :mod:`app.main` and :mod:`app.core.config`).
5
+ It searches a small list of candidate locations for the project's
6
+ ``.env`` file and loads it via :func:`dotenv.load_dotenv`.
7
+
8
+ **Search order** (first hit wins):
9
+
10
+ 1. ``./.env`` (process working directory)
11
+ 2. ``../.env`` (one level up — useful when running from ``app/``)
12
+ 3. ``<this_file>/../.env`` (sibling of the ``app/`` package — the
13
+ canonical location: ``CyberArena/.env``)
14
+ 4. ``<this_file>/.env`` (inside the ``app/`` package — auto-created
15
+ copies; we tolerate them but don't prefer them)
16
+
17
+ The function prints a single line of status to stdout so the operator
18
+ knows exactly which file was used, and which critical variables were
19
+ actually picked up.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import sys
26
+ from pathlib import Path
27
+ from typing import Iterable, Optional
28
+
29
+
30
+ # --------------------------------------------------------------------------- #
31
+ # Internal helpers #
32
+ # --------------------------------------------------------------------------- #
33
+
34
+ _TRUTHY = {"1", "true", "yes", "on"}
35
+
36
+
37
+ def _is_loaded_marker_set() -> bool:
38
+ """We use a sentinel env var so we never load twice."""
39
+ return os.environ.get("_APEX_ENV_LOADED") == "1"
40
+
41
+
42
+ def _set_loaded_marker() -> None:
43
+ os.environ["_APEX_ENV_LOADED"] = "1"
44
+
45
+
46
+ def _mask(value: str) -> str:
47
+ """Return a masked version of a secret suitable for printing."""
48
+ if not value:
49
+ return "<MISSING>"
50
+ if len(value) <= 8:
51
+ return "***"
52
+ return f"{value[:4]}…{value[-4:]} (len={len(value)})"
53
+
54
+
55
+ def _candidate_paths() -> Iterable[Path]:
56
+ """Yield the candidate .env locations, in priority order."""
57
+ here = Path(__file__).resolve()
58
+ app_dir = here.parent
59
+ backend_dir = app_dir.parent # CyberArena/
60
+ cwd = Path.cwd()
61
+
62
+ seen: set[Path] = set()
63
+
64
+ for path in (
65
+ cwd / ".env",
66
+ cwd.parent / ".env", # one above cwd (in case you ran from app/)
67
+ backend_dir / ".env", # the canonical location
68
+ app_dir / ".env", # the stray copy inside the package
69
+ ):
70
+ try:
71
+ resolved = path.resolve()
72
+ except FileNotFoundError:
73
+ continue
74
+ if resolved in seen:
75
+ continue
76
+ seen.add(resolved)
77
+ yield path
78
+
79
+
80
+ def _find_env() -> Optional[Path]:
81
+ """Return the first existing .env in the candidate list."""
82
+ for path in _candidate_paths():
83
+ if path.is_file():
84
+ return path
85
+ return None
86
+
87
+
88
+ # --------------------------------------------------------------------------- #
89
+ # Public entry point #
90
+ # --------------------------------------------------------------------------- #
91
+
92
+
93
+ def load_app_env(verbose: bool = True) -> Optional[Path]:
94
+ """Load the CyberArena ``.env`` file into ``os.environ``.
95
+
96
+ Idempotent: a second call is a no-op. Returns the path that was
97
+ loaded, or ``None`` if no file was found.
98
+ """
99
+ if _is_loaded_marker_set():
100
+ return None
101
+
102
+ from dotenv import load_dotenv # local import — cheap, no I/O
103
+
104
+ env_path = _find_env()
105
+ if env_path is None:
106
+ if verbose:
107
+ print(
108
+ "[env] WARNING: no .env file found. Searched:",
109
+ file=sys.stderr,
110
+ )
111
+ for p in _candidate_paths():
112
+ print(f" - {p}", file=sys.stderr)
113
+ print(
114
+ "[env] Create CyberArena/.env with SUPABASE_URL / "
115
+ "SUPABASE_ANON_KEY / CLOUDFLARE_* / GROQ_API_KEY.",
116
+ file=sys.stderr,
117
+ )
118
+ _set_loaded_marker()
119
+ return None
120
+
121
+ # ``override=False`` so process env wins over .env when both are set
122
+ # (e.g. secrets injected by Hugging Face Spaces / Docker).
123
+ load_dotenv(dotenv_path=str(env_path), override=False, encoding="utf-8")
124
+ _set_loaded_marker()
125
+
126
+ if verbose:
127
+ supabase_url = os.environ.get("SUPABASE_URL", "")
128
+ supabase_key = os.environ.get("SUPABASE_ANON_KEY", "")
129
+ cf_token = os.environ.get("CLOUDFLARE_API_TOKEN", "")
130
+ groq_key = os.environ.get("GROQ_API_KEY", "")
131
+ nvidia_key = os.environ.get("NVIDIA_API_KEY", "")
132
+ mistral_key = os.environ.get("MISTRAL_API_KEY", "")
133
+
134
+ print(f"[env] Loaded: {env_path}")
135
+ print(
136
+ f"[env] SUPABASE_URL = {supabase_url or '<MISSING>'}",
137
+ )
138
+ print(
139
+ f"[env] SUPABASE_ANON_KEY = {_mask(supabase_key)}",
140
+ )
141
+ print(
142
+ f"[env] CLOUDFLARE_API_TOKEN= {_mask(cf_token)}",
143
+ )
144
+ print(
145
+ f"[env] GROQ_API_KEY = {_mask(groq_key)}",
146
+ )
147
+ print(
148
+ f"[env] NVIDIA_API_KEY = {_mask(nvidia_key)}",
149
+ )
150
+ print(
151
+ f"[env] MISTRAL_API_KEY = {_mask(mistral_key)}",
152
+ )
153
+
154
+ return env_path
155
+
156
+
157
+ def assert_critical_env(*keys: str) -> None:
158
+ """Raise :class:`RuntimeError` if any of ``keys`` is missing.
159
+
160
+ Use it right after :func:`load_app_env` to fail loudly instead of
161
+ later with a cryptic ``KeyError`` deep in an httpx call.
162
+ """
163
+ missing = [k for k in keys if not os.environ.get(k)]
164
+ if missing:
165
+ raise RuntimeError(
166
+ "Missing required environment variables: "
167
+ + ", ".join(missing)
168
+ + ".\nMake sure CyberArena/.env is present and contains them."
169
+ )
170
+
171
+
172
+ __all__ = ["load_app_env", "assert_critical_env"]
app/api/__init__.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI routers grouped by concern.
2
+
3
+ Each module in this package defines a single ``router = APIRouter(...)``
4
+ and exposes the route handlers. The actual app lives in
5
+ :mod:`app.main` and mounts every router with ``app.include_router(...)``.
6
+ """
7
+
8
+ from . import auth
9
+ from . import xp
10
+ from . import leaderboard
11
+ from . import training
12
+ from . import terminal
13
+ from . import certificates
14
+ from . import onevone
15
+
16
+ __all__ = [
17
+ "auth",
18
+ "xp",
19
+ "leaderboard",
20
+ "training",
21
+ "terminal",
22
+ "certificates",
23
+ "onevone",
24
+ ]
app/api/auth.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``/api/auth`` — proxy to the Supabase ``apex-auth`` edge function."""
2
+
3
+ import httpx
4
+ from fastapi import APIRouter, HTTPException
5
+
6
+ from app.core.config import SUPABASE_EDGE_URL
7
+ from app.types import AuthRequest
8
+
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.post("/api/auth")
14
+ async def handle_auth(req: AuthRequest):
15
+ async with httpx.AsyncClient(timeout=30) as client:
16
+ resp = await client.post(
17
+ f"{SUPABASE_EDGE_URL}/apex-auth",
18
+ json={"action": req.action, "email": req.email, "password": req.password, "name": req.name},
19
+ )
20
+ data = resp.json()
21
+ if resp.status_code != 200:
22
+ raise HTTPException(status_code=resp.status_code, detail=data.get("error", "Auth failed"))
23
+ return data
app/api/certificates.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``/api/certificates`` — issuance, download, verify, progress."""
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from app.types import CertificateRequest
6
+ from app.services.certificate_service import (
7
+ handle_certificates,
8
+ download_certificate_pdf,
9
+ verify_certificate,
10
+ cert_progress,
11
+ )
12
+
13
+
14
+ router = APIRouter()
15
+
16
+
17
+ @router.post("/api/certificates")
18
+ async def post_certificates(req: CertificateRequest):
19
+ return await handle_certificates(req)
20
+
21
+
22
+ @router.get("/api/certificates/{cert_id}/pdf")
23
+ async def get_certificate_pdf(cert_id: str, lang: str = "en"):
24
+ return await download_certificate_pdf(cert_id, lang)
25
+
26
+
27
+ @router.get("/api/certificates/verify/{verify_code}")
28
+ async def get_verify(verify_code: str):
29
+ return await verify_certificate(verify_code)
30
+
31
+
32
+ @router.get("/api/certificates/progress")
33
+ async def get_progress(user_id: str, category: str):
34
+ return await cert_progress(user_id, category)
app/api/leaderboard.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``/api/leaderboard`` — top users ranked by XP."""
2
+
3
+ import httpx
4
+ from fastapi import APIRouter
5
+
6
+ from app.core.config import SUPABASE_URL, SUPABASE_ANON_KEY
7
+ from app.services.supabase_service import supabase_headers
8
+
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.get("/api/leaderboard")
14
+ async def get_leaderboard(limit: int = 50):
15
+ """Fetch top users ranked by XP from the public ``leaderboard`` view."""
16
+ headers = {
17
+ "apikey": SUPABASE_ANON_KEY,
18
+ "Authorization": f"Bearer {SUPABASE_ANON_KEY}",
19
+ "Content-Type": "application/json",
20
+ }
21
+ safe_limit = max(1, min(limit, 100))
22
+ url = (
23
+ f"{SUPABASE_URL}/rest/v1/leaderboard"
24
+ f"?select=id,name,xp,completed_trainings"
25
+ f"&order=xp.desc"
26
+ f"&limit={safe_limit}"
27
+ )
28
+ try:
29
+ async with httpx.AsyncClient(timeout=20) as client:
30
+ resp = await client.get(url, headers=headers)
31
+ if resp.status_code != 200:
32
+ print(f"Leaderboard fetch error: {resp.status_code} {resp.text}")
33
+ return {"users": [], "total": 0}
34
+ rows = resp.json()
35
+ except Exception as e:
36
+ print(f"Leaderboard exception: {e}")
37
+ return {"users": [], "total": 0}
38
+
39
+ cleaned = []
40
+ for i, row in enumerate(rows, start=1):
41
+ cleaned.append({
42
+ "rank": i,
43
+ "id": row.get("id"),
44
+ "name": row.get("name") or "مشغل",
45
+ "xp": int(row.get("xp") or 0),
46
+ "completed_trainings": int(row.get("completed_trainings") or 0),
47
+ })
48
+ return {"users": cleaned, "total": len(cleaned)}
onevone_router.py → app/api/onevone.py RENAMED
@@ -252,14 +252,18 @@ async def _ai_check_blue_answer(challenge_row: dict, challenge_type: str, payloa
252
  return False
253
  # Reuse the exact same AI evaluator that /api/training/evaluate-code-fix
254
  # uses, so the 1v1 server agrees with the local TrainingSession eval.
255
- from main import _ai_evaluate_code_fix
256
- evaluation = await _ai_evaluate_code_fix(str(challenge_row["id"]), fixed, "blue")
257
  return bool(evaluation.get("secured"))
258
 
259
  if challenge_type == "log-analysis":
260
- # inline re-implementation of the 4-field match (same logic as main.py)
261
- from main import (
262
- _normalize_str, _ip_matches, _timestamp_close, _ioc_matches
 
 
 
 
263
  )
264
  expected_attack = _normalize_str(challenge_row.get("expected_attack_type", ""))
265
  expected_ip = challenge_row.get("expected_attacker_ip", "") or ""
@@ -277,9 +281,10 @@ async def _ai_check_blue_answer(challenge_row: dict, challenge_type: str, payloa
277
  return correct >= 3 # 3 of 4 — same threshold as main.py
278
 
279
  if challenge_type == "vulnerability-hunter":
280
- # deterministic key match — same logic as main.py::evaluate_vuln_hunter
281
- from main import _normalize_vuln_key
282
- expected = _normalize_vuln_key(challenge_row.get("vulnerability_type", ""))
 
283
  user = _normalize_vuln_key((payload or {}).get("vulnerabilityType", ""))
284
  return bool(expected) and (user == expected)
285
 
@@ -726,13 +731,13 @@ async def get_match_challenge(match_id: str, userId: str):
726
  ps = await _sb_select("onevone_players", f"room_id=eq.{match['room_id']}&select=user_id")
727
  if not any(str(p["user_id"]) == str(userId) for p in ps):
728
  raise HTTPException(status_code=403, detail="لست لاعباً في هذه المباراة")
729
- # delegate to the existing mapper in main.py
730
- from main import (
731
- _map_encryption_row_to_training,
732
- _map_webex_row_to_training,
733
- _map_code_fixing_row_to_training,
734
- _map_log_analysis_row_to_training,
735
- _map_vuln_hunter_row_to_training,
736
  )
737
  row = await _load_challenge_row(match["challenge_id"], match["challenge_type"])
738
  # team_role on the row is the source of truth for evaluator side
 
252
  return False
253
  # Reuse the exact same AI evaluator that /api/training/evaluate-code-fix
254
  # uses, so the 1v1 server agrees with the local TrainingSession eval.
255
+ from app.services.evaluator import ai_evaluate_code_fix
256
+ evaluation = await ai_evaluate_code_fix(str(challenge_row["id"]), fixed, "blue")
257
  return bool(evaluation.get("secured"))
258
 
259
  if challenge_type == "log-analysis":
260
+ # inline re-implementation of the 4-field match (same logic as
261
+ # app.services.evaluator.evaluate_log_analysis)
262
+ from app.core.security import (
263
+ normalize_str as _normalize_str,
264
+ ip_matches as _ip_matches,
265
+ timestamp_close as _timestamp_close,
266
+ ioc_matches as _ioc_matches,
267
  )
268
  expected_attack = _normalize_str(challenge_row.get("expected_attack_type", ""))
269
  expected_ip = challenge_row.get("expected_attacker_ip", "") or ""
 
281
  return correct >= 3 # 3 of 4 — same threshold as main.py
282
 
283
  if challenge_type == "vulnerability-hunter":
284
+ # deterministic key match — same logic as
285
+ # app.services.evaluator.evaluate_vuln_hunter
286
+ from app.core.security import normalize_vuln_key
287
+ expected = normalize_vuln_key(challenge_row.get("vulnerability_type", ""))
288
  user = _normalize_vuln_key((payload or {}).get("vulnerabilityType", ""))
289
  return bool(expected) and (user == expected)
290
 
 
731
  ps = await _sb_select("onevone_players", f"room_id=eq.{match['room_id']}&select=user_id")
732
  if not any(str(p["user_id"]) == str(userId) for p in ps):
733
  raise HTTPException(status_code=403, detail="لست لاعباً في هذه المباراة")
734
+ # delegate to the existing mapper in app.services.challenge_loader
735
+ from app.services.challenge_loader import (
736
+ map_encryption_row_to_training as _map_encryption_row_to_training,
737
+ map_webex_row_to_training as _map_webex_row_to_training,
738
+ map_code_fixing_row_to_training as _map_code_fixing_row_to_training,
739
+ map_log_analysis_row_to_training as _map_log_analysis_row_to_training,
740
+ map_vuln_hunter_row_to_training as _map_vuln_hunter_row_to_training,
741
  )
742
  row = await _load_challenge_row(match["challenge_id"], match["challenge_type"])
743
  # team_role on the row is the source of truth for evaluator side
app/api/terminal.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``/api/training/terminal*`` — the in-process OS simulator endpoints."""
2
+
3
+ import os
4
+
5
+ from fastapi import APIRouter
6
+
7
+ from app.sandbox.terminal import run_terminal_command
8
+ from app.sandbox.workdir import get_or_create_workdir, safe_join
9
+ from app.types import (
10
+ TerminalRequest,
11
+ TerminalWriteRequest,
12
+ TerminalListRequest,
13
+ )
14
+
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ @router.post("/api/training/terminal")
20
+ async def post_terminal(req: TerminalRequest):
21
+ """Execute a terminal command in the challenge's sandbox for real."""
22
+ team_role = req.teamRole or "red"
23
+ return await run_terminal_command(team_role, req.challengeId, req.command)
24
+
25
+
26
+ @router.post("/api/training/terminal/write")
27
+ async def post_terminal_write(req: TerminalWriteRequest):
28
+ """Write/edit a file inside the sandbox workdir (path-traversal safe)."""
29
+ team_role = req.teamRole or "red"
30
+ challenge_id = req.challengeId
31
+ if not challenge_id:
32
+ return {"ok": False, "error": "❌ challengeId مفقود"}
33
+ if not req.filename:
34
+ return {"ok": False, "error": "❌ اسم الملف مفقود"}
35
+ if len(req.content) > 200_000:
36
+ return {"ok": False, "error": "❌ الملف كبير جداً (الحد 200KB)"}
37
+
38
+ workdir, _row = await get_or_create_workdir(team_role, challenge_id)
39
+ try:
40
+ target = safe_join(workdir, req.filename)
41
+ except ValueError as e:
42
+ return {"ok": False, "error": f"❌ مسار غير مسموح: {e}"}
43
+
44
+ try:
45
+ os.makedirs(os.path.dirname(target) or workdir, exist_ok=True)
46
+ with open(target, "w", encoding="utf-8", newline="\n") as f:
47
+ f.write(req.content)
48
+ return {"ok": True, "path": os.path.relpath(target, workdir).replace("\\", "/")}
49
+ except Exception as e:
50
+ return {"ok": False, "error": f"❌ تعذّر الحفظ: {e}"}
51
+
52
+
53
+ @router.post("/api/training/terminal/list")
54
+ async def post_terminal_list(req: TerminalListRequest):
55
+ """List files currently in the sandbox workdir (for the file explorer)."""
56
+ team_role = req.teamRole or "red"
57
+ challenge_id = req.challengeId
58
+ if not challenge_id:
59
+ return {"files": []}
60
+
61
+ workdir, _row = await get_or_create_workdir(team_role, challenge_id)
62
+ files = []
63
+ try:
64
+ for name in sorted(os.listdir(workdir)):
65
+ if name.startswith("."):
66
+ continue
67
+ p = os.path.join(workdir, name)
68
+ if os.path.isfile(p):
69
+ try:
70
+ with open(p, "r", encoding="utf-8", errors="replace") as f:
71
+ content = f.read()
72
+ if len(content) > 50_000:
73
+ content = content[:50_000] + "\n... (truncated)"
74
+ files.append({"name": name, "content": content})
75
+ except Exception:
76
+ files.append({"name": name, "content": "(binary file)"})
77
+ except Exception:
78
+ pass
79
+ return {"files": files}
app/api/training.py ADDED
@@ -0,0 +1,387 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``/api/training/*`` — list, generate, evaluate, solve endpoints.
2
+
3
+ Per-type generation / mapping logic lives in
4
+ :mod:`app.services.scenario_service` and
5
+ :mod:`app.services.challenge_loader`. Per-type evaluation lives in
6
+ :mod:`app.services.evaluator`. This router is a thin HTTP shell.
7
+ """
8
+
9
+ from typing import Optional
10
+ import random
11
+
12
+ from fastapi import APIRouter, BackgroundTasks, HTTPException
13
+
14
+ from app.core.constants import CYBER_SECURITY_TOPICS
15
+ from app.core.module_router import challenge_type_for_module
16
+ from app.services.challenge_loader import (
17
+ map_encryption_row_to_training,
18
+ map_webex_row_to_training,
19
+ map_code_fixing_row_to_training,
20
+ map_log_analysis_row_to_training,
21
+ map_vuln_hunter_row_to_training,
22
+ )
23
+ from app.services.evaluator import (
24
+ evaluate_training,
25
+ evaluate_web_exploitation,
26
+ evaluate_code_fix,
27
+ evaluate_log_analysis,
28
+ evaluate_vuln_hunter,
29
+ )
30
+ from app.services.scenario_service import (
31
+ attach_scenario_metadata,
32
+ handle_background_replacement,
33
+ map_scenario_to_list_item,
34
+ )
35
+ from app.services.supabase_service import (
36
+ fetch_scenario_by_id,
37
+ fetch_random_scenario_from_supabase,
38
+ fetch_all_scenarios_for_type,
39
+ get_supabase_scenario_count,
40
+ scenario_table,
41
+ )
42
+ from app.types import (
43
+ EvaluateRequest,
44
+ WebEvaluateRequest,
45
+ TrainingRequest,
46
+ SolvedRequest,
47
+ CodeFixEvaluateRequest,
48
+ LogAnalysisEvaluateRequest,
49
+ VulnHunterEvaluateRequest,
50
+ )
51
+
52
+
53
+ router = APIRouter()
54
+
55
+
56
+ # --------------------------------------------------------------------------- #
57
+ # /api/training/list — dashboard list #
58
+ # --------------------------------------------------------------------------- #
59
+
60
+ @router.get("/api/training/list")
61
+ async def list_challenges(team_role: str = "blue", difficulty: Optional[str] = None, limit: int = 100):
62
+ """List cached challenges for a team.
63
+
64
+ For each challenge type the team has access to, fetch all rows
65
+ (capped at ``limit``) and map them to the lightweight list shape
66
+ the dashboard renders. Blue team gets code-fixing / log-analysis
67
+ / vulnerability-hunter; red team gets crypto / web.
68
+
69
+ Note: the previous implementation called
70
+ :func:`fetch_random_scenario_from_supabase` with an empty
71
+ ``module`` argument, which built a ``module=eq.`` PostgREST
72
+ filter that never matched (modules in the DB are never empty).
73
+ That made the dashboard report zero challenges even when the pool
74
+ was full. This version uses
75
+ :func:`fetch_all_scenarios_for_type` instead.
76
+ """
77
+ candidates: list[str] = []
78
+ if team_role == "blue":
79
+ candidates = ["code-fixing", "log-analysis", "vulnerability-hunter"]
80
+ else:
81
+ candidates = ["crypto", "web"]
82
+
83
+ items: list[dict] = []
84
+ per_type_cap = max(1, limit // max(1, len(candidates)))
85
+ for ctype in candidates:
86
+ try:
87
+ rows = await fetch_all_scenarios_for_type(
88
+ team_role, challenge_type=ctype, limit=per_type_cap
89
+ )
90
+ if not rows:
91
+ continue
92
+ for row in rows:
93
+ row["_challenge_type"] = ctype
94
+ items.append(map_scenario_to_list_item(row))
95
+ if len(items) >= limit:
96
+ break
97
+ except Exception as e:
98
+ print(f"[list_challenges] {ctype}: {e}")
99
+ continue
100
+ if len(items) >= limit:
101
+ break
102
+
103
+ return {"items": items, "total": len(items)}
104
+
105
+
106
+ # --------------------------------------------------------------------------- #
107
+ # /api/training/solved — consume one from the pool #
108
+ # --------------------------------------------------------------------------- #
109
+
110
+ @router.post("/api/training/solved")
111
+ async def solve_challenge(req: SolvedRequest, background_tasks: BackgroundTasks):
112
+ ctype = challenge_type_for_module(req.module or "")
113
+ background_tasks.add_task(
114
+ handle_background_replacement,
115
+ req.scenarioId,
116
+ req.teamRole or "red",
117
+ req.module or "",
118
+ req.path or "web-security",
119
+ req.category or "web",
120
+ req.difficulty or "متوسط",
121
+ )
122
+ return {"status": "consumed", "challenge_type": ctype}
123
+
124
+
125
+ # --------------------------------------------------------------------------- #
126
+ # /api/training/generate — hydrate a cached scenario into a full challenge #
127
+ # --------------------------------------------------------------------------- #
128
+
129
+ @router.post("/api/training/generate")
130
+ async def generate_training(req: TrainingRequest, background_tasks: BackgroundTasks):
131
+ module = req.module
132
+ path = req.path
133
+ category = req.category
134
+ challenge_id = req.challengeId
135
+ team_role = req.teamRole or "red"
136
+
137
+ if not module or not path or not category:
138
+ raise HTTPException(status_code=400, detail="module / path / category are required")
139
+
140
+ # The ``module`` field on the request is the **challenge type** (one of
141
+ # the five canonical values) — see AGENTS.md "Challenge Type vs Module".
142
+ # We use it to (a) look the row up in the right table and (b) pin
143
+ # training.type to the canonical value.
144
+ challenge_type = _normalize_challenge_type(module)
145
+
146
+ # 1) Try to grab a cached scenario for this (type, team). When
147
+ # ``challenge_id`` is given we look in every per-type table until
148
+ # we find the row; otherwise we pick a random row from the table
149
+ # that matches the requested type.
150
+ scenario: Optional[dict] = None
151
+ if challenge_id:
152
+ scenario = await _fetch_scenario_by_id_typed(
153
+ team_role, challenge_id, challenge_type
154
+ )
155
+ if not scenario:
156
+ scenario = await _fetch_random_scenario_typed(
157
+ team_role, challenge_type
158
+ )
159
+
160
+ if not scenario:
161
+ raise HTTPException(
162
+ status_code=404,
163
+ detail=(
164
+ f"لا يوجد سيناريو في البركة لهذه الوحدة ({module}). "
165
+ "سيناريو جديد سيُولَّد قريباً."
166
+ ),
167
+ )
168
+
169
+ # 2) Project the raw DB row straight into the TrainingData shape.
170
+ # The row mappers in app.services.challenge_loader are the
171
+ # single source of truth for "DB row → front-end payload" — they
172
+ # include vulnerable_code / log_url / htmlPreview / etc. for
173
+ # each type, so no further AI round-trip is needed.
174
+ training_data = _project_row_to_training(scenario, team_role, challenge_type)
175
+
176
+ # 3) For legacy callers / fronts that still expect a ``type``
177
+ # alias and a stable id, run the metadata attach. It is now a
178
+ # pure pass-through because the mapper already set every field.
179
+ training_data = attach_scenario_metadata(
180
+ training_data, scenario, challenge_type=challenge_type
181
+ )
182
+
183
+ return {"training": training_data}
184
+
185
+
186
+ # --------------------------------------------------------------------------- #
187
+ # Row → TrainingData projection #
188
+ # --------------------------------------------------------------------------- #
189
+
190
+ _TYPE_TO_MAPPER = {
191
+ "crypto": "map_encryption_row_to_training",
192
+ "web": "map_webex_row_to_training",
193
+ "code-fixing": "map_code_fixing_row_to_training",
194
+ "log-analysis": "map_log_analysis_row_to_training",
195
+ "vulnerability-hunter": "map_vuln_hunter_row_to_training",
196
+ }
197
+
198
+
199
+ def _project_row_to_training(
200
+ scenario: dict, team_role: str, challenge_type: str
201
+ ) -> dict:
202
+ """Run the per-type mapper against a raw DB row.
203
+
204
+ Falls back to a minimal "best-effort" shape when the challenge
205
+ type is unknown so that the front-end at least gets a
206
+ ``type``/``id`` pair to render a generic error. This should
207
+ never fire in practice because the candidate list in
208
+ ``list_challenges`` only returns the five canonical types.
209
+ """
210
+ mapper_name = _TYPE_TO_MAPPER.get(challenge_type)
211
+ if mapper_name is None:
212
+ return {
213
+ "id": str(scenario.get("id", "")),
214
+ "scenarioId": str(scenario.get("id", "")),
215
+ "title": scenario.get("title", ""),
216
+ "story": scenario.get("story", ""),
217
+ "task": scenario.get("task_outline", ""),
218
+ "type": challenge_type,
219
+ "difficulty": scenario.get("difficulty", "متوسط"),
220
+ "xpReward": scenario.get("xp_reward", 150),
221
+ "hints": scenario.get("hints") or [],
222
+ }
223
+ mapper = globals()[mapper_name]
224
+ return mapper(scenario, team_role)
225
+
226
+
227
+ # --------------------------------------------------------------------------- #
228
+ # Helpers used only by /api/training/generate #
229
+ # --------------------------------------------------------------------------- #
230
+
231
+ # Canonical challenge types — these are the ONLY values that may appear
232
+ # in ``training["type"]`` and they map 1:1 to a per-type Supabase table.
233
+ _CANONICAL_CHALLENGE_TYPES: dict[str, str] = {
234
+ "crypto": "crypto",
235
+ "encryption": "crypto",
236
+ "web": "web",
237
+ "code-fixing": "code-fixing",
238
+ "code_fixing": "code-fixing",
239
+ "log-analysis": "log-analysis",
240
+ "log_analysis": "log-analysis",
241
+ "vulnerability-hunter": "vulnerability-hunter",
242
+ "vulnerability_hunter": "vulnerability-hunter",
243
+ }
244
+
245
+
246
+ def _normalize_challenge_type(module: str) -> str:
247
+ """Resolve any module alias to its canonical challenge type.
248
+
249
+ Unknown values fall through to ``"crypto"`` (the legacy default) so
250
+ that the red-team code path keeps working. The trainer endpoint
251
+ never raises on bad input — it logs and best-efforts.
252
+ """
253
+ key = (module or "").strip().lower()
254
+ return _CANONICAL_CHALLENGE_TYPES.get(key, "crypto")
255
+
256
+
257
+ def _tables_for_team(team_role: str) -> list[str]:
258
+ """All per-type tables that can hold a row for ``team_role``.
259
+
260
+ Used to look up a row by id without knowing its type up front. The
261
+ list is short and stable so a sequential scan is fine.
262
+ """
263
+ if team_role == "red":
264
+ return [
265
+ "encryption_challenges",
266
+ "web_exploitation_challenges",
267
+ ]
268
+ return [
269
+ "code_fixing_challenges",
270
+ "log_analysis_challenges",
271
+ "vulnerability_hunter_challenges",
272
+ ]
273
+
274
+
275
+ async def _fetch_scenario_by_id_typed(
276
+ team_role: str, scenario_id: str, hint_type: str
277
+ ) -> Optional[dict]:
278
+ """Look up a scenario by id, scoped to the team.
279
+
280
+ Tries the hint table first (single round-trip), then falls back to
281
+ every other table the team owns. This replaces the old behaviour
282
+ where ``fetch_scenario_by_id`` always looked in
283
+ ``encryption_challenges`` — which would silently return ``None``
284
+ for code-fixing / log-analysis / vuln-hunter / web rows.
285
+ """
286
+ if not scenario_id:
287
+ return None
288
+ tables = [scenario_table(team_role, hint_type)] + [
289
+ t for t in _tables_for_team(team_role)
290
+ if t != scenario_table(team_role, hint_type)
291
+ ]
292
+ for table in tables:
293
+ row = await _fetch_scenario_in_table(team_role, scenario_id, table)
294
+ if row:
295
+ return row
296
+ return None
297
+
298
+
299
+ async def _fetch_scenario_in_table(
300
+ team_role: str, scenario_id: str, table: str
301
+ ) -> Optional[dict]:
302
+ import httpx
303
+ from app.services.supabase_service import supabase_headers
304
+ from app.core.config import SUPABASE_URL, SUPABASE_ANON_KEY
305
+ if not SUPABASE_ANON_KEY or not SUPABASE_URL:
306
+ return None
307
+ url = (
308
+ f"{SUPABASE_URL}/rest/v1/{table}"
309
+ f"?id=eq.{scenario_id}&team_role=eq.{team_role}&limit=1"
310
+ )
311
+ try:
312
+ async with httpx.AsyncClient() as client:
313
+ resp = await client.get(url, headers=supabase_headers())
314
+ if resp.status_code == 200:
315
+ rows = resp.json()
316
+ if rows:
317
+ return rows[0]
318
+ except Exception as e:
319
+ print(f"[generate_training] {table} lookup failed: {e}")
320
+ return None
321
+
322
+
323
+ async def _fetch_random_scenario_typed(
324
+ team_role: str, challenge_type: str
325
+ ) -> Optional[dict]:
326
+ """Pick a random row from the table that matches ``challenge_type``."""
327
+ from app.services.supabase_service import fetch_all_scenarios_for_type
328
+ rows = await fetch_all_scenarios_for_type(team_role, challenge_type, limit=50)
329
+ if rows:
330
+ return random.choice(rows)
331
+ return None
332
+
333
+
334
+ # --------------------------------------------------------------------------- #
335
+ # /api/training/evaluate — generic / legacy red+blue #
336
+ # --------------------------------------------------------------------------- #
337
+
338
+ @router.post("/api/training/evaluate")
339
+ async def post_evaluate_training(req: EvaluateRequest, background_tasks: BackgroundTasks):
340
+ return await evaluate_training(req, background_tasks)
341
+
342
+
343
+ # --------------------------------------------------------------------------- #
344
+ # /api/training/evaluate-web — 3-layer web exploitation validation #
345
+ # --------------------------------------------------------------------------- #
346
+
347
+ @router.post("/api/training/evaluate-web")
348
+ async def post_evaluate_web(req: WebEvaluateRequest):
349
+ return await evaluate_web_exploitation(req)
350
+
351
+
352
+ # --------------------------------------------------------------------------- #
353
+ # /api/training/evaluate-code-fix #
354
+ # --------------------------------------------------------------------------- #
355
+
356
+ @router.post("/api/training/evaluate-code-fix")
357
+ async def post_evaluate_code_fix(req: CodeFixEvaluateRequest, background_tasks: BackgroundTasks):
358
+ return await evaluate_code_fix(req, background_tasks)
359
+
360
+
361
+ # --------------------------------------------------------------------------- #
362
+ # /api/training/evaluate-log-analysis #
363
+ # --------------------------------------------------------------------------- #
364
+
365
+ @router.post("/api/training/evaluate-log-analysis")
366
+ async def post_evaluate_log_analysis(req: LogAnalysisEvaluateRequest, background_tasks: BackgroundTasks):
367
+ return await evaluate_log_analysis(req, background_tasks)
368
+
369
+
370
+ # --------------------------------------------------------------------------- #
371
+ # /api/training/evaluate-vulnerability-hunter #
372
+ # --------------------------------------------------------------------------- #
373
+
374
+ @router.post("/api/training/evaluate-vulnerability-hunter")
375
+ async def post_evaluate_vuln_hunter(req: VulnHunterEvaluateRequest, background_tasks: BackgroundTasks):
376
+ return await evaluate_vuln_hunter(req, background_tasks)
377
+
378
+
379
+ # Re-export the row mappers so 1v1 can keep importing them from a
380
+ # stable location without taking a hard dependency on the service layer.
381
+ __all__ = [
382
+ "map_encryption_row_to_training",
383
+ "map_webex_row_to_training",
384
+ "map_code_fixing_row_to_training",
385
+ "map_log_analysis_row_to_training",
386
+ "map_vuln_hunter_row_to_training",
387
+ ]
app/api/xp.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``/api/xp`` — proxy to the Supabase ``apex-xp`` edge function."""
2
+
3
+ import httpx
4
+ from fastapi import APIRouter, HTTPException
5
+
6
+ from app.core.config import SUPABASE_EDGE_URL
7
+ from app.types import XpRequest
8
+
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.post("/api/xp")
14
+ async def handle_xp(req: XpRequest):
15
+ if not req.user_id:
16
+ raise HTTPException(status_code=400, detail="user_id is required")
17
+
18
+ async with httpx.AsyncClient(timeout=30) as client:
19
+ resp = await client.post(
20
+ f"{SUPABASE_EDGE_URL}/apex-xp",
21
+ json={"action": req.action, "user_id": req.user_id, "xp_amount": req.xp_amount},
22
+ )
23
+ data = resp.json()
24
+ if resp.status_code != 200:
25
+ raise HTTPException(status_code=resp.status_code, detail=data.get("error", "XP operation failed"))
26
+ return data
app/core/__init__.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core constants and small pure-Python helpers.
2
+
3
+ Nothing in this package performs I/O — only constants, mappings, and
4
+ deterministic utility functions safe to import from anywhere.
5
+ """
6
+
7
+ from .constants import (
8
+ CYBER_SECURITY_TOPICS,
9
+ MODULE_TOPIC_MAP,
10
+ TOPIC_KEYWORDS,
11
+ FALLBACK_HTML,
12
+ HELP_TEXT,
13
+ WEB_EXPLOIT_MODULES,
14
+ CERT_REQUIRED_COMPLETIONS,
15
+ CERT_VERIFY_BASE_URL,
16
+ )
17
+ from .config import (
18
+ SUPABASE_URL,
19
+ SUPABASE_ANON_KEY,
20
+ SUPABASE_EDGE_URL,
21
+ GROQ_API_KEY,
22
+ GROQ_API_URL,
23
+ GROQ_DEFAULT_MODEL,
24
+ MISTRAL_API_KEY,
25
+ MISTRAL_MODEL,
26
+ MISTRAL_API_URL,
27
+ CHALLENGE_FILES_BUCKET,
28
+ )
29
+ from .text import parse_json_safe
30
+ from .security import (
31
+ normalize_vuln_key,
32
+ normalize_str,
33
+ ip_matches,
34
+ ioc_matches,
35
+ timestamp_close,
36
+ )
37
+ from .module_router import challenge_type_for_module
38
+
39
+ __all__ = [
40
+ # constants
41
+ "CYBER_SECURITY_TOPICS",
42
+ "MODULE_TOPIC_MAP",
43
+ "TOPIC_KEYWORDS",
44
+ "FALLBACK_HTML",
45
+ "HELP_TEXT",
46
+ "WEB_EXPLOIT_MODULES",
47
+ "CERT_REQUIRED_COMPLETIONS",
48
+ "CERT_VERIFY_BASE_URL",
49
+ # config
50
+ "SUPABASE_URL",
51
+ "SUPABASE_ANON_KEY",
52
+ "SUPABASE_EDGE_URL",
53
+ "GROQ_API_KEY",
54
+ "GROQ_API_URL",
55
+ "GROQ_DEFAULT_MODEL",
56
+ "MISTRAL_API_KEY",
57
+ "MISTRAL_MODEL",
58
+ "MISTRAL_API_URL",
59
+ "CHALLENGE_FILES_BUCKET",
60
+ # helpers
61
+ "parse_json_safe",
62
+ "normalize_vuln_key",
63
+ "normalize_str",
64
+ "ip_matches",
65
+ "ioc_matches",
66
+ "timestamp_close",
67
+ "challenge_type_for_module",
68
+ ]
app/core/config.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Environment-loaded configuration.
2
+
3
+ All environment variables used anywhere in the backend live here so
4
+ the routers / services never call ``os.environ.get`` directly.
5
+ """
6
+
7
+ # Make sure .env is loaded no matter who imports us first.
8
+ from app._env import load_app_env # noqa: E402
9
+ load_app_env()
10
+
11
+ import os
12
+
13
+ # --------------------------------------------------------------------------- #
14
+ # Supabase #
15
+ # --------------------------------------------------------------------------- #
16
+
17
+ SUPABASE_URL = os.environ.get(
18
+ "SUPABASE_URL",
19
+ "https://yevtnyokixocpihpdwqu.supabase.co",
20
+ )
21
+ SUPABASE_ANON_KEY = os.environ.get("SUPABASE_ANON_KEY", "")
22
+
23
+
24
+ # --------------------------------------------------------------------------- #
25
+ # AI providers — endpoints, model names, keys #
26
+ # --------------------------------------------------------------------------- #
27
+
28
+ # Cloudflare Workers AI is the primary tier in the three-tier fallback
29
+ # chain documented in AGENTS.md. It's only used inside individual
30
+ # generators; the main backend talks to Groq / Mistral directly.
31
+
32
+ # Groq (secondary AI, also the legacy scenario/eval backend).
33
+ GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
34
+ GROQ_API_URL = "https://api.groq.com/openai/v1/chat/completions"
35
+ GROQ_DEFAULT_MODEL = os.environ.get("GROQ_MODEL", "llama-3.1-8b-instant")
36
+
37
+ # Mistral — used for log-analysis Arabic feedback only.
38
+ MISTRAL_API_KEY = os.environ.get("MISTRAL_API_KEY", "")
39
+ MISTRAL_MODEL = os.environ.get("MISTRAL_MODEL", "mistral-large-latest")
40
+ MISTRAL_API_URL = os.environ.get(
41
+ "MISTRAL_API_URL",
42
+ "https://api.mistral.ai/v1/chat/completions",
43
+ )
44
+
45
+ # Supabase Edge Functions used as auth / XP proxies.
46
+ SUPABASE_EDGE_URL = f"{SUPABASE_URL}/functions/v1"
47
+
48
+
49
+ # --------------------------------------------------------------------------- #
50
+ # Supabase Storage bucket for AI-generated challenge files #
51
+ # --------------------------------------------------------------------------- #
52
+
53
+ # Name of the public bucket ``app.services.file_storage`` uploads
54
+ # ``fileToGenerate`` payloads to. The bucket's RLS policies live in
55
+ # ``db/schema/010_challenge_files_bucket.sql``.
56
+ CHALLENGE_FILES_BUCKET = "challenge-files"
57
+
58
+
59
+ # --------------------------------------------------------------------------- #
60
+ # Per-table canonical module (see AGENTS.md "Challenge Type vs Module") #
61
+ # --------------------------------------------------------------------------- #
62
+
63
+ # One entry per per-type challenges table — the ONLY value the
64
+ # ``module`` column is allowed to take for rows going into that
65
+ # table. ``normalize_row_module()`` enforces this at insert time so
66
+ # AI generators can keep using topic-shaped names internally
67
+ # (``xss``, ``sqli``, ``hash-cracking``) without violating the
68
+ # post-011 CHECK constraints.
69
+ TABLE_CANONICAL_MODULE: dict[str, str] = {
70
+ "encryption_challenges": "crypto",
71
+ "web_exploitation_challenges": "web",
72
+ "code_fixing_challenges": "code-fixing",
73
+ "log_analysis_challenges": "log-analysis",
74
+ "vulnerability_hunter_challenges": "vulnerability-hunter",
75
+ }
76
+
77
+
78
+ def normalize_row_module(table: str, row: dict) -> dict:
79
+ """Enforce the canonical ``module`` value for a row going to ``table``.
80
+
81
+ The migration 011 CHECK constraint rejects any row whose
82
+ ``module`` column does not equal the canonical challenge type
83
+ for its table. The AI generators used to populate ``module``
84
+ with a topic-shaped string (``xss``, ``hash-cracking``,
85
+ ``web-security``, …) — we transparently rewrite that into:
86
+
87
+ * ``module`` = canonical challenge type (so the DB accepts it)
88
+ * ``topic`` = the original topic-shaped string (so the front-end
89
+ can still show / filter on it)
90
+
91
+ If the row already carries the canonical value, ``topic`` falls
92
+ back to whatever ``module`` said. The function returns the
93
+ mutated ``row`` (in place) for convenience; the return value is
94
+ the same dict.
95
+
96
+ Raises ``ValueError`` when called with an unknown table name —
97
+ that always indicates a programming error, never a bad row.
98
+ """
99
+ canonical = TABLE_CANONICAL_MODULE.get(table)
100
+ if canonical is None:
101
+ raise ValueError(
102
+ f"normalize_row_module: unknown table {table!r}. "
103
+ f"Add it to app.core.config.TABLE_CANONICAL_MODULE."
104
+ )
105
+
106
+ original = row.get("module", "")
107
+ # Move the original topic-shaped value into ``topic`` (if not
108
+ # already set) and pin ``module`` to the canonical challenge type.
109
+ if row.get("topic") is None or row.get("topic") == "":
110
+ row["topic"] = original or canonical
111
+ row["module"] = canonical
112
+ return row
113
+
app/core/constants.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure constants — no I/O, no imports beyond stdlib.
2
+
3
+ This module is the single source of truth for the topic catalog, the
4
+ difficulty / module maps, and the small strings the backend embeds
5
+ verbatim in HTTP responses (HTML fallback, terminal help text, etc.).
6
+ """
7
+
8
+ import os
9
+
10
+
11
+ # --------------------------------------------------------------------------- #
12
+ # Topic catalog (module key -> human-readable name + category + path) #
13
+ # --------------------------------------------------------------------------- #
14
+
15
+ MODULE_TOPIC_MAP = {
16
+ "xss": "ثغرات XSS (Cross-Site Scripting) - هجمات الحقن البرمجي في المتصفح",
17
+ "sql-injection": "ثغرات SQL Injection - حقن استعلامات خبيثة في قاعدة البيانات",
18
+ "csrf": "ثغرات CSRF (Cross-Site Request Forgery) - تزوير الطلبات عبر المواقع",
19
+ "auth-bypass": "ثغرات المصادقة - تجاوز أنظمة تسجيل الدخول",
20
+ "misconfig": "التكوين الأمني الخاطئ - إعدادات غير آمنة",
21
+ "packet-analysis": "تحليل حزم الشبكة Network Packet Analysis",
22
+ "firewall": "جدران الحماية Firewall Configuration",
23
+ "scanning": "مسح الشبكات Network Scanning",
24
+ "encryption-basics": "أساسيات التشفير Encryption",
25
+ "hash-cracking": "كسر الهاش Hash Cracking",
26
+ "log-analysis": "تحليل سجلات الخادم Log Analysis",
27
+ "forensics": "الأدلة الرقمية Forensics",
28
+ }
29
+
30
+
31
+ CYBER_SECURITY_TOPICS = {
32
+ # 1. Web Security
33
+ "xss": {"name": "ثغرات XSS (Cross-Site Scripting)", "category": "Web Security", "path": "web-security"},
34
+ "sql-injection": {"name": "ثغرات SQL Injection", "category": "Web Security", "path": "web-security"},
35
+ "csrf": {"name": "ثغرات CSRF (Cross-Site Request Forgery)", "category": "Web Security", "path": "web-security"},
36
+ "ssrf": {"name": "ثغرات SSRF (Server-Side Request Forgery)", "category": "Web Security", "path": "web-security"},
37
+ "idor": {"name": "ثغرات IDOR (Insecure Direct Object Reference)", "category": "Web Security", "path": "web-security"},
38
+ "lfi-rfi": {"name": "ثغرات تضمين الملفات LFI/RFI", "category": "Web Security", "path": "web-security"},
39
+ "xxe": {"name": "ثغرات XML External Entity (XXE)", "category": "Web Security", "path": "web-security"},
40
+ "command-injection": {"name": "حقن الأوامر البرمجية Command Injection", "category": "Web Security", "path": "web-security"},
41
+ # New 10-module set (replaces the 13 above)
42
+ "sqli": {"name": "ثغرات SQL Injection", "category": "Web Security", "path": "web-security"},
43
+ "cmdi": {"name": "حقن الأوامر Command Injection","category": "Web Security", "path": "web-security"},
44
+ "auth": {"name": "ثغرات المصادقة Auth Flaws", "category": "Web Security", "path": "web-security"},
45
+ "upload": {"name": "رفع الملفات الخبيثة Upload", "category": "Web Security", "path": "web-security"},
46
+
47
+ # 2. Network Security
48
+ "packet-analysis": {"name": "تحليل حزم الشبكات Packet Analysis", "category": "Network Security", "path": "network-security"},
49
+ "firewall": {"name": "إعدادات وجدران الحماية Firewall", "category": "Network Security", "path": "network-security"},
50
+ "scanning": {"name": "مسح وتفحص الشبكات Network Scanning", "category": "Network Security", "path": "network-security"},
51
+ "mitm": {"name": "هجمات رجل في المنتصف MITM Attacks", "category": "Network Security", "path": "network-security"},
52
+ "dns-poisoning": {"name": "تسميم سجلات الـ DNS", "category": "Network Security", "path": "network-security"},
53
+
54
+ # 3. Cryptography
55
+ "encryption-basics": {"name": "أساسيات التشفير وفك الترميز", "category": "Cryptography", "path": "cryptography"},
56
+ "hash-cracking": {"name": "كسر شفرات الهاش Hash Cracking", "category": "Cryptography", "path": "cryptography"},
57
+ "rsa-aes": {"name": "خوارزميات التشفير المتقدمة RSA/AES", "category": "Cryptography", "path": "cryptography"},
58
+ "steganography": {"name": "إخفاء المعلومات في الوسائط Steganography", "category": "Cryptography", "path": "cryptography"},
59
+
60
+ # 4. Reverse Engineering & Binaries
61
+ "binary-analysis": {"name": "تحليل الملفات الثنائية Binary Analysis", "category": "Reverse Engineering", "path": "reverse-engineering"},
62
+ "assembly-cracking": {"name": "هندسة الأكواد العكسية وقراءة الأسمبلي", "category": "Reverse Engineering", "path": "reverse-engineering"},
63
+
64
+ # 5. OS & Systems Security
65
+ "linux-privesc": {"name": "تصعيد الصلاحيات في أنظمة Linux", "category": "Systems Security", "path": "systems-security"},
66
+ "windows-privesc": {"name": "تصعيد الصلاحيات في أنظمة Windows", "category": "Systems Security", "path": "systems-security"},
67
+ "active-directory": {"name": "اختراق وإدارة بيئة الـ Active Directory", "category": "Systems Security", "path": "systems-security"},
68
+
69
+ # 6. Mobile Security
70
+ "android-ios": {"name": "أمن تطبيقات الهواتف الذكية Android/iOS", "category": "Mobile Security", "path": "mobile-security"},
71
+
72
+ # 7. Cloud Security
73
+ "cloud-config": {"name": "أمن الخدمات السحابية وتهيئة الحاويات Docker/Kubernetes", "category": "Cloud Security", "path": "cloud-security"},
74
+
75
+ # 8. Digital Forensics & Log Analysis
76
+ "log-analysis": {"name": "تحليل سجلات الخادم والأنظمة Log Analysis", "category": "Digital Forensics", "path": "forensics"},
77
+ "memory-forensics": {"name": "تحليل الذاكرة العشوائية Memory Forensics", "category": "Digital Forensics", "path": "forensics"},
78
+
79
+ # 9. Code Fixing (Blue Team)
80
+ "code-fixing": {"name": "تصحيح الكود المصاب بثغرات أمنية", "category": "تصحيح الكود (Code Fixing)", "path": "code-fixing"},
81
+ "web-security": {"name": "تصحيح الكود المصاب بثغرات أمنية", "category": "تصحيح الكود (Code Fixing)", "path": "code-fixing"},
82
+ "systems-security": {"name": "تصحيح الكود المصاب بثغرات أمنية", "category": "تصحيح الكود (Code Fixing)", "path": "code-fixing"},
83
+
84
+ # 10. Vulnerability Hunter (Blue Team)
85
+ "vulnerability-hunter": {"name": "صياد الثغرات Vulnerability Hunter", "category": "Vulnerability Hunter", "path": "vulnerability-hunter"},
86
+ }
87
+
88
+
89
+ TOPIC_KEYWORDS = {
90
+ "xss": ["xss", "cross-site", "حقن نصوص", "innerHTML", "script", "تعليق", "textContent"],
91
+ "sql-injection": ["sql", "injection", "استعلام", "قاعدة بيانات", "database", "query", "select", "union"],
92
+ "csrf": ["csrf", "forgery", "تزوير الطلبات", "طلب عبر المواقع", "token", "رمز الحماية"],
93
+ "ssrf": ["ssrf", "server-side request", "تزوير الطلب من جانب الخادم", "طلب داخلي", "fetch"],
94
+ "idor": ["idor", "direct object", "معرف", "رابط مباشر", "projectId", "userId", "OID"],
95
+ "lfi-rfi": ["lfi", "rfi", "تضمين", "ملفات", "include", "file inclusion"],
96
+ "xxe": ["xxe", "xml", "external entity", "كيان خارجي", "entity"],
97
+ "command-injection": ["command", "أوامر", "exec", "system", "حقن الأوامر"],
98
+ "packet-analysis": ["packet", "حزم", "شبكة", "pcap", "wireshark", "تحليل حزم"],
99
+ "firewall": ["firewall", "جدار حماية", "جدران حماية", "قواعد المرور", "port", "منفذ"],
100
+ "scanning": ["scan", "مسح", "فحص شبكات", "nmap", "منفذ مفتوح", "ports"],
101
+ "mitm": ["mitm", "رجل في المنتصف", "منصف", "تسميم", "arp poisoning", "dns poisoning"],
102
+ "dns-poisoning": ["dns", "poisoning", "تسميم سجلات", "نظام أسماء النطاقات"],
103
+ "encryption-basics": ["encryption", "تشفير", "فك ترميز", "base64", "rot13", "caesar", "cipher"],
104
+ "hash-cracking": ["hash", "هاش", "md5", "sha", "cracking", "كسر"],
105
+ "rsa-aes": ["rsa", "aes", "خوارزميات التشفير المتقدمة"],
106
+ "steganography": ["steganography", "إخفاء", "وسائط", "صورة", "ملف مخفي"],
107
+ "binary-analysis": ["binary", "ثنائي", "ملف ثنائي", "قراءة الملفات الثنائية"],
108
+ "assembly-cracking": ["assembly", "أسمبلي", "عكسي", "هندسة عكسية"],
109
+ "linux-privesc": ["linux", "صلاحيات root", "تصعيد", "privilege escalation"],
110
+ "windows-privesc": ["windows", "صلاحيات", "تصعيد", "administrator"],
111
+ "active-directory": ["active directory", "دليل نشط", "kerberos", "domain controller"],
112
+ "android-ios": ["android", "ios", "هاتف", "تطبيق ذكي", "موبايل"],
113
+ "cloud-config": ["cloud", "docker", "kubernetes", "سحابية", "حاويات"],
114
+ "log-analysis": ["log", "سجل", "سجلات", "خادم", "تحليل سجلات"],
115
+ "memory-forensics": ["memory", "ذاكرة عشوائية", "forensics", "volatility"],
116
+ }
117
+
118
+
119
+ # Web exploitation module set (Red Team offensive) — used by
120
+ # app.core.module_router.challenge_type_for_module.
121
+ WEB_EXPLOIT_MODULES = {
122
+ "xss", "sqli", "csrf", "ssrf",
123
+ "idor", "lfi-rfi", "xxe", "cmdi",
124
+ "auth", "upload",
125
+ }
126
+
127
+
128
+ # --------------------------------------------------------------------------- #
129
+ # Embedded HTML / text the backend serves when AI generation fails #
130
+ # --------------------------------------------------------------------------- #
131
+
132
+ FALLBACK_HTML = """<!DOCTYPE html><html dir="rtl" lang="ar"><head><meta charset="UTF-8"><style>body{margin:0;padding:40px;font-family:sans-serif;background:#0b0b12;color:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;text-align:center}h1{color:#00d4aa;font-size:22px;margin-bottom:16px}p{color:rgba(255,255,255,0.6);font-size:14px;line-height:1.8;max-width:500px}</style></head><body><h1>🔐 منصة APEX للتدريب</h1><p>بيئة التدريب التفاعلية جاهزة. اتبع التعليمات في لوحة المهام على اليمين لإكمال التحدي.</p></body></html>"""
133
+
134
+
135
+ HELP_TEXT = """الأوامر الأساسية (تعمل دائماً):
136
+ ls, cat <file>, pwd, echo <text>, whoami, clear, help
137
+ sha256sum <file>, md5sum <file>, sha1sum <file>
138
+ base64 [-d] <file> ترميز/فك Base64
139
+ xxd <file> عرض hex
140
+ tr SET1 SET2 <text> استبدال أحرف
141
+
142
+ الأدوات الخارجية (مفعّلة لهذا التحدي): انظر whitelist في Cheat Sheet.
143
+ مثال: python -c "print('hello')"
144
+ openssl enc -d -aes-256-cbc -in f.enc -k SECRET
145
+ """
146
+
147
+
148
+ # --------------------------------------------------------------------------- #
149
+ # Certificate system tuning #
150
+ # --------------------------------------------------------------------------- #
151
+
152
+ CERT_REQUIRED_COMPLETIONS = 50
153
+ CERT_VERIFY_BASE_URL = os.environ.get("CERT_VERIFY_BASE_URL", "").rstrip("/")
app/core/module_router.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Map module names to challenge types and back.
2
+
3
+ The frontend uses ``module`` to route a row to the right editor.
4
+ We keep two parallel maps and a single function for clarity.
5
+ """
6
+
7
+ from .constants import WEB_EXPLOIT_MODULES
8
+
9
+
10
+ def challenge_type_for_module(module: str) -> str:
11
+ """Return challenge type for a module name.
12
+
13
+ Currently: ``"web"`` for the offensive web-exploitation set,
14
+ ``"crypto"`` for the legacy crypto challenges. New challenge types
15
+ are wired by extending this function — no caller change required.
16
+ """
17
+ if module in WEB_EXPLOIT_MODULES:
18
+ return "web"
19
+ return "crypto"
app/core/security.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Normalization helpers used by the evaluators.
2
+
3
+ Each function is intentionally tiny and side-effect-free so the
4
+ 1v1 verifier and the regular /evaluate endpoint can call the same code
5
+ and stay in lock-step.
6
+ """
7
+
8
+ import re
9
+
10
+
11
+ def normalize_vuln_key(s: str) -> str:
12
+ """Canonicalise a typed vulnerability key.
13
+
14
+ Rules:
15
+ - lowercase
16
+ - trim
17
+ - replace spaces / underscores with dashes
18
+ - strip surrounding punctuation
19
+ """
20
+ if not s:
21
+ return ""
22
+ s = s.strip().lower()
23
+ s = re.sub(r"[\s_]+", "-", s)
24
+ s = re.sub(r"[^a-z0-9\-]", "", s)
25
+ return s
26
+
27
+
28
+ def normalize_str(s: str) -> str:
29
+ return (s or "").strip().lower()
30
+
31
+
32
+ def ip_matches(user_ip: str, expected_ip: str) -> bool:
33
+ """Loose IP match: trim, lowercase, ignore trailing port or zone id."""
34
+ u = normalize_str(user_ip).split("/")[0]
35
+ e = normalize_str(expected_ip).split("/")[0]
36
+ return u == e and u != ""
37
+
38
+
39
+ def ioc_matches(user_ioc: str, expected_ioc: str) -> bool:
40
+ """IOC match: case-insensitive substring."""
41
+ u = normalize_str(user_ioc)
42
+ e = normalize_str(expected_ioc)
43
+ if not u or not e:
44
+ return False
45
+ return e in u or u in e
46
+
47
+
48
+ def timestamp_close(user_ts: str, expected_ts: str) -> bool:
49
+ """Timestamp match: exact OR every alphanumeric token of the expected
50
+ timestamp appears in the user input (lenient by design)."""
51
+ u = normalize_str(user_ts)
52
+ e = normalize_str(expected_ts)
53
+ if not u or not e:
54
+ return False
55
+ if u == e:
56
+ return True
57
+ # Try day-hour match (e.g., "15/Dec" or "Mar 12" or "2024-11-08")
58
+ e_tokens = re.findall(r"[a-zA-Z]+|\d+", e)
59
+ if not e_tokens:
60
+ return False
61
+ return all(tok.lower() in u for tok in e_tokens[:2])
app/core/text.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """JSON parsing helpers used by every AI call site."""
2
+
3
+ import json
4
+ import re
5
+
6
+
7
+ def parse_json_safe(raw: str) -> dict:
8
+ """Best-effort JSON parser for raw LLM output.
9
+
10
+ Handles three common failure modes:
11
+ 1. The model wraps the object in ```json ... ``` fences.
12
+ 2. The model adds prose before/after the object.
13
+ 3. The model emits literal control characters (newlines, tabs)
14
+ inside a string value that ``json.loads`` rejects.
15
+ """
16
+ cleaned = raw.strip()
17
+
18
+ fence_match = re.search(r"```(?:json)?\s*([\s\S]*?)```", cleaned, re.IGNORECASE)
19
+ if fence_match:
20
+ cleaned = fence_match.group(1).strip()
21
+
22
+ start = cleaned.find("{")
23
+ end = cleaned.rfind("}")
24
+ if start != -1 and end != -1 and end > start:
25
+ cleaned = cleaned[start:end + 1]
26
+
27
+ cleaned = re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]", "", cleaned)
28
+
29
+ try:
30
+ # Use strict=False to allow literal control characters (like newlines) in strings
31
+ return json.loads(cleaned, strict=False)
32
+ except json.JSONDecodeError as exc:
33
+ # One last ditch effort: replace literal newlines with escaped ones
34
+ try:
35
+ # This is risky but sometimes helps with unescaped newlines in middle of strings
36
+ cleaned_fix = cleaned.replace('\n', '\\n').replace('\r', '\\r')
37
+ # But the start/end might be messed up now if it was already formatted.
38
+ # Let's just try strict=False first as it's the standard solution for "control character" errors.
39
+ return json.loads(cleaned, strict=False)
40
+ except Exception:
41
+ preview = raw[:500].replace("\n", " ")
42
+ raise ValueError(f"Invalid JSON from model: {exc}. Raw preview: {preview}") from exc
app/generators/__init__.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-challenge-type pool generators.
2
+
3
+ Each module here owns ONE challenge table and exposes the same public API
4
+ that :func:`app.main.populate_pool_background` consumes:
5
+
6
+ POOL_TARGET / POOL_THRESHOLD / POOL_BATCH (module-level constants)
7
+ async get_pool_count(team_role) -> int
8
+ async refill_pool(team_role, count) -> int
9
+ async start_pool_watcher(team_role) # long-running coroutine
10
+
11
+ The five generators currently registered are: crypto, web, code-fixing,
12
+ log-analysis, and vulnerability-hunter.
13
+ """
14
+
15
+ from . import crypto
16
+ from . import web
17
+ from . import code_fixing
18
+ from . import log_analysis
19
+ from . import vulnerability_hunter
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
23
+ # app.services.supabase_service.scenario_table).
24
+ REGISTRY = [
25
+ # (slug, module, list_of_teams)
26
+ ("crypto", crypto, ["red"]),
27
+ ("web", web, ["red"]),
28
+ ("code-fixing", code_fixing, ["blue"]),
29
+ ("log-analysis", log_analysis, ["blue"]),
30
+ ("vulnerability-hunter", vulnerability_hunter, ["blue"]),
31
+ ]
32
+
33
+ __all__ = [
34
+ "crypto",
35
+ "web",
36
+ "code_fixing",
37
+ "log_analysis",
38
+ "vulnerability_hunter",
39
+ "REGISTRY",
40
+ ]
code_fixing_generator.py → app/generators/code_fixing.py RENAMED
@@ -166,6 +166,13 @@ NVIDIA_API_KEY = os.environ.get("NVIDIA_API_KEY", "")
166
  NVIDIA_MODEL = os.environ.get("NVIDIA_MODEL", "deepseek-ai/deepseek-v4-pro")
167
  NVIDIA_API_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
168
 
 
 
 
 
 
 
 
169
  TABLE_NAME = "code_fixing_challenges"
170
 
171
  # Per-team backoff tracker
@@ -780,6 +787,52 @@ async def _post_with_json_fallback(client, url: str, payload: dict, headers: dic
780
  return resp
781
 
782
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
783
  async def _try_cloudflare(prompt: str, system: str) -> Optional[str]:
784
  """Try Cloudflare Workers AI. Cycles through multiple models until one works.
785
  Returns raw text on success, None if all models fail.
@@ -809,7 +862,7 @@ async def _try_cloudflare(prompt: str, system: str) -> Optional[str]:
809
  }
810
  payload = {**payload_base, "response_format": {"type": "json_object"}}
811
  try:
812
- async with _httpx.AsyncClient(timeout=120) as client:
813
  resp = await _post_with_json_fallback(client, url, payload, headers, f"cloudflare/{model_name.split('/')[-1]}")
814
  if resp.status_code == 200:
815
  result = resp.json()
@@ -910,10 +963,14 @@ async def _try_nvidia(prompt: str, system: str, model: str) -> Optional[str]:
910
 
911
 
912
  async def _call_ai(prompt: str, system: str = "", model: str = "") -> Optional[str]:
913
- """Try providers in strict order: Cloudflare -> Groq -> NVIDIA/DeepSeek.
914
  Returns the first valid response, or None if all fail.
915
  Each provider must return successfully; the call to parse_json_safe happens later.
916
  """
 
 
 
 
917
  print(f" [ai] Trying Cloudflare...")
918
  r = await _try_cloudflare(prompt, system)
919
  if r:
@@ -926,7 +983,7 @@ async def _call_ai(prompt: str, system: str = "", model: str = "") -> Optional[s
926
  r = await _try_nvidia(prompt, system, model)
927
  if r:
928
  return r
929
- print(f" [ai] All 3 providers failed")
930
  return None
931
 
932
 
@@ -1054,6 +1111,12 @@ async def get_pool_count(team_role: str) -> int:
1054
  async def _insert_to_db(row: dict) -> bool:
1055
  if not SUPABASE_ANON_KEY or not SUPABASE_URL:
1056
  return False
 
 
 
 
 
 
1057
  url = f"{SUPABASE_URL}/rest/v1/{TABLE_NAME}"
1058
  headers = supabase_headers(content_type=True)
1059
  headers["Prefer"] = "return=representation"
@@ -1130,7 +1193,15 @@ async def refill_pool(team_role: str, count: int) -> int:
1130
  return True
1131
  return False
1132
 
1133
- results = await asyncio.gather(*[_gen_one(i) for i in range(target)])
 
 
 
 
 
 
 
 
1134
  return sum(1 for r in results if r)
1135
 
1136
 
@@ -1162,12 +1233,13 @@ async def start_pool_watcher(team_role: str):
1162
 
1163
  print(f"[code-fixing] Pool watcher started for '{team_role}' (target={POOL_TARGET}, threshold={POOL_THRESHOLD}, batch={POOL_BATCH})")
1164
 
1165
- # Pre-warm at startup
 
1166
  try:
1167
  current = await get_pool_count(team_role)
1168
  if current < POOL_TARGET:
1169
  print(f"[code-fixing] Pre-warming pool ({current} → {POOL_TARGET})...")
1170
- added = await _refill_with_seeds_only(team_role, POOL_TARGET - current)
1171
  print(f"[code-fixing] Pre-warm done: +{added} challenges")
1172
  except Exception as e:
1173
  print(f"[code-fixing] Pre-warm failed: {e}")
@@ -1180,6 +1252,7 @@ async def start_pool_watcher(team_role: str):
1180
  added = await refill_pool(team_role, POOL_BATCH)
1181
  print(f"[code-fixing] Refill done: +{added} (now {count + added}/{POOL_TARGET})")
1182
  await asyncio.sleep(2)
 
1183
  else:
1184
  await asyncio.sleep(30)
1185
  except Exception as e:
 
166
  NVIDIA_MODEL = os.environ.get("NVIDIA_MODEL", "deepseek-ai/deepseek-v4-pro")
167
  NVIDIA_API_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
168
 
169
+ # Mistral — primary AI for challenge generation (per the user's request).
170
+ MISTRAL_API_KEY = os.environ.get("MISTRAL_API_KEY", "")
171
+ MISTRAL_MODEL = os.environ.get("MISTRAL_MODEL", "mistral-large-latest")
172
+ MISTRAL_API_URL = os.environ.get(
173
+ "MISTRAL_API_URL", "https://api.mistral.ai/v1/chat/completions"
174
+ )
175
+
176
  TABLE_NAME = "code_fixing_challenges"
177
 
178
  # Per-team backoff tracker
 
787
  return resp
788
 
789
 
790
+ async def _try_mistral(prompt: str, system: str, model: str) -> Optional[str]:
791
+ """Try Mistral API (mistral-large-latest by default).
792
+
793
+ Per the user's request, Mistral is the PRIMARY tier for challenge
794
+ generation — it runs first in the orchestrator. Only on a hard
795
+ failure (429 / timeout / 5xx / parse) do we fall through to
796
+ Cloudflare, then Groq, then NVIDIA/DeepSeek, then seed.
797
+ """
798
+ if not MISTRAL_API_KEY:
799
+ return None
800
+ import httpx as _httpx
801
+ try:
802
+ headers = {
803
+ "Content-Type": "application/json",
804
+ "Authorization": f"Bearer {MISTRAL_API_KEY}",
805
+ }
806
+ messages = []
807
+ if system:
808
+ messages.append({"role": "system", "content": system})
809
+ messages.append({"role": "user", "content": prompt})
810
+ payload = {
811
+ "model": model or MISTRAL_MODEL,
812
+ "messages": messages,
813
+ "temperature": 0.7,
814
+ "max_tokens": 4096,
815
+ "response_format": {"type": "json_object"},
816
+ }
817
+ async with _httpx.AsyncClient(timeout=30) as client:
818
+ resp = await _post_with_json_fallback(client, MISTRAL_API_URL, payload, headers, "mistral")
819
+ if resp.status_code == 200:
820
+ data = resp.json()
821
+ content = data.get("choices", [{}])[0].get("message", {}).get("content")
822
+ if content:
823
+ return content
824
+ print(f" [mistral] 200 but empty content: {str(data)[:200]}")
825
+ return None
826
+ if resp.status_code == 429:
827
+ print(f" [mistral] 429 rate-limited")
828
+ else:
829
+ print(f" [mistral] error {resp.status_code}: {resp.text[:200]}")
830
+ return None
831
+ except Exception as e:
832
+ print(f" [mistral] exception: {type(e).__name__}: {e}")
833
+ return None
834
+
835
+
836
  async def _try_cloudflare(prompt: str, system: str) -> Optional[str]:
837
  """Try Cloudflare Workers AI. Cycles through multiple models until one works.
838
  Returns raw text on success, None if all models fail.
 
862
  }
863
  payload = {**payload_base, "response_format": {"type": "json_object"}}
864
  try:
865
+ async with _httpx.AsyncClient(timeout=30) as client:
866
  resp = await _post_with_json_fallback(client, url, payload, headers, f"cloudflare/{model_name.split('/')[-1]}")
867
  if resp.status_code == 200:
868
  result = resp.json()
 
963
 
964
 
965
  async def _call_ai(prompt: str, system: str = "", model: str = "") -> Optional[str]:
966
+ """Try providers in strict order: Mistral -> Cloudflare -> Groq -> NVIDIA/DeepSeek.
967
  Returns the first valid response, or None if all fail.
968
  Each provider must return successfully; the call to parse_json_safe happens later.
969
  """
970
+ print(f" [ai] Trying Mistral...")
971
+ r = await _try_mistral(prompt, system, model)
972
+ if r:
973
+ return r
974
  print(f" [ai] Trying Cloudflare...")
975
  r = await _try_cloudflare(prompt, system)
976
  if r:
 
983
  r = await _try_nvidia(prompt, system, model)
984
  if r:
985
  return r
986
+ print(f" [ai] All 4 providers failed")
987
  return None
988
 
989
 
 
1111
  async def _insert_to_db(row: dict) -> bool:
1112
  if not SUPABASE_ANON_KEY or not SUPABASE_URL:
1113
  return False
1114
+ # Enforce the migration 011 CHECK constraint: ``module`` MUST
1115
+ # be the canonical challenge type ('code-fixing') for this
1116
+ # table — the AI / seed rows used to carry topic-shaped values
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"
 
1193
  return True
1194
  return False
1195
 
1196
+ # Sequential generation: each challenge is fully generated, validated,
1197
+ # inserted, AND confirmed in the DB before the next one starts. Hitting
1198
+ # the AI in parallel triggers Mistral/CF 429 rate limits.
1199
+ results = []
1200
+ for i in range(target):
1201
+ ok = await _gen_one(i)
1202
+ results.append(ok)
1203
+ # Small backoff between calls to stay well under per-minute quotas.
1204
+ await asyncio.sleep(2.0)
1205
  return sum(1 for r in results if r)
1206
 
1207
 
 
1233
 
1234
  print(f"[code-fixing] Pool watcher started for '{team_role}' (target={POOL_TARGET}, threshold={POOL_THRESHOLD}, batch={POOL_BATCH})")
1235
 
1236
+ # Pre-warm at startup — tries AI first (Mistral → CF → Groq → NVIDIA),
1237
+ # falls back to seeds only if every AI tier fails.
1238
  try:
1239
  current = await get_pool_count(team_role)
1240
  if current < POOL_TARGET:
1241
  print(f"[code-fixing] Pre-warming pool ({current} → {POOL_TARGET})...")
1242
+ added = await refill_pool(team_role, POOL_TARGET - current)
1243
  print(f"[code-fixing] Pre-warm done: +{added} challenges")
1244
  except Exception as e:
1245
  print(f"[code-fixing] Pre-warm failed: {e}")
 
1252
  added = await refill_pool(team_role, POOL_BATCH)
1253
  print(f"[code-fixing] Refill done: +{added} (now {count + added}/{POOL_TARGET})")
1254
  await asyncio.sleep(2)
1255
+ # else: pool healthy — silent (keep logs clean)
1256
  else:
1257
  await asyncio.sleep(30)
1258
  except Exception as e:
crypto_generator.py → app/generators/crypto.py RENAMED
@@ -320,6 +320,13 @@ NVIDIA_API_KEY = os.environ.get("NVIDIA_API_KEY", "")
320
  NVIDIA_MODEL = os.environ.get("NVIDIA_MODEL", "deepseek-ai/deepseek-v4-pro")
321
  NVIDIA_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
322
 
 
 
 
 
 
 
 
323
  # Flag format: CyberArena{<hex>}
324
  FLAG_PREFIX = "CyberArena{"
325
 
@@ -1133,6 +1140,12 @@ def insert_to_db(challenge: Challenge, supabase_url: str = SUPABASE_URL,
1133
  "Prefer": "return=minimal",
1134
  }
1135
  payload = challenge.to_db_row()
 
 
 
 
 
 
1136
  last_err: str = ""
1137
  for attempt in range(1, 4):
1138
  try:
@@ -1837,6 +1850,96 @@ def get_pool_count(team_role: str, module: Optional[str] = None) -> int:
1837
  return 0
1838
 
1839
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1840
  async def refill_pool(team_role: str, count: int = POOL_BATCH,
1841
  groq_api_key: str = GROQ_API_KEY) -> int:
1842
  """Generate and insert up to `count` challenges for a team.
@@ -1894,11 +1997,36 @@ async def _refill_pool_unlocked(team_role: str, count: int,
1894
  difficulty = difficulties[i % len(difficulties)]
1895
  spec = None
1896
  source = "ai"
 
1897
  _maybe_try_groq = False
1898
  _maybe_try_deepseek = False
1899
 
1900
- # ---- 1) Cloudflare (PRIMARY AI) ----
1901
- if CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1902
  try:
1903
  spec = await ai_generate_scenario_via_cloudflare(
1904
  team_role, module, difficulty,
@@ -2061,8 +2189,7 @@ async def start_pool_watcher(team_role: str, groq_api_key: str = GROQ_API_KEY) -
2061
  new_count = count + added
2062
  print(f"{label} refilled: {count} → {new_count} (target {POOL_TARGET}).")
2063
  sleep_secs = 5
2064
- else:
2065
- print(f"{label} pool healthy ({count}/{POOL_TARGET}) — sleeping {IDLE_POLL_SECS}s.")
2066
  await asyncio.sleep(sleep_secs)
2067
  except Exception as e:
2068
  import traceback
 
320
  NVIDIA_MODEL = os.environ.get("NVIDIA_MODEL", "deepseek-ai/deepseek-v4-pro")
321
  NVIDIA_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
322
 
323
+ # PRIMARY AI: Mistral (per the user's request — Mistral runs first)
324
+ MISTRAL_API_KEY = os.environ.get("MISTRAL_API_KEY", "")
325
+ MISTRAL_MODEL = os.environ.get("MISTRAL_MODEL", "mistral-large-latest")
326
+ MISTRAL_API_URL = os.environ.get(
327
+ "MISTRAL_API_URL", "https://api.mistral.ai/v1/chat/completions"
328
+ )
329
+
330
  # Flag format: CyberArena{<hex>}
331
  FLAG_PREFIX = "CyberArena{"
332
 
 
1140
  "Prefer": "return=minimal",
1141
  }
1142
  payload = challenge.to_db_row()
1143
+ # Enforce the migration 011 CHECK constraint: ``module`` MUST be
1144
+ # the canonical challenge type ('crypto') for this table — the
1145
+ # AI / seed rows used to carry topic-shaped values like
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:
 
1850
  return 0
1851
 
1852
 
1853
+
1854
+ async def ai_generate_scenario_via_mistral(team_role: str, module: str, difficulty: str,
1855
+ mistral_api_key: str,
1856
+ model: str | None = None,
1857
+ algorithm: str | None = None) -> ScenarioSpec:
1858
+ """Primary AI: call Mistral (mistral-large-latest) for scenario generation.
1859
+
1860
+ Per the user's request, Mistral is the first provider tried for
1861
+ challenge generation. OpenAI-compatible chat-completions endpoint.
1862
+ Returns a ScenarioSpec the same way ai_generate_scenario does.
1863
+ Raises on transport errors so the caller can decide what to do
1864
+ next (fall back to Cloudflare, then Groq, then NVIDIA/DeepSeek,
1865
+ then seed).
1866
+
1867
+ The ``algorithm`` is pre-selected by the system; the AI is asked
1868
+ to write a story that fits it, not to invent one.
1869
+ """
1870
+ if not mistral_api_key:
1871
+ raise RuntimeError("MISTRAL_API_KEY not configured")
1872
+ model = model or MISTRAL_MODEL
1873
+ prompt = CRYPTO_SCENARIO_PROMPT.format(
1874
+ team_role=team_role, module=module, difficulty=difficulty,
1875
+ algorithm=algorithm or "any",
1876
+ )
1877
+ body = {
1878
+ "model": model,
1879
+ "messages": [
1880
+ {"role": "system", "content": "You output valid JSON only. No prose, no markdown fences."},
1881
+ {"role": "user", "content": prompt},
1882
+ ],
1883
+ "temperature": 0.7,
1884
+ "max_tokens": 2500,
1885
+ "response_format": {"type": "json_object"},
1886
+ }
1887
+ async with httpx.AsyncClient(timeout=60) as client:
1888
+ r = await client.post(
1889
+ MISTRAL_API_URL,
1890
+ headers={"Authorization": f"Bearer {mistral_api_key}", "Content-Type": "application/json"},
1891
+ json=body,
1892
+ )
1893
+ if r.status_code == 429:
1894
+ raise RuntimeError("429 from Mistral - rate limit")
1895
+ if r.status_code != 200:
1896
+ raise RuntimeError(f"Mistral HTTP {r.status_code}: {r.text[:200]}")
1897
+
1898
+ content = (r.json().get("choices") or [{}])[0].get("message", {}).get("content", "")
1899
+ if not content:
1900
+ raise RuntimeError("Mistral returned empty content")
1901
+
1902
+ if "```" in content:
1903
+ m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", content, re.DOTALL)
1904
+ if m:
1905
+ content = m.group(1)
1906
+ content = content.strip()
1907
+ if content.lower().startswith("json"):
1908
+ content = content[4:]
1909
+ content = content.strip().rstrip("`")
1910
+
1911
+ data = _repair_json(content)
1912
+ extra = data.get("extra", {}) or {}
1913
+ if "filename" not in extra or not extra["filename"]:
1914
+ m = _FILENAME_RE.findall(data.get("story", "") or "")
1915
+ if m:
1916
+ extra["filename"] = m[0]
1917
+ if extra.get("filename"):
1918
+ data["story"] = _align_filename_in_story(data.get("story", ""), extra["filename"])
1919
+ data["task_outline"] = _align_filename_in_story(data.get("task_outline", ""), extra["filename"])
1920
+ data["task_outline"] = _sanitize_task_outline(data.get("task_outline", ""))
1921
+
1922
+ final_algo = _normalize_algo(algorithm or data.get("algorithm", ""))
1923
+ ai_key = (data.get("key_material") or "").strip()
1924
+ if ai_key:
1925
+ key_material, extra = ai_key, extra
1926
+ else:
1927
+ key_material, extra = _generate_key_for_algo(final_algo, extra)
1928
+
1929
+ hints_raw = data.get("hints") or []
1930
+ if isinstance(hints_raw, list):
1931
+ hints = [str(h).strip() for h in hints_raw if str(h).strip()]
1932
+ else:
1933
+ hints = []
1934
+
1935
+ return ScenarioSpec(
1936
+ team_role=team_role, module=module, difficulty=difficulty,
1937
+ algorithm=final_algo, plaintext=data["plaintext"],
1938
+ key_material=key_material,
1939
+ title=data["title"], story=data["story"], task_outline=data["task_outline"],
1940
+ extra=extra, hints=hints,
1941
+ )
1942
+
1943
  async def refill_pool(team_role: str, count: int = POOL_BATCH,
1944
  groq_api_key: str = GROQ_API_KEY) -> int:
1945
  """Generate and insert up to `count` challenges for a team.
 
1997
  difficulty = difficulties[i % len(difficulties)]
1998
  spec = None
1999
  source = "ai"
2000
+ _maybe_try_cf = False
2001
  _maybe_try_groq = False
2002
  _maybe_try_deepseek = False
2003
 
2004
+ # ---- 0) Mistral (PRIMARY AI per user request) ----
2005
+ if MISTRAL_API_KEY:
2006
+ try:
2007
+ spec = await ai_generate_scenario_via_mistral(
2008
+ team_role, module, difficulty,
2009
+ MISTRAL_API_KEY,
2010
+ algorithm=algorithm,
2011
+ )
2012
+ print(f"[crypto_generator] Mistral OK: algo={spec.algorithm} title={spec.title[:50]}")
2013
+ except Exception as e:
2014
+ err = str(e)
2015
+ if "429" in err or "Rate Limit" in err:
2016
+ print(f"[crypto_generator] Mistral 429 — falling through to Cloudflare")
2017
+ _maybe_try_cf = True
2018
+ elif "Could not repair JSON" in err or "empty content" in err:
2019
+ print(f"[crypto_generator] Mistral bad response ({type(e).__name__}) — trying Cloudflare")
2020
+ _maybe_try_cf = True
2021
+ else:
2022
+ print(f"[crypto_generator] Mistral failed ({type(e).__name__}): {e} — trying Cloudflare")
2023
+ _maybe_try_cf = True
2024
+ await asyncio.sleep(0.3)
2025
+ else:
2026
+ _maybe_try_cf = bool(CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID)
2027
+
2028
+ # ---- 1) Cloudflare (SECONDARY AI) ----
2029
+ if spec is None and _maybe_try_cf and CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID:
2030
  try:
2031
  spec = await ai_generate_scenario_via_cloudflare(
2032
  team_role, module, difficulty,
 
2189
  new_count = count + added
2190
  print(f"{label} refilled: {count} → {new_count} (target {POOL_TARGET}).")
2191
  sleep_secs = 5
2192
+ # else: pool healthy — silent (keep logs clean)
 
2193
  await asyncio.sleep(sleep_secs)
2194
  except Exception as e:
2195
  import traceback
log_analysis_generator.py → app/generators/log_analysis.py RENAMED
@@ -147,6 +147,13 @@ NVIDIA_API_KEY = os.environ.get("NVIDIA_API_KEY", "")
147
  NVIDIA_MODEL = os.environ.get("NVIDIA_MODEL", "deepseek-ai/deepseek-v4-pro")
148
  NVIDIA_API_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
149
 
 
 
 
 
 
 
 
150
  TABLE_NAME = "log_analysis_challenges"
151
 
152
  # Per-team backoff tracker
@@ -700,6 +707,51 @@ async def _post_with_json_fallback(client, url: str, payload: dict, headers: dic
700
  return resp
701
 
702
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
703
  async def _try_cloudflare(prompt: str, system: str) -> Optional[str]:
704
  """Try Cloudflare Workers AI.
705
 
@@ -840,7 +892,11 @@ async def _try_nvidia(prompt: str, system: str, model: str) -> Optional[str]:
840
 
841
 
842
  async def _call_ai(prompt: str, system: str = "") -> Optional[str]:
843
- """Try Cloudflare -> Groq -> NVIDIA. Returns first success."""
 
 
 
 
844
  print(f" [ai] Trying Cloudflare...")
845
  r = await _try_cloudflare(prompt, system)
846
  if r:
@@ -973,6 +1029,13 @@ async def get_pool_count(team_role: str) -> int:
973
  async def _insert_to_db(row: dict) -> bool:
974
  if not SUPABASE_ANON_KEY or not SUPABASE_URL:
975
  return False
 
 
 
 
 
 
 
976
  url = f"{SUPABASE_URL}/rest/v1/{TABLE_NAME}"
977
  headers = supabase_headers(content_type=True)
978
  headers["Prefer"] = "return=representation"
@@ -1158,7 +1221,14 @@ async def refill_pool(team_role: str, count: int) -> int:
1158
  return False
1159
 
1160
  # Generate all target challenges in parallel
1161
- results = await asyncio.gather(*[_gen_one(i) for i in range(target)])
 
 
 
 
 
 
 
1162
  return sum(1 for r in results if r)
1163
 
1164
 
@@ -1187,13 +1257,14 @@ async def start_pool_watcher(team_role: str):
1187
  """
1188
  print(f"[log-analysis] Pool watcher started for {team_role} (target={POOL_TARGET}, threshold={POOL_THRESHOLD}, batch={POOL_BATCH})")
1189
 
1190
- # Pre-warm at startup
 
1191
  try:
1192
  current = await get_pool_count(team_role)
1193
  if current < POOL_TARGET:
1194
  print(f"[log-analysis] Pre-warming pool ({current} → {POOL_TARGET})...")
1195
- added = await _refill_with_seeds_only(team_role, POOL_TARGET - current)
1196
- print(f"[log-analysis] Pre-warm done: +{added} seed challenges")
1197
  except Exception as e:
1198
  print(f"[log-analysis] Pre-warm failed: {e}")
1199
 
@@ -1205,6 +1276,7 @@ async def start_pool_watcher(team_role: str):
1205
  added = await refill_pool(team_role, POOL_BATCH)
1206
  print(f"[log-analysis] Refill done: +{added} (now {count + added}/{POOL_TARGET})")
1207
  await asyncio.sleep(2)
 
1208
  else:
1209
  await asyncio.sleep(30)
1210
  except Exception as e:
 
147
  NVIDIA_MODEL = os.environ.get("NVIDIA_MODEL", "deepseek-ai/deepseek-v4-pro")
148
  NVIDIA_API_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
149
 
150
+ # PRIMARY AI: Mistral (per the user's request — Mistral runs first)
151
+ MISTRAL_API_KEY = os.environ.get("MISTRAL_API_KEY", "")
152
+ MISTRAL_MODEL = os.environ.get("MISTRAL_MODEL", "mistral-large-latest")
153
+ MISTRAL_API_URL = os.environ.get(
154
+ "MISTRAL_API_URL", "https://api.mistral.ai/v1/chat/completions"
155
+ )
156
+
157
  TABLE_NAME = "log_analysis_challenges"
158
 
159
  # Per-team backoff tracker
 
707
  return resp
708
 
709
 
710
+ async def _try_mistral(prompt: str, system: str, model: str) -> Optional[str]:
711
+ """Try Mistral API (mistral-large-latest by default).
712
+
713
+ Per the user's request, Mistral is the PRIMARY tier for challenge
714
+ generation — it runs first in the orchestrator. Only on a hard
715
+ failure (429 / timeout / 5xx / parse) do we fall through to
716
+ Cloudflare, then Groq, then NVIDIA/DeepSeek, then seed.
717
+ """
718
+ if not MISTRAL_API_KEY:
719
+ return None
720
+ try:
721
+ headers = {
722
+ "Content-Type": "application/json",
723
+ "Authorization": f"Bearer {MISTRAL_API_KEY}",
724
+ }
725
+ messages = []
726
+ if system:
727
+ messages.append({"role": "system", "content": system})
728
+ messages.append({"role": "user", "content": prompt})
729
+ payload = {
730
+ "model": model or MISTRAL_MODEL,
731
+ "messages": messages,
732
+ "temperature": 0.7,
733
+ "max_tokens": 2500,
734
+ "response_format": {"type": "json_object"},
735
+ }
736
+ async with httpx.AsyncClient(timeout=30) as client:
737
+ resp = await _post_with_json_fallback(client, MISTRAL_API_URL, payload, headers, "mistral")
738
+ if resp.status_code == 200:
739
+ data = resp.json()
740
+ content = data.get("choices", [{}])[0].get("message", {}).get("content")
741
+ if content:
742
+ return content
743
+ print(f" [mistral] 200 but empty content: {str(data)[:200]}")
744
+ return None
745
+ if resp.status_code == 429:
746
+ print(f" [mistral] 429 rate-limited")
747
+ else:
748
+ print(f" [mistral] error {resp.status_code}: {resp.text[:200]}")
749
+ return None
750
+ except Exception as e:
751
+ print(f" [mistral] exception: {type(e).__name__}: {e}")
752
+ return None
753
+
754
+
755
  async def _try_cloudflare(prompt: str, system: str) -> Optional[str]:
756
  """Try Cloudflare Workers AI.
757
 
 
892
 
893
 
894
  async def _call_ai(prompt: str, system: str = "") -> Optional[str]:
895
+ """Try Mistral -> Cloudflare -> Groq -> NVIDIA. Returns first success."""
896
+ print(f" [ai] Trying Mistral...")
897
+ r = await _try_mistral(prompt, system, "")
898
+ if r:
899
+ return r
900
  print(f" [ai] Trying Cloudflare...")
901
  r = await _try_cloudflare(prompt, system)
902
  if r:
 
1029
  async def _insert_to_db(row: dict) -> bool:
1030
  if not SUPABASE_ANON_KEY or not SUPABASE_URL:
1031
  return False
1032
+ # Enforce the migration 011 CHECK constraint: ``module`` MUST be
1033
+ # the canonical challenge type ('log-analysis') for this table.
1034
+ # log-analysis already happens to use the same value as the
1035
+ # topic so this is mostly a no-op, but it also pins ``topic``
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"
 
1221
  return False
1222
 
1223
  # Generate all target challenges in parallel
1224
+ # Sequential generation: each challenge is fully generated, validated,
1225
+ # inserted, AND confirmed in the DB before the next one starts. Hitting
1226
+ # the AI in parallel triggers Mistral/CF 429 rate limits.
1227
+ results = []
1228
+ for i in range(target):
1229
+ results.append(await _gen_one(i))
1230
+ # Small backoff between calls to stay well under per-minute quotas.
1231
+ await asyncio.sleep(2.0)
1232
  return sum(1 for r in results if r)
1233
 
1234
 
 
1257
  """
1258
  print(f"[log-analysis] Pool watcher started for {team_role} (target={POOL_TARGET}, threshold={POOL_THRESHOLD}, batch={POOL_BATCH})")
1259
 
1260
+ # Pre-warm at startup — tries AI first (Mistral → CF → Groq → NVIDIA),
1261
+ # falls back to seeds only if every AI tier fails.
1262
  try:
1263
  current = await get_pool_count(team_role)
1264
  if current < POOL_TARGET:
1265
  print(f"[log-analysis] Pre-warming pool ({current} → {POOL_TARGET})...")
1266
+ added = await refill_pool(team_role, POOL_TARGET - current)
1267
+ print(f"[log-analysis] Pre-warm done: +{added} challenges")
1268
  except Exception as e:
1269
  print(f"[log-analysis] Pre-warm failed: {e}")
1270
 
 
1276
  added = await refill_pool(team_role, POOL_BATCH)
1277
  print(f"[log-analysis] Refill done: +{added} (now {count + added}/{POOL_TARGET})")
1278
  await asyncio.sleep(2)
1279
+ # else: pool healthy — silent (keep logs clean)
1280
  else:
1281
  await asyncio.sleep(30)
1282
  except Exception as e:
vulnerability_hunter_generator.py → app/generators/vulnerability_hunter.py RENAMED
@@ -295,6 +295,13 @@ NVIDIA_API_KEY = os.environ.get("NVIDIA_API_KEY", "")
295
  NVIDIA_MODEL = os.environ.get("NVIDIA_MODEL", "deepseek-ai/deepseek-v4-pro")
296
  NVIDIA_API_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
297
 
 
 
 
 
 
 
 
298
  TABLE_NAME = "vulnerability_hunter_challenges"
299
 
300
  # Per-team backoff tracker
@@ -827,6 +834,52 @@ async def _post_with_json_fallback(client, url: str, payload: dict, headers: dic
827
  return resp
828
 
829
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
830
  async def _try_cloudflare(prompt: str, system: str) -> Optional[str]:
831
  if not (CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID):
832
  return None
@@ -844,7 +897,7 @@ async def _try_cloudflare(prompt: str, system: str) -> Optional[str]:
844
  headers = {"Content-Type": "application/json", "Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
845
  payload = {**payload_base, "response_format": {"type": "json_object"}}
846
  try:
847
- async with _httpx.AsyncClient(timeout=120) as client:
848
  resp = await _post_with_json_fallback(client, url, payload, headers, f"cloudflare/{model_name.split('/')[-1]}")
849
  if resp.status_code == 200:
850
  result = resp.json()
@@ -936,6 +989,10 @@ async def _try_nvidia(prompt: str, system: str, model: str) -> Optional[str]:
936
 
937
 
938
  async def _call_ai(prompt: str, system: str = "", model: str = "") -> Optional[str]:
 
 
 
 
939
  print(f" [ai] Trying Cloudflare...")
940
  r = await _try_cloudflare(prompt, system)
941
  if r:
@@ -948,7 +1005,7 @@ async def _call_ai(prompt: str, system: str = "", model: str = "") -> Optional[s
948
  r = await _try_nvidia(prompt, system, model)
949
  if r:
950
  return r
951
- print(f" [ai] All 3 providers failed")
952
  return None
953
 
954
 
@@ -1136,6 +1193,12 @@ async def get_pool_count(team_role: str) -> int:
1136
  async def _insert_to_db(row: dict) -> bool:
1137
  if not SUPABASE_ANON_KEY or not SUPABASE_URL:
1138
  return False
 
 
 
 
 
 
1139
  url = f"{SUPABASE_URL}/rest/v1/{TABLE_NAME}"
1140
  headers = supabase_headers(content_type=True)
1141
  headers["Prefer"] = "return=representation"
@@ -1211,7 +1274,15 @@ async def refill_pool(team_role: str, count: int) -> int:
1211
  return True
1212
  return False
1213
 
1214
- results = await asyncio.gather(*[_gen_one(i) for i in range(target)])
 
 
 
 
 
 
 
 
1215
  return sum(1 for r in results if r)
1216
 
1217
 
@@ -1225,12 +1296,13 @@ async def start_pool_watcher(team_role: str):
1225
 
1226
  print(f"[vuln-hunter] Pool watcher started for '{team_role}' (target={POOL_TARGET}, threshold={POOL_THRESHOLD}, batch={POOL_BATCH})")
1227
 
1228
- # Pre-warm
 
1229
  try:
1230
  current = await get_pool_count(team_role)
1231
  if current < POOL_TARGET:
1232
  print(f"[vuln-hunter] Pre-warming pool ({current} → {POOL_TARGET})...")
1233
- added = await _refill_with_seeds_only(team_role, POOL_TARGET - current)
1234
  print(f"[vuln-hunter] Pre-warm done: +{added} challenges")
1235
  except Exception as e:
1236
  print(f"[vuln-hunter] Pre-warm failed: {e}")
@@ -1243,6 +1315,7 @@ async def start_pool_watcher(team_role: str):
1243
  added = await refill_pool(team_role, POOL_BATCH)
1244
  print(f"[vuln-hunter] Refill done: +{added} (now {count + added}/{POOL_TARGET})")
1245
  await asyncio.sleep(2)
 
1246
  else:
1247
  await asyncio.sleep(30)
1248
  except Exception as e:
 
295
  NVIDIA_MODEL = os.environ.get("NVIDIA_MODEL", "deepseek-ai/deepseek-v4-pro")
296
  NVIDIA_API_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
297
 
298
+ # PRIMARY AI: Mistral (per the user's request — Mistral runs first)
299
+ MISTRAL_API_KEY = os.environ.get("MISTRAL_API_KEY", "")
300
+ MISTRAL_MODEL = os.environ.get("MISTRAL_MODEL", "mistral-large-latest")
301
+ MISTRAL_API_URL = os.environ.get(
302
+ "MISTRAL_API_URL", "https://api.mistral.ai/v1/chat/completions"
303
+ )
304
+
305
  TABLE_NAME = "vulnerability_hunter_challenges"
306
 
307
  # Per-team backoff tracker
 
834
  return resp
835
 
836
 
837
+ async def _try_mistral(prompt: str, system: str, model: str) -> Optional[str]:
838
+ """Try Mistral API (mistral-large-latest by default).
839
+
840
+ Per the user's request, Mistral is the PRIMARY tier for challenge
841
+ generation — it runs first in the orchestrator. Only on a hard
842
+ failure (429 / timeout / 5xx / parse) do we fall through to
843
+ Cloudflare, then Groq, then NVIDIA/DeepSeek, then seed.
844
+ """
845
+ if not MISTRAL_API_KEY:
846
+ return None
847
+ import httpx as _httpx
848
+ try:
849
+ headers = {
850
+ "Content-Type": "application/json",
851
+ "Authorization": f"Bearer {MISTRAL_API_KEY}",
852
+ }
853
+ messages = []
854
+ if system:
855
+ messages.append({"role": "system", "content": system})
856
+ messages.append({"role": "user", "content": prompt})
857
+ payload = {
858
+ "model": model or MISTRAL_MODEL,
859
+ "messages": messages,
860
+ "temperature": 0.7,
861
+ "max_tokens": 4096,
862
+ "response_format": {"type": "json_object"},
863
+ }
864
+ async with _httpx.AsyncClient(timeout=30) as client:
865
+ resp = await _post_with_json_fallback(client, MISTRAL_API_URL, payload, headers, "mistral")
866
+ if resp.status_code == 200:
867
+ data = resp.json()
868
+ content = data.get("choices", [{}])[0].get("message", {}).get("content")
869
+ if content:
870
+ return content
871
+ print(f" [mistral] 200 but empty content: {str(data)[:200]}")
872
+ return None
873
+ if resp.status_code == 429:
874
+ print(f" [mistral] 429 rate-limited")
875
+ else:
876
+ print(f" [mistral] error {resp.status_code}: {resp.text[:200]}")
877
+ return None
878
+ except Exception as e:
879
+ print(f" [mistral] exception: {type(e).__name__}: {e}")
880
+ return None
881
+
882
+
883
  async def _try_cloudflare(prompt: str, system: str) -> Optional[str]:
884
  if not (CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID):
885
  return None
 
897
  headers = {"Content-Type": "application/json", "Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
898
  payload = {**payload_base, "response_format": {"type": "json_object"}}
899
  try:
900
+ async with _httpx.AsyncClient(timeout=30) as client:
901
  resp = await _post_with_json_fallback(client, url, payload, headers, f"cloudflare/{model_name.split('/')[-1]}")
902
  if resp.status_code == 200:
903
  result = resp.json()
 
989
 
990
 
991
  async def _call_ai(prompt: str, system: str = "", model: str = "") -> Optional[str]:
992
+ print(f" [ai] Trying Mistral...")
993
+ r = await _try_mistral(prompt, system, model)
994
+ if r:
995
+ return r
996
  print(f" [ai] Trying Cloudflare...")
997
  r = await _try_cloudflare(prompt, system)
998
  if r:
 
1005
  r = await _try_nvidia(prompt, system, model)
1006
  if r:
1007
  return r
1008
+ print(f" [ai] All 4 providers failed")
1009
  return None
1010
 
1011
 
 
1193
  async def _insert_to_db(row: dict) -> bool:
1194
  if not SUPABASE_ANON_KEY or not SUPABASE_URL:
1195
  return False
1196
+ # Enforce the migration 011 CHECK constraint: ``module`` MUST
1197
+ # be the canonical challenge type ('vulnerability-hunter') for
1198
+ # this table — the AI / seed rows used to carry topic-shaped
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"
 
1274
  return True
1275
  return False
1276
 
1277
+ # Sequential generation: each challenge is fully generated, validated,
1278
+ # inserted, AND confirmed in the DB before the next one starts. Hitting
1279
+ # the AI in parallel triggers Mistral/CF 429 rate limits.
1280
+ results = []
1281
+ for i in range(target):
1282
+ ok = await _gen_one(i)
1283
+ results.append(ok)
1284
+ # Small backoff between calls to stay well under per-minute quotas.
1285
+ await asyncio.sleep(2.0)
1286
  return sum(1 for r in results if r)
1287
 
1288
 
 
1296
 
1297
  print(f"[vuln-hunter] Pool watcher started for '{team_role}' (target={POOL_TARGET}, threshold={POOL_THRESHOLD}, batch={POOL_BATCH})")
1298
 
1299
+ # Pre-warm — tries AI first (Mistral → CF → Groq → NVIDIA),
1300
+ # falls back to seeds only if every AI tier fails.
1301
  try:
1302
  current = await get_pool_count(team_role)
1303
  if current < POOL_TARGET:
1304
  print(f"[vuln-hunter] Pre-warming pool ({current} → {POOL_TARGET})...")
1305
+ added = await refill_pool(team_role, POOL_TARGET - current)
1306
  print(f"[vuln-hunter] Pre-warm done: +{added} challenges")
1307
  except Exception as e:
1308
  print(f"[vuln-hunter] Pre-warm failed: {e}")
 
1315
  added = await refill_pool(team_role, POOL_BATCH)
1316
  print(f"[vuln-hunter] Refill done: +{added} (now {count + added}/{POOL_TARGET})")
1317
  await asyncio.sleep(2)
1318
+ # else: pool healthy — silent (keep logs clean)
1319
  else:
1320
  await asyncio.sleep(30)
1321
  except Exception as e:
web_exploitation_generator.py → app/generators/web.py RENAMED
@@ -310,6 +310,13 @@ NVIDIA_API_KEY = os.environ.get("NVIDIA_API_KEY", "")
310
  NVIDIA_MODEL = os.environ.get("NVIDIA_MODEL", "deepseek-ai/deepseek-v4-pro")
311
  NVIDIA_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
312
 
 
 
 
 
 
 
 
313
  FLAG_PREFIX = "CyberArena{"
314
 
315
  LAB_PARENT_ORIGIN = "*" # postMessage target — sandboxed iframe, no real origin concern
@@ -2660,8 +2667,15 @@ def insert_to_db(challenge: Challenge, supabase_url: str = SUPABASE_URL,
2660
  "Content-Type": "application/json",
2661
  "Prefer": "return=minimal",
2662
  }
 
 
 
 
 
 
 
2663
  with httpx.Client(timeout=30) as client:
2664
- r = client.post(url, headers=headers, json=challenge.to_db_row())
2665
  if r.status_code in (200, 201, 204):
2666
  print(f"✓ Inserted: {challenge.title[:60]}")
2667
  return True
@@ -3474,6 +3488,80 @@ def get_pool_count(team_role: str) -> int:
3474
  except Exception as e:
3475
  print(f"[web_exploitation_generator] get_pool_count error: {e}")
3476
  return 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3477
 
3478
 
3479
  async def refill_pool(team_role: str, count: int = POOL_BATCH,
@@ -3530,10 +3618,32 @@ async def _refill_pool_unlocked(team_role: str, count: int,
3530
 
3531
  spec = None
3532
  source = "ai"
 
3533
  _maybe_try_deepseek = False
3534
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3535
  # ---- 1) Try Groq for this slot ----
3536
- if groq_api_key and not in_backoff:
3537
  try:
3538
  spec = await ai_generate_scenario(
3539
  team_role, module, vuln_type, difficulty, groq_api_key,
@@ -3655,8 +3765,7 @@ async def start_pool_watcher(team_role: str, groq_api_key: str = GROQ_API_KEY) -
3655
  new_count = count + added
3656
  print(f"{label} refilled: {count} → {new_count} (target {POOL_TARGET}).")
3657
  sleep_secs = 5
3658
- else:
3659
- print(f"{label} pool healthy ({count}/{POOL_TARGET}) — sleeping {IDLE_POLL_SECS}s.")
3660
  await asyncio.sleep(sleep_secs)
3661
  except Exception as e:
3662
  import traceback
 
310
  NVIDIA_MODEL = os.environ.get("NVIDIA_MODEL", "deepseek-ai/deepseek-v4-pro")
311
  NVIDIA_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
312
 
313
+ # PRIMARY AI: Mistral (per the user's request — Mistral runs first)
314
+ MISTRAL_API_KEY = os.environ.get("MISTRAL_API_KEY", "")
315
+ MISTRAL_MODEL = os.environ.get("MISTRAL_MODEL", "mistral-large-latest")
316
+ MISTRAL_API_URL = os.environ.get(
317
+ "MISTRAL_API_URL", "https://api.mistral.ai/v1/chat/completions"
318
+ )
319
+
320
  FLAG_PREFIX = "CyberArena{"
321
 
322
  LAB_PARENT_ORIGIN = "*" # postMessage target — sandboxed iframe, no real origin concern
 
2667
  "Content-Type": "application/json",
2668
  "Prefer": "return=minimal",
2669
  }
2670
+ payload = challenge.to_db_row()
2671
+ # Enforce the migration 011 CHECK constraint: ``module`` MUST be
2672
+ # the canonical challenge type ('web') for this table — the
2673
+ # AI / seed rows used to carry topic-shaped values like 'xss',
2674
+ # 'sqli', 'csrf', etc.
2675
+ from app.core.config import normalize_row_module
2676
+ payload = normalize_row_module("web_exploitation_challenges", payload)
2677
  with httpx.Client(timeout=30) as client:
2678
+ r = client.post(url, headers=headers, json=payload)
2679
  if r.status_code in (200, 201, 204):
2680
  print(f"✓ Inserted: {challenge.title[:60]}")
2681
  return True
 
3488
  except Exception as e:
3489
  print(f"[web_exploitation_generator] get_pool_count error: {e}")
3490
  return 0
3491
+ async def ai_generate_scenario_via_mistral(team_role: str, module: str, vuln_type: str,
3492
+ difficulty: str, mistral_api_key: str,
3493
+ model: str | None = None,
3494
+ theme: dict | None = None) -> "ScenarioSpec":
3495
+ """Primary AI: call Mistral (mistral-large-latest) for web scenario generation.
3496
+
3497
+ Per the user's request, Mistral is the first provider tried. Same
3498
+ contract as ``ai_generate_scenario`` (Groq) — returns a
3499
+ ``ScenarioSpec`` and raises on transport errors so the caller
3500
+ can fall back to Groq, then DeepSeek, then seed.
3501
+ """
3502
+ if not mistral_api_key:
3503
+ raise RuntimeError("MISTRAL_API_KEY not configured")
3504
+ model = model or MISTRAL_MODEL
3505
+
3506
+ prompt = WEBEX_SCENARIO_PROMPT.format(
3507
+ team_role=team_role, module=module, vuln_type=vuln_type,
3508
+ difficulty=difficulty,
3509
+ theme_name=(theme or {}).get("name", ""),
3510
+ theme_desc=(theme or {}).get("description", ""),
3511
+ )
3512
+
3513
+ body = {
3514
+ "model": model,
3515
+ "messages": [
3516
+ {"role": "system", "content": "You output valid JSON only. No prose, no markdown fences."},
3517
+ {"role": "user", "content": prompt},
3518
+ ],
3519
+ "temperature": 0.7,
3520
+ "max_tokens": 2500,
3521
+ "response_format": {"type": "json_object"},
3522
+ }
3523
+ async with httpx.AsyncClient(timeout=60) as client:
3524
+ r = await client.post(
3525
+ MISTRAL_API_URL,
3526
+ headers={"Authorization": f"Bearer {mistral_api_key}", "Content-Type": "application/json"},
3527
+ json=body,
3528
+ )
3529
+ if r.status_code == 429:
3530
+ raise RuntimeError("429 from Mistral - rate limit")
3531
+ if r.status_code != 200:
3532
+ raise RuntimeError(f"Mistral HTTP {r.status_code}: {r.text[:200]}")
3533
+
3534
+ content = (r.json().get("choices") or [{}])[0].get("message", {}).get("content", "")
3535
+ if not content:
3536
+ raise RuntimeError("Mistral returned empty content")
3537
+
3538
+ if "```" in content:
3539
+ m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", content, re.DOTALL)
3540
+ if m:
3541
+ content = m.group(1)
3542
+ content = content.strip()
3543
+ if content.lower().startswith("json"):
3544
+ content = content[4:]
3545
+ content = content.strip().rstrip("`")
3546
+
3547
+ data = _repair_json(content)
3548
+ if not _vuln_type_matches_module(data.get("vuln_type", ""), module):
3549
+ # Reject: AI returned the wrong vuln family for this module.
3550
+ raise ValueError(
3551
+ f"Mistral returned vuln_type={data.get('vuln_type')!r} for module={module!r}"
3552
+ )
3553
+
3554
+ return ScenarioSpec(
3555
+ team_role=team_role,
3556
+ module=module,
3557
+ vuln_type=_coerce_str(data.get("vuln_type"), vuln_type),
3558
+ difficulty=difficulty,
3559
+ title=_coerce_str(data.get("title"), "Untitled"),
3560
+ story=_coerce_str(data.get("story"), ""),
3561
+ task_outline=_coerce_str(data.get("task_outline"), ""),
3562
+ target_input=_coerce_str(data.get("target_input"), "input"),
3563
+ vulnerable_sink=_coerce_str(data.get("vulnerable_sink"), ""),
3564
+ )
3565
 
3566
 
3567
  async def refill_pool(team_role: str, count: int = POOL_BATCH,
 
3618
 
3619
  spec = None
3620
  source = "ai"
3621
+ _maybe_try_groq = False
3622
  _maybe_try_deepseek = False
3623
 
3624
+ # ---- 0) Try Mistral for this slot (PRIMARY AI per user request) ----
3625
+ if MISTRAL_API_KEY:
3626
+ try:
3627
+ spec = await ai_generate_scenario_via_mistral(
3628
+ team_role, module, vuln_type, difficulty, MISTRAL_API_KEY,
3629
+ theme=theme,
3630
+ )
3631
+ print(f"[webex] Mistral OK: vuln={spec.vuln_type} title={spec.title[:50]}")
3632
+ except Exception as e:
3633
+ err = str(e)
3634
+ if "429" in err or "Rate Limit" in err:
3635
+ print(f"[webex] Mistral 429 — falling through to Groq")
3636
+ _maybe_try_groq = True
3637
+ elif "vuln_type=" in err or "empty content" in err:
3638
+ print(f"[webex] Mistral bad response ({type(e).__name__}) — trying Groq")
3639
+ _maybe_try_groq = True
3640
+ else:
3641
+ print(f"[webex] Mistral failed ({team_role}/{module}): {e} — trying Groq")
3642
+ _maybe_try_groq = True
3643
+ await asyncio.sleep(0.3)
3644
+
3645
  # ---- 1) Try Groq for this slot ----
3646
+ if spec is None and (groq_api_key and not in_backoff or _maybe_try_groq) and groq_api_key and not in_backoff:
3647
  try:
3648
  spec = await ai_generate_scenario(
3649
  team_role, module, vuln_type, difficulty, groq_api_key,
 
3765
  new_count = count + added
3766
  print(f"{label} refilled: {count} → {new_count} (target {POOL_TARGET}).")
3767
  sleep_secs = 5
3768
+ # else: pool healthy — silent (keep logs clean)
 
3769
  await asyncio.sleep(sleep_secs)
3770
  except Exception as e:
3771
  import traceback
app/main.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI application entry point.
2
+
3
+ This module builds the ``app`` object that :mod:`main` (the 5-line
4
+ top-level entry) imports and feeds to uvicorn. All real logic lives
5
+ in the subpackages — this file is the wiring diagram.
6
+ """
7
+
8
+ # IMPORTANT: load .env BEFORE anything else in the package. This way
9
+ # `python main.py`, `uvicorn app.main:app`, `uvicorn main:app`, or
10
+ # any script that imports `app.main` will all pick up the same
11
+ # environment, regardless of cwd.
12
+ from app._env import load_app_env, assert_critical_env # noqa: E402
13
+ load_app_env()
14
+
15
+ import asyncio
16
+ import os
17
+ import sys
18
+
19
+ from fastapi import FastAPI
20
+ from fastapi.middleware.cors import CORSMiddleware
21
+
22
+ from app.generators import REGISTRY as GENERATOR_REGISTRY
23
+
24
+
25
+ # Force UTF-8 in stdout (Windows Arabic)
26
+ try:
27
+ sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
28
+ except Exception:
29
+ pass
30
+
31
+
32
+ # --------------------------------------------------------------------------- #
33
+ # App + middleware #
34
+ # --------------------------------------------------------------------------- #
35
+
36
+ app = FastAPI(title="CyberArena Backend")
37
+
38
+ app.add_middleware(
39
+ CORSMiddleware,
40
+ allow_origins=["*"],
41
+ allow_credentials=True,
42
+ allow_methods=["*"],
43
+ allow_headers=["*"],
44
+ )
45
+
46
+
47
+ # --------------------------------------------------------------------------- #
48
+ # Routers #
49
+ # --------------------------------------------------------------------------- #
50
+
51
+ from app.api import ( # noqa: E402 (import after middleware so order is right)
52
+ auth as _auth,
53
+ xp as _xp,
54
+ leaderboard as _leaderboard,
55
+ certificates as _certificates,
56
+ training as _training,
57
+ terminal as _terminal,
58
+ onevone as _onevone,
59
+ )
60
+
61
+
62
+ def _include_all_routers(app: FastAPI) -> None:
63
+ app.include_router(_auth.router)
64
+ app.include_router(_xp.router)
65
+ app.include_router(_leaderboard.router)
66
+ app.include_router(_certificates.router)
67
+ app.include_router(_training.router)
68
+ app.include_router(_terminal.router)
69
+ app.include_router(_onevone.router)
70
+
71
+
72
+ _include_all_routers(app)
73
+
74
+
75
+ # --------------------------------------------------------------------------- #
76
+ # Background pool watcher #
77
+ # --------------------------------------------------------------------------- #
78
+
79
+ async def populate_pool_background() -> None:
80
+ """Spawn one ``start_pool_watcher(team)`` task per (type, team).
81
+
82
+ See AGENTS.md "Pool Architecture" for the per-team registration
83
+ model. The five registered generators come from
84
+ :data:`app.generators.REGISTRY`.
85
+ """
86
+ await asyncio.sleep(8) # Gentle wait on startup
87
+ print("Background pool watcher orchestrator started.")
88
+ print(f"[main] Active generators: {[name for name, _, _ in GENERATOR_REGISTRY]}")
89
+
90
+ for name, gen, teams in GENERATOR_REGISTRY:
91
+ for team in teams:
92
+ asyncio.create_task(gen.start_pool_watcher(team))
93
+
94
+ # Keep the orchestrator alive
95
+ while True:
96
+ await asyncio.sleep(3600)
97
+
98
+
99
+ @app.on_event("startup")
100
+ async def startup_event():
101
+ # Make sure the backend dir is on sys.path so generators (which are
102
+ # self-contained) and their relative imports keep working.
103
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
104
+ asyncio.create_task(populate_pool_background())
105
+
106
+
107
+ # --------------------------------------------------------------------------- #
108
+ # Environment validation #
109
+ # --------------------------------------------------------------------------- #
110
+
111
+ # Run this as soon as a worker imports the module. It only fires once
112
+ # (see app/_env.py::_is_loaded_marker_set) and gives a clear, single-line
113
+ # error instead of a 401 from Supabase ten seconds later.
114
+ assert_critical_env("SUPABASE_URL", "SUPABASE_ANON_KEY")
115
+
116
+
117
+ __all__ = ["app", "populate_pool_background"]
app/sandbox/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sandbox: in-process terminal that the OS-simulator frontend talks to.
2
+
3
+ A per-challenge temp workdir holds the files declared in the row's
4
+ ``files`` JSONB column. Built-in Unix tools (cat, ls, sha256sum, base64,
5
+ …) are reimplemented in Python so they work on Windows. External tools
6
+ (``python``, ``openssl`` …) are executed via subprocess ONLY if the
7
+ challenge row whitelists them.
8
+ """
9
+
10
+ from .workdir import get_or_create_workdir, safe_join
11
+ from .tools import shell_builtins
12
+ from .terminal import run_terminal_command
13
+
14
+ __all__ = [
15
+ "get_or_create_workdir",
16
+ "safe_join",
17
+ "shell_builtins",
18
+ "run_terminal_command",
19
+ ]
app/sandbox/terminal.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """In-process terminal command runner.
2
+
3
+ Parses a single command with ``shlex``, runs built-ins via
4
+ :mod:`app.sandbox.tools`, and runs external tools via subprocess — but
5
+ only if the challenge row whitelists them. ``python`` / ``python3`` are
6
+ always allowed so the student can run their own scripts.
7
+
8
+ Returns the standard ``{stdout, stderr, exitCode}`` dict (plus ``clear``
9
+ for the ``clear`` built-in). Used by :mod:`app.api.terminal`.
10
+ """
11
+
12
+ import os
13
+ import shlex
14
+ import subprocess
15
+ from typing import Optional
16
+
17
+ from .workdir import get_or_create_workdir
18
+ from .tools import shell_builtins
19
+
20
+
21
+ # External tools that are always allowed regardless of row whitelist.
22
+ ALWAYS_ALLOWED = {"python", "python3", "py"}
23
+
24
+
25
+ def parse_command(command: str) -> Optional[list[str]]:
26
+ """Return shlex-split tokens or None on a parse error."""
27
+ try:
28
+ return shlex.split(command)
29
+ except ValueError:
30
+ return None
31
+
32
+
33
+ async def run_terminal_command(team_role: str, challenge_id: str, command: str) -> dict:
34
+ """Execute a terminal command in the challenge's sandbox for real."""
35
+ command = (command or "").strip()
36
+
37
+ if not command:
38
+ return {"stdout": "", "stderr": "❌ أمر فارغ", "exitCode": 1}
39
+ if not challenge_id:
40
+ return {"stdout": "", "stderr": "❌ challengeId مفقود", "exitCode": 1}
41
+
42
+ workdir, row = await get_or_create_workdir(team_role, challenge_id)
43
+ allowed = (row or {}).get("tools_whitelist") or []
44
+
45
+ parts = parse_command(command)
46
+ if parts is None:
47
+ return {"stdout": "", "stderr": "❌ صيغة الأمر غير صحيحة", "exitCode": 2}
48
+ if not parts:
49
+ return {"stdout": "", "stderr": "", "exitCode": 0}
50
+
51
+ tool = parts[0]
52
+
53
+ # submit is handled client-side already; refuse here to avoid confusion
54
+ if tool == "submit":
55
+ return {
56
+ "stdout": "",
57
+ "stderr": "❌ استخدم نموذج الإجابة في اللوحة، أو اكتب submit في الـ terminal بعد ربطه بالـ backend.",
58
+ "exitCode": 1,
59
+ }
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
67
+
68
+ # Whitelist check (python/python3 are always allowed for user-authored scripts)
69
+ if tool not in allowed and tool not in ALWAYS_ALLOWED:
70
+ return {
71
+ "stdout": "",
72
+ "stderr": (
73
+ f"❌ '{tool}' غير مسموح في هذا التحدي.\n"
74
+ f"الأدوات المسموحة: {', '.join(allowed) if allowed else '(لا توجد)'}"
75
+ ),
76
+ "exitCode": 126,
77
+ }
78
+
79
+ # Run real subprocess with UTF-8 forced (Arabic in child process output)
80
+ try:
81
+ env = os.environ.copy()
82
+ env["PYTHONIOENCODING"] = "utf-8"
83
+ env["PYTHONUTF8"] = "1"
84
+ proc = subprocess.run(
85
+ parts,
86
+ cwd=workdir,
87
+ capture_output=True,
88
+ timeout=8,
89
+ env=env,
90
+ )
91
+ stdout = (proc.stdout or b"").decode("utf-8", errors="replace")
92
+ stderr = (proc.stderr or b"").decode("utf-8", errors="replace")
93
+ return {
94
+ "stdout": stdout[:8000],
95
+ "stderr": stderr[:3000],
96
+ "exitCode": proc.returncode,
97
+ }
98
+ except FileNotFoundError:
99
+ return {"stdout": "", "stderr": f"❌ '{tool}' غير مثبت على خادم الساندبوكس.", "exitCode": 127}
100
+ except subprocess.TimeoutExpired:
101
+ return {"stdout": "", "stderr": "⏱️ انتهت المهلة (8 ثوانٍ). قد يكون الأمر يدور في حلقة لا نهائية.", "exitCode": 124}
102
+ except Exception as e:
103
+ return {"stdout": "", "stderr": f"❌ خطأ: {e}", "exitCode": 1}
app/sandbox/tools.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Built-in Unix tools reimplemented in Python so they work on Windows.
2
+
3
+ These are what the OS simulator uses for cat / ls / sha256sum / base64 /
4
+ xxd / tr / … . External tools (python, openssl, …) are executed via
5
+ subprocess from :mod:`app.sandbox.terminal`.
6
+ """
7
+
8
+ import base64
9
+ import binascii
10
+ import hashlib
11
+ import os
12
+ from typing import Optional
13
+
14
+ from app.core.constants import HELP_TEXT
15
+
16
+
17
+ def shell_builtins(args: list[str], workdir: str) -> Optional[dict]:
18
+ """Run a built-in command. Returns a ``{stdout, stderr, exitCode}``
19
+ result dict, or ``None`` if the tool is not a built-in (caller should
20
+ fall through to subprocess)."""
21
+ tool = args[0] if args else ""
22
+ rest = args[1:]
23
+ try:
24
+ if tool == "cat":
25
+ if not rest:
26
+ return {"stdout": "", "stderr": "cat: missing filename", "exitCode": 1}
27
+ out, err = [], ""
28
+ for fname in rest:
29
+ p = os.path.join(workdir, fname.lstrip("/\\").replace("..", "_"))
30
+ if not os.path.isfile(p):
31
+ err += f"cat: {fname}: No such file\n"
32
+ continue
33
+ with open(p, "r", encoding="utf-8", errors="replace") as f:
34
+ out.append(f.read())
35
+ return {"stdout": "\n".join(out), "stderr": err, "exitCode": 0 if not err else 1}
36
+ if tool == "ls":
37
+ entries = sorted(os.listdir(workdir))
38
+ lines = []
39
+ for n in entries:
40
+ if n.startswith("."):
41
+ continue
42
+ p = os.path.join(workdir, n)
43
+ if os.path.isdir(p):
44
+ lines.append(f"<DIR> {n}")
45
+ else:
46
+ lines.append(f" {n}")
47
+ return {"stdout": "\n".join(lines), "stderr": "", "exitCode": 0}
48
+ if tool == "pwd":
49
+ return {"stdout": workdir, "stderr": "", "exitCode": 0}
50
+ if tool == "echo":
51
+ return {"stdout": " ".join(rest), "stderr": "", "exitCode": 0}
52
+ if tool == "whoami":
53
+ return {"stdout": os.environ.get("USERNAME") or os.environ.get("USER") or "student", "stderr": "", "exitCode": 0}
54
+ if tool == "clear":
55
+ return {"stdout": "\x1b[2J\x1b[H", "stderr": "", "exitCode": 0, "clear": True}
56
+ if tool == "help":
57
+ return {"stdout": HELP_TEXT, "stderr": "", "exitCode": 0}
58
+ if tool in ("sha256sum", "md5sum", "sha1sum"):
59
+ algo = {"sha256sum": "sha256", "md5sum": "md5", "sha1sum": "sha1"}[tool]
60
+ if not rest:
61
+ return {"stdout": "", "stderr": f"{tool}: missing filename", "exitCode": 1}
62
+ out, err = [], ""
63
+ for fname in rest:
64
+ p = os.path.join(workdir, fname.lstrip("/\\").replace("..", "_"))
65
+ if not os.path.isfile(p):
66
+ err += f"{tool}: {fname}: No such file\n"
67
+ continue
68
+ with open(p, "rb") as f:
69
+ h = hashlib.new(algo, f.read()).hexdigest()
70
+ out.append(f"{h} {fname}")
71
+ return {"stdout": "\n".join(out), "stderr": err, "exitCode": 0 if not err else 1}
72
+ if tool == "base64":
73
+ decode = "-d" in rest
74
+ args2 = [a for a in rest if a != "-d"]
75
+ if not args2:
76
+ import sys
77
+ data = sys.stdin.read() if not sys.stdin.isatty() else b""
78
+ if decode:
79
+ return {"stdout": base64.b64decode(data).decode("utf-8", "replace"), "stderr": "", "exitCode": 0}
80
+ return {"stdout": base64.b64encode(data).decode(), "stderr": "", "exitCode": 0}
81
+ out, err = [], ""
82
+ for fname in args2:
83
+ p = os.path.join(workdir, fname.lstrip("/\\").replace("..", "_"))
84
+ if not os.path.isfile(p):
85
+ err += f"base64: {fname}: No such file\n"
86
+ continue
87
+ with open(p, "rb") as f:
88
+ data = f.read()
89
+ if decode:
90
+ out.append(base64.b64decode(data).decode("utf-8", "replace"))
91
+ else:
92
+ out.append(base64.b64encode(data).decode())
93
+ return {"stdout": "\n".join(out), "stderr": err, "exitCode": 0 if not err else 1}
94
+ if tool == "xxd":
95
+ if not rest:
96
+ return {"stdout": "", "stderr": "xxd: missing filename", "exitCode": 1}
97
+ p = os.path.join(workdir, rest[0].lstrip("/\\").replace("..", "_"))
98
+ if not os.path.isfile(p):
99
+ return {"stdout": "", "stderr": f"xxd: {rest[0]}: No such file", "exitCode": 1}
100
+ with open(p, "rb") as f:
101
+ data = f.read()
102
+ return {"stdout": binascii.hexlify(data).decode(), "stderr": "", "exitCode": 0}
103
+ if tool == "tr":
104
+ if len(rest) < 2:
105
+ return {"stdout": "", "stderr": "tr: usage: tr SET1 SET2", "exitCode": 1}
106
+ set1, set2 = rest[0], rest[1]
107
+ if len(rest) > 2:
108
+ src = " ".join(rest[2:])
109
+ for a, b in zip(set1, set2):
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
app/sandbox/workdir.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-challenge temp workdir for the OS simulator.
2
+
3
+ Each (team_role, challenge_id) tuple gets its own tempdir; the row's
4
+ ``files`` JSONB is materialized into real files on first access. The
5
+ workdir is cached in-process so the user can `cd` / write scripts
6
+ across multiple terminal calls.
7
+ """
8
+
9
+ import os
10
+ import tempfile
11
+ import base64
12
+ from typing import Optional, Tuple
13
+
14
+ from app.services.supabase_service import fetch_scenario_by_id
15
+
16
+
17
+ # In-process cache: key -> (workdir_path, last_loaded_row_dict)
18
+ _workdirs: dict[str, str] = {}
19
+ _last_loaded_row: dict[str, dict] = {}
20
+
21
+
22
+ async def get_or_create_workdir(team_role: str, challenge_id: str) -> Tuple[str, dict]:
23
+ """Return (workdir_path, challenge_row) — cached per challenge.
24
+
25
+ If the row isn't in Supabase (e.g. ad-hoc training mode), an empty
26
+ workdir is created and an empty row dict is returned.
27
+ """
28
+ key = f"{team_role}:{challenge_id}"
29
+ if key in _workdirs and os.path.isdir(_workdirs[key]):
30
+ return _workdirs[key], _last_loaded_row.get(key, {})
31
+
32
+ row: Optional[dict] = None
33
+ if challenge_id:
34
+ try:
35
+ row = await fetch_scenario_by_id(team_role, challenge_id)
36
+ except Exception:
37
+ row = None
38
+ if row is None:
39
+ workdir = tempfile.mkdtemp(prefix=f"ca_{team_role}_{challenge_id[:8]}_")
40
+ _workdirs[key] = workdir
41
+ return workdir, {}
42
+
43
+ workdir = tempfile.mkdtemp(prefix=f"ca_{team_role}_{challenge_id[:8]}_")
44
+ files = row.get("files") or {}
45
+ for filename, b64 in files.items():
46
+ # Sanitize: /etc/shadow -> etc/shadow inside workdir
47
+ clean = filename.lstrip("/\\").replace("..", "_").replace("\\", "/")
48
+ target = os.path.join(workdir, clean)
49
+ os.makedirs(os.path.dirname(target) or workdir, exist_ok=True)
50
+ try:
51
+ data = base64.b64decode(b64)
52
+ with open(target, "wb") as f:
53
+ f.write(data)
54
+ except Exception:
55
+ pass
56
+ with open(os.path.join(workdir, ".challenge_id"), "w") as f:
57
+ f.write(challenge_id)
58
+ _workdirs[key] = workdir
59
+ _last_loaded_row[key] = row
60
+ return workdir, row
61
+
62
+
63
+ def safe_join(workdir: str, filename: str) -> str:
64
+ """Resolve ``filename`` inside ``workdir``; block path traversal."""
65
+ clean = (filename or "").lstrip("/\\").replace("..", "_").replace("\\", "/")
66
+ if not clean:
67
+ raise ValueError("empty filename")
68
+ target = os.path.normpath(os.path.join(workdir, clean))
69
+ workdir_abs = os.path.normpath(workdir)
70
+ if not target.startswith(workdir_abs):
71
+ raise ValueError("path traversal blocked")
72
+ return target
app/services/__init__.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Service layer: business logic and external I/O.
2
+
3
+ Modules in this package talk to Supabase, AI providers, and the file
4
+ system. They never import from :mod:`app.api` (the routers depend on
5
+ them, not the other way around).
6
+ """
7
+
8
+ from . import (
9
+ supabase_service,
10
+ challenge_loader,
11
+ scenario_service,
12
+ evaluator,
13
+ completion_service,
14
+ certificate_service,
15
+ file_storage,
16
+ )
17
+
18
+ from .supabase_service import (
19
+ supabase_headers,
20
+ scenario_table,
21
+ get_supabase_scenario_count,
22
+ fetch_scenario_by_id,
23
+ fetch_random_scenario_from_supabase,
24
+ delete_scenario_from_supabase,
25
+ insert_scenario_to_supabase,
26
+ )
27
+ from .challenge_loader import (
28
+ map_encryption_row_to_training,
29
+ map_webex_row_to_training,
30
+ map_code_fixing_row_to_training,
31
+ map_log_analysis_row_to_training,
32
+ map_vuln_hunter_row_to_training,
33
+ )
34
+ from .scenario_service import (
35
+ generate_scenario_from_groq,
36
+ generate_challenge_from_scenario,
37
+ generate_challenge_from_groq,
38
+ generate_and_store_scenario,
39
+ handle_background_replacement,
40
+ map_scenario_to_list_item,
41
+ attach_scenario_metadata,
42
+ )
43
+ from .evaluator import (
44
+ evaluate_training,
45
+ evaluate_web_exploitation,
46
+ evaluate_code_fix,
47
+ evaluate_log_analysis,
48
+ evaluate_vuln_hunter,
49
+ ai_evaluate_code_fix,
50
+ )
51
+ from .completion_service import (
52
+ record_user_completion,
53
+ count_completions,
54
+ )
55
+ from .certificate_service import (
56
+ handle_certificates,
57
+ download_certificate_pdf,
58
+ verify_certificate,
59
+ cert_progress,
60
+ make_verify_code,
61
+ build_cert_title,
62
+ category_label,
63
+ )
64
+ from .file_storage import (
65
+ upload_challenge_file,
66
+ )
67
+
68
+ __all__ = [
69
+ # modules
70
+ "supabase_service",
71
+ "challenge_loader",
72
+ "scenario_service",
73
+ "evaluator",
74
+ "completion_service",
75
+ "certificate_service",
76
+ "file_storage",
77
+ # supabase
78
+ "supabase_headers",
79
+ "scenario_table",
80
+ "get_supabase_scenario_count",
81
+ "fetch_scenario_by_id",
82
+ "fetch_random_scenario_from_supabase",
83
+ "delete_scenario_from_supabase",
84
+ "insert_scenario_to_supabase",
85
+ # challenge_loader
86
+ "map_encryption_row_to_training",
87
+ "map_webex_row_to_training",
88
+ "map_code_fixing_row_to_training",
89
+ "map_log_analysis_row_to_training",
90
+ "map_vuln_hunter_row_to_training",
91
+ # scenario_service
92
+ "generate_scenario_from_groq",
93
+ "generate_challenge_from_scenario",
94
+ "generate_challenge_from_groq",
95
+ "generate_and_store_scenario",
96
+ "handle_background_replacement",
97
+ "map_scenario_to_list_item",
98
+ "attach_scenario_metadata",
99
+ # evaluator
100
+ "evaluate_training",
101
+ "evaluate_web_exploitation",
102
+ "evaluate_code_fix",
103
+ "evaluate_log_analysis",
104
+ "evaluate_vuln_hunter",
105
+ "ai_evaluate_code_fix",
106
+ # completion
107
+ "record_user_completion",
108
+ "count_completions",
109
+ # certificate
110
+ "handle_certificates",
111
+ "download_certificate_pdf",
112
+ "verify_certificate",
113
+ "cert_progress",
114
+ "make_verify_code",
115
+ "build_cert_title",
116
+ "category_label",
117
+ # file_storage
118
+ "upload_challenge_file",
119
+ ]
app/services/certificate_service.py ADDED
@@ -0,0 +1,464 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Certificate service: issuance, verification, PDF rendering."""
2
+
3
+ import io
4
+ import os
5
+ import random
6
+ import re
7
+ import tempfile
8
+ from datetime import datetime
9
+ from typing import Optional
10
+
11
+ import httpx
12
+ from fastapi import HTTPException
13
+ from fastapi.responses import Response
14
+
15
+ from app.core.constants import CERT_REQUIRED_COMPLETIONS, CERT_VERIFY_BASE_URL
16
+ from app.core.config import SUPABASE_URL, SUPABASE_ANON_KEY
17
+ from app.services.supabase_service import supabase_headers
18
+ from app.services.completion_service import count_completions
19
+
20
+
21
+ # --------------------------------------------------------------------------- #
22
+ # Verifiability #
23
+ # --------------------------------------------------------------------------- #
24
+
25
+ def make_verify_code(user_id: str) -> str:
26
+ """Generate a short, human-friendly verify code (8-10 chars)."""
27
+ alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" # no 0/1/I/O confusion
28
+ suffix = "".join(random.choice(alphabet) for _ in range(4))
29
+ head = re.sub(r"[^a-f0-9]", "", (user_id or "").lower())[:6].upper()
30
+ if len(head) < 4:
31
+ head = (head + "USER1")[:6]
32
+ return f"APEX-{head}-{suffix}"
33
+
34
+
35
+ def build_cert_title(category: str) -> str:
36
+ cat = (category or "").strip().lower()
37
+ mapping = {
38
+ "vulnerability-hunter": "Cybersecurity Vulnerability Hunter",
39
+ "code-fixing": "Secure Code Specialist",
40
+ "log-analysis": "SOC Log Analysis Operator",
41
+ "web": "Web Application Security",
42
+ "crypto": "Applied Cryptography",
43
+ }
44
+ return mapping.get(cat, category or "Cybersecurity")
45
+
46
+
47
+ def category_label(category: str, lang: str = "en") -> str:
48
+ cat = (category or "").strip().lower()
49
+ ar_map = {
50
+ "vulnerability-hunter": "صياد الثغرات",
51
+ "code-fixing": "إصلاح الأكواد",
52
+ "log-analysis": "تحليل السجلات",
53
+ "web": "أمن الويب",
54
+ "crypto": "التشفير",
55
+ }
56
+ en_map = {
57
+ "vulnerability-hunter": "Vulnerability Hunter",
58
+ "code-fixing": "Code Fixing",
59
+ "log-analysis": "Log Analysis",
60
+ "web": "Web Security",
61
+ "crypto": "Cryptography",
62
+ }
63
+ return (ar_map if lang == "ar" else en_map).get(cat, category or "")
64
+
65
+
66
+ def _format_date_en(dt_value) -> str:
67
+ if not dt_value:
68
+ return ""
69
+ try:
70
+ if isinstance(dt_value, str):
71
+ s = dt_value.replace("Z", "+00:00")
72
+ dt = datetime.fromisoformat(s)
73
+ else:
74
+ dt = dt_value
75
+ return dt.strftime("%B %d, %Y")
76
+ except Exception:
77
+ return str(dt_value)[:10]
78
+
79
+
80
+ def _format_date_ar(dt_value) -> str:
81
+ if not dt_value:
82
+ return ""
83
+ try:
84
+ if isinstance(dt_value, str):
85
+ s = dt_value.replace("Z", "+00:00")
86
+ dt = datetime.fromisoformat(s)
87
+ else:
88
+ dt = dt_value
89
+ months = ["يناير", "فبراير", "مارس", "أبريل", "مايو", "يونيو",
90
+ "يوليو", "أغسطس", "سبتمبر", "أكتوبر", "نوفمبر", "ديسمبر"]
91
+ return f"{dt.day} {months[dt.month-1]} {dt.year}"
92
+ except Exception:
93
+ return str(dt_value)[:10]
94
+
95
+
96
+ # --------------------------------------------------------------------------- #
97
+ # PDF generation #
98
+ # --------------------------------------------------------------------------- #
99
+
100
+ def build_certificate_pdf(
101
+ user_name: str,
102
+ category: str,
103
+ title: str,
104
+ issue_date: str,
105
+ verify_code: str,
106
+ cert_id: str,
107
+ lang: str = "en",
108
+ ) -> bytes:
109
+ """Render the certificate as a real PDF (landscape A4) using reportlab.
110
+
111
+ Layout (LTR, Latin-friendly typography for English copy, Arabic for ar):
112
+ - top brand bar
113
+ - big title "CERTIFICATE OF COMPLETION" / "شهادة إتمام"
114
+ - intro line
115
+ - student name (large)
116
+ - "has successfully completed" / "أكمل بنجاح"
117
+ - category name (large)
118
+ - summary line
119
+ - footer: QR code (left), date (middle), signature (right)
120
+ """
121
+ from reportlab.lib.pagesizes import landscape, A4
122
+ from reportlab.lib import colors
123
+ from reportlab.lib.units import mm
124
+ from reportlab.pdfgen import canvas
125
+ import qrcode
126
+ from qrcode.image.pil import PilImage
127
+
128
+ width, height = landscape(A4)
129
+ buf = io.BytesIO()
130
+ c = canvas.Canvas(buf, pagesize=landscape(A4))
131
+
132
+ # ---- Background (cream / parchment) ----
133
+ c.setFillColorRGB(0.99, 0.98, 0.94)
134
+ c.rect(0, 0, width, height, fill=1, stroke=0)
135
+
136
+ # ---- Outer border (double) ----
137
+ c.setStrokeColorRGB(0.13, 0.18, 0.32)
138
+ c.setLineWidth(4)
139
+ c.rect(12 * mm, 12 * mm, width - 24 * mm, height - 24 * mm)
140
+ c.setLineWidth(1.2)
141
+ c.rect(16 * mm, 16 * mm, width - 32 * mm, height - 32 * mm)
142
+
143
+ # ---- Corner ornaments ----
144
+ c.setFillColorRGB(0.13, 0.18, 0.32)
145
+ for cx, cy in [
146
+ (18 * mm, height - 18 * mm),
147
+ (width - 18 * mm, height - 18 * mm),
148
+ (18 * mm, 18 * mm),
149
+ (width - 18 * mm, 18 * mm),
150
+ ]:
151
+ c.circle(cx, cy, 3 * mm, fill=1, stroke=0)
152
+ c.setStrokeColorRGB(0.92, 0.72, 0.18)
153
+ c.setLineWidth(1.4)
154
+ c.circle(cx, cy, 5 * mm, fill=0, stroke=1)
155
+ c.setStrokeColorRGB(0.13, 0.18, 0.32)
156
+ c.setLineWidth(4)
157
+
158
+ # ---- Top brand bar ----
159
+ c.setFillColorRGB(0.13, 0.18, 0.32)
160
+ c.rect(0, height - 26 * mm, width, 14 * mm, fill=1, stroke=0)
161
+ c.setFillColorRGB(0.92, 0.72, 0.18)
162
+ c.rect(0, height - 27 * mm, width, 1.5 * mm, fill=1, stroke=0)
163
+ c.setFillColorRGB(1, 1, 1)
164
+ c.setFont("Helvetica-Bold", 22)
165
+ c.drawString(20 * mm, height - 21 * mm, "◆ CYBERARENA")
166
+ c.setFont("Helvetica", 11)
167
+ label = "ALPHA TEAM · CYBERSECURITY TRAINING"
168
+ c.drawRightString(width - 20 * mm, height - 21 * mm, label)
169
+
170
+ # ---- Title ----
171
+ if lang == "ar":
172
+ title_text = "شهادة إتمام"
173
+ subtitle_text = "CERTIFICATE OF COMPLETION"
174
+ else:
175
+ title_text = "CERTIFICATE OF COMPLETION"
176
+ subtitle_text = ""
177
+ c.setFillColorRGB(0.13, 0.18, 0.32)
178
+ c.setFont("Helvetica-Bold", 38)
179
+ c.drawCentredString(width / 2, height - 56 * mm, title_text)
180
+ if subtitle_text:
181
+ c.setFillColorRGB(0.45, 0.45, 0.55)
182
+ c.setFont("Helvetica", 12)
183
+ c.drawCentredString(width / 2, height - 64 * mm, subtitle_text)
184
+
185
+ # ---- Intro ----
186
+ if lang == "ar":
187
+ intro = "نشهد بأن الطالب"
188
+ else:
189
+ intro = "This is to certify that"
190
+ c.setFillColorRGB(0.25, 0.25, 0.3)
191
+ c.setFont("Helvetica", 14)
192
+ c.drawCentredString(width / 2, height - 80 * mm, intro)
193
+
194
+ # ---- Student name ----
195
+ c.setFillColorRGB(0.13, 0.18, 0.32)
196
+ c.setFont("Helvetica-Bold", 30)
197
+ c.drawCentredString(width / 2, height - 96 * mm, (user_name or "Trainee")[:60])
198
+
199
+ # ---- Underline ----
200
+ c.setStrokeColorRGB(0.92, 0.72, 0.18)
201
+ c.setLineWidth(1.4)
202
+ name_w = c.stringWidth((user_name or "Trainee")[:60], "Helvetica-Bold", 30)
203
+ c.line(width/2 - name_w/2 - 4, height - 100 * mm, width/2 + name_w/2 + 4, height - 100 * mm)
204
+ c.setStrokeColorRGB(0.13, 0.18, 0.32)
205
+ c.setLineWidth(4)
206
+
207
+ # ---- Body line ----
208
+ if lang == "ar":
209
+ body = "قد أتمّ بنجاح جميع التحديات العملية في مجال:"
210
+ else:
211
+ body = "has successfully completed all interactive challenges and labs in:"
212
+ c.setFillColorRGB(0.25, 0.25, 0.3)
213
+ c.setFont("Helvetica", 13)
214
+ c.drawCentredString(width / 2, height - 116 * mm, body)
215
+
216
+ # ---- Category ----
217
+ cat_label = category_label(category, lang=lang)
218
+ c.setFillColorRGB(0.92, 0.45, 0.10)
219
+ c.setFont("Helvetica-Bold", 24)
220
+ c.drawCentredString(width / 2, height - 132 * mm, cat_label)
221
+
222
+ # ---- Sub-summary ----
223
+ if lang == "ar":
224
+ sub = f"المستوى: {title}"
225
+ else:
226
+ sub = f"Specialization: {title}"
227
+ c.setFillColorRGB(0.35, 0.35, 0.4)
228
+ c.setFont("Helvetica-Oblique", 11)
229
+ c.drawCentredString(width / 2, height - 142 * mm, sub)
230
+
231
+ # ---- QR code (left) ----
232
+ qr_payload = (CERT_VERIFY_BASE_URL or "https://cyberarena.app/verify") + "/" + verify_code
233
+ qr = qrcode.QRCode(box_size=4, border=1)
234
+ qr.add_data(qr_payload)
235
+ qr.make(fit=True)
236
+ qr_img = qr.make_image(fill_color="#1d2440", back_color="white", image_factory=PilImage)
237
+ qr_path = os.path.join(tempfile.gettempdir(), f"ca_qr_{verify_code.replace(' ', '_')}.png")
238
+ qr_img.save(qr_path, format="PNG")
239
+ qr_size = 28 * mm
240
+ qr_x = 32 * mm
241
+ qr_y = 32 * mm
242
+ c.drawImage(
243
+ qr_path,
244
+ qr_x,
245
+ qr_y,
246
+ width=qr_size,
247
+ height=qr_size,
248
+ preserveAspectRatio=True,
249
+ mask="auto",
250
+ )
251
+ c.setFillColorRGB(0.13, 0.18, 0.32)
252
+ c.setFont("Helvetica-Bold", 9)
253
+ qr_caption = "SCAN TO VERIFY" if lang == "en" else "امسح للتحقق"
254
+ c.drawCentredString(qr_x + qr_size / 2, qr_y - 4 * mm, qr_caption)
255
+ c.setFont("Helvetica", 8)
256
+ c.setFillColorRGB(0.35, 0.35, 0.4)
257
+ c.drawCentredString(qr_x + qr_size / 2, qr_y - 7.5 * mm, verify_code)
258
+
259
+ # ---- Date (center) ----
260
+ date_str = (_format_date_en(issue_date) if lang == "en" else _format_date_ar(issue_date))
261
+ if not date_str:
262
+ date_str = "—"
263
+ cx = width / 2
264
+ c.setFillColorRGB(0.13, 0.18, 0.32)
265
+ c.setFont("Helvetica-Bold", 11)
266
+ c.drawCentredString(cx, 36 * mm, "ISSUE DATE" if lang == "en" else "تاريخ الإصدار")
267
+ c.setFont("Helvetica", 12)
268
+ c.setFillColorRGB(0.25, 0.25, 0.3)
269
+ c.drawCentredString(cx, 28 * mm, date_str)
270
+ c.setStrokeColorRGB(0.13, 0.18, 0.32)
271
+ c.setLineWidth(0.6)
272
+ c.line(cx - 22 * mm, 24 * mm, cx + 22 * mm, 24 * mm)
273
+ c.setFillColorRGB(0.45, 0.45, 0.55)
274
+ c.setFont("Helvetica", 9)
275
+ sign_label = "Alpha Team Academic Board" if lang == "en" else "المجلس الأكاديمي · فريق ألفا"
276
+ c.drawCentredString(cx, 20 * mm, sign_label)
277
+
278
+ # ---- Certificate ID (right) ----
279
+ rx = width - 60 * mm
280
+ c.setFillColorRGB(0.13, 0.18, 0.32)
281
+ c.setFont("Helvetica-Bold", 9)
282
+ c.drawString(rx, 44 * mm, "CERTIFICATE ID" if lang == "en" else "رقم الشهادة")
283
+ c.setFont("Helvetica", 9)
284
+ c.setFillColorRGB(0.25, 0.25, 0.3)
285
+ c.drawString(rx, 39 * mm, str(cert_id)[:36])
286
+ c.setFont("Helvetica-Bold", 9)
287
+ c.setFillColorRGB(0.13, 0.18, 0.32)
288
+ c.drawString(rx, 32 * mm, "VERIFICATION" if lang == "en" else "رمز التحقق")
289
+ c.setFont("Helvetica", 9)
290
+ c.setFillColorRGB(0.25, 0.25, 0.3)
291
+ c.drawString(rx, 27 * mm, verify_code)
292
+
293
+ c.showPage()
294
+ c.save()
295
+ return buf.getvalue()
296
+
297
+
298
+ # --------------------------------------------------------------------------- #
299
+ # HTTP handlers (called by the api/certificates router) #
300
+ # --------------------------------------------------------------------------- #
301
+
302
+ async def handle_certificates(req) -> dict:
303
+ headers = {
304
+ "apikey": SUPABASE_ANON_KEY,
305
+ "Authorization": f"Bearer {SUPABASE_ANON_KEY}",
306
+ "Content-Type": "application/json",
307
+ "Prefer": "return=representation",
308
+ }
309
+
310
+ if req.action == "list":
311
+ url = f"{SUPABASE_URL}/rest/v1/certificates?select=*"
312
+ async with httpx.AsyncClient() as client:
313
+ resp = await client.get(url, headers=headers)
314
+ if resp.status_code != 200:
315
+ raise HTTPException(status_code=resp.status_code, detail="Failed to fetch certificates")
316
+ certs = resp.json()
317
+ user_certs = [c for c in certs if str(c.get("user_id")) == str(req.user_id)]
318
+ return {"certificates": user_certs}
319
+
320
+ if req.action == "issue":
321
+ async with httpx.AsyncClient() as client:
322
+ # 1) already-issued?
323
+ check_url = (
324
+ f"{SUPABASE_URL}/rest/v1/certificates?user_id=eq.{req.user_id}"
325
+ f"&category=eq.{req.category}&select=*&limit=1"
326
+ )
327
+ check_resp = await client.get(check_url, headers=headers)
328
+ if check_resp.status_code == 200 and check_resp.json():
329
+ return {"status": "already_issued", "certificate": check_resp.json()[0]}
330
+
331
+ # 2) Eligibility
332
+ completions = await count_completions(req.user_id, req.category or "")
333
+ if completions < CERT_REQUIRED_COMPLETIONS:
334
+ return {
335
+ "status": "not_eligible",
336
+ "error": "completion_threshold_not_met",
337
+ "message": (
338
+ f"You need {CERT_REQUIRED_COMPLETIONS} completed challenges "
339
+ f"in {req.category} to earn this certificate "
340
+ f"(you currently have {completions})."
341
+ ),
342
+ "completions": completions,
343
+ "required": CERT_REQUIRED_COMPLETIONS,
344
+ }
345
+
346
+ # 3) User name
347
+ user_name = ""
348
+ try:
349
+ users_resp = await client.get(
350
+ f"{SUPABASE_URL}/rest/v1/users?id=eq.{req.user_id}&select=name&limit=1",
351
+ headers=headers,
352
+ )
353
+ if users_resp.status_code == 200 and users_resp.json():
354
+ user_name = (users_resp.json()[0].get("name") or "").strip()
355
+ except Exception:
356
+ pass
357
+
358
+ # 4) Issue
359
+ verify_code = req.verify_code or make_verify_code(req.user_id)
360
+ category = req.category or "general"
361
+ payload = {
362
+ "user_id": req.user_id,
363
+ "user_name": user_name,
364
+ "category": category,
365
+ "title": (
366
+ req.details.get("title")
367
+ if isinstance(req.details, dict) and req.details.get("title")
368
+ else build_cert_title(category)
369
+ ),
370
+ "verify_code": verify_code,
371
+ "issue_date": datetime.utcnow().isoformat() + "Z",
372
+ "details": req.details or {"issue_reason": "50 challenges completed"},
373
+ }
374
+ resp = await client.post(
375
+ f"{SUPABASE_URL}/rest/v1/certificates",
376
+ headers={**headers, "Prefer": "return=representation"},
377
+ json=payload,
378
+ )
379
+ if resp.status_code not in (200, 201):
380
+ raise HTTPException(
381
+ status_code=resp.status_code,
382
+ detail=f"Failed to issue certificate: {resp.text[:200]}",
383
+ )
384
+ certs = resp.json()
385
+ return {"status": "issued", "certificate": certs[0] if certs else {}}
386
+
387
+ raise HTTPException(status_code=400, detail="Invalid action")
388
+
389
+
390
+ async def download_certificate_pdf(cert_id: str, lang: str = "en") -> Response:
391
+ if not SUPABASE_URL or not SUPABASE_ANON_KEY:
392
+ raise HTTPException(status_code=503, detail="Supabase not configured")
393
+ url = f"{SUPABASE_URL}/rest/v1/certificates?id=eq.{cert_id}&select=*&limit=1"
394
+ try:
395
+ async with httpx.AsyncClient(timeout=15) as client:
396
+ r = await client.get(url, headers=supabase_headers())
397
+ if r.status_code != 200:
398
+ raise HTTPException(status_code=502, detail=f"Supabase error: {r.text[:200]}")
399
+ rows = r.json()
400
+ except HTTPException:
401
+ raise
402
+ except Exception as e:
403
+ raise HTTPException(status_code=502, detail=str(e))
404
+ if not rows:
405
+ raise HTTPException(status_code=404, detail="الشهادة غير موجودة / Certificate not found")
406
+ cert = rows[0]
407
+
408
+ pdf_bytes = build_certificate_pdf(
409
+ user_name=cert.get("user_name") or "",
410
+ category=cert.get("category") or "",
411
+ title=cert.get("title") or build_cert_title(cert.get("category", "")),
412
+ issue_date=cert.get("issue_date") or cert.get("issued_at") or "",
413
+ verify_code=cert.get("verify_code") or "",
414
+ cert_id=cert.get("id") or cert_id,
415
+ lang=lang if lang in ("ar", "en") else "en",
416
+ )
417
+ cat = (cert.get("category") or "CyberArena").replace(" ", "_")
418
+ fname = f"CyberArena-Certificate-{cat}-{cert.get('verify_code', cert_id)[:20]}.pdf"
419
+ return Response(
420
+ content=pdf_bytes,
421
+ media_type="application/pdf",
422
+ headers={
423
+ "Content-Disposition": f'attachment; filename="{fname}"',
424
+ "Cache-Control": "no-store",
425
+ },
426
+ )
427
+
428
+
429
+ async def verify_certificate(verify_code: str) -> dict:
430
+ if not SUPABASE_URL or not SUPABASE_ANON_KEY:
431
+ raise HTTPException(status_code=503, detail="Supabase not configured")
432
+ url = f"{SUPABASE_URL}/rest/v1/certificates?verify_code=eq.{verify_code}&select=*&limit=1"
433
+ try:
434
+ async with httpx.AsyncClient(timeout=15) as client:
435
+ r = await client.get(url, headers=supabase_headers())
436
+ if r.status_code != 200:
437
+ raise HTTPException(status_code=502, detail=f"Supabase error: {r.text[:200]}")
438
+ rows = r.json()
439
+ except HTTPException:
440
+ raise
441
+ except Exception as e:
442
+ raise HTTPException(status_code=502, detail=str(e))
443
+ if not rows:
444
+ return {"valid": False, "verify_code": verify_code, "message": "Certificate not found"}
445
+ c = rows[0]
446
+ return {
447
+ "valid": True,
448
+ "verify_code": c.get("verify_code"),
449
+ "certificate_id": c.get("id"),
450
+ "user_name": c.get("user_name"),
451
+ "category": c.get("category"),
452
+ "title": c.get("title") or build_cert_title(c.get("category", "")),
453
+ "issue_date": c.get("issue_date") or c.get("issued_at"),
454
+ }
455
+
456
+
457
+ async def cert_progress(user_id: str, category: str) -> dict:
458
+ n = await count_completions(user_id, category)
459
+ return {
460
+ "category": category,
461
+ "completions": n,
462
+ "required": CERT_REQUIRED_COMPLETIONS,
463
+ "ready": n >= CERT_REQUIRED_COMPLETIONS,
464
+ }
app/services/challenge_loader.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Map raw Supabase rows to the ``TrainingData`` shape the frontend expects.
2
+
3
+ Each ``_map_*_row_to_training(row, team_role) -> dict`` function knows
4
+ the columns of one challenge table and returns a consistent object so
5
+ the frontend editor can render any challenge type.
6
+ """
7
+
8
+ import base64 as _b64
9
+
10
+
11
+ _CODE_LANG_BY_EXT = {
12
+ "html": "html", "js": "javascript", "ts": "typescript",
13
+ "py": "python", "json": "json", "csv": "text",
14
+ "pem": "text", "log": "text", "txt": "text", "bin": "binary",
15
+ }
16
+
17
+
18
+ def _infer_code_language(filename, team_role: str) -> str:
19
+ if not filename:
20
+ return "text"
21
+ ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
22
+ return _CODE_LANG_BY_EXT.get(ext, "text")
23
+
24
+
25
+ def map_encryption_row_to_training(row: dict, team_role: str) -> dict:
26
+ """Map a row from ``encryption_challenges`` to TrainingData."""
27
+ files = row.get("files") or {}
28
+ file_meta = row.get("file_metadata") or {}
29
+ first_filename = next(iter(files), None)
30
+ code = ""
31
+ if first_filename:
32
+ try:
33
+ code = _b64.b64decode(files[first_filename]).decode("utf-8", errors="replace")
34
+ except Exception:
35
+ code = ""
36
+
37
+ return {
38
+ "id": row.get("id"),
39
+ "scenarioId": row.get("id"),
40
+ "title": row.get("title", ""),
41
+ "story": row.get("story", ""),
42
+ # Challenge type is always 'crypto' for rows coming from
43
+ # encryption_challenges — the ``module`` column is no longer
44
+ # authoritative after the 011 normalization migration.
45
+ "type": "crypto",
46
+ "topic": row.get("topic") or row.get("module", "encryption-basics"),
47
+ "task": row.get("task_outline", ""),
48
+ "code": code,
49
+ "codeLanguage": _infer_code_language(first_filename, team_role),
50
+ "htmlPreview": code if (first_filename or "").endswith(".html") else None,
51
+ "logData": code if (first_filename or "").endswith((".log", ".txt")) else None,
52
+ "configData": code if (first_filename or "").endswith((".json", ".csv", ".pem")) else None,
53
+ "vulnerabilityLocation": None,
54
+ "hints": row.get("hints") or [],
55
+ "expectedAnswer": row.get("flag_preview", ""), # CyberArena{...}
56
+ "expectedAnswerHash": row.get("flag_hash", ""), # server-side check
57
+ "explanation": "العلم يظهر في مخرجات الطرفية بعد تنفيذ الأمر الصحيح.",
58
+ "xpReward": row.get("xp_reward", 100),
59
+ "difficulty": row.get("difficulty", "متوسط"),
60
+ "files": files,
61
+ "fileMetadata": file_meta,
62
+ "commandOutputs": row.get("command_outputs") or {},
63
+ "toolsWhitelist": row.get("tools_whitelist") or [],
64
+ "challengeType": "crypto",
65
+ }
66
+
67
+
68
+ def map_webex_row_to_training(row: dict, team_role: str) -> dict:
69
+ """Map a row from ``web_exploitation_challenges`` to TrainingData (v2)."""
70
+ files = row.get("files") or {}
71
+ file_meta = row.get("file_metadata") or {}
72
+ html_preview = row.get("html_preview") or ""
73
+ code_view = row.get("code_view") or ""
74
+ expected_payload = row.get("expected_payload") or ""
75
+ exploits_accepted = row.get("exploits_accepted") or []
76
+
77
+ primary_exploit = exploits_accepted[0] if exploits_accepted else expected_payload
78
+ expected_answer = f"{primary_exploit}|{row.get('flag_preview', '')}"
79
+
80
+ return {
81
+ "id": row.get("id"),
82
+ "scenarioId": row.get("id"),
83
+ "title": row.get("title", ""),
84
+ "story": row.get("story", ""),
85
+ # Challenge type is always 'web' for rows coming from
86
+ # web_exploitation_challenges — see the 011 normalization
87
+ # migration. The specific vuln family (xss, sqli, ...) lives
88
+ # in the ``topic`` column now.
89
+ "type": "web",
90
+ "topic": row.get("topic") or row.get("module", "xss"),
91
+ "vulnType": row.get("vuln_type", ""),
92
+ "task": row.get("task_outline", ""),
93
+
94
+ # v2 lab content
95
+ "code": html_preview,
96
+ "codeLanguage": "html",
97
+ "htmlPreview": html_preview,
98
+ "codeView": code_view,
99
+ "logData": None,
100
+ "configData": None,
101
+ "vulnerabilityLocation": f"نوع الثغرة: {row.get('vuln_type', '')} — ابحث عن الـ sink الخطير في الـ HTML",
102
+
103
+ # v2 validation metadata
104
+ "sinkType": row.get("sink_type", ""),
105
+ "validationPattern": row.get("validation_pattern", ""),
106
+ "exploitsAccepted": exploits_accepted,
107
+ # NOTE: secret_marker is NEVER sent to the client (security)
108
+ # Backend uses it during /api/training/evaluate-web
109
+
110
+ "hints": row.get("hints") or [],
111
+ "expectedAnswer": expected_answer,
112
+ "expectedAnswerHash": row.get("flag_hash", ""),
113
+ "expectedPayload": expected_payload,
114
+ "explanation": (
115
+ "الحل الصحيح: حقن payload يستغل الـ sink الموضّح في الكود المصدري. "
116
+ "أي vector مقبول من نفس عائلة الثغرة (مثلاً: "
117
+ f"{', '.join(exploits_accepted[:3]) if exploits_accepted else 'XSS vector'}"
118
+ ")."
119
+ ),
120
+ "xpReward": row.get("xp_reward", 100),
121
+ "difficulty": row.get("difficulty", "متوسط"),
122
+ "files": files,
123
+ "fileMetadata": file_meta,
124
+ "commandOutputs": row.get("command_outputs") or {},
125
+ "toolsWhitelist": row.get("tools_whitelist") or [],
126
+ "challengeType": "web",
127
+ "labKind": row.get("lab_kind", "iframe"),
128
+ }
129
+
130
+
131
+ def map_code_fixing_row_to_training(row: dict, team_role: str) -> dict:
132
+ """Map a row from ``code_fixing_challenges`` to TrainingData."""
133
+ return {
134
+ "id": row.get("id"),
135
+ "scenarioId": row.get("id"),
136
+ "title": row.get("title", ""),
137
+ "story": row.get("story", ""),
138
+ "type": "code-fixing",
139
+ "topic": row.get("topic") or row.get("module", "code-fixing"),
140
+ "task": row.get("task_outline", ""),
141
+
142
+ # Additional fields for code-fixing
143
+ "language": row.get("language", "PYTHON"),
144
+ "vulnerable_code": row.get("vulnerable_code", ""),
145
+ "vulnerability_type": row.get("vulnerability_type", ""),
146
+ "vulnerability_description": row.get("vulnerability_description", ""),
147
+ "difficulty": row.get("difficulty", "متوسط"),
148
+ "xpReward": row.get("xp_reward", 150),
149
+ "hints": row.get("hints") or [],
150
+ }
151
+
152
+
153
+ def map_log_analysis_row_to_training(row: dict, team_role: str) -> dict:
154
+ """Map a row from ``log_analysis_challenges`` to TrainingData."""
155
+ from app.core.config import SUPABASE_URL
156
+ storage_path = row.get("storage_path", "")
157
+ is_inline = storage_path.startswith("inline://")
158
+ actual_path = storage_path.replace("inline://", "") if is_inline else storage_path
159
+
160
+ log_url = ""
161
+ if not is_inline and SUPABASE_URL and actual_path:
162
+ log_url = f"{SUPABASE_URL}/storage/v1/object/public/log-analysis-files/{actual_path}"
163
+
164
+ return {
165
+ "id": row.get("id"),
166
+ "scenarioId": row.get("id"),
167
+ "title": row.get("title", ""),
168
+ "story": row.get("story", ""),
169
+ "type": "log-analysis",
170
+ "topic": row.get("topic") or row.get("module", "log-analysis"),
171
+ "task": row.get("task_outline", ""),
172
+ # Log-analysis specific
173
+ "log_type": row.get("log_type", "auth"),
174
+ "storage_path": storage_path,
175
+ "log_url": log_url,
176
+ "is_inline": is_inline,
177
+ "file_size_bytes": row.get("file_size_bytes", 0),
178
+ "vulnerability_description": row.get("vulnerability_description", ""),
179
+ "difficulty": row.get("difficulty", "متوسط"),
180
+ "xpReward": row.get("xp_reward", 150),
181
+ "hints": row.get("hints") or [],
182
+ }
183
+
184
+
185
+ def map_vuln_hunter_row_to_training(row: dict, team_role: str) -> dict:
186
+ """Map a row from ``vulnerability_hunter_challenges`` to TrainingData.
187
+
188
+ Consumed by the Cyberpunk-themed VulnerabilityHunterEditor.
189
+ """
190
+ return {
191
+ "id": row.get("id"),
192
+ "scenarioId": row.get("id"),
193
+ "title": row.get("title", ""),
194
+ "story": row.get("story", ""),
195
+ "type": "vulnerability-hunter",
196
+ "topic": row.get("topic") or row.get("module", "vulnerability-hunter"),
197
+ "task": row.get("task_outline", ""),
198
+ "language": row.get("language", "PYTHON"),
199
+ "vulnerable_code": row.get("vulnerable_code", ""),
200
+ "vulnerability_type": row.get("vulnerability_type", ""),
201
+ "vulnerability_class": row.get("vulnerability_class", ""),
202
+ "vulnerability_description": row.get("vulnerability_description", ""),
203
+ "difficulty": row.get("difficulty", "متوسط"),
204
+ "xpReward": row.get("xp_reward", 150),
205
+ "hints": row.get("hints") or [],
206
+ }
app/services/completion_service.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-category completion tracker — powers certificate eligibility.
2
+
3
+ Idempotent via ``UNIQUE(user_id, category, challenge_id)`` so duplicate
4
+ ``/api/training/solved`` calls never double-count.
5
+ """
6
+
7
+ from typing import Optional
8
+
9
+ import httpx
10
+
11
+ from app.core.config import SUPABASE_URL, SUPABASE_ANON_KEY
12
+ from app.services.supabase_service import supabase_headers
13
+
14
+
15
+ async def record_user_completion(
16
+ user_id: Optional[str],
17
+ team_role: str,
18
+ category: str,
19
+ module: str,
20
+ challenge_id: str,
21
+ xp_awarded: int,
22
+ ):
23
+ """Insert (or ignore) a row in ``user_completions``."""
24
+ if not user_id:
25
+ return
26
+ if not (SUPABASE_URL and SUPABASE_ANON_KEY):
27
+ return
28
+ headers = supabase_headers(content_type=True)
29
+ headers["Prefer"] = "return=minimal,resolution=ignore-duplicates"
30
+ payload = {
31
+ "user_id": user_id,
32
+ "category": category,
33
+ "module": module,
34
+ "challenge_id": challenge_id,
35
+ "xp_awarded": int(xp_awarded or 0),
36
+ }
37
+ try:
38
+ async with httpx.AsyncClient(timeout=10) as client:
39
+ r = await client.post(
40
+ f"{SUPABASE_URL}/rest/v1/user_completions",
41
+ json=payload,
42
+ headers=headers,
43
+ )
44
+ if r.status_code not in (200, 201):
45
+ print(f"[completions] insert failed {r.status_code}: {r.text[:200]}")
46
+ except Exception as e:
47
+ print(f"[completions] insert exception: {e}")
48
+
49
+
50
+ async def count_completions(user_id: str, category: str) -> int:
51
+ if not (SUPABASE_URL and SUPABASE_ANON_KEY):
52
+ return 0
53
+ url = (
54
+ f"{SUPABASE_URL}/rest/v1/user_completions"
55
+ f"?user_id=eq.{user_id}&category=eq.{category}&select=id"
56
+ )
57
+ try:
58
+ async with httpx.AsyncClient(timeout=10) as client:
59
+ r = await client.get(url, headers=supabase_headers())
60
+ if r.status_code == 200:
61
+ return len(r.json())
62
+ except Exception as e:
63
+ print(f"[completions] count exception: {e}")
64
+ return 0
app/services/evaluator.py ADDED
@@ -0,0 +1,715 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """AI-powered challenge evaluators (legacy + per-type).
2
+
3
+ Each ``evaluate_*`` function consumes the request + scenario row, runs
4
+ the appropriate AI (or pattern check), and returns the JSON shape the
5
+ frontend expects. Where the result can be computed without AI (e.g. a
6
+ plain flag check), we do so directly.
7
+ """
8
+
9
+ import re
10
+ from typing import Optional
11
+
12
+ import httpx
13
+ from fastapi import BackgroundTasks, HTTPException
14
+
15
+ from app.core.constants import CYBER_SECURITY_TOPICS
16
+ from app.core.security import (
17
+ normalize_str,
18
+ normalize_vuln_key,
19
+ ip_matches,
20
+ ioc_matches,
21
+ timestamp_close,
22
+ )
23
+ from app.core.config import GROQ_API_URL, GROQ_API_KEY, MISTRAL_API_URL, MISTRAL_MODEL, MISTRAL_API_KEY
24
+ from app.core.text import parse_json_safe
25
+ from app.services.supabase_service import fetch_scenario_by_id
26
+ from app.services.scenario_service import handle_background_replacement
27
+ from app.services.completion_service import record_user_completion
28
+
29
+
30
+ # --------------------------------------------------------------------------- #
31
+ # Legacy: red-team / generic / blue-code-fix AI evaluator #
32
+ # --------------------------------------------------------------------------- #
33
+
34
+ async def evaluate_training(req, background_tasks: BackgroundTasks) -> dict:
35
+ """The original /api/training/evaluate (red-team payload + blue fix)."""
36
+ challenge = req.originalChallenge
37
+ user_code = req.userCode
38
+
39
+ eval_prompt = f"""أنت مهندس أمن سيبراني خبير ومراجع أكواد.
40
+ مهمتك: تقييم الكود أو الاستغلال الذي قدمه المستخدم.
41
+ دور المستخدم هو: {req.teamRole}
42
+
43
+ إذا كان دور المستخدم هو "blue" (مدافع):
44
+ - إذا كان الكود المعدل يسد الثغرة الأمنية ويحل المشكلة بشكل صحيح، أرجع secured: true.
45
+ - إذا لم تحل المشكلة أو كان خاطئاً، أرجع secured: false.
46
+
47
+ إذا كان دور المستخدم هو "red" (مهاجم):
48
+ - إذا كان الكود (أو Payload) يستغل الثغرة بنجاح، أرجع secured: true (نقصد بها النجاح).
49
+ - إذا كان الاستغلال فاشلاً، أرجع secured: false.
50
+
51
+ أرجع JSON فقط:
52
+ {{
53
+ "secured": true/false,
54
+ "feedback": "تقييمك باللغة العربية"
55
+ }}
56
+
57
+ التحدي الأصلي:
58
+ - الثغرة: {challenge.get("vulnerabilityLocation", "")}
59
+ - الإجابة المتوقعة: {challenge.get("expectedAnswer", "")}
60
+ - الشرح: {challenge.get("explanation", "")}
61
+
62
+ كود/استغلال المستخدم:
63
+ {user_code}"""
64
+
65
+ print(f"[DEBUG] GROQ_API_KEY exists: {bool(GROQ_API_KEY)}")
66
+ print(f"[DEBUG] GROQ_API_KEY length: {len(GROQ_API_KEY) if GROQ_API_KEY else 0}")
67
+
68
+ async with httpx.AsyncClient(timeout=60) as client:
69
+ resp = await client.post(
70
+ GROQ_API_URL,
71
+ json={
72
+ "model": "llama-3.3-70b-versatile",
73
+ "messages": [
74
+ {"role": "system", "content": "أنت مقيّم أكواد أمني. أعد JSON فقط."},
75
+ {"role": "user", "content": eval_prompt},
76
+ ],
77
+ "temperature": 0.2,
78
+ "max_tokens": 1024,
79
+ },
80
+ headers={
81
+ "Content-Type": "application/json",
82
+ "Authorization": f"Bearer {GROQ_API_KEY}",
83
+ },
84
+ )
85
+
86
+ print(f"[DEBUG] Groq API response status: {resp.status_code}")
87
+ if resp.status_code != 200:
88
+ error_detail = f"Groq API error: {resp.status_code}"
89
+ try:
90
+ error_body = resp.text
91
+ print(f"[DEBUG] Groq API error body: {error_body}")
92
+ error_detail += f" - {error_body}"
93
+ except Exception:
94
+ pass
95
+ raise HTTPException(status_code=500, detail=error_detail)
96
+
97
+ content = resp.json()["choices"][0]["message"]["content"]
98
+ evaluation = parse_json_safe(content)
99
+
100
+ if evaluation.get("secured") is True:
101
+ scenario_id = challenge.get("scenarioId") or challenge.get("id")
102
+ if scenario_id:
103
+ module_name = challenge.get("type", "")
104
+ topic_info = CYBER_SECURITY_TOPICS.get(
105
+ module_name, {"path": "web-security", "category": "web"}
106
+ )
107
+ path = topic_info.get("path", "web-security")
108
+ category = topic_info.get("category", "web")
109
+ difficulty = challenge.get("difficulty", "متوسط")
110
+
111
+ background_tasks.add_task(
112
+ handle_background_replacement,
113
+ scenario_id,
114
+ req.teamRole,
115
+ module_name,
116
+ path,
117
+ category,
118
+ difficulty,
119
+ )
120
+
121
+ return {"evaluation": evaluation}
122
+
123
+
124
+ # --------------------------------------------------------------------------- #
125
+ # Web exploitation — 3-layer validation (v2) #
126
+ # --------------------------------------------------------------------------- #
127
+
128
+ async def evaluate_web_exploitation(req) -> dict:
129
+ """Layer 1 (PATTERN) + Layer 2 (SINK) + Layer 3 (SECRET)."""
130
+ challenge_id = req.challengeId
131
+ payload = (req.payload or "").strip()
132
+ signal = req.exploitSignal or {}
133
+
134
+ if not challenge_id:
135
+ return {"success": False, "error": "challengeId مفقود"}
136
+ if not payload:
137
+ return {"success": False, "error": "لم تُرسل payload", "layer": "input"}
138
+
139
+ row = await fetch_scenario_by_id(req.teamRole or "red", challenge_id, challenge_type="web")
140
+ if not row:
141
+ return {"success": False, "error": "التحدي غير موجود", "layer": "load"}
142
+
143
+ sink_expected = row.get("sink_type") or ""
144
+ secret_expected = row.get("secret_marker") or ""
145
+ pattern_str = row.get("validation_pattern") or ""
146
+ flag_preview = row.get("flag_preview") or ""
147
+ xp_reward = int(row.get("xp_reward") or 100)
148
+
149
+ user_flag = payload.strip().lower()
150
+ expected_flag = flag_preview.strip().lower()
151
+ expected_secret = secret_expected.strip().lower()
152
+
153
+ if user_flag == expected_flag or user_flag == expected_secret or user_flag.replace("cyberarena{", "").replace("}", "") == expected_secret:
154
+ print(f"[evaluate-web] flag solved directly: challenge={challenge_id}")
155
+ return {
156
+ "success": True,
157
+ "flag": flag_preview,
158
+ "xp": xp_reward,
159
+ "message": "تم استغلال الثغرة واستخراج العلم بنجاح! 🎉",
160
+ "layer": "all",
161
+ "sink_confirmed": True,
162
+ }
163
+
164
+ # ---- Layer 1: PATTERN ----
165
+ if pattern_str:
166
+ try:
167
+ if not re.search(pattern_str, payload, re.IGNORECASE):
168
+ return {
169
+ "success": False,
170
+ "error": "الـ payload لا يطابق نمط الثغرة المتوقع",
171
+ "layer": "pattern",
172
+ "hint": "راجع الكود المصدري وحدد نوع الـ sink (innerHTML / eval / SQL concat / SSRF / etc.)",
173
+ }
174
+ except re.error as e:
175
+ print(f"[evaluate-web] bad pattern in DB: {e}")
176
+ # Don't block the user on a backend regex bug
177
+
178
+ # ---- Layer 2: SINK ----
179
+ sink_observed = signal.get("sink") or ""
180
+ if sink_expected:
181
+ if not sink_observed:
182
+ return {
183
+ "success": False,
184
+ "error": "لم يثبت الـ sink تنفيذه في الـ iframe. حمّل الـ payload في المعاينة أولاً",
185
+ "layer": "sink",
186
+ "expected_sink": sink_expected,
187
+ }
188
+ if sink_observed != sink_expected:
189
+ return {
190
+ "success": False,
191
+ "error": f"الـ sink المُلتقَط ({sink_observed}) لا يطابق المتوقع ({sink_expected})",
192
+ "layer": "sink",
193
+ "expected_sink": sink_expected,
194
+ "observed_sink": sink_observed,
195
+ }
196
+
197
+ # ---- Layer 3: SECRET ----
198
+ secret_observed = signal.get("secret") or ""
199
+ if secret_expected:
200
+ if not secret_observed:
201
+ return {
202
+ "success": False,
203
+ "error": "لم نستلم الـ secret. الـ flag مخفي في الـ lab — استخدم الثغرة لاستخراجه (مثلاً عبر document.cookie أو الـ response)",
204
+ "layer": "secret",
205
+ }
206
+ if secret_observed.lower() != secret_expected.lower():
207
+ return {
208
+ "success": False,
209
+ "error": "الـ secret المُرسَل لا يطابق المتوقع",
210
+ "layer": "secret",
211
+ }
212
+
213
+ print(f"[evaluate-web] ✓ challenge={challenge_id} sink={sink_observed} secret_ok=True")
214
+ return {
215
+ "success": True,
216
+ "flag": flag_preview,
217
+ "xp": xp_reward,
218
+ "message": "تم استغلال الثغرة بنجاح! 🎉",
219
+ "layer": "all",
220
+ "sink_confirmed": sink_observed,
221
+ }
222
+
223
+
224
+ # --------------------------------------------------------------------------- #
225
+ # Code-fix AI evaluator (single source of truth, shared with 1v1) #
226
+ # --------------------------------------------------------------------------- #
227
+
228
+ async def ai_evaluate_code_fix(challenge_id: str, fixed_code: str, team_role: str = "blue") -> dict:
229
+ """Single source of truth for both /evaluate-code-fix and 1v1's blue verifier."""
230
+ row = await fetch_scenario_by_id(team_role or "blue", challenge_id, challenge_type="code-fixing")
231
+ if not row:
232
+ return {"secured": False, "feedback": "التحدي غير موجود"}
233
+
234
+ vulnerable_code = row.get("vulnerable_code") or ""
235
+ vuln_type = row.get("vulnerability_type") or ""
236
+ vuln_desc = row.get("vulnerability_description") or ""
237
+ language = row.get("language") or ""
238
+
239
+ eval_prompt = f"""أنت مهندس أمن سيبراني خبير ومراجع أكواد.
240
+
241
+ مهمتك: تقييم الكود ال��ُعدل الذي قدمه المتدرب لتصحيح ثغرة أمنية.
242
+
243
+ معلومات التحدي:
244
+ - اللغة: {language}
245
+ - نوع الثغرة: {vuln_type}
246
+ - وصف الثغرة: {vuln_desc}
247
+
248
+ الكود الأصلي (المصاب بالثغرة):
249
+ ```{language}
250
+ {vulnerable_code}
251
+ ```
252
+
253
+ الكود المُعدل من المتدرب:
254
+ ```{language}
255
+ {fixed_code}
256
+ ```
257
+
258
+ قيّم الكود المُعدل وتحقق من:
259
+ 1. هل الثغرة أُصلحت فعلياً؟
260
+ 2. هل الكود صحيح نحويًا (syntax)؟
261
+ 3. هل الحل يتبع أفضل الممارسات الأمنية؟
262
+
263
+ قاعدة صارمة: اكتب حقل "feedback" باللغة العربية فقط. ممنوع منعاً باتاً استخدام أي كلمة بلغة أخرى.
264
+
265
+ أرجع JSON صالحاً فقط بالشكل التالي (بدون أي نص قبله أو بعده):
266
+ {{
267
+ "secured": true/false,
268
+ "feedback": "تقييمك المختصر بالعربية فقط يشرح هل الثغرة أُصلحت ولماذا",
269
+ "vulnerability_fixed": true/false,
270
+ "code_valid": true/false
271
+ }}"""
272
+
273
+ print(f"[ai-evaluate-code-fix] challenge={challenge_id} lang={language}")
274
+
275
+ try:
276
+ async with httpx.AsyncClient(timeout=60) as client:
277
+ resp = await client.post(
278
+ GROQ_API_URL,
279
+ json={
280
+ "model": "llama-3.3-70b-versatile",
281
+ "messages": [
282
+ {"role": "system", "content": "أنت مقيّم أكواد أمني. أعد JSON فقط."},
283
+ {"role": "user", "content": eval_prompt},
284
+ ],
285
+ "temperature": 0.2,
286
+ "max_tokens": 1024,
287
+ },
288
+ headers={
289
+ "Content-Type": "application/json",
290
+ "Authorization": f"Bearer {GROQ_API_KEY}",
291
+ },
292
+ )
293
+ except Exception as e:
294
+ return {"secured": False, "feedback": f"AI request failed: {e}"}
295
+
296
+ if resp.status_code != 200:
297
+ return {"secured": False, "feedback": f"AI evaluation error: {resp.status_code}"}
298
+
299
+ try:
300
+ content = resp.json()["choices"][0]["message"]["content"]
301
+ evaluation = parse_json_safe(content)
302
+ except Exception as e:
303
+ return {"secured": False, "feedback": f"AI response parse failed: {e}"}
304
+
305
+ if not isinstance(evaluation, dict):
306
+ return {"secured": False, "feedback": "AI response was not a JSON object"}
307
+ return evaluation
308
+
309
+
310
+ async def evaluate_code_fix(req, background_tasks: BackgroundTasks) -> dict:
311
+ """Public /evaluate-code-fix handler (regular training)."""
312
+ challenge_id = req.challengeId
313
+ fixed_code = req.fixedCode
314
+
315
+ if not challenge_id:
316
+ return {"success": False, "error": "challengeId مفقود"}
317
+ if not fixed_code or not fixed_code.strip():
318
+ return {"success": False, "error": "لم تُرسل كود مُعدل"}
319
+
320
+ evaluation = await ai_evaluate_code_fix(challenge_id, fixed_code, req.teamRole or "blue")
321
+
322
+ if "error" in evaluation and "secured" not in evaluation:
323
+ return {"success": False, "error": evaluation.get("error", "AI evaluation failed")}
324
+
325
+ if evaluation.get("secured") is True:
326
+ row = await fetch_scenario_by_id(req.teamRole or "blue", challenge_id, challenge_type="code-fixing")
327
+ if row:
328
+ background_tasks.add_task(
329
+ handle_background_replacement,
330
+ challenge_id,
331
+ req.teamRole or "blue",
332
+ row.get("module", ""),
333
+ "web-security",
334
+ row.get("difficulty", "متوسط"),
335
+ row.get("difficulty", "متوسط"),
336
+ )
337
+ background_tasks.add_task(
338
+ record_user_completion,
339
+ req.userId,
340
+ req.teamRole or "blue",
341
+ "code-fixing",
342
+ row.get("module", ""),
343
+ challenge_id,
344
+ int(row.get("xp_reward") or 150),
345
+ )
346
+
347
+ return {"evaluation": evaluation}
348
+
349
+
350
+ # --------------------------------------------------------------------------- #
351
+ # Log analysis — exact 4-field match + AI feedback via Mistral #
352
+ # --------------------------------------------------------------------------- #
353
+
354
+ async def evaluate_log_analysis(req, background_tasks: BackgroundTasks) -> dict:
355
+ challenge_id = req.challengeId
356
+ if not challenge_id:
357
+ return {"success": False, "error": "challengeId مفقود"}
358
+
359
+ row = await fetch_scenario_by_id(req.teamRole or "blue", challenge_id, challenge_type="log-analysis")
360
+ if not row:
361
+ return {"success": False, "error": "التحدي غير موجود"}
362
+
363
+ expected_attack = normalize_str(row.get("expected_attack_type", ""))
364
+ expected_ip = row.get("expected_attacker_ip") or ""
365
+ expected_ts = row.get("expected_timestamp") or ""
366
+ expected_ioc = row.get("expected_ioc") or ""
367
+
368
+ correct_fields = []
369
+ if normalize_str(req.attackType) == expected_attack:
370
+ correct_fields.append("نوع الهجوم")
371
+ if ip_matches(req.attackerIp, expected_ip):
372
+ correct_fields.append("عنوان IP المهاجم")
373
+ if timestamp_close(req.timestamp, expected_ts):
374
+ correct_fields.append("الطابع الزمني")
375
+ if ioc_matches(req.ioc, expected_ioc):
376
+ correct_fields.append("مؤشر الاختراق (IOC)")
377
+
378
+ total = 4
379
+ score = int(len(correct_fields) * 100 / total)
380
+ passed = score >= 75 # need 3 of 4
381
+
382
+ # AI feedback (Arabic-only) via Mistral
383
+ feedback_text = await _log_analysis_ai_feedback(
384
+ row, expected_attack, expected_ip, expected_ts, expected_ioc,
385
+ req, correct_fields, total, passed,
386
+ )
387
+
388
+ if not feedback_text:
389
+ if passed:
390
+ feedback_text = f"تحليل ممتاز! حددت {len(correct_fields)} من {total} حقول بشكل صحيح. {row.get('vulnerability_description', '')}"
391
+ else:
392
+ missed = [f for f in ["نوع الهجوم", "عنوان IP المهاجم", "الطابع الزمني", "مؤشر الاختراق (IOC)"] if f not in correct_fields]
393
+ feedback_text = f"تم تحديد {len(correct_fields)} من {total} حقول. الحقول التي تحتاج مراجعة: {', '.join(missed)}. راجع السجل مرة أخرى وابحث عن الأنماط المشبوهة."
394
+
395
+ xp_awarded = int((row.get("xp_reward") or 150) * score / 100)
396
+
397
+ if passed:
398
+ background_tasks.add_task(
399
+ handle_background_replacement,
400
+ challenge_id,
401
+ req.teamRole or "blue",
402
+ row.get("module", "forensics"),
403
+ "forensics",
404
+ row.get("difficulty", "متوسط"),
405
+ row.get("difficulty", "متوسط"),
406
+ )
407
+ background_tasks.add_task(
408
+ record_user_completion,
409
+ req.userId,
410
+ req.teamRole or "blue",
411
+ "log-analysis",
412
+ row.get("module", "forensics"),
413
+ challenge_id,
414
+ xp_awarded,
415
+ )
416
+
417
+ return {
418
+ "evaluation": {
419
+ "passed": passed,
420
+ "score": score,
421
+ "correct_fields": correct_fields,
422
+ "feedback": feedback_text,
423
+ "xp_awarded": xp_awarded,
424
+ }
425
+ }
426
+
427
+
428
+ async def _log_analysis_ai_feedback(
429
+ row, expected_attack, expected_ip, expected_ts, expected_ioc,
430
+ req, correct_fields, total, passed,
431
+ ) -> str:
432
+ """Use Mistral to generate a short Arabic SOC-style feedback paragraph."""
433
+ correct_str = "، ".join(correct_fields) if correct_fields else "لا شيء"
434
+ missed = [f for f in ["نوع الهجوم", "عنوان IP المهاجم", "الطابع الزمني", "مؤشر الاختراق (IOC)"] if f not in correct_fields]
435
+ missed_str = "، ".join(missed) if missed else "لا شيء"
436
+
437
+ feedback_prompt = f"""أنت محلل خبير في مركز عمليات الأمن السيبراني (SOC). راجع إجابة المتدرب.
438
+
439
+ ═══════════════════════════════════════
440
+ قواعد اللغة (مطلوبة بصرامة):
441
+ - اكتب بالعربية الفصحى فقط، بدون أي كلمات إنجليزية.
442
+ - ممنوع منعاً باتاً استخدام: nor، and، or، the، to، of، in، على الإطلاق.
443
+ - استخدم: و، أو، ثم، لكن، بل، لأن.
444
+ - لا تذكر أسماء حقول تقنية (attack_type، IP، timestamp) — استخدم الأسماء العربية فقط.
445
+ ═══════════════════════════════════════
446
+
447
+ معلومات التحدي:
448
+ - العنوان: {row.get("title", "")}
449
+ - نوع السجل: {row.get("log_type", "")}
450
+ - نوع الهجوم الصحيح: {expected_attack}
451
+ - عنوان الـ IP الصحيح: {expected_ip}
452
+ - الطابع الزمني الصحيح: {expected_ts}
453
+ - مؤشر الاختراق الصحيح: {expected_ioc}
454
+
455
+ إجابة المتدرب:
456
+ - نوع الهجوم: {req.attackType or "(فارغ)"}
457
+ - عنوان الـ IP: {req.attackerIp or "(فارغ)"}
458
+ - الطابع الزمني: {req.timestamp or "(فارغ)"}
459
+ - مؤشر الاختراق: {req.ioc or "(فارغ)"}
460
+ - التحليل الحر: {req.explanation or "(لم يكتب شيئاً)"}
461
+
462
+ نتيجة التقييم: {len(correct_fields)} من {total} حقول صحيحة.
463
+ الحقول الصحيحة: {correct_str}
464
+ الحقول الخاطئة أو الفارغة: {missed_str}
465
+
466
+ ═══════════════════════════════════════
467
+ التعليمات:
468
+ - اكتب فقرة واحدة إلى ثلاث فقرات قصيرة بالعربية فقط.
469
+ - ابدأ بجملة افتتاحية تصف النتيجة (مثلاً: "أصبت في X من Y" أو "لم تتمكن من كشف أي حقل بشكل صحيح").
470
+ - إذا كانت النتيجة 3 أو 4 من 4: امدح المتدرب وأض�� سياقاً أمنياً مختصراً.
471
+ - إذا كانت النتيجة أقل من 3: وضّح الحقول التي أخطأ فيها وقل كيف يكتشفها مستقبلاً، مع ربط بالإجابة الصحيحة.
472
+ - لا تتجاوز 150 كلمة.
473
+ ═══════════════════════════════════════
474
+
475
+ أرجع JSON فقط بدون أي شرح إضافي:
476
+ {{"feedback": "النص هنا"}}"""
477
+
478
+ try:
479
+ async with httpx.AsyncClient(timeout=60) as client:
480
+ fb_resp = await client.post(
481
+ MISTRAL_API_URL,
482
+ json={
483
+ "model": MISTRAL_MODEL,
484
+ "messages": [
485
+ {"role": "system", "content": "أنت محلل خبير في مركز عمليات الأمن السيبراني (SOC). أرجع JSON فقط، اكتب بالعربية الفصحى حصراً، ولا تستخدم أي كلمات إنجليزية على الإطلاق."},
486
+ {"role": "user", "content": feedback_prompt},
487
+ ],
488
+ "temperature": 0.3,
489
+ "max_tokens": 1024,
490
+ "response_format": {"type": "json_object"},
491
+ },
492
+ headers={
493
+ "Content-Type": "application/json",
494
+ "Authorization": f"Bearer {MISTRAL_API_KEY}",
495
+ },
496
+ )
497
+ if fb_resp.status_code == 200:
498
+ fb_content = fb_resp.json()["choices"][0]["message"]["content"]
499
+ fb_data = parse_json_safe(fb_content)
500
+ return fb_data.get("feedback", "")
501
+ print(f"[log-analysis eval] Mistral HTTP {fb_resp.status_code}: {fb_resp.text[:200]}")
502
+ except Exception as e:
503
+ print(f"[log-analysis eval] AI feedback failed: {e}")
504
+ return ""
505
+
506
+
507
+ # --------------------------------------------------------------------------- #
508
+ # Vulnerability hunter — exact canonical-key match #
509
+ # --------------------------------------------------------------------------- #
510
+
511
+ # Common alias → canonical form. Pre-filters well-known abbreviations so
512
+ # we don't pay for a Mistral call when the user types "sqli" or "xss".
513
+ # If the alias matches, the answer is treated as exact-match (full XP).
514
+ _VULN_ALIASES: dict[str, str] = {
515
+ "sqli": "sql-injection",
516
+ "sql": "sql-injection",
517
+ "sqlinj": "sql-injection",
518
+ "xss": "xss",
519
+ "crosssitescripting": "xss",
520
+ "csrf": "csrf",
521
+ "xsrf": "csrf",
522
+ "ssrf": "ssrf",
523
+ "xxe": "xxe",
524
+ "idor": "idor",
525
+ "lfi": "local-file-inclusion",
526
+ "rfi": "remote-file-inclusion",
527
+ "rce": "remote-code-execution",
528
+ "lpe": "local-privilege-escalation",
529
+ "privesc": "privilege-escalation",
530
+ "bof": "buffer-overflow",
531
+ "uaf": "use-after-free",
532
+ "formatstring": "format-string",
533
+ "integeroverflow": "integer-overflow",
534
+ "npd": "null-pointer-dereference",
535
+ "doublefree": "double-free",
536
+ "offbyone": "off-by-one",
537
+ "uninit": "uninitialized-memory",
538
+ "race": "race-condition",
539
+ "toctou": "time-of-check-time-of-use",
540
+ "proto": "prototype-pollution",
541
+ "deserialization": "insecure-deserialization",
542
+ "misconfig": "security-misconfiguration",
543
+ "bac": "broken-access-control",
544
+ "openredirect": "open-redirect",
545
+ "ssti": "server-side-template-injection",
546
+ }
547
+
548
+
549
+ def _alias_resolve(s: str) -> str:
550
+ """Lowercase + strip dashes/spaces. Returns the normalised token or the
551
+ canonical alias if it matches ``_VULN_ALIASES``."""
552
+ if not s:
553
+ return ""
554
+ norm = re.sub(r"[\s_\-]+", "", (s or "").lower().strip())
555
+ return _VULN_ALIASES.get(norm, norm)
556
+
557
+
558
+ async def _grade_vuln_hunter_with_mistral(
559
+ user_answer: str,
560
+ expected: str,
561
+ ) -> dict:
562
+ """Ask Mistral to grade the student's vuln-name answer semantically.
563
+
564
+ Returns ``{similarity, is_match, reason, xp_ratio}``. ``xp_ratio`` is
565
+ clamped between 0.4 and 1.0 when ``is_match`` is true (so partial
566
+ credit is always at least 40% of the reward). Returns
567
+ ``{"similarity": 0.0, "is_match": False, "reason": "...", "xp_ratio": 0}``
568
+ on any Mistral error so the caller can fall back to a hard fail.
569
+ """
570
+ if not MISTRAL_API_KEY:
571
+ return {"similarity": 0.0, "is_match": False, "reason": "Mistral not configured", "xp_ratio": 0}
572
+
573
+ system_prompt = (
574
+ "You are a senior cybersecurity instructor grading a student's answer "
575
+ "in an Arabic-first training platform. The student is asked to name a "
576
+ "specific vulnerability class shown in a code exhibit. Compare the "
577
+ "STUDENT_ANSWER to the EXPECTED_ANSWER and decide if they refer to the "
578
+ "same vulnerability.\n\n"
579
+ "Aliases are equivalent (e.g. 'sqli' = 'sql-injection', "
580
+ "'xss' = 'cross-site-scripting', 'bof' = 'buffer-overflow', "
581
+ "'rce' = 'remote-code-execution'). Different specific attack families "
582
+ "are NOT the same (e.g. 'sql-injection' is NOT 'xss').\n\n"
583
+ "Return raw JSON only, no markdown:\n"
584
+ '{"similarity": 0.0-1.0, "is_match": true|false, "reason": "..."}'
585
+ )
586
+ user_prompt = (
587
+ f"EXPECTED_ANSWER: {expected}\n"
588
+ f"STUDENT_ANSWER: {user_answer}\n\n"
589
+ "Are these the same vulnerability? Grade it."
590
+ )
591
+
592
+ try:
593
+ async with httpx.AsyncClient(timeout=20) as client:
594
+ r = await client.post(
595
+ MISTRAL_API_URL,
596
+ json={
597
+ "model": MISTRAL_MODEL,
598
+ "messages": [
599
+ {"role": "system", "content": system_prompt},
600
+ {"role": "user", "content": user_prompt},
601
+ ],
602
+ "temperature": 0.0,
603
+ "max_tokens": 300,
604
+ "response_format": {"type": "json_object"},
605
+ },
606
+ headers={
607
+ "Content-Type": "application/json",
608
+ "Authorization": f"Bearer {MISTRAL_API_KEY}",
609
+ },
610
+ )
611
+ if r.status_code != 200:
612
+ return {"similarity": 0.0, "is_match": False, "reason": f"Mistral HTTP {r.status_code}", "xp_ratio": 0}
613
+ content = r.json().get("choices", [{}])[0].get("message", {}).get("content", "")
614
+ data = parse_json_safe(content) or {}
615
+ similarity = float(data.get("similarity", 0) or 0)
616
+ is_match = bool(data.get("is_match", False)) or similarity >= 0.65
617
+ reason = str(data.get("reason", "")).strip() or "تم التقييم بواسطة Mistral"
618
+ xp_ratio = 0
619
+ if is_match:
620
+ xp_ratio = max(0.4, min(1.0, similarity)) if similarity >= 0.95 else max(0.4, min(0.85, similarity * 0.9))
621
+ return {"similarity": round(similarity, 3), "is_match": is_match, "reason": reason, "xp_ratio": xp_ratio}
622
+ except Exception as e:
623
+ return {"similarity": 0.0, "is_match": False, "reason": f"Mistral error: {e}", "xp_ratio": 0}
624
+
625
+
626
+ async def evaluate_vuln_hunter(req, background_tasks: BackgroundTasks) -> dict:
627
+ challenge_id = req.challengeId
628
+ if not challenge_id:
629
+ return {"success": False, "error": "challengeId مفقود"}
630
+ if not (req.vulnerabilityType or "").strip():
631
+ return {"success": False, "error": "لم تُرسل اسم الثغرة"}
632
+
633
+ row = await fetch_scenario_by_id(req.teamRole or "blue", challenge_id, challenge_type="vulnerability-hunter")
634
+ if not row:
635
+ return {"success": False, "error": "التحدي غير موجود"}
636
+
637
+ expected = normalize_vuln_key(row.get("vulnerability_type", ""))
638
+ user = normalize_vuln_key(req.vulnerabilityType)
639
+ # Also resolve common aliases ("sqli" → "sql-injection", "xss" → "xss").
640
+ # This catches well-known abbreviations without paying for a Mistral call.
641
+ user_resolved = _alias_resolve(req.vulnerabilityType)
642
+ expected_resolved = _alias_resolve(row.get("vulnerability_type", ""))
643
+ correct = (bool(expected) and (user == expected)) or (
644
+ bool(expected_resolved) and (user_resolved == expected_resolved)
645
+ )
646
+
647
+ xp_reward = int(row.get("xp_reward") or 150)
648
+ ai_grade: dict = {"similarity": 0.0, "is_match": False, "reason": "", "xp_ratio": 0}
649
+ if not correct:
650
+ # Ask Mistral to compare semantically when the alias/exact match fails.
651
+ # This recognises near-misses and ambiguous phrasing so the student
652
+ # gets partial credit instead of a hard fail.
653
+ ai_grade = await _grade_vuln_hunter_with_mistral(
654
+ req.vulnerabilityType, row.get("vulnerability_type", "")
655
+ )
656
+
657
+ passed = correct or ai_grade["is_match"]
658
+ if ai_grade["is_match"]:
659
+ xp_awarded = int(xp_reward * ai_grade["xp_ratio"])
660
+ elif correct:
661
+ xp_awarded = xp_reward
662
+ else:
663
+ xp_awarded = 0
664
+
665
+ if passed:
666
+ background_tasks.add_task(
667
+ handle_background_replacement,
668
+ challenge_id,
669
+ req.teamRole or "blue",
670
+ row.get("module", "vulnerability-hunter"),
671
+ "web-security",
672
+ row.get("difficulty", "متوسط"),
673
+ row.get("difficulty", "متوسط"),
674
+ )
675
+ background_tasks.add_task(
676
+ record_user_completion,
677
+ req.userId,
678
+ req.teamRole or "blue",
679
+ "vulnerability-hunter",
680
+ row.get("module", "vulnerability-hunter"),
681
+ challenge_id,
682
+ xp_awarded,
683
+ )
684
+
685
+ if correct:
686
+ feedback = (
687
+ "إجابة صحيحة! الثغرة هي فعلاً "
688
+ f"{expected}. {row.get('vulnerability_description', '')}"
689
+ ).strip()
690
+ elif ai_grade["is_match"]:
691
+ feedback = (
692
+ f"إجابة قريبة ({int(ai_grade['similarity'] * 100)}% تشابه). "
693
+ f"الثغرة المتوقعة: {row.get('vulnerability_type', 'غير معروف')}. "
694
+ f"تم احتساب {xp_awarded} من {xp_reward} XP. {ai_grade.get('reason', '')}"
695
+ ).strip()
696
+ else:
697
+ feedback = (
698
+ f"الثغرة المتوقعة هي: {row.get('vulnerability_type', 'غير معروف')}. "
699
+ f"{row.get('vulnerability_description', '')}"
700
+ ).strip()
701
+
702
+ return {
703
+ "success": True,
704
+ "evaluation": {
705
+ "passed": passed,
706
+ "score": int((100 if correct else (ai_grade["similarity"] * 100)) if passed else 0),
707
+ "feedback": feedback,
708
+ "xp_awarded": xp_awarded,
709
+ "similarity": ai_grade["similarity"],
710
+ "ai_graded": ai_grade["is_match"] and not correct,
711
+ "ai_reason": ai_grade.get("reason", ""),
712
+ "vulnerability_type": row.get("vulnerability_type", ""),
713
+ "vulnerability_class": row.get("vulnerability_class", ""),
714
+ },
715
+ }
app/services/file_storage.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Upload AI-generated challenge files to Supabase Storage.
2
+
3
+ The backend NEVER writes files to its own disk — every
4
+ ``fileToGenerate`` payload is uploaded to the public
5
+ ``challenge-files`` Supabase Storage bucket and the resulting URL is
6
+ returned in the training payload (``logData.downloadable_url``) so
7
+ the frontend can offer a direct download link.
8
+
9
+ If the upload fails for any reason, the function still returns a
10
+ best-effort relative path string and the rest of the challenge still
11
+ loads — the file just becomes a no-op for that one challenge.
12
+ """
13
+
14
+ import time
15
+
16
+ import httpx
17
+
18
+ from app.core.config import (
19
+ SUPABASE_URL,
20
+ SUPABASE_ANON_KEY,
21
+ CHALLENGE_FILES_BUCKET,
22
+ )
23
+
24
+
25
+ def _public_url(storage_path: str) -> str:
26
+ """Build the public URL for an object in the bucket."""
27
+ return f"{SUPABASE_URL}/storage/v1/object/public/{CHALLENGE_FILES_BUCKET}/{storage_path}"
28
+
29
+
30
+ async def upload_challenge_file(file_data: dict, module: str, team_role: str) -> str:
31
+ """Upload the ``fileToGenerate`` payload to Supabase Storage.
32
+
33
+ Returns the **public URL** of the uploaded object, or ``""`` if
34
+ nothing was saved.
35
+ """
36
+ if not file_data or not file_data.get("fileName") or not file_data.get("content"):
37
+ return ""
38
+
39
+ file_name = str(file_data.get("fileName")).replace(" ", "_")
40
+ content = file_data.get("content")
41
+
42
+ # Sanitize the filename: strip path components + traversal sequences.
43
+ safe_name = file_name.replace("\\", "/").split("/")[-1].replace("..", "_")
44
+ if not safe_name:
45
+ return ""
46
+
47
+ # Object key: <team>/<module>/<timestamp>_<name> so two solves
48
+ # with the same filename never overwrite each other.
49
+ storage_path = f"{team_role}/{module}/{int(time.time())}_{safe_name}"
50
+
51
+ upload_url = f"{SUPABASE_URL}/storage/v1/object/{CHALLENGE_FILES_BUCKET}/{storage_path}"
52
+ headers = {
53
+ "apikey": SUPABASE_ANON_KEY,
54
+ "Authorization": f"Bearer {SUPABASE_ANON_KEY}",
55
+ "Content-Type": "application/octet-stream",
56
+ }
57
+
58
+ try:
59
+ async with httpx.AsyncClient(timeout=15) as client:
60
+ resp = await client.post(upload_url, content=content.encode("utf-8"), headers=headers)
61
+ if resp.status_code in (200, 201):
62
+ print(f" [+] تم رفع ملف التحدي إلى Storage: {storage_path}")
63
+ return _public_url(storage_path)
64
+ print(f" [-] فشل رفع الملف إلى Storage: {resp.status_code} {resp.text[:200]}")
65
+ except Exception as e:
66
+ print(f" [-] استثناء أثناء رفع الملف إلى Storage: {e}")
67
+ return ""
68
+
app/services/scenario_service.py ADDED
@@ -0,0 +1,491 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Scenario generation service.
2
+
3
+ Three things live here:
4
+ * ``generate_scenario_from_groq`` — pulls a fresh *scenario seed*
5
+ (title + story + task) from Groq for the pool cache.
6
+ * ``generate_challenge_from_groq`` — turns a scenario seed into a
7
+ full interactive challenge (HTML, files, hints, expected answer).
8
+ * ``generate_and_store_scenario`` / ``handle_background_replacement``
9
+ — background-task glue used by the routers and the pool watcher.
10
+ """
11
+
12
+ import os
13
+ import random
14
+ from typing import Optional
15
+
16
+ import httpx
17
+ from fastapi import HTTPException
18
+
19
+ from app.core.constants import CYBER_SECURITY_TOPICS, FALLBACK_HTML
20
+ from app.core.config import GROQ_API_URL, GROQ_API_KEY
21
+ from app.core.text import parse_json_safe
22
+ from app.services.supabase_service import (
23
+ SUPABASE_URL,
24
+ SUPABASE_ANON_KEY,
25
+ supabase_headers,
26
+ insert_scenario_to_supabase,
27
+ delete_scenario_from_supabase,
28
+ fetch_scenario_by_id,
29
+ scenario_table,
30
+ )
31
+
32
+
33
+ # --------------------------------------------------------------------------- #
34
+ # Scenario seed (cache pool) #
35
+ # --------------------------------------------------------------------------- #
36
+
37
+ async def generate_scenario_from_groq(
38
+ team_role: str,
39
+ module: str,
40
+ path: str = "web-security",
41
+ category: str = "web",
42
+ difficulty: str = "متوسط",
43
+ ) -> dict:
44
+ """Generate a lightweight scenario seed for the cache pool (no full challenge artifacts)."""
45
+ topic_info = CYBER_SECURITY_TOPICS.get(
46
+ module, {"name": f"موضوع: {module}", "category": category, "path": path}
47
+ )
48
+ topic = topic_info["name"]
49
+ team_label = "الفريق الأزرق (مدافع)" if team_role == "blue" else "الفريق الأحمر (مهاجم)"
50
+
51
+ # Pull recent titles to avoid duplicates
52
+ existing_titles = await _fetch_existing_titles(team_role)
53
+
54
+ system_prompt = f"""أنت كبير مصممي سيناريوهات الأمن السيبراني في منصة APEX، وتعمل بمعايير المنصات العالمية (HackTheBox، PortSwigger، SANS، MITRE ATT&CK، PicoCTF).
55
+
56
+ مهمتك: تصميم سيناريو تدريبي **مبتكر وواقعي** بمستوى احترافي عالٍ.
57
+ السيناريو سيُستخدم كقصة أساسية يبني عليها ذكاء اصطناعي آخر تحدٍ تفاعلي كامل.
58
+
59
+ ### الدور
60
+ {team_label}
61
+
62
+ ### الموضوع التقني
63
+ {topic} (module key: {module})
64
+
65
+ ### مستوى الصعوبة
66
+ {difficulty}
67
+
68
+ ### قواعد الجودة الصارمة (مهم جداً)
69
+ 1. **واقعية قصوى**: السيناريو يجب أن يكون مستوحى من حادثة أمنية حقيقية أو حالة اختراق معروفة (مثل اختراق SolarWinds، ثغرة Log4Shell، تسريبات Capital One S3، تسريبات Facebook GraphQL، Kerberoasting، إلخ). لا قصص طفولية.
70
+ 2. **تفاصيل تقنية دقيقة**: اذكر تقنيات حقيقية (CVEs بأرقامها، نُسخ منتجات، أدوات)، أسماء شركات وهمية واقعية، أرقام دقيقة (IP، نطاقات، hashes، endpoints، CVSS scores).
71
+ 3. **تنوع**: لا تكرّر نفس القصة/السيناريو بتغييرات طفيفة. كل سيناريو يجب أن يحكي قصة فريدة.
72
+ 4. **بيئة تقنية محددة**: اذكر stack تقني واضح (مثل Nginx 1.18, MySQL 8, AWS EC2 t3.medium, K8s 1.28, Active Directory 2019، إلخ).
73
+ 5. **مخرجات قابلة للقياس**: الـ "task" يجب أن يحدد مهمة واحدة واضحة ومحددة يمكن للذكاء الاصطناعي التالي تحويلها إلى تحدٍ تفاعلي.
74
+ 6. **تخصص**: استخدم مصطلحات أمان سيبراني احترافية (TTPs من MITRE ATT&CK، CVE/CWE، IOC، C2، lateral movement، exfiltration، persistence).
75
+
76
+ ### منع التكرار
77
+ العناوين الموجودة حالياً في البركة (لا تُكرّرها أو تشابهها):
78
+ {', '.join(existing_titles[:25]) if existing_titles else 'لا يوجد'}
79
+
80
+ ### الإخراج (JSON خام فقط، بدون Markdown)
81
+ {{
82
+ "title": "عنوان احترافي مبتكر بالإنجليزية أو العربية يصف السيناريو بدقة",
83
+ "story": "قصة سياقية واقعية (3-4 جمل) تصف: من الشركة؟ ما التقنية المستخدمة؟ ما الثغرة أو السياق الأمني؟ ما الأثر؟",
84
+ "task": "مهمة واحدة واضحة ومحددة باللغة العربية (فقرة واحدة) تصف ما يجب على المتدرب فعله",
85
+ "difficulty": "{difficulty}",
86
+ "xpReward": {200 if difficulty == "قوي" else 150 if difficulty == "متوسط" else 100}
87
+ }}"""
88
+
89
+ # Pick a creative seed based on difficulty + module to ensure variety
90
+ creative_seeds = [
91
+ f"حادثة أمنية في شركة {random.choice(['CloudWave', 'FinShield', 'TechCorp', 'MediCore', 'DataHub', 'ApexBank', 'NexusLogistics', 'QuantumHealth', 'SolarSync', 'PayStream'])}",
92
+ f"سيناريو هجوم/دفاع مبني على {random.choice(['MITRE ATT&CK T1059', 'CVE-2024-3094', 'OWASP API Top 10', 'NIST SP 800-53', 'PCI-DSS violation', 'insider threat', 'supply chain attack', 'zero-day exploit', 'privilege escalation chain'])}",
93
+ f"بيئة {random.choice(['Kubernetes 1.28', 'AWS EKS', 'Azure AD', 'GCP IAM', 'Active Directory 2019', 'Java Spring Boot', 'Node.js Express', 'PHP 8.2', 'Python FastAPI', 'Go microservice', 'React SPA', 'GraphQL API'])} مع {random.choice(['misconfig', 'memory leak', 'race condition', 'TOCTOU', 'insecure deserialization', 'weak crypto'])}",
94
+ ]
95
+ user_prompt = (
96
+ f"صمم سيناريو {team_label} جديد وحصري عن {topic}.\n"
97
+ f"الإلهام: {random.choice(creative_seeds)}.\n"
98
+ f"تذكير: اجعل السيناريو **مختلفاً كلياً** عن العناوين السابقة، واذكر stack تقني محدد، واصف الأثر بالأرقام (مستخدمين/سجلات/دولار)."
99
+ )
100
+
101
+ async with httpx.AsyncClient(timeout=60) as client:
102
+ resp = await client.post(
103
+ GROQ_API_URL,
104
+ json={
105
+ "model": "llama-3.3-70b-versatile",
106
+ "messages": [
107
+ {"role": "system", "content": "أنت مقيّم أكواد أمني خبير. أعد JSON صالحاً فقط بدون أي نص قبله أو بعده. اكتب جميع القيم النصية باللغة العربية حصراً. لا تستخدم أي لغة أخرى (لا إنجليزية، لا صينية، لا أي لغة غير العربية) في حقل feedback."},
108
+ {"role": "user", "content": user_prompt},
109
+ ],
110
+ "temperature": 0.2,
111
+ "max_tokens": 2048,
112
+ },
113
+ headers={
114
+ "Content-Type": "application/json",
115
+ "Authorization": f"Bearer {GROQ_API_KEY}",
116
+ },
117
+ )
118
+
119
+ if resp.status_code == 429:
120
+ raise ValueError("Groq API Rate Limit Reached (429)")
121
+ if resp.status_code != 200:
122
+ raise HTTPException(status_code=500, detail=f"Groq API error: {resp.status_code}")
123
+
124
+ return parse_json_safe(resp.json()["choices"][0]["message"]["content"])
125
+
126
+
127
+ async def _fetch_existing_titles(team_role: str) -> list:
128
+ """Fetch recent scenario titles for uniqueness check."""
129
+ if not SUPABASE_ANON_KEY or not SUPABASE_URL:
130
+ return []
131
+ table = scenario_table(team_role)
132
+ url = f"{SUPABASE_URL}/rest/v1/{table}?select=title&team_role=eq.{team_role}&order=created_at.desc&limit=40"
133
+ try:
134
+ async with httpx.AsyncClient() as client:
135
+ resp = await client.get(url, headers=supabase_headers())
136
+ if resp.status_code == 200:
137
+ return [row.get("title", "") for row in resp.json() if row.get("title")]
138
+ except Exception:
139
+ pass
140
+ return []
141
+
142
+
143
+ # --------------------------------------------------------------------------- #
144
+ # Full challenge (from scenario or from scratch) #
145
+ # --------------------------------------------------------------------------- #
146
+
147
+ async def generate_challenge_from_scenario(
148
+ scenario: dict,
149
+ team_role: str,
150
+ module: str,
151
+ path: str = "web-security",
152
+ category: str = "web",
153
+ ) -> dict:
154
+ """Build a full interactive challenge from a cached scenario via Groq."""
155
+ difficulty = scenario.get("difficulty") or "متوسط"
156
+ return await generate_challenge_from_groq(
157
+ team_role, module, path, category, difficulty, scenario=scenario
158
+ )
159
+
160
+
161
+ async def generate_challenge_from_groq(
162
+ team_role: str,
163
+ module: str,
164
+ path: str = "web-security",
165
+ category: str = "web",
166
+ difficulty: str = "متوسط",
167
+ scenario: Optional[dict] = None,
168
+ ) -> dict:
169
+ """Build a complete interactive challenge via Groq."""
170
+ topic_info = CYBER_SECURITY_TOPICS.get(
171
+ module, {"name": f"موضوع: {module}", "category": category, "path": path}
172
+ )
173
+ topic = topic_info["name"]
174
+
175
+ is_web = module in ["xss", "sqli", "csrf", "ssrf", "idor", "lfi-rfi", "xxe", "cmdi", "auth", "upload"]
176
+
177
+ role_instructions = _build_role_instructions(module, team_role, difficulty, is_web)
178
+
179
+ if scenario:
180
+ scenario_block = f"""
181
+ === السيناريو الأساسي (يجب الالتزام به بالكامل) ===
182
+ عنوان السيناريو: {scenario.get("title", "")}
183
+ قصة السيناريو: {scenario.get("story", "")}
184
+ ملخص المهمة: {scenario.get("task_outline") or scenario.get("task", "")}
185
+ === نهاية السيناريو ===
186
+
187
+ يجب أن تبني التحدي الكامل (HTML، الملفات، التلميحات، الإجابة المتوقعة) انطلاقاً من هذا السيناريو.
188
+ يمكنك توسيع التفاصيل التقنية، لكن لا تغيّر جوهر القصة أو الهدف الأمني.
189
+ """
190
+ else:
191
+ scenario_block = "لا يوجد سيناريو مسبق — أنشئ تحدياً أصلياً يتوافق مع الموضوع والدور."
192
+
193
+ system_prompt = f"""أنت مدرب أمن سيبراني خبير ومحترف للغاية.
194
+ مهمتك: بناء تحدٍ تفاعلي كامل ومفصّل بناءً على السيناريو المُزوَّد أدناه.
195
+ لا تنحرف عن السيناريو — طوّره إلى تحدٍ قابل للعب مع أكواد وملفات وتلميحات وإجابة متوقعة.
196
+ درجة الصعوبة المطلوبة للتحدي: {difficulty}
197
+ {role_instructions}
198
+ {scenario_block}
199
+
200
+ قواعد صارمة لواقعية التحدي (Realistic & Production-Ready Scenarios):
201
+ 1. يمنع منعاً باتاً كتابة سيناريوهات وهمية أو أكواد غير حقيقية أو استبدال الأكواد بتعليقات توضيحية. يجب أن يكون الكود المصدري كاملاً وقابلاً للتنفيذ ومطابقاً للواقع البرمجي 100%.
202
+ 2. يجب صياغة القصة والسيناريو والمهام بشكل واقعي ومأخوذ من حوادث أمنية حقيقية أو حالات اختراق معروفة في الشركات والأنظمة الحية، وتجنب القصص التبسيطية أو الطفولية.
203
+ 3. يجب أن تحتوي الـ htmlPreview على كود برمجي متكامل ومبني بعناية فائقة، ويحتوي على الخلل الأمني الدقيق (مثل استخدام غير آمن للدوال, عدم فلترة المدخلات، ثغرات التوجيه، التشفير الضعيف، إلخ).
204
+ 4. بالنسبة للبلو تيم: يجب أن يكون الحل قابلاً للتطبيق برمجياً (مثل استبدال الدوال الضعيفة ببدائل حقيقية وآمنة)، ويجب أن يحتوي التحدي على وصف برمني وتفسير دقيق للثغرة في حقل vulnerabilityLocation.
205
+ 5. بالنسبة للريد تيم: يجب أن تكون الثغرة قابلة للاستغلال الفعلي، والـ expectedAnswer يجب أن تحتوي على حمولات واقعية ومقبولة برمجياً.
206
+
207
+ مهم جداً: أرجع JSON خام فقط بدون أي Markdown أو شرح أو أسطر إضافية أو ```json.
208
+
209
+ أرجع JSON فقط وبشكل صحيح، بهذا التنسيق وبدون أي نصوص إضافية خارج الـ JSON:
210
+
211
+ {{
212
+ "title": "عنوان التدريب المناسب للمسار والدور",
213
+ "story": "قصة سيبرانية واقعية وحماسية باللغة العربية مأخوذة من سيناريو شركة أو بيئة إنتاج حقيقية",
214
+ "task": "المهمة المطلوبة من المستخدم بوضوح تام وبلغة أمنية دقيقة",
215
+ "htmlPreview": "صفحة HTML كاملة تفاعلية وحقيقية تحتوي على كود وتصميم ذو جودة عالية جداً وخلفيات داكنة متناسقة مع هوية CyberArena السيبرانية",
216
+ "fileToGenerate": {{
217
+ "fileName": "اسم الملف مع الامتداد المناسب (مثل capture.log أو hashes.txt)",
218
+ "content": "المحتوى الفعلي للملف الذي سيقوم المتدرب بتحميله وتحليله"
219
+ }},
220
+ "vulnerabilityLocation": "تحديد وتفسير دقيق جداً لمكان وجود الثغرة برمجياً ومنطقياً في الكود",
221
+ "hints": ["تلميح تقني عميق يساعده في الحل","تلميح إضافي","تلميح يوضح الفكرة تماماً"],
222
+ "expectedAnswer": "الاستغلال المتوقع أو الحمولة (payload) المتوقعة بالنسبة للفريق الأحمر، أو فكرة الحل للفريق الأزرق",
223
+ "explanation": "شرح أمني عميق للثغرة وكيفية معالجتها في الواقع والأبعاد الأمنية لها وكيفية تجنبها في بيئات الإنتاج",
224
+ "configData": "محتوى ملف التهيئة أو الشيفرة البرمجية المفترضة للتحدي (مثال: محتويات ملف التشفير الضعيف، أو ملف secret.enc المشفر، أو إعدادات الشبكة) بشكل نصي كامل إذا كان التحدي يحتاج ملفات للتأمين أو الفك.",
225
+ "logData": "محتوى سجلات النظام أو حزم الشبكة النصية المفترضة بدقة وواقعية.",
226
+ "xpReward": {200 if difficulty == "قوي" else 150 if difficulty == "متوسط" else 100},
227
+ "difficulty": "{difficulty}"
228
+ }}
229
+
230
+ ملاحظة هامة: يجب أن تكون صفحة htmlPreview تفاعلية بالكامل، ذات ألوان داكنة تتطابق مع هوية CyberArena السيبرانية الفخمة، وتدعم اللغة العربية والاتجاه RTL.
231
+
232
+ تنبيه للمبرمج: يمنع استخدام كلمات من مسارات أخرى في غير محلها (مثلاً ��ا تستخدم "تشفير" أو "جدار حماية" في تحدي XSS أو SQL Injection) حتى لا يتم استبعاد التحدي من نظام التدقيق آلياً."""
233
+
234
+ user_prompt = f"""الموضوع: {topic}
235
+ الوحدة: {module}
236
+ دور اللاعب الحالي: {team_role}
237
+ درجة الصعوبة المطلوبة: {difficulty}
238
+ المسار: {path}
239
+ التصنيف: {category}
240
+
241
+ بناءً على السيناريو المُزوَّد، أنشئ تحدياً تفاعلياً كاملاً جاهزاً للعب."""
242
+
243
+ async with httpx.AsyncClient(timeout=60) as client:
244
+ resp = await client.post(
245
+ GROQ_API_URL,
246
+ json={
247
+ "model": "llama-3.3-70b-versatile",
248
+ "messages": [
249
+ {"role": "system", "content": system_prompt},
250
+ {"role": "user", "content": user_prompt},
251
+ ],
252
+ "temperature": 0.7,
253
+ "max_tokens": 4096,
254
+ },
255
+ headers={
256
+ "Content-Type": "application/json",
257
+ "Authorization": f"Bearer {GROQ_API_KEY}",
258
+ },
259
+ )
260
+
261
+ if resp.status_code == 429:
262
+ raise ValueError("Groq API Rate Limit Reached (429)")
263
+ elif resp.status_code != 200:
264
+ raise HTTPException(status_code=500, detail=f"Groq API error: {resp.status_code}")
265
+
266
+ content = resp.json()["choices"][0]["message"]["content"]
267
+ training = parse_json_safe(content)
268
+
269
+ if not training.get("htmlPreview", "").strip():
270
+ training["htmlPreview"] = FALLBACK_HTML
271
+
272
+ return training
273
+
274
+
275
+ def _build_role_instructions(module: str, team_role: str, difficulty: str, is_web: bool) -> str:
276
+ """The role-specific guidance block injected into the Groq system prompt."""
277
+ if is_web:
278
+ if team_role == "blue":
279
+ return f"""
280
+ المستخدم هو من الفريق الأزرق (Blue Team - مدافع أمني) في مسار الويب والتطبيقات.
281
+ يجب أن تركز المهمة والقصة والخطوات بالكامل على الجانب الدفاعي وسد الثغرة البرمجية في صفحة الويب.
282
+ درجة الصعوبة المطلوبة للتحدي: {difficulty}
283
+ - يجب أن يكون التحدي عبارة عن كود مصاب بثغرة أمنية واضحة في ملف index.html.
284
+ - المطلوب من المستخدم هو فتح محرر الأكواد (VS Code) وتعديل الكود المصدري لسد الثغرة الأمنية وجعل الصفحة آمنة تماماً.
285
+ - اجعل قصة التحدي تدور حول مهندس أمن يقوم بإصلاح وتأمين نظام ويب تم اكتشاف ثغرة فيه.
286
+ - اجعل "task" واضحة جداً وتطلب منه صراحة تعديل الكود المصدري لإغلاق الثغرة (مثلاً استبدال الكود غير الآمن ببديل آمن).
287
+ - لا تطلب منه أبداً إدخال payload أو البحث عن علم (flag)، بل اطلب منه إصلاح الكود والضغط على 'أكملت الإصلاح' ليقوم المقيم الذكي بفحص الكود.
288
+ """
289
+ return f"""
290
+ المستخدم هو من الفريق الأحمر (Red Team - مهاجم ومخترق) في مسار الويب والتطبيقات.
291
+ يجب أن تركز المهمة والقصة والخطوات بالكامل على الجانب الهجومي واكتشاف الثغرة واستغلالها في الويب.
292
+ درجة الصعوبة المطلوبة للتحدي: {difficulty}
293
+ - المطلوب من المستخدم هو اكتشاف الثغرة في الموقع التفاعلي وحقن حمولة مناسبة (payload) أو إيجاد العلم (flag) للحصول على النقاط.
294
+ - اجعل قصة التحدي تدور حول مخترق أخلاقي يحاول استغلال ثغرة أمنية لإثبات وجود الخلل.
295
+ - اجعل "task" تطلب منه صراحة إدخال الحمولة (payload) أو العلم في حقل الإدخال.
296
+ - يجب أن يحتوي "expectedAnswer" على نمط الحمولة أو الكود المتوقع للاستغلال (مثلاً: alert, script, onerror, hex, base64, etc.) أو العلم المطلوب.
297
+ """
298
+
299
+ # Non-web challenges
300
+ if team_role == "blue":
301
+ return f"""
302
+ المستخدم هو من الفريق الأزرق (Blue Team - مدافع ومحلل أمني) في مسار الأنظمة والشبكات والتشفير والتحقيق الجنائي (غير الويب).
303
+ يجب أن تركز المهمة والقصة والخطوات بالكامل على الجانب الدفاعي وتحليل السجلات والأنظمة وتكوينات الأمان.
304
+ درجة الصعوبة المطلوبة للتحدي: {difficulty}
305
+ - التحدي هنا غير معني بالويب أو الأكواد البرمجية البرونت-إند (لا توجد صفحة index.html تفاعلية).
306
+ - المطلوب من المستخدم هو تحليل سجلات نظام أو شبكة، أو تدقيق تهيئة جدار حماية (firewall config)، أو تحليل بصمات تشفيرية (hashes/ciphers)، أو استخراج ملفات وتتبع خوادم.
307
+ - **تنبيه هام جداً**: يجب أن تقوم بتوليد البيانات الفنية والملفات في حقل "logData" (مثل سجلات خادم Apache/Nginx، أو حزم شبكة PCAP كخيار نصي، أو سجلات الحوادث) أو في حقل "configData" (مثل ملفات تهيئة شبكة، ملفات سريّة أو نصوص برمجية بحاجة للمراجعة).
308
+ - **إنشاء الملفات**: إذا كان التحدي يتعلق بالتشفير أو التحقيق الجنائي أو الشبكات، قم بصياغة وتمثيل محتويات الملفات البرمجية المفترضة (مثل ملف تشفير ضعيف، ملفات سجل، إلخ) وضعها بوضوح داخل "configData" كـ JSON أو نص برمجي مهيأ للمستخدم، ومثّل السجلات البرمجية في "logData" حتى يتمكن نظام محاكي Windows من تقديمها كملفات أو سجلات للمتدرب.
309
+ - اجعل "task" تطلب منه صراحة تحليل البيانات أو قراءة الملفات والإجابة عن سؤال دفاعي محدد (مثلاً: تحديد الـ IP المهاجم، كتابة الأمر الصحيح المفقود، تحديد خوارزمية التشفير الضعيفة المستخدمة مثل md5 أو sha1، أو المنفذ المشبوه).
310
+ - يجب أن تحتوي "expectedAnswer" على الإجابة أو الكلمة المفتاحية المباشرة والدقيقة المتوقعة للحل (مثال: md5, sha1, 192.168.1.100, port 21, etc.).
311
+ - يجب ألا يكون المطلوب إصلاح كود HTML، بل إدخال إجابة نصية دقيقة ومباشرة في حقل الإجابة.
312
+ """
313
+ return f"""
314
+ المستخدم هو من الفريق الأحمر (Red Team - مهاجم ومخترق) في مسار الأنظمة والشبكات والتشفير والاختراق (غير الويب).
315
+ يجب أن تركز المهمة والقصة والخطوات بالكامل على كسر الشيفرات، تحليل السجلات، اختراق الأنظمة، وجمع المعلومات.
316
+ درجة الصعوبة المطلوبة للتحدي: {difficulty}
317
+ - التحدي غير معني بصفحات الويب تفاعلية، بل بتحليل الشبكات والأنظمة وفك التشفير.
318
+ - المطلوب هو كسر تشفير ملفات، فك تشفير هاشات، البحث في سجلات الخادم، أو استغلال ثغرات المنافذ للحصول على العلم (Flag).
319
+ - **إنشاء الملفات والتسريبات**: ضع النصوص المشفرة أو الشيفرات أو الهاشات كملفات أو مسودات فنية داخل حقل "configData" (مثال: ملف secret.enc يحتوي على نص مشفر، أو ملف hash.txt يحتوي على بصمات)، وضع السجلات الفنية للشبكة أو المنافذ في حقل "logData".
320
+ - اجعل "task" تطلب منه بوضوح إدخال العلم المستخرج في حقل الإجابة.
321
+ - يجب أن يحتوي "expectedAnswer" على العلم الدقيق المطلوب (مثل CyberArena{{...}}).
322
+ """
323
+
324
+
325
+ # --------------------------------------------------------------------------- #
326
+ # Scenario ↔ DB mapping helpers #
327
+ # --------------------------------------------------------------------------- #
328
+
329
+ def map_groq_scenario_to_db(groq_scenario: dict, module: str, difficulty: str) -> dict:
330
+ xp = groq_scenario.get("xpReward") or groq_scenario.get("xp_reward")
331
+ if xp is None:
332
+ xp = 200 if difficulty == "قوي" else 150 if difficulty == "متوسط" else 100
333
+ return {
334
+ "module": module,
335
+ "title": groq_scenario.get("title", ""),
336
+ "story": groq_scenario.get("story", ""),
337
+ "task_outline": (
338
+ groq_scenario.get("task")
339
+ or groq_scenario.get("taskOutline")
340
+ or groq_scenario.get("task_outline", "")
341
+ ),
342
+ "difficulty": groq_scenario.get("difficulty") or difficulty,
343
+ "xp_reward": xp,
344
+ }
345
+
346
+
347
+ def map_scenario_to_list_item(scenario: dict) -> dict:
348
+ """Project a row into the lightweight shape used by the dashboard list."""
349
+ mod = scenario.get("module", "unknown")
350
+ info = CYBER_SECURITY_TOPICS.get(mod, {})
351
+ challenge_type = scenario.get("_challenge_type") or scenario.get("type") or ""
352
+
353
+ # The frontend uses `module` to route the challenge to the right editor.
354
+ # We override the raw `module` per challenge_type so the round-trip
355
+ # Dashboard → TrainingSession → backend stays type-correct.
356
+ if challenge_type == "vulnerability-hunter":
357
+ module_out = "vulnerability-hunter"
358
+ elif challenge_type == "code-fixing":
359
+ module_out = "code-fixing"
360
+ elif challenge_type == "log-analysis":
361
+ module_out = "log-analysis"
362
+ elif challenge_type == "web":
363
+ module_out = "web"
364
+ elif challenge_type == "crypto":
365
+ module_out = "crypto"
366
+ else:
367
+ module_out = mod
368
+
369
+ category = info.get("category", "Web Security")
370
+ if challenge_type == "vulnerability-hunter":
371
+ category = "Vulnerability Hunter"
372
+ elif challenge_type == "code-fixing":
373
+ category = "Code Fixing"
374
+ elif challenge_type == "log-analysis":
375
+ category = "Log Analysis"
376
+ elif challenge_type == "web":
377
+ category = "Web Security"
378
+ elif challenge_type == "crypto":
379
+ category = "Cryptography"
380
+
381
+ return {
382
+ "id": scenario["id"],
383
+ "title": scenario.get("title") or info.get("name", "سيناريو غير معروف"),
384
+ "module": module_out,
385
+ "path": info.get("path", "web-security"),
386
+ "category": category,
387
+ "difficulty": scenario.get("difficulty") or "متوسط",
388
+ "xpReward": scenario.get("xp_reward", 150),
389
+ }
390
+
391
+
392
+ def attach_scenario_metadata(
393
+ training: dict,
394
+ scenario: dict,
395
+ challenge_type: Optional[str] = None,
396
+ ) -> dict:
397
+ """Backfill id / difficulty / xpReward / type from a scenario row.
398
+
399
+ ``challenge_type`` is the canonical challenge type
400
+ (``"crypto"`` / ``"web"`` / ``"code-fixing"`` / ``"log-analysis"`` /
401
+ ``"vulnerability-hunter"``) of the table the scenario came from.
402
+ It is the source of truth for ``training["type"]`` — the previous
403
+ behaviour fell back to ``scenario.get("module")``, which after
404
+ the 011 normalization migration holds the *topic* (e.g. ``xss``)
405
+ not the *type* (which is ``web``), so a code-fixing challenge
406
+ could end up with ``type="web-security"`` and route to the wrong
407
+ editor.
408
+ """
409
+ scenario_id = str(scenario.get("id")) if scenario.get("id") else None
410
+ training["id"] = scenario_id
411
+ training["scenarioId"] = scenario_id
412
+
413
+ if challenge_type:
414
+ # Authoritative: overwrite whatever Groq may have set.
415
+ training["type"] = challenge_type
416
+ training["challengeType"] = challenge_type
417
+ else:
418
+ # Fallback to the in-training value, then to the canonical
419
+ # module of the row's table. The list endpoint always sets
420
+ # ``_challenge_type`` on the row, so this only fires in
421
+ # legacy code paths.
422
+ if not training.get("type"):
423
+ training["type"] = scenario.get("module")
424
+
425
+ # Keep the topic (the specific subject) accessible to the editor
426
+ # even though we no longer use the column for routing.
427
+ if not training.get("topic"):
428
+ training["topic"] = scenario.get("topic") or scenario.get("module")
429
+
430
+ if not training.get("difficulty"):
431
+ training["difficulty"] = scenario.get("difficulty", "متوسط")
432
+ if not training.get("xpReward"):
433
+ training["xpReward"] = scenario.get("xp_reward", 150)
434
+ return training
435
+
436
+
437
+ # --------------------------------------------------------------------------- #
438
+ # Background pool hooks #
439
+ # --------------------------------------------------------------------------- #
440
+
441
+ # State to keep track of rate limits and log only once
442
+ RATE_LIMIT_ALERTED = False
443
+
444
+
445
+ async def generate_and_store_scenario(
446
+ team_role: str,
447
+ module: str,
448
+ path: str = "web-security",
449
+ category: str = "web",
450
+ difficulty: str = "متوسط",
451
+ ):
452
+ global RATE_LIMIT_ALERTED
453
+ try:
454
+ groq_scenario = await generate_scenario_from_groq(team_role, module, path, category, difficulty)
455
+ db_scenario = map_groq_scenario_to_db(groq_scenario, module, difficulty)
456
+ await insert_scenario_to_supabase(db_scenario, team_role)
457
+ print(f"Background Generator: Stored a new {team_role} scenario ({difficulty}) for {module} in Supabase pool.")
458
+ RATE_LIMIT_ALERTED = False
459
+ except Exception as e:
460
+ if "429" in str(e) or "Rate Limit" in str(e):
461
+ if not RATE_LIMIT_ALERTED:
462
+ print("⚠️ [Groq AI] Rate Limit 429. Retrying...")
463
+ RATE_LIMIT_ALERTED = True
464
+ else:
465
+ import traceback
466
+ traceback.print_exc()
467
+ print(f"Background Scenario Generator Error for {team_role} - {module}: {e}")
468
+
469
+
470
+ async def handle_background_replacement(
471
+ scenario_id: str,
472
+ team_role: str,
473
+ module: str,
474
+ path: str,
475
+ category: str,
476
+ difficulty: str,
477
+ ):
478
+ """Consume (delete) a solved/used challenge from the pool.
479
+
480
+ Per AGENTS.md pool architecture: the pool watcher is the ONLY
481
+ component allowed to call Groq for refills, and only when the pool
482
+ count drops to POOL_THRESHOLD. We therefore just delete and let the
483
+ watcher catch up later.
484
+ """
485
+ from app.core.module_router import challenge_type_for_module
486
+ ctype = challenge_type_for_module(module)
487
+ try:
488
+ await delete_scenario_from_supabase(scenario_id, team_role, challenge_type=ctype)
489
+ print(f"Deleted solved {ctype} scenario {scenario_id} from Supabase. (refill deferred to pool watcher)")
490
+ except Exception as e:
491
+ print(f"Error in background scenario replacement task: {e}")
app/services/supabase_service.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Supabase PostgREST client for the per-type challenge tables.
2
+
3
+ Wraps the raw ``httpx`` calls in small, typed async functions. The
4
+ challenge-loader / scenario-service modules build on top of these.
5
+ """
6
+
7
+ import random
8
+ from typing import Optional
9
+
10
+ import httpx
11
+
12
+ from app.core.config import SUPABASE_URL, SUPABASE_ANON_KEY
13
+
14
+
15
+ # --------------------------------------------------------------------------- #
16
+ # Table routing #
17
+ # --------------------------------------------------------------------------- #
18
+
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"``, ``"web"``,
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 == "web":
28
+ return "web_exploitation_challenges"
29
+ if challenge_type == "crypto":
30
+ return "encryption_challenges"
31
+ if challenge_type == "code-fixing":
32
+ return "code_fixing_challenges"
33
+ if challenge_type == "log-analysis":
34
+ return "log_analysis_challenges"
35
+ if challenge_type == "vulnerability-hunter":
36
+ return "vulnerability_hunter_challenges"
37
+ # Auto-detect (legacy callers that pass only team_role still work)
38
+ return "encryption_challenges"
39
+
40
+
41
+ # --------------------------------------------------------------------------- #
42
+ # HTTP helpers #
43
+ # --------------------------------------------------------------------------- #
44
+
45
+ def supabase_headers(content_type: bool = False) -> dict:
46
+ headers = {
47
+ "apikey": SUPABASE_ANON_KEY,
48
+ "Authorization": f"Bearer {SUPABASE_ANON_KEY}",
49
+ }
50
+ if content_type:
51
+ headers["Content-Type"] = "application/json"
52
+ return headers
53
+
54
+
55
+ # --------------------------------------------------------------------------- #
56
+ # Scenario CRUD #
57
+ # --------------------------------------------------------------------------- #
58
+
59
+ async def get_supabase_scenario_count(team_role: str, challenge_type: str = "") -> int:
60
+ if not SUPABASE_ANON_KEY or not SUPABASE_URL:
61
+ return 0
62
+ table = scenario_table(team_role, challenge_type)
63
+ url = f"{SUPABASE_URL}/rest/v1/{table}?select=id"
64
+ try:
65
+ async with httpx.AsyncClient() as client:
66
+ resp = await client.get(url, headers=supabase_headers())
67
+ if resp.status_code == 200:
68
+ return len(resp.json())
69
+ except Exception as e:
70
+ print(f"Error checking Supabase scenario count: {e}")
71
+ return 0
72
+
73
+
74
+ async def fetch_scenario_by_id(
75
+ team_role: str, scenario_id: str, challenge_type: str = ""
76
+ ) -> Optional[dict]:
77
+ if not SUPABASE_ANON_KEY or not SUPABASE_URL:
78
+ return None
79
+ table = scenario_table(team_role, challenge_type)
80
+ url = f"{SUPABASE_URL}/rest/v1/{table}?id=eq.{scenario_id}&team_role=eq.{team_role}"
81
+ try:
82
+ async with httpx.AsyncClient() as client:
83
+ resp = await client.get(url, headers=supabase_headers())
84
+ if resp.status_code == 200:
85
+ rows = resp.json()
86
+ if rows:
87
+ return rows[0]
88
+ except Exception as e:
89
+ print(f"Error fetching scenario {scenario_id}: {e}")
90
+ return None
91
+
92
+
93
+ async def fetch_random_scenario_from_supabase(
94
+ team_role: str, module: str, challenge_type: str = ""
95
+ ) -> Optional[dict]:
96
+ if not SUPABASE_ANON_KEY or not SUPABASE_URL:
97
+ return None
98
+ table = scenario_table(team_role, challenge_type)
99
+ # When ``module`` is empty we deliberately skip the module filter
100
+ # — this is the "give me any row of this type" code path used by
101
+ # the dashboard list. The previous behaviour built a
102
+ # ``module=eq.`` clause, which matched nothing because the module
103
+ # column never stores an empty string.
104
+ params: list[str] = [f"team_role=eq.{team_role}"]
105
+ if module:
106
+ params.append(f"module=eq.{module}")
107
+ # Ask for a small page (PostgREST default is 1000). The pool sizes
108
+ # we deal with are < 30, so a single page is enough.
109
+ params.append("limit=50")
110
+ url = f"{SUPABASE_URL}/rest/v1/{table}?{'&'.join(params)}"
111
+ try:
112
+ async with httpx.AsyncClient() as client:
113
+ resp = await client.get(url, headers=supabase_headers())
114
+ if resp.status_code == 200:
115
+ scenarios = resp.json()
116
+ if scenarios:
117
+ return random.choice(scenarios)
118
+ except Exception as e:
119
+ print(f"Error fetching scenario from Supabase: {e}")
120
+ return None
121
+
122
+
123
+ async def fetch_all_scenarios_for_type(
124
+ team_role: str, challenge_type: str = "", limit: int = 100
125
+ ) -> list[dict]:
126
+ """Return every row for a (team, type) pair, optionally limited.
127
+
128
+ Used by ``/api/training/list`` — the dashboard wants a complete
129
+ catalogue, not a random sample. The function caps the result at
130
+ ``limit`` (default 100) so a runaway pool can't blow up the
131
+ payload.
132
+ """
133
+ if not SUPABASE_ANON_KEY or not SUPABASE_URL:
134
+ return []
135
+ table = scenario_table(team_role, challenge_type)
136
+ url = (
137
+ f"{SUPABASE_URL}/rest/v1/{table}"
138
+ f"?team_role=eq.{team_role}&limit={int(limit)}&order=created_at.desc"
139
+ )
140
+ try:
141
+ async with httpx.AsyncClient() as client:
142
+ resp = await client.get(url, headers=supabase_headers())
143
+ if resp.status_code == 200:
144
+ rows = resp.json()
145
+ return list(rows) if isinstance(rows, list) else []
146
+ except Exception as e:
147
+ print(f"Error fetching scenarios for {table}: {e}")
148
+ return []
149
+
150
+
151
+ async def delete_scenario_from_supabase(
152
+ scenario_id: str, team_role: str, challenge_type: str = ""
153
+ ):
154
+ if not SUPABASE_ANON_KEY or not SUPABASE_URL:
155
+ return
156
+ table = scenario_table(team_role, challenge_type)
157
+ url = f"{SUPABASE_URL}/rest/v1/{table}?id=eq.{scenario_id}&team_role=eq.{team_role}"
158
+ try:
159
+ async with httpx.AsyncClient() as client:
160
+ await client.delete(url, headers=supabase_headers())
161
+ except Exception as e:
162
+ print(f"Error deleting scenario {scenario_id} from Supabase: {e}")
163
+
164
+
165
+ async def insert_scenario_to_supabase(
166
+ scenario_data: dict, team_role: str, challenge_type: str = ""
167
+ ) -> Optional[dict]:
168
+ if not SUPABASE_ANON_KEY or not SUPABASE_URL:
169
+ return None
170
+ table = scenario_table(team_role, challenge_type)
171
+ url = f"{SUPABASE_URL}/rest/v1/{table}"
172
+ headers = supabase_headers(content_type=True)
173
+ headers["Prefer"] = "return=representation"
174
+ try:
175
+ async with httpx.AsyncClient() as client:
176
+ resp = await client.post(url, json=scenario_data, headers=headers)
177
+ if resp.status_code in (200, 201):
178
+ rows = resp.json()
179
+ if rows:
180
+ return rows[0]
181
+ except Exception as e:
182
+ print(f"Error inserting scenario to Supabase: {e}")
183
+ return None
app/types.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic request models, one block per concern.
2
+
3
+ Kept in a single file because they're all small BaseModel definitions
4
+ and importing them from one place makes the routers easy to scan.
5
+ """
6
+
7
+ from typing import Optional
8
+ from pydantic import BaseModel
9
+
10
+
11
+ # ---------- Auth ---------- #
12
+
13
+ class AuthRequest(BaseModel):
14
+ action: str
15
+ email: str
16
+ password: str
17
+ name: Optional[str] = None
18
+
19
+
20
+ # ---------- XP ---------- #
21
+
22
+ class XpRequest(BaseModel):
23
+ action: str
24
+ user_id: Optional[str] = None
25
+ xp_amount: Optional[int] = None
26
+
27
+
28
+ # ---------- Training (generic / red-team web) ---------- #
29
+
30
+ class TrainingRequest(BaseModel):
31
+ module: str
32
+ path: str
33
+ category: str
34
+ moduleId: Optional[str] = None
35
+ teamRole: Optional[str] = "red"
36
+ challengeId: Optional[str] = None
37
+
38
+
39
+ class EvaluateRequest(BaseModel):
40
+ action: str = "evaluate"
41
+ originalChallenge: dict
42
+ userCode: str
43
+ teamRole: Optional[str] = "red"
44
+
45
+
46
+ class WebEvaluateRequest(BaseModel):
47
+ """3-layer validation payload for web exploitation challenges (v2)."""
48
+ challengeId: str
49
+ payload: str
50
+ teamRole: Optional[str] = "red"
51
+ exploitSignal: Optional[dict] = None
52
+
53
+
54
+ # ---------- Terminal ---------- #
55
+
56
+ class TerminalRequest(BaseModel):
57
+ teamRole: Optional[str] = "red"
58
+ challengeId: str
59
+ command: str
60
+
61
+
62
+ class TerminalWriteRequest(BaseModel):
63
+ teamRole: Optional[str] = "red"
64
+ challengeId: str
65
+ filename: str
66
+ content: str
67
+
68
+
69
+ class TerminalListRequest(BaseModel):
70
+ teamRole: Optional[str] = "red"
71
+ challengeId: str
72
+
73
+
74
+ # ---------- Certificates ---------- #
75
+
76
+ class CertificateRequest(BaseModel):
77
+ action: str = "list"
78
+ user_id: str
79
+ category: Optional[str] = None
80
+ verify_code: Optional[str] = None
81
+ details: Optional[dict] = None
82
+
83
+
84
+ # ---------- Solved (consume one from the pool) ---------- #
85
+
86
+ class SolvedRequest(BaseModel):
87
+ scenarioId: str
88
+ module: Optional[str] = None
89
+ teamRole: Optional[str] = "red"
90
+ difficulty: Optional[str] = "متوسط"
91
+ path: Optional[str] = "web-security"
92
+ category: Optional[str] = "web"
93
+
94
+
95
+ # ---------- Blue-team evaluators ---------- #
96
+
97
+ class CodeFixEvaluateRequest(BaseModel):
98
+ """Request model for code-fixing challenge evaluation."""
99
+ challengeId: str
100
+ fixedCode: str
101
+ teamRole: Optional[str] = "blue"
102
+ userId: Optional[str] = None
103
+
104
+
105
+ class LogAnalysisEvaluateRequest(BaseModel):
106
+ """Request model for log-analysis challenge evaluation."""
107
+ challengeId: str
108
+ attackType: str
109
+ attackerIp: str
110
+ timestamp: str
111
+ ioc: str
112
+ explanation: str = ""
113
+ teamRole: Optional[str] = "blue"
114
+ userId: Optional[str] = None
115
+
116
+
117
+ class VulnHunterEvaluateRequest(BaseModel):
118
+ """Request model for vulnerability-hunter challenge evaluation."""
119
+ challengeId: str
120
+ vulnerabilityType: str
121
+ teamRole: Optional[str] = "blue"
122
+ userId: Optional[str] = None
apply_migration_005.py DELETED
@@ -1,169 +0,0 @@
1
- """
2
- apply_migration_005.py
3
- ======================
4
- Applies migration 005 to create the `web_exploitation_challenges` table.
5
-
6
- Tries three strategies in order:
7
- 1. Supabase Management API (requires SUPABASE_ACCESS_TOKEN env var)
8
- 2. Direct PostgreSQL connection (requires DATABASE_URL env var)
9
- 3. Fall back to: print the SQL + clipboard + step-by-step instructions
10
-
11
- Usage:
12
- python apply_migration_005.py # tries strategies 1+2 then prints
13
- python apply_migration_005.py --print # skip strategies, just print SQL
14
- python apply_migration_005.py --copy # print + copy to clipboard
15
- """
16
- import argparse
17
- import os
18
- import sys
19
- import time
20
- from pathlib import Path
21
-
22
- # Force UTF-8 output
23
- sys.stdout.reconfigure(encoding="utf-8", errors="replace")
24
-
25
- # Load .env if available
26
- try:
27
- from dotenv import load_dotenv
28
- _env_path = Path(__file__).parent / ".env"
29
- if _env_path.exists():
30
- load_dotenv(_env_path)
31
- except ImportError:
32
- pass
33
-
34
- MIGRATION_FILE = Path(__file__).parent / "migrations" / "005_web_exploitation_challenges.sql"
35
- SUPABASE_URL = os.environ.get("SUPABASE_URL", "https://yevtnyokixocpihpdwqu.supabase.co")
36
- SUPABASE_PROJECT_ID = SUPABASE_URL.replace("https://", "").replace("http://", "").split(".")[0]
37
- SUPABASE_ACCESS_TOKEN = os.environ.get("SUPABASE_ACCESS_TOKEN", "")
38
- DATABASE_URL = os.environ.get("DATABASE_URL", "")
39
-
40
-
41
- def banner(msg: str) -> None:
42
- print()
43
- print("=" * 70)
44
- print(f" {msg}")
45
- print("=" * 70)
46
-
47
-
48
- def print_sql() -> None:
49
- print(MIGRATION_FILE.read_text(encoding="utf-8"))
50
-
51
-
52
- def try_supabase_management_api() -> bool:
53
- """Strategy 1: use the Supabase Management API to run SQL.
54
-
55
- Requires a personal access token from https://supabase.com/dashboard/account/tokens
56
- in SUPABASE_ACCESS_TOKEN env var.
57
- """
58
- if not SUPABASE_ACCESS_TOKEN:
59
- return False
60
-
61
- import httpx
62
-
63
- banner("Strategy 1: Supabase Management API")
64
- print(f"Project: {SUPABASE_PROJECT_ID}")
65
-
66
- sql = MIGRATION_FILE.read_text(encoding="utf-8")
67
- url = f"https://api.supabase.com/v1/projects/{SUPABASE_PROJECT_ID}/database/query"
68
- headers = {
69
- "Authorization": f"Bearer {SUPABASE_ACCESS_TOKEN}",
70
- "Content-Type": "application/json",
71
- }
72
- try:
73
- with httpx.Client(timeout=30) as client:
74
- resp = client.post(url, headers=headers, json={"query": sql})
75
- if resp.status_code == 200:
76
- print("✓ Migration applied via Management API")
77
- return True
78
- else:
79
- print(f"✗ Management API returned {resp.status_code}: {resp.text[:200]}")
80
- return False
81
- except Exception as e:
82
- print(f"✗ Management API failed: {e}")
83
- return False
84
-
85
-
86
- def try_direct_postgres() -> bool:
87
- """Strategy 2: connect directly to the Postgres database.
88
-
89
- Requires DATABASE_URL env var (e.g. postgresql://postgres:PASS@db.PROJECT.supabase.co:5432/postgres)
90
- Find this in Supabase Dashboard > Settings > Database > Connection string.
91
- """
92
- if not DATABASE_URL:
93
- return False
94
-
95
- banner("Strategy 2: Direct Postgres connection")
96
- try:
97
- import psycopg2
98
- except ImportError:
99
- print("✗ psycopg2 not installed. Run: pip install psycopg2-binary")
100
- return False
101
-
102
- sql = MIGRATION_FILE.read_text(encoding="utf-8")
103
- try:
104
- conn = psycopg2.connect(DATABASE_URL)
105
- conn.autocommit = True
106
- with conn.cursor() as cur:
107
- cur.execute(sql)
108
- conn.close()
109
- print("✓ Migration applied via direct Postgres connection")
110
- return True
111
- except Exception as e:
112
- print(f"✗ Direct Postgres failed: {e}")
113
- return False
114
-
115
-
116
- def fallback_manual() -> None:
117
- """Strategy 3: print the SQL and clear instructions."""
118
- banner("Strategy 3: Manual — paste into Supabase SQL Editor")
119
- print()
120
- print("The migration SQL is printed below.")
121
- print("To apply it manually:")
122
- print(f" 1. Open: https://supabase.com/dashboard/project/{SUPABASE_PROJECT_ID}/sql/new")
123
- print(" 2. Paste the SQL into the editor")
124
- print(" 3. Click 'Run' (or press Ctrl+Enter)")
125
- print()
126
- print("-" * 70)
127
- print_sql()
128
- print("-" * 70)
129
-
130
- # Optionally copy to clipboard (Windows)
131
- if "--copy" in sys.argv:
132
- try:
133
- import subprocess
134
- sql = MIGRATION_FILE.read_text(encoding="utf-8")
135
- p = subprocess.Popen(["clip"], stdin=subprocess.PIPE, shell=True)
136
- p.communicate(sql.encode("utf-16le"))
137
- print("\n✓ SQL copied to clipboard")
138
- except Exception as e:
139
- print(f"\n(Clipboard copy failed: {e})")
140
-
141
-
142
- def main():
143
- parser = argparse.ArgumentParser()
144
- parser.add_argument("--print", action="store_true", help="Only print the SQL, skip auto-apply")
145
- parser.add_argument("--copy", action="store_true", help="Print + copy to clipboard")
146
- args = parser.parse_args()
147
-
148
- banner("CyberArena — Migration 005 (web_exploitation_challenges)")
149
-
150
- if args.print:
151
- print_sql()
152
- return
153
-
154
- # Try automatic strategies first
155
- if try_supabase_management_api():
156
- return
157
- if try_direct_postgres():
158
- return
159
-
160
- # Fall back to manual
161
- fallback_manual()
162
-
163
- print()
164
- print("After applying the migration, restart the backend.")
165
- print("The [webex:red] pool watcher will populate 5 challenges within ~8 seconds.")
166
-
167
-
168
- if __name__ == "__main__":
169
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apply_migration_009.py DELETED
@@ -1,208 +0,0 @@
1
- """
2
- Apply migration 009 (1v1 mode tables) to the Supabase project.
3
-
4
- ================================================================
5
- 3 ways to run this. Pick one:
6
- ================================================================
7
-
8
- A) RECOMMENDED — one-shot via the Supabase SQL editor
9
- 1. Open https://app.supabase.com/project/yevtnyokixocpihpdwqu/sql/new
10
- 2. Paste the contents of backend/migrations/009_onevone_mode.sql
11
- 3. Click "Run"
12
- (no service key needed, takes 5 seconds)
13
-
14
- B) AUTOMATIC — this script, if you set SUPABASE_SERVICE_KEY in backend/.env
15
- Get the key from: https://app.supabase.com/project/yevtnyokixocpihpdwqu/settings/api
16
- → service_role / secret (NOT the anon key)
17
-
18
- C) PSQL DIRECT — if you have the postgres connection string
19
- psql "postgresql://postgres:[PASSWORD]@db.yevtnyokixocpihpdwqu.supabase.co:5432/postgres" \
20
- -f backend/migrations/009_onevone_mode.sql
21
-
22
- ================================================================
23
- The script tries method B first, then falls back to printing the SQL
24
- for method A. It's idempotent — every CREATE uses IF NOT EXISTS.
25
- ================================================================
26
- """
27
-
28
- import os
29
- import sys
30
- import httpx
31
- from dotenv import load_dotenv
32
-
33
- backend_dir = os.path.dirname(os.path.abspath(__file__))
34
- load_dotenv(dotenv_path=os.path.join(backend_dir, ".env"))
35
-
36
- SUPABASE_URL = os.environ.get("SUPABASE_URL", "https://yevtnyokixocpihpdwqu.supabase.co").rstrip("/")
37
- SUPABASE_ANON_KEY = os.environ.get("SUPABASE_ANON_KEY", "")
38
- SUPABASE_SERVICE_KEY = os.environ.get("SUPABASE_SERVICE_KEY", "").strip()
39
- MIGRATION_PATH = os.path.join(backend_dir, "migrations", "009_onevone_mode.sql")
40
-
41
-
42
- def read_sql() -> str:
43
- with open(MIGRATION_PATH, "r", encoding="utf-8") as f:
44
- return f.read()
45
-
46
-
47
- def split_sql(sql: str) -> list[str]:
48
- """Split a SQL script into individual statements (semi-colon separated,
49
- ignoring those inside $$ ... $$ blocks)."""
50
- statements: list[str] = []
51
- buf: list[str] = []
52
- in_dollar = False
53
- for raw in sql.splitlines():
54
- line = raw
55
- if line.strip().startswith("--") and not buf:
56
- continue
57
- buf.append(line)
58
- if "$$" in line:
59
- in_dollar = not in_dollar
60
- if line.rstrip().endswith(";") and not in_dollar:
61
- stmt = "\n".join(buf).strip().rstrip(";").strip()
62
- if stmt:
63
- statements.append(stmt)
64
- buf = []
65
- if buf:
66
- tail = "\n".join(buf).strip().rstrip(";").strip()
67
- if tail:
68
- statements.append(tail)
69
- return statements
70
-
71
-
72
- def try_pg_endpoint(path: str, headers: dict, statements: list[str]) -> bool:
73
- url = f"{SUPABASE_URL}{path}"
74
- print(f" → trying {url}")
75
- try:
76
- with httpx.Client(timeout=60) as client:
77
- for i, stmt in enumerate(statements, 1):
78
- preview = stmt.splitlines()[0][:70]
79
- r = client.post(url, headers=headers, json={"query": stmt})
80
- if r.status_code in (200, 201):
81
- print(f" [{i}/{len(statements)}] OK {preview}")
82
- else:
83
- body = r.text[:200]
84
- if " PGREST205" in body or "schema cache" in body or "must be owner" in body:
85
- return False
86
- print(f" [{i}/{len(statements)}] FAIL HTTP {r.status_code} {preview}")
87
- print(f" {body}")
88
- return False
89
- return True
90
- except Exception as e:
91
- print(f" → network error: {e}")
92
- return False
93
-
94
-
95
- def apply_via_pg_meta() -> bool:
96
- if not SUPABASE_SERVICE_KEY:
97
- return False
98
- sql = read_sql()
99
- statements = split_sql(sql)
100
- print(f"Attempting pg-meta apply with {len(statements)} statements ...")
101
- headers = {
102
- "apikey": SUPABASE_SERVICE_KEY,
103
- "Authorization": f"Bearer {SUPABASE_SERVICE_KEY}",
104
- "Content-Type": "application/json",
105
- }
106
- # try common paths
107
- for path in ("/pg/query", "/pg-meta/query", "/rest/v1/rpc/exec_sql"):
108
- if try_pg_endpoint(path, headers, statements):
109
- return True
110
- return False
111
-
112
-
113
- def apply_via_management_api() -> bool:
114
- """Try the Supabase management API (requires access token, not service key)."""
115
- access = os.environ.get("SUPABASE_ACCESS_TOKEN", "").strip()
116
- if not access:
117
- return False
118
- project_ref = SUPABASE_URL.split("//")[-1].split(".")[0]
119
- url = f"https://api.supabase.com/v1/projects/{project_ref}/database/query"
120
- sql = read_sql()
121
- headers = {
122
- "Authorization": f"Bearer {access}",
123
- "Content-Type": "application/json",
124
- }
125
- print(f"Attempting Management API apply ...")
126
- try:
127
- with httpx.Client(timeout=60) as client:
128
- r = client.post(url, headers=headers, json={"query": sql})
129
- if r.status_code in (200, 201):
130
- print(f" → OK")
131
- return True
132
- print(f" → HTTP {r.status_code}: {r.text[:200]}")
133
- except Exception as e:
134
- print(f" → network error: {e}")
135
- return False
136
-
137
-
138
- def show_manual_steps() -> None:
139
- print()
140
- print("=" * 70)
141
- print("MANUAL STEPS (5 seconds, no token needed)")
142
- print("=" * 70)
143
- print()
144
- print("1. Open this URL in your browser:")
145
- print(f" https://app.supabase.com/project/{SUPABASE_URL.split('//')[-1].split('.')[0]}/sql/new")
146
- print()
147
- print("2. Paste the following SQL:")
148
- print("-" * 70)
149
- print(read_sql())
150
- print("-" * 70)
151
- print()
152
- print("3. Click 'Run' (or press Ctrl+Enter)")
153
- print()
154
- print("4. Re-run this script to verify:")
155
- print(" python apply_migration_009.py --verify")
156
- print()
157
-
158
-
159
- def verify() -> bool:
160
- """Hit a known table via PostgREST to confirm it exists."""
161
- if not SUPABASE_ANON_KEY:
162
- print("!! SUPABASE_ANON_KEY missing — cannot verify")
163
- return False
164
- tables = [
165
- "onevone_rooms", "onevone_players",
166
- "onevone_matches", "onevone_submissions",
167
- ]
168
- headers = {
169
- "apikey": SUPABASE_ANON_KEY,
170
- "Authorization": f"Bearer {SUPABASE_ANON_KEY}",
171
- }
172
- all_ok = True
173
- print("Verifying 1v1 tables exist ...")
174
- with httpx.Client(timeout=20) as client:
175
- for t in tables:
176
- r = client.get(f"{SUPABASE_URL}/rest/v1/{t}?select=id&limit=1", headers=headers)
177
- if r.status_code == 200:
178
- print(f" [OK] {t}")
179
- else:
180
- body = r.text[:120].replace("\n", " ")
181
- print(f" [!!] {t} → HTTP {r.status_code}: {body}")
182
- all_ok = False
183
- return all_ok
184
-
185
-
186
- def main() -> int:
187
- if "--verify" in sys.argv:
188
- return 0 if verify() else 2
189
-
190
- if not os.path.exists(MIGRATION_PATH):
191
- print(f"!! migration file not found: {MIGRATION_PATH}")
192
- return 1
193
-
194
- if apply_via_pg_meta():
195
- print("\n[OK] Migration applied via pg-meta.")
196
- verify()
197
- return 0
198
- if apply_via_management_api():
199
- print("\n[OK] Migration applied via management API.")
200
- verify()
201
- return 0
202
-
203
- show_manual_steps()
204
- return 2
205
-
206
-
207
- if __name__ == "__main__":
208
- sys.exit(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
challenge_files/blue_android-ios_1780374909_app_logs.txt DELETED
@@ -1,3 +0,0 @@
1
- 2023-02-15 14:30:00 - Sent data to 192.168.1.100:8080
2
- 2023-02-15 14:31:00 - Received response from 192.168.1.100:8080
3
- 2023-02-15 14:32:00 - Sent data to 192.168.1.101:8080
 
 
 
 
challenge_files/blue_binary-analysis_1780074195_MalwareX.exe DELETED
@@ -1 +0,0 @@
1
- 00000000 4d 5a 90 00 03 00 00 00 04 00 00 00 ff ff 00 00
 
 
challenge_files/blue_binary-analysis_1780439585_MalwareX.exe DELETED
@@ -1,16 +0,0 @@
1
- 00000000 4d 5a 90 00 03 00 00 00 04 00 00 00 ff ff 00 00 |MZ..............|
2
- 00000010 b8 00 00 00 00 00 00 00 40 00 00 00 00 00 00 00 |........@.......|
3
- 00000020 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
4
- 00000030 00 00 00 00 00 00 00 00 00 10 00 00 00 02 00 00 |................|
5
- 00000040 14 01 00 00 00 10 00 00 00 00 00 00 00 00 00 00 |................|
6
- 00000050 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
7
- 00000060 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
8
- 00000070 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
9
- 00000080 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
10
- 00000090 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
11
- 000000a0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
12
- 000000b0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
13
- 000000c0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
14
- 000000d0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
15
- 000000e0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
16
- 000000f0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
challenge_files/blue_cloud-config_1780424632_docker-compose.yml DELETED
@@ -1 +0,0 @@
1
- version: '3'<br/>services:<br/> web:<br/> build: .<br/> ports:<br/> - "80:80"<br/> volumes:<br/> - .:/app
 
 
challenge_files/blue_csrf_1780074134_index.html DELETED
@@ -1 +0,0 @@
1
- <!DOCTYPE html><html><head><title>تعديل الملفات الشخصية</title></head><body><h1>تعديل الملفات الشخصية</h1><form action="/edit-profile" method="post"><input type="hidden" name="csrf_token" value="1234567890"><input type="text" name="username" placeholder="اسم المستخدم"><input type="email" name="email" placeholder="البريد الإلكتروني"><input type="password" name="password" placeholder="كلمة المرور"><button type="submit">حفظ التغييرات</button></form></body></html>
 
 
challenge_files/blue_dns-poisoning_1780073949_dns-logs.txt DELETED
@@ -1,3 +0,0 @@
1
- 2023-02-16 14:30:00, 192.168.1.100, 8.8.8.8, A, apex.com, 192.168.1.200
2
- 2023-02-16 14:31:00, 192.168.1.101, 8.8.8.8, A, apex.com, 192.168.1.201
3
- 2023-02-16 14:32:00, 192.168.1.102, 8.8.8.8, A, apex.com, 192.168.1.202
 
 
 
 
challenge_files/blue_encryption-basics_1780073989_encrypted_message.txt DELETED
@@ -1 +0,0 @@
1
- GUR PENML XRL VF ZL FRPERG CBFG
 
 
challenge_files/blue_encryption-basics_1780074215_hashes.txt DELETED
@@ -1 +0,0 @@
1
- 098f6bcd4621d373cade4e832627b4f6
 
 
challenge_files/blue_encryption-basics_1780430362_encryption_logs.txt DELETED
@@ -1,3 +0,0 @@
1
- 2022-01-01 12:00:00 - ملف1.txt - md5
2
- 2022-01-01 12:05:00 - ملف2.txt - sha1
3
- 2022-01-01 12:10:00 - ملف3.txt - md5
 
 
 
 
challenge_files/blue_encryption-basics_1780439473_file.enc DELETED
@@ -1 +0,0 @@
1
- md5_hash_here