Spaces:
Sleeping
Sleeping
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
- .gitignore +5 -0
- README.md +164 -10
- app/__init__.py +15 -0
- app/_env.py +172 -0
- app/api/__init__.py +24 -0
- app/api/auth.py +23 -0
- app/api/certificates.py +34 -0
- app/api/leaderboard.py +48 -0
- onevone_router.py → app/api/onevone.py +20 -15
- app/api/terminal.py +79 -0
- app/api/training.py +387 -0
- app/api/xp.py +26 -0
- app/core/__init__.py +68 -0
- app/core/config.py +113 -0
- app/core/constants.py +153 -0
- app/core/module_router.py +19 -0
- app/core/security.py +61 -0
- app/core/text.py +42 -0
- app/generators/__init__.py +40 -0
- code_fixing_generator.py → app/generators/code_fixing.py +79 -6
- crypto_generator.py → app/generators/crypto.py +131 -4
- log_analysis_generator.py → app/generators/log_analysis.py +77 -5
- vulnerability_hunter_generator.py → app/generators/vulnerability_hunter.py +78 -5
- web_exploitation_generator.py → app/generators/web.py +113 -4
- app/main.py +117 -0
- app/sandbox/__init__.py +19 -0
- app/sandbox/terminal.py +103 -0
- app/sandbox/tools.py +115 -0
- app/sandbox/workdir.py +72 -0
- app/services/__init__.py +119 -0
- app/services/certificate_service.py +464 -0
- app/services/challenge_loader.py +206 -0
- app/services/completion_service.py +64 -0
- app/services/evaluator.py +715 -0
- app/services/file_storage.py +68 -0
- app/services/scenario_service.py +491 -0
- app/services/supabase_service.py +183 -0
- app/types.py +122 -0
- apply_migration_005.py +0 -169
- apply_migration_009.py +0 -208
- challenge_files/blue_android-ios_1780374909_app_logs.txt +0 -3
- challenge_files/blue_binary-analysis_1780074195_MalwareX.exe +0 -1
- challenge_files/blue_binary-analysis_1780439585_MalwareX.exe +0 -16
- challenge_files/blue_cloud-config_1780424632_docker-compose.yml +0 -1
- challenge_files/blue_csrf_1780074134_index.html +0 -1
- challenge_files/blue_dns-poisoning_1780073949_dns-logs.txt +0 -3
- challenge_files/blue_encryption-basics_1780073989_encrypted_message.txt +0 -1
- challenge_files/blue_encryption-basics_1780074215_hashes.txt +0 -1
- challenge_files/blue_encryption-basics_1780430362_encryption_logs.txt +0 -3
- 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 256 |
-
evaluation = await
|
| 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 |
-
|
| 262 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 281 |
-
|
| 282 |
-
|
|
|
|
| 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
|
| 730 |
-
from
|
| 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=
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
# ----
|
| 1901 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 1196 |
-
print(f"[log-analysis] Pre-warm done: +{added}
|
| 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=
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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=
|
| 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
|
|
|
|
|
|