diff --git a/backend/app/routers/system.py b/backend/app/routers/system.py index 8422d23..33c9b6a 100644 --- a/backend/app/routers/system.py +++ b/backend/app/routers/system.py @@ -515,20 +515,18 @@ def list_evaluation_rounds( Each entry includes whether it has already been imported into this platform. """ - from app.services.attck_evaluations_service import fetch_available_rounds + from app.services.attck_evaluations_service import fetch_rounds_with_status from app.models.evaluation_import import EvaluationImport - try: - rounds = fetch_available_rounds() - except Exception as exc: - raise HTTPException(status_code=502, detail=f"Could not reach MITRE Evaluations API: {exc}") + status_info = fetch_rounds_with_status() + rounds = status_info["rounds"] imported = { row.adversary_name.lower(): row for row in db.query(EvaluationImport).filter(EvaluationImport.status == "completed").all() } - return [ + round_list = [ { "name": r["name"], "display_name": r.get("display_name", r["name"]), @@ -544,6 +542,12 @@ def list_evaluation_rounds( for r in rounds ] + return { + "rounds": round_list, + "api_reachable": status_info["api_reachable"], + "api_error": status_info.get("api_error"), + } + @router.post("/attck-evaluations/import") def import_evaluation_round( diff --git a/backend/app/services/attck_evaluations_service.py b/backend/app/services/attck_evaluations_service.py index 7750cec..3cc6e74 100644 --- a/backend/app/services/attck_evaluations_service.py +++ b/backend/app/services/attck_evaluations_service.py @@ -49,6 +49,62 @@ _TIMEOUT = 30 # seconds per HTTP call _VENDOR = "crowdstrike" _DOMAIN = "ENTERPRISE" +# Browser-like headers to bypass Cloudflare bot protection on evals.mitre.org +_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/124.0.0.0 Safari/537.36" + ), + "Accept": "application/json, text/plain, */*", + "Accept-Language": "en-US,en;q=0.9", + "Referer": "https://evals.mitre.org/", + "Origin": "https://evals.mitre.org", +} + +# --------------------------------------------------------------------------- +# Fallback: hardcoded public CrowdStrike ENTERPRISE rounds +# Used when evals.mitre.org API is unreachable (Cloudflare, outage, etc.) +# These are well-known, publicly documented rounds — safe to hardcode. +# --------------------------------------------------------------------------- +_FALLBACK_ROUNDS: list[dict[str, Any]] = [ + { + "name": "apt29", + "display_name": "APT29", + "eval_round": 2, + "domain": "ENTERPRISE", + "status": "PUBLIC", + }, + { + "name": "carbanak_fin7", + "display_name": "Carbanak+FIN7", + "eval_round": 3, + "domain": "ENTERPRISE", + "status": "PUBLIC", + }, + { + "name": "wizard_spider_sandworm", + "display_name": "Wizard Spider + Sandworm", + "eval_round": 4, + "domain": "ENTERPRISE", + "status": "PUBLIC", + }, + { + "name": "turla", + "display_name": "Turla", + "eval_round": 5, + "domain": "ENTERPRISE", + "status": "PUBLIC", + }, + { + "name": "oilrig", + "display_name": "OilRig", + "eval_round": 6, + "domain": "ENTERPRISE", + "status": "PUBLIC", + }, +] + # Detection type → quality score (higher = better) _DETECTION_SCORE: dict[str, int] = { "none": 0, @@ -84,26 +140,45 @@ def _score_to_result(score: int) -> TestResult: # --------------------------------------------------------------------------- -def fetch_available_rounds() -> list[dict[str, Any]]: - """Return all evaluation rounds CrowdStrike has completed (ENTERPRISE only). +def fetch_rounds_with_status() -> dict[str, Any]: + """Fetch CrowdStrike ENTERPRISE rounds and report whether the live API was reachable. - Each dict has: name, display_name, eval_round. - Sorted by eval_round ascending. + Returns:: + + { + "rounds": [{"name": ..., "display_name": ..., "eval_round": ...}, ...], + "api_reachable": True | False, + "api_error": None | "", + } """ try: - resp = requests.get(f"{_BASE}/api/participants/", timeout=_TIMEOUT) + session = requests.Session() + session.headers.update(_HEADERS) + resp = session.get(f"{_BASE}/api/participants/", timeout=_TIMEOUT) resp.raise_for_status() participants = resp.json() except Exception as exc: - logger.error("Failed to fetch ATT&CK Evaluations participants: %s", exc) - raise + logger.warning( + "evals.mitre.org API unreachable (%s) — using hardcoded fallback round list.", + exc, + ) + return { + "rounds": list(_FALLBACK_ROUNDS), + "api_reachable": False, + "api_error": str(exc), + } crowdstrike = next( (p for p in participants if p.get("name", "").lower() == _VENDOR), None, ) if not crowdstrike: - raise ValueError(f"Vendor '{_VENDOR}' not found in evaluations participants list") + logger.warning("Vendor '%s' not found in live data — using fallback.", _VENDOR) + return { + "rounds": list(_FALLBACK_ROUNDS), + "api_reachable": True, # API was reachable, vendor just wasn't listed + "api_error": f"Vendor '{_VENDOR}' not found in participants list", + } rounds = [ adv @@ -112,7 +187,22 @@ def fetch_available_rounds() -> list[dict[str, Any]]: and adv.get("status", "").upper() == "PUBLIC" ] rounds.sort(key=lambda x: x.get("eval_round", 0)) - return rounds + return { + "rounds": rounds if rounds else list(_FALLBACK_ROUNDS), + "api_reachable": True, + "api_error": None, + } + + +def fetch_available_rounds() -> list[dict[str, Any]]: + """Return all evaluation rounds CrowdStrike has completed (ENTERPRISE only). + + Each dict has: name, display_name, eval_round. + Sorted by eval_round ascending. + + Falls back to ``_FALLBACK_ROUNDS`` if the live API is unreachable. + """ + return fetch_rounds_with_status()["rounds"] def get_latest_round() -> dict[str, Any]: @@ -131,7 +221,9 @@ def fetch_results_for_adversary(adversary_name: str) -> list[dict[str, Any]]: """ url = f"{_BASE}/api/results/?participant={_VENDOR}&domain={_DOMAIN}" try: - resp = requests.get(url, timeout=_TIMEOUT) + session = requests.Session() + session.headers.update(_HEADERS) + resp = session.get(url, timeout=_TIMEOUT) resp.raise_for_status() data = resp.json() except Exception as exc: diff --git a/frontend/src/api/system.ts b/frontend/src/api/system.ts index 7d568a4..63bc7b3 100644 --- a/frontend/src/api/system.ts +++ b/frontend/src/api/system.ts @@ -53,6 +53,12 @@ export interface EvaluationRound { techniques_covered: number | null; } +export interface EvaluationRoundsResponse { + rounds: EvaluationRound[]; + api_reachable: boolean; + api_error: string | null; +} + export interface EvaluationImportResult { message: string; created: number; @@ -70,8 +76,8 @@ export interface NewRoundCheckResult { } /** List all public CrowdStrike evaluation rounds with import status. */ -export async function listEvaluationRounds(): Promise { - const { data } = await client.get("/system/attck-evaluations/rounds"); +export async function listEvaluationRounds(): Promise { + const { data } = await client.get("/system/attck-evaluations/rounds"); return data; } diff --git a/frontend/src/pages/SystemPage.tsx b/frontend/src/pages/SystemPage.tsx index 32ace86..49147fc 100644 --- a/frontend/src/pages/SystemPage.tsx +++ b/frontend/src/pages/SystemPage.tsx @@ -39,6 +39,7 @@ import { type SyncMitreResponse, type IntelScanResponse, type EvaluationRound, + type EvaluationRoundsResponse, type EvaluationImportResult, type NewRoundCheckResult, } from "../api/system"; @@ -164,14 +165,18 @@ export default function SystemPage() { // ── ATT&CK Evaluations queries & mutations ─────────────────────── const { - data: evalRounds, + data: evalRoundsData, isLoading: evalRoundsLoading, refetch: refetchEvalRounds, - } = useQuery({ + } = useQuery({ queryKey: ["eval-rounds"], queryFn: listEvaluationRounds, }); + const evalRounds = evalRoundsData?.rounds; + const evalApiReachable = evalRoundsData?.api_reachable ?? true; + const evalApiError = evalRoundsData?.api_error ?? null; + const checkNewRoundMutation = useMutation({ mutationFn: checkNewEvaluationRound, onSuccess: (data) => { @@ -433,6 +438,19 @@ export default function SystemPage() { + {/* API fallback warning */} + {!evalApiReachable && evalApiError && ( +
+ +
+ Live API unavailable + {" — "}showing known public rounds (hardcoded). Importing will attempt to fetch + live results and may also fail if the API remains unreachable. + {evalApiError} +
+
+ )} + {/* New round check result */} {evalCheckResult && (
) : ( -
- - Unable to load evaluation rounds. Check network connectivity to evals.mitre.org. +
+

No evaluation rounds available.

+ {evalApiError && ( +

{evalApiError}

+ )}
)}