From e3e79be35a24fc6141c90d6350d19670de0a7dcf Mon Sep 17 00:00:00 2001 From: kitos Date: Fri, 5 Jun 2026 15:57:03 +0200 Subject: [PATCH] feat(evaluations): ATT&CK Evaluations importer for CrowdStrike Falcon [FASE-6.1] - Migration b048: evaluation_imports table (adversary, round, status, tests_created) - EvaluationImport SQLAlchemy model - attck_evaluations_service: fetch rounds from evals.mitre.org API, import per-technique detection results (Technique/Tactic/Telemetry -> detected/partially/not_detected) - All imported tests land in in_review state with lab-environment disclaimer - Idempotency guard prevents duplicate round imports - 4 new endpoints: list rounds, import specific, import latest, check-new - Weekly APScheduler cron (Mon 06:00) auto-checks and imports new rounds - SystemPage UI: rounds table, import buttons, check-new, result feedback - Disclaimer callout reminding admins these are lab results not org coverage Co-Authored-By: Claude Sonnet 4.6 --- .../versions/b048_evaluation_imports.py | 39 ++ backend/app/jobs/mitre_sync_job.py | 89 +++- backend/app/models/evaluation_import.py | 34 ++ backend/app/routers/system.py | 127 ++++++ .../app/services/attck_evaluations_service.py | 401 ++++++++++++++++++ frontend/src/api/system.ts | 56 +++ frontend/src/pages/SystemPage.tsx | 322 ++++++++++++++ 7 files changed, 1067 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/b048_evaluation_imports.py create mode 100644 backend/app/models/evaluation_import.py create mode 100644 backend/app/services/attck_evaluations_service.py diff --git a/backend/alembic/versions/b048_evaluation_imports.py b/backend/alembic/versions/b048_evaluation_imports.py new file mode 100644 index 0000000..2f4711b --- /dev/null +++ b/backend/alembic/versions/b048_evaluation_imports.py @@ -0,0 +1,39 @@ +"""Add evaluation_imports table. + +Revision ID: b048 +Revises: b047 +Create Date: 2026-06-05 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + +revision = "b048" +down_revision = "b047" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "evaluation_imports", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("adversary_name", sa.String, nullable=False), + sa.Column("adversary_display", sa.String, nullable=False), + sa.Column("eval_round", sa.Integer, nullable=False), + sa.Column("imported_at", sa.DateTime, nullable=False), + sa.Column("imported_by", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True), + sa.Column("tests_created", sa.Integer, default=0), + sa.Column("techniques_covered", sa.Integer, default=0), + sa.Column("status", sa.String, default="completed"), + sa.Column("notes", sa.Text, nullable=True), + ) + op.create_index("ix_evaluation_imports_adversary", "evaluation_imports", ["adversary_name"]) + op.create_index("ix_evaluation_imports_round", "evaluation_imports", ["eval_round"]) + + +def downgrade() -> None: + op.drop_index("ix_evaluation_imports_round", table_name="evaluation_imports") + op.drop_index("ix_evaluation_imports_adversary", table_name="evaluation_imports") + op.drop_table("evaluation_imports") diff --git a/backend/app/jobs/mitre_sync_job.py b/backend/app/jobs/mitre_sync_job.py index 0da99e4..2c69e1f 100644 --- a/backend/app/jobs/mitre_sync_job.py +++ b/backend/app/jobs/mitre_sync_job.py @@ -204,6 +204,83 @@ def _run_intel_scan() -> None: db.close() +def _run_evaluation_round_check() -> None: + """Weekly job: check if a new ATT&CK Evaluation round is available. + + If a new round is found it is imported automatically and an admin + notification is created so the team knows new baseline data is available. + """ + logger.info("ATT&CK Evaluations new-round check starting...") + db = SessionLocal() + try: + from app.services.attck_evaluations_service import check_for_new_round, import_evaluation_round + from app.models.user import User as UserModel + + result = check_for_new_round(db) + if result.get("error"): + logger.warning("ATT&CK Evaluations check failed: %s", result["error"]) + return + + if not result.get("new_round_available"): + logger.info( + "ATT&CK Evaluations check — latest round '%s' already imported", + result.get("latest_round", {}).get("display_name", "?"), + ) + return + + latest = result["latest_round"] + logger.info( + "New ATT&CK Evaluation round detected: %s (round %d) — starting auto-import", + latest["display_name"], latest["eval_round"], + ) + + # Use the first admin user as the importer (system action) + admin = db.query(UserModel).filter(UserModel.role == "admin").first() + if not admin: + logger.warning("ATT&CK Evaluations auto-import: no admin user found — skipping") + return + + summary = import_evaluation_round( + db, + latest["name"], + latest["display_name"], + latest["eval_round"], + admin, + ) + logger.info( + "ATT&CK Evaluations auto-import complete — round %d (%s): %d tests created", + latest["eval_round"], latest["display_name"], summary["created"], + ) + + # Notify all admins + try: + from app.services.notification_service import create_notification + admins = db.query(UserModel).filter(UserModel.role == "admin").all() + for adm in admins: + create_notification( + db, + user_id=adm.id, + title="New ATT&CK Evaluation round imported", + message=( + f"Round {latest['eval_round']} — {latest['display_name']} — " + f"has been automatically imported. " + f"{summary['created']} tests created in In Review state. " + f"Blue Leads must validate each result before it counts as coverage." + ), + notification_type="eval_import", + entity_type="evaluation", + entity_id=None, + ) + db.commit() + except Exception: + logger.warning("Failed to send eval import notifications", exc_info=True) + + except Exception: + logger.exception("ATT&CK Evaluations round check job failed") + finally: + db.close() + + def _run_osint_enrichment() -> None: """Execute weekly OSINT enrichment inside its own DB session.""" logger.info("Scheduled OSINT enrichment job starting...") @@ -463,6 +540,16 @@ def start_scheduler() -> None: name="Operational alert evaluation (hourly)", replace_existing=True, ) + scheduler.add_job( + _run_evaluation_round_check, + trigger="cron", + day_of_week="mon", + hour=6, + minute=0, + id="attck_evaluation_check", + name="ATT&CK Evaluations new-round check (Mondays 06:00)", + replace_existing=True, + ) scheduler.start() logger.info( "Background scheduler started — mitre_sync (24h), intel_scan (7d), " @@ -470,5 +557,5 @@ def start_scheduler() -> None: "recurring_campaigns (daily), jira_sync (1h), " "osint_enrichment (weekly), stale_detection (daily), " "retention_policies (daily), data_sources_sync (6h), " - "alert_evaluation (1h)" + "alert_evaluation (1h), attck_evaluation_check (Mondays 06:00)" ) diff --git a/backend/app/models/evaluation_import.py b/backend/app/models/evaluation_import.py new file mode 100644 index 0000000..ddc9c11 --- /dev/null +++ b/backend/app/models/evaluation_import.py @@ -0,0 +1,34 @@ +"""SQLAlchemy model for tracking imported ATT&CK Evaluation rounds.""" +import uuid +from datetime import datetime + +from sqlalchemy import Column, String, Integer, DateTime, Text, ForeignKey, Index +from sqlalchemy.dialects.postgresql import UUID + +from app.database import Base + + +class EvaluationImport(Base): + """Tracks which ATT&CK Evaluation rounds have been imported into the platform. + + Each row represents one vendor+adversary combination that has been processed + and turned into Test records. Used to avoid duplicate imports and to show + the admin panel which rounds are available vs imported. + """ + __tablename__ = "evaluation_imports" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + adversary_name = Column(String, nullable=False) # "apt29", "turla" + adversary_display = Column(String, nullable=False) # "APT29", "Turla" + eval_round = Column(Integer, nullable=False) # 1, 2, 3 … + imported_at = Column(DateTime, nullable=False, default=datetime.utcnow) + imported_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + tests_created = Column(Integer, default=0) + techniques_covered = Column(Integer, default=0) + status = Column(String, default="completed") # "completed" | "failed" + notes = Column(Text, nullable=True) + + __table_args__ = ( + Index("ix_evaluation_imports_adversary", "adversary_name"), + Index("ix_evaluation_imports_round", "eval_round"), + ) diff --git a/backend/app/routers/system.py b/backend/app/routers/system.py index f032fa3..8422d23 100644 --- a/backend/app/routers/system.py +++ b/backend/app/routers/system.py @@ -501,6 +501,133 @@ def update_email_config( # --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# ATT&CK Evaluations endpoints (admin only) +# --------------------------------------------------------------------------- + + +@router.get("/attck-evaluations/rounds") +def list_evaluation_rounds( + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Return all public CrowdStrike ENTERPRISE evaluation rounds with import status. + + Each entry includes whether it has already been imported into this platform. + """ + from app.services.attck_evaluations_service import fetch_available_rounds + 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}") + + imported = { + row.adversary_name.lower(): row + for row in db.query(EvaluationImport).filter(EvaluationImport.status == "completed").all() + } + + return [ + { + "name": r["name"], + "display_name": r.get("display_name", r["name"]), + "eval_round": r["eval_round"], + "imported": r["name"].lower() in imported, + "imported_at": imported[r["name"].lower()].imported_at.isoformat() + if r["name"].lower() in imported else None, + "tests_created": imported[r["name"].lower()].tests_created + if r["name"].lower() in imported else None, + "techniques_covered": imported[r["name"].lower()].techniques_covered + if r["name"].lower() in imported else None, + } + for r in rounds + ] + + +@router.post("/attck-evaluations/import") +def import_evaluation_round( + payload: dict, + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Import a specific ATT&CK Evaluation round for CrowdStrike. + + Body: { "adversary_name": "apt29", "adversary_display": "APT29", "eval_round": 2 } + + Creates tests in ``in_review`` state — Blue Leads must validate each + result before it counts as real coverage. + """ + from app.services.attck_evaluations_service import import_evaluation_round as _import + + adversary_name = payload.get("adversary_name", "") + adversary_display = payload.get("adversary_display", adversary_name) + eval_round = payload.get("eval_round", 0) + + if not adversary_name or not eval_round: + raise HTTPException(status_code=400, detail="adversary_name and eval_round are required") + + try: + summary = _import(db, adversary_name, adversary_display, eval_round, current_user) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) + except Exception as exc: + logger.error("ATT&CK Evaluation import failed: %s", exc, exc_info=True) + raise HTTPException(status_code=502, detail=f"Import failed: {exc}") + + return { + "message": f"Import complete — {summary['created']} tests created", + **summary, + } + + +@router.post("/attck-evaluations/import-latest") +def import_latest_evaluation( + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Import the latest available CrowdStrike evaluation round automatically. + + Returns 409 if the latest round was already imported. + """ + from app.services.attck_evaluations_service import get_latest_round, import_evaluation_round as _import + + try: + latest = get_latest_round() + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Could not reach MITRE Evaluations API: {exc}") + + try: + summary = _import( + db, + latest["name"], + latest.get("display_name", latest["name"]), + latest["eval_round"], + current_user, + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) + except Exception as exc: + logger.error("ATT&CK Evaluation import failed: %s", exc, exc_info=True) + raise HTTPException(status_code=502, detail=f"Import failed: {exc}") + + return { + "message": f"Import complete — {summary['created']} tests created", + **summary, + } + + +@router.get("/attck-evaluations/check-new") +def check_new_evaluation_round( + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Check if a new ATT&CK Evaluation round is available that hasn't been imported yet.""" + from app.services.attck_evaluations_service import check_for_new_round + + return check_for_new_round(db) + + @router.post("/email-test") def send_test_email( payload: EmailTestRequest, diff --git a/backend/app/services/attck_evaluations_service.py b/backend/app/services/attck_evaluations_service.py new file mode 100644 index 0000000..7750cec --- /dev/null +++ b/backend/app/services/attck_evaluations_service.py @@ -0,0 +1,401 @@ +"""ATT&CK Evaluations importer — fetches real CrowdStrike detection results +from MITRE Engenuity's public API and seeds the platform with validated tests. + +Data source +----------- +https://evals.mitre.org/api/ + - /participants/ → list of vendors + rounds they completed + - /results/?participant=crowdstrike&domain=ENTERPRISE + → per-substep detection results per adversary + +Detection level mapping (MITRE → Aegis) +--------------------------------------- + Technique / Specific Behavior → detected (correctly identified ATT&CK technique) + Tactic → partially_detected (behavior noted but not categorized) + General / IOC / MSSP → partially_detected (anomaly detected, not ATT&CK-mapped) + Telemetry → partially_detected (raw data only — marginal detection) + None / N/A → not_detected + +All imported tests are created in ``in_review`` state so Blue Leads must +confirm each result before it counts as real coverage for the organisation. + +Important caveats stored in every test's description +------------------------------------------------------ + "Source: MITRE ATT&CK Evaluation (Round N — Adversary). Results reflect + CrowdStrike Falcon in a controlled lab environment, NOT this organisation's + deployment. Validate detection in your own environment before approving." +""" + +import logging +import uuid +from datetime import datetime +from typing import Any + +import requests +from sqlalchemy.orm import Session + +from app.models.enums import TestState, TestResult +from app.models.evaluation_import import EvaluationImport +from app.models.technique import Technique +from app.models.test import Test +from app.models.user import User +from app.services.audit_service import log_action +from app.services.status_service import recalculate_technique_status + +logger = logging.getLogger(__name__) + +_BASE = "https://evals.mitre.org" +_TIMEOUT = 30 # seconds per HTTP call +_VENDOR = "crowdstrike" +_DOMAIN = "ENTERPRISE" + +# Detection type → quality score (higher = better) +_DETECTION_SCORE: dict[str, int] = { + "none": 0, + "n/a": 0, + "telemetry": 1, + "mssp": 2, + "general": 2, + "ioc": 2, + "tactic": 3, + "technique": 4, + "specific behavior": 4, +} + + +def _score(detection_type: str) -> int: + key = (detection_type or "").lower().strip() + for pattern, score in _DETECTION_SCORE.items(): + if pattern in key: + return score + return 0 + + +def _score_to_result(score: int) -> TestResult: + if score >= 4: + return TestResult.detected + if score >= 1: + return TestResult.partially_detected + return TestResult.not_detected + + +# --------------------------------------------------------------------------- +# Public API helpers +# --------------------------------------------------------------------------- + + +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. + """ + try: + resp = requests.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 + + 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") + + rounds = [ + adv + for adv in crowdstrike.get("adversaries_completed", []) + if adv.get("domain", "").upper() == _DOMAIN + and adv.get("status", "").upper() == "PUBLIC" + ] + rounds.sort(key=lambda x: x.get("eval_round", 0)) + return rounds + + +def get_latest_round() -> dict[str, Any]: + """Return the most recent PUBLIC ENTERPRISE round CrowdStrike participated in.""" + rounds = fetch_available_rounds() + if not rounds: + raise ValueError("No public Enterprise evaluation rounds found for CrowdStrike") + return rounds[-1] + + +def fetch_results_for_adversary(adversary_name: str) -> list[dict[str, Any]]: + """Fetch all per-substep detection results for a specific adversary round. + + Returns a flat list of substep dicts, each containing: + technique_id, technique_name, tactic_id, best_score, detection_type, note. + """ + url = f"{_BASE}/api/results/?participant={_VENDOR}&domain={_DOMAIN}" + try: + resp = requests.get(url, timeout=_TIMEOUT) + resp.raise_for_status() + data = resp.json() + except Exception as exc: + logger.error("Failed to fetch ATT&CK Evaluations results: %s", exc) + raise + + # Find the adversary in the response + adversaries = data.get("adversaries", []) + target = next( + (a for a in adversaries if a.get("Adversary_Name", "").lower() == adversary_name.lower()), + None, + ) + if not target: + raise ValueError( + f"Adversary '{adversary_name}' not found in results. " + f"Available: {[a.get('Adversary_Name') for a in adversaries]}" + ) + + substeps: list[dict[str, Any]] = [] + + scenarios = target.get("Detections_By_Step", {}) + for _scenario_name, scenario_data in scenarios.items(): + for step in scenario_data.get("Steps", []): + for substep in step.get("Substeps", []): + # Prefer sub-technique over technique + sub = substep.get("Subtechnique") or {} + tech = substep.get("Technique") or {} + tactic = substep.get("Tactic") or {} + + technique_id = ( + sub.get("Subtechnique_Id") + or tech.get("Technique_Id") + or "" + ).strip() + technique_name = ( + sub.get("Subtechnique_Name") + or tech.get("Technique_Name") + or "Unknown" + ).strip() + + if not technique_id: + continue + + detections = substep.get("Detections", []) + best_score = 0 + best_type = "None" + best_note = "" + for det in detections: + dtype = det.get("Detection_Type", "None") + s = _score(dtype) + if s > best_score: + best_score = s + best_type = dtype + best_note = det.get("Detection_Note", "") + + substeps.append( + { + "technique_id": technique_id, + "technique_name": technique_name, + "tactic_id": tactic.get("Tactic_Id", ""), + "tactic_name": tactic.get("Tactic_Name", ""), + "best_score": best_score, + "detection_type": best_type, + "note": best_note, + } + ) + + return substeps + + +def _aggregate_by_technique(substeps: list[dict]) -> dict[str, dict]: + """Aggregate substep results per technique — keep best detection score.""" + by_technique: dict[str, dict] = {} + for sub in substeps: + tid = sub["technique_id"] + if tid not in by_technique or sub["best_score"] > by_technique[tid]["best_score"]: + by_technique[tid] = sub + return by_technique + + +# --------------------------------------------------------------------------- +# Main import function +# --------------------------------------------------------------------------- + + +def import_evaluation_round( + db: Session, + adversary_name: str, + adversary_display: str, + eval_round: int, + current_user: User, +) -> dict[str, Any]: + """Import a single ATT&CK Evaluation round for CrowdStrike into the platform. + + Creates one Test per unique technique with the best detection result + observed across all substeps for that technique. All tests land in + ``in_review`` state — Blue Leads must confirm before they count as coverage. + + Returns a summary dict: created, skipped, techniques_covered. + Raises if the round was already imported (idempotency guard). + """ + # Idempotency — refuse duplicate imports + existing = ( + db.query(EvaluationImport) + .filter( + EvaluationImport.adversary_name == adversary_name.lower(), + EvaluationImport.status == "completed", + ) + .first() + ) + if existing: + raise ValueError( + f"Round '{adversary_display}' (round {eval_round}) was already imported " + f"on {existing.imported_at.date()}. Re-import is not allowed." + ) + + # Fetch and aggregate substep results + substeps = fetch_results_for_adversary(adversary_name) + by_technique = _aggregate_by_technique(substeps) + + created = 0 + skipped = 0 + affected_technique_ids: set = set() + + for mitre_id, agg in by_technique.items(): + # Look up the technique in our DB + technique = ( + db.query(Technique) + .filter(Technique.mitre_id == mitre_id.upper()) + .first() + ) + if technique is None: + skipped += 1 + continue + + detection_result = _score_to_result(agg["best_score"]) + + description = ( + f"Source: MITRE ATT&CK Evaluation — Round {eval_round} ({adversary_display}).\n" + f"Vendor: CrowdStrike Falcon.\n" + f"Detection type achieved: {agg['detection_type']}.\n\n" + f"⚠️ IMPORTANT: These results reflect CrowdStrike Falcon performance in a " + f"controlled MITRE lab environment against a simulated {adversary_display} " + f"adversary. They do NOT represent your organisation's actual detection " + f"capability. Validate in your own environment before approving." + ) + if agg["note"]: + description += f"\n\nMITRE note: {agg['note']}" + + red_summary = ( + f"MITRE ATT&CK Evaluation — Round {eval_round} ({adversary_display})\n" + f"Vendor: CrowdStrike Falcon\n" + f"Best detection level: {agg['detection_type']}\n" + f"Tactic: {agg['tactic_name']} ({agg['tactic_id']})" + ) + + test = Test( + technique_id=technique.id, + name=f"[EVAL R{eval_round}] {adversary_display} — {technique.name}", + description=description, + platform=None, + procedure_text=( + f"MITRE ATT&CK Evaluation simulation using {adversary_display} TTPs. " + f"See evaluation report at https://evals.mitre.org for full details." + ), + created_by=current_user.id, + state=TestState.in_review, + attack_success=True, + red_summary=red_summary, + red_validation_status="approved", + red_validated_by=current_user.id, + red_validated_at=datetime.utcnow(), + detection_result=detection_result, + blue_validation_status=None, + execution_date=datetime.utcnow(), + created_at=datetime.utcnow(), + ) + db.add(test) + db.flush() + + log_action( + db, + user_id=current_user.id, + action="eval_import_test", + entity_type="test", + entity_id=test.id, + details={ + "adversary": adversary_name, + "eval_round": eval_round, + "mitre_id": mitre_id, + "detection_type": agg["detection_type"], + }, + ) + + affected_technique_ids.add(technique.id) + created += 1 + + # Recalculate coverage for all touched techniques + for tech_id in affected_technique_ids: + tech = db.query(Technique).filter(Technique.id == tech_id).first() + if tech: + recalculate_technique_status(db, tech) + + # Record the import + record = EvaluationImport( + id=uuid.uuid4(), + adversary_name=adversary_name.lower(), + adversary_display=adversary_display, + eval_round=eval_round, + imported_at=datetime.utcnow(), + imported_by=current_user.id, + tests_created=created, + techniques_covered=len(affected_technique_ids), + status="completed", + notes=f"Skipped {skipped} techniques not found in local DB.", + ) + db.add(record) + db.commit() + + logger.info( + "ATT&CK Evaluation import complete — round %d (%s): %d tests created, %d skipped", + eval_round, adversary_display, created, skipped, + ) + return { + "created": created, + "skipped": skipped, + "techniques_covered": len(affected_technique_ids), + "adversary": adversary_display, + "eval_round": eval_round, + } + + +# --------------------------------------------------------------------------- +# New-round check (used by the weekly scheduler) +# --------------------------------------------------------------------------- + + +def check_for_new_round(db: Session) -> dict[str, Any]: + """Check if a new evaluation round is available that hasn't been imported yet. + + Returns: + {"new_round_available": bool, "latest_round": dict | None, "already_imported": bool} + """ + try: + latest = get_latest_round() + except Exception as exc: + logger.warning("Could not check for new ATT&CK Evaluation round: %s", exc) + return {"new_round_available": False, "latest_round": None, "error": str(exc)} + + already = ( + db.query(EvaluationImport) + .filter( + EvaluationImport.adversary_name == latest["name"].lower(), + EvaluationImport.status == "completed", + ) + .first() + ) + + return { + "new_round_available": already is None, + "already_imported": already is not None, + "latest_round": { + "name": latest["name"], + "display_name": latest.get("display_name", latest["name"]), + "eval_round": latest["eval_round"], + }, + } diff --git a/frontend/src/api/system.ts b/frontend/src/api/system.ts index 5f8237b..7d568a4 100644 --- a/frontend/src/api/system.ts +++ b/frontend/src/api/system.ts @@ -40,3 +40,59 @@ export async function getSchedulerStatus(): Promise { const { data } = await client.get("/system/scheduler-status"); return data; } + +// ── ATT&CK Evaluations ───────────────────────────────────────────── + +export interface EvaluationRound { + name: string; + display_name: string; + eval_round: number; + imported: boolean; + imported_at: string | null; + tests_created: number | null; + techniques_covered: number | null; +} + +export interface EvaluationImportResult { + message: string; + created: number; + skipped: number; + techniques_covered: number; + adversary: string; + eval_round: number; +} + +export interface NewRoundCheckResult { + new_round_available: boolean; + already_imported: boolean; + latest_round: { name: string; display_name: string; eval_round: number } | null; + error?: string; +} + +/** List all public CrowdStrike evaluation rounds with import status. */ +export async function listEvaluationRounds(): Promise { + const { data } = await client.get("/system/attck-evaluations/rounds"); + return data; +} + +/** Import a specific evaluation round. */ +export async function importEvaluationRound(payload: { + adversary_name: string; + adversary_display: string; + eval_round: number; +}): Promise { + const { data } = await client.post("/system/attck-evaluations/import", payload); + return data; +} + +/** Import the latest available round automatically. */ +export async function importLatestEvaluation(): Promise { + const { data } = await client.post("/system/attck-evaluations/import-latest"); + return data; +} + +/** Check if a new round is available. */ +export async function checkNewEvaluationRound(): Promise { + const { data } = await client.get("/system/attck-evaluations/check-new"); + return data; +} diff --git a/frontend/src/pages/SystemPage.tsx b/frontend/src/pages/SystemPage.tsx index a8ee57a..32ace86 100644 --- a/frontend/src/pages/SystemPage.tsx +++ b/frontend/src/pages/SystemPage.tsx @@ -21,14 +21,26 @@ import { Download, Upload, PackageOpen, + Swords, + Sparkles, + AlertTriangle, + ExternalLink, + CalendarCheck, } from "lucide-react"; import client from "../api/client"; import { triggerMitreSync, triggerIntelScan, getSchedulerStatus, + listEvaluationRounds, + importEvaluationRound, + importLatestEvaluation, + checkNewEvaluationRound, type SyncMitreResponse, type IntelScanResponse, + type EvaluationRound, + type EvaluationImportResult, + type NewRoundCheckResult, } from "../api/system"; import { getTemplateStats, @@ -51,6 +63,11 @@ export default function SystemPage() { const [bulkConfirm, setBulkConfirm] = useState<"activate" | "deactivate" | null>(null); const [selectedTemplateId, setSelectedTemplateId] = useState(null); + // ── ATT&CK Evaluations state ───────────────────────────────────── + const [evalImportResult, setEvalImportResult] = useState(null); + const [evalCheckResult, setEvalCheckResult] = useState(null); + const [evalImportingRound, setEvalImportingRound] = useState(null); + // ── Existing queries ───────────────────────────────────────────── const { data: schedulerStatus, @@ -145,6 +162,53 @@ export default function SystemPage() { }, }); + // ── ATT&CK Evaluations queries & mutations ─────────────────────── + const { + data: evalRounds, + isLoading: evalRoundsLoading, + refetch: refetchEvalRounds, + } = useQuery({ + queryKey: ["eval-rounds"], + queryFn: listEvaluationRounds, + }); + + const checkNewRoundMutation = useMutation({ + mutationFn: checkNewEvaluationRound, + onSuccess: (data) => { + setEvalCheckResult(data); + refetchEvalRounds(); + }, + }); + + const importLatestMutation = useMutation({ + mutationFn: importLatestEvaluation, + onSuccess: (data) => { + setEvalImportResult(data); + setEvalImportingRound(null); + refetchEvalRounds(); + queryClient.invalidateQueries({ queryKey: ["techniques"] }); + queryClient.invalidateQueries({ queryKey: ["metrics"] }); + }, + onError: () => { + setEvalImportingRound(null); + }, + }); + + const importRoundMutation = useMutation({ + mutationFn: (payload: { adversary_name: string; adversary_display: string; eval_round: number }) => + importEvaluationRound(payload), + onSuccess: (data) => { + setEvalImportResult(data); + setEvalImportingRound(null); + refetchEvalRounds(); + queryClient.invalidateQueries({ queryKey: ["techniques"] }); + queryClient.invalidateQueries({ queryKey: ["metrics"] }); + }, + onError: () => { + setEvalImportingRound(null); + }, + }); + const formatNextRun = (dateStr: string | null) => { if (!dateStr) return "Not scheduled"; const date = new Date(dateStr); @@ -294,6 +358,264 @@ export default function SystemPage() { + {/* ──────────────────────────────────────────────────────────────── + ATT&CK EVALUATIONS — CrowdStrike + ──────────────────────────────────────────────────────────────── */} +
+ {/* Header */} +
+
+
+ +
+
+

+ ATT&CK Evaluations — CrowdStrike Falcon +

+

+ Seed the platform with real detection data from MITRE Engenuity's public ATT&CK + Evaluations. Results are imported as In Review tests — + Blue Leads must validate each one before it counts as coverage. +

+
+
+
+ + +
+
+ + {/* Disclaimer callout */} +
+ +
+

Lab environment data — validation required

+

+ These results reflect CrowdStrike Falcon performance in a controlled MITRE lab against + simulated adversaries. They do not represent + your organisation's actual detection capability. All imported tests require Blue Lead + validation before contributing to programme coverage. +

+ + + View official evaluation reports at evals.mitre.org + +
+
+ + {/* New round check result */} + {evalCheckResult && ( +
+ {evalCheckResult.error ? ( +
+ + Check failed: {evalCheckResult.error} +
+ ) : evalCheckResult.new_round_available ? ( +
+ + + New round available: {evalCheckResult.latest_round?.display_name} (Round {evalCheckResult.latest_round?.eval_round}) + +
+ ) : ( +
+ + + Up to date — latest round ({evalCheckResult.latest_round?.display_name}) is already imported. + +
+ )} +
+ )} + + {/* Import result feedback */} + {evalImportResult && ( +
+
+ + Import complete +
+
+
+

{evalImportResult.created}

+

Tests created

+
+
+

{evalImportResult.techniques_covered}

+

Techniques covered

+
+
+

{evalImportResult.adversary}

+

Round {evalImportResult.eval_round}

+
+
+

+ + All tests are in Review Queue — Blue Leads must validate before they count as coverage. +

+
+ )} + + {/* Import error */} + {(importLatestMutation.isError || importRoundMutation.isError) && ( +
+
+ + + {((importLatestMutation.error || importRoundMutation.error) as Error)?.message ?? + "Import failed. This round may already be imported."} + +
+
+ )} + + {/* Rounds table */} +
+

+ Available Rounds +

+ {evalRoundsLoading ? ( +
+ +
+ ) : evalRounds && evalRounds.length > 0 ? ( +
+ + + + + + + + + + + + + {evalRounds.map((round) => ( + + + + + + + + + ))} + +
RoundAdversaryStatusImportedTestsAction
+ + R{round.eval_round} + + +

{round.display_name}

+

{round.name}

+
+ {round.imported ? ( + + + Imported + + ) : ( + + Not imported + + )} + + {round.imported_at + ? new Date(round.imported_at).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }) + : "—"} + + {round.imported ? ( +
+

{round.tests_created ?? "—"}

+

{round.techniques_covered ?? ""} techniques

+
+ ) : ( + + )} +
+ {round.imported ? ( + Already imported + ) : ( + + )} +
+
+ ) : ( +
+ + Unable to load evaluation rounds. Check network connectivity to evals.mitre.org. +
+ )} +
+
+ {/* ──────────────────────────────────────────────────────────────── TEMPLATE ADMINISTRATION (T-124) ──────────────────────────────────────────────────────────────── */}