From 12f33307fd9895a21f423ca8e7502a047f5e76ab Mon Sep 17 00:00:00 2001 From: Kitos Date: Mon, 9 Feb 2026 17:24:44 +0100 Subject: [PATCH] feat(phase-28): add scoring system, operational metrics and executive dashboard (T-224 to T-226) --- backend/app/config.py | 7 + backend/app/main.py | 4 + backend/app/routers/operational_metrics.py | 56 ++ backend/app/routers/scores.py | 189 +++++++ .../services/operational_metrics_service.py | 468 ++++++++++++++++ backend/app/services/scoring_service.py | 467 ++++++++++++++++ frontend/src/App.tsx | 9 + frontend/src/api/operational-metrics.ts | 101 ++++ frontend/src/api/scores.ts | 100 ++++ frontend/src/components/Sidebar.tsx | 2 + frontend/src/pages/ExecutiveDashboardPage.tsx | 527 ++++++++++++++++++ 11 files changed, 1930 insertions(+) create mode 100644 backend/app/routers/operational_metrics.py create mode 100644 backend/app/routers/scores.py create mode 100644 backend/app/services/operational_metrics_service.py create mode 100644 backend/app/services/scoring_service.py create mode 100644 frontend/src/api/operational-metrics.ts create mode 100644 frontend/src/api/scores.ts create mode 100644 frontend/src/pages/ExecutiveDashboardPage.tsx diff --git a/backend/app/config.py b/backend/app/config.py index 95706f7..2f676b2 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -11,6 +11,13 @@ class Settings(BaseSettings): MINIO_SECRET_KEY: str = "minioadmin" MINIO_BUCKET: str = "evidence" + # Scoring weights (must sum to 100) + SCORING_WEIGHT_TESTS: int = 40 + SCORING_WEIGHT_DETECTION_RULES: int = 20 + SCORING_WEIGHT_D3FEND: int = 15 + SCORING_WEIGHT_FRESHNESS: int = 15 + SCORING_WEIGHT_PLATFORM_DIVERSITY: int = 10 + class Config: env_file = ".env" diff --git a/backend/app/main.py b/backend/app/main.py index 14ddadf..81a4b85 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -24,6 +24,8 @@ from app.routers import d3fend as d3fend_router from app.routers import detection_rules as detection_rules_router from app.routers import campaigns as campaigns_router from app.routers import heatmap as heatmap_router +from app.routers import scores as scores_router +from app.routers import operational_metrics as operational_metrics_router from app.storage import ensure_bucket_exists from app.jobs.mitre_sync_job import start_scheduler, scheduler @@ -72,6 +74,8 @@ app.include_router(d3fend_router.router, prefix="/api/v1") app.include_router(detection_rules_router.router, prefix="/api/v1") app.include_router(campaigns_router.router, prefix="/api/v1") app.include_router(heatmap_router.router, prefix="/api/v1") +app.include_router(scores_router.router, prefix="/api/v1") +app.include_router(operational_metrics_router.router, prefix="/api/v1") @app.get("/health") diff --git a/backend/app/routers/operational_metrics.py b/backend/app/routers/operational_metrics.py new file mode 100644 index 0000000..3aaf061 --- /dev/null +++ b/backend/app/routers/operational_metrics.py @@ -0,0 +1,56 @@ +"""Operational metrics endpoints — MTTD, MTTR, Detection Efficacy, and more. + +Provides operational KPIs for security teams with trend analysis +and team-level breakdowns. +""" + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.database import get_db +from app.dependencies.auth import get_current_user +from app.models.user import User +from app.services.operational_metrics_service import ( + get_all_operational_metrics, + get_operational_trend, + get_metrics_by_team, +) + +router = APIRouter(prefix="/metrics/operational", tags=["operational-metrics"]) + + +# ── GET /metrics/operational ────────────────────────────────────────── + + +@router.get("") +def operational_metrics( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get all operational metrics (MTTD, MTTR, Detection Efficacy, etc.).""" + return get_all_operational_metrics(db) + + +# ── GET /metrics/operational/trend ──────────────────────────────────── + + +@router.get("/trend") +def operational_trend( + period: str = Query("90d", pattern="^(30d|90d|1y)$"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get weekly trend data for operational metrics.""" + return get_operational_trend(db, period) + + +# ── GET /metrics/operational/by-team ────────────────────────────────── + + +@router.get("/by-team") +def metrics_by_team( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get metrics broken down by Red Team vs Blue Team.""" + return get_metrics_by_team(db) diff --git a/backend/app/routers/scores.py b/backend/app/routers/scores.py new file mode 100644 index 0000000..d17163d --- /dev/null +++ b/backend/app/routers/scores.py @@ -0,0 +1,189 @@ +"""Scoring endpoints — technique, tactic, threat actor, and organization scores. + +Provides granular scoring with breakdowns and configurable weights. +""" + +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.database import get_db +from app.dependencies.auth import get_current_user, require_role +from app.models.user import User +from app.models.technique import Technique +from app.models.threat_actor import ThreatActor +from app.config import settings +from app.services.scoring_service import ( + calculate_technique_score, + calculate_tactic_score, + calculate_actor_coverage_score, + calculate_organization_score, + get_score_history, +) + +router = APIRouter(prefix="/scores", tags=["scores"]) + + +# ── GET /scores/technique/{mitre_id} ───────────────────────────────── + + +@router.get("/technique/{mitre_id}") +def score_technique( + mitre_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get detailed score with breakdown for a specific technique.""" + technique = ( + db.query(Technique) + .filter(Technique.mitre_id == mitre_id) + .first() + ) + if not technique: + raise HTTPException(status_code=404, detail="Technique not found") + + result = calculate_technique_score(technique, db) + + return { + "mitre_id": technique.mitre_id, + "name": technique.name, + "tactic": technique.tactic, + "status_global": technique.status_global.value if technique.status_global else None, + **result, + } + + +# ── GET /scores/tactic/{tactic} ────────────────────────────────────── + + +@router.get("/tactic/{tactic}") +def score_tactic( + tactic: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get average score for a tactic.""" + return calculate_tactic_score(tactic, db) + + +# ── GET /scores/threat-actor/{id} ──────────────────────────────────── + + +@router.get("/threat-actor/{actor_id}") +def score_threat_actor( + actor_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get coverage score against a specific threat actor.""" + actor = db.query(ThreatActor).filter(ThreatActor.id == actor_id).first() + if not actor: + raise HTTPException(status_code=404, detail="Threat actor not found") + + return calculate_actor_coverage_score(actor_id, db) + + +# ── GET /scores/organization ───────────────────────────────────────── + + +@router.get("/organization") +def score_organization( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get the overall organization security score.""" + return calculate_organization_score(db) + + +# ── GET /scores/history ────────────────────────────────────────────── + + +@router.get("/history") +def score_history( + period: str = Query("90d", pattern="^(30d|90d|1y)$"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get historical score data points (weekly).""" + return get_score_history(db, period) + + +# ── GET /scores/config ─────────────────────────────────────────────── + + +@router.get("/config") +def get_scoring_config( + current_user: User = Depends(require_role("admin")), +): + """Get current scoring weights (admin only).""" + return { + "weights": { + "tests": settings.SCORING_WEIGHT_TESTS, + "detection_rules": settings.SCORING_WEIGHT_DETECTION_RULES, + "d3fend": settings.SCORING_WEIGHT_D3FEND, + "freshness": settings.SCORING_WEIGHT_FRESHNESS, + "platform_diversity": settings.SCORING_WEIGHT_PLATFORM_DIVERSITY, + }, + "total": ( + settings.SCORING_WEIGHT_TESTS + + settings.SCORING_WEIGHT_DETECTION_RULES + + settings.SCORING_WEIGHT_D3FEND + + settings.SCORING_WEIGHT_FRESHNESS + + settings.SCORING_WEIGHT_PLATFORM_DIVERSITY + ), + } + + +# ── PATCH /scores/config ───────────────────────────────────────────── + + +class ScoringConfigUpdate(BaseModel): + tests: Optional[int] = None + detection_rules: Optional[int] = None + d3fend: Optional[int] = None + freshness: Optional[int] = None + platform_diversity: Optional[int] = None + + +@router.patch("/config") +def update_scoring_config( + payload: ScoringConfigUpdate, + current_user: User = Depends(require_role("admin")), +): + """Update scoring weights (admin only). + + Note: Since we're using Opcion A (env vars / Settings), changes + are applied at runtime but won't persist across restarts unless + the .env file is also updated. For production, consider migrating + to Option B (database table). + """ + if payload.tests is not None: + settings.SCORING_WEIGHT_TESTS = payload.tests + if payload.detection_rules is not None: + settings.SCORING_WEIGHT_DETECTION_RULES = payload.detection_rules + if payload.d3fend is not None: + settings.SCORING_WEIGHT_D3FEND = payload.d3fend + if payload.freshness is not None: + settings.SCORING_WEIGHT_FRESHNESS = payload.freshness + if payload.platform_diversity is not None: + settings.SCORING_WEIGHT_PLATFORM_DIVERSITY = payload.platform_diversity + + return { + "message": "Scoring config updated", + "weights": { + "tests": settings.SCORING_WEIGHT_TESTS, + "detection_rules": settings.SCORING_WEIGHT_DETECTION_RULES, + "d3fend": settings.SCORING_WEIGHT_D3FEND, + "freshness": settings.SCORING_WEIGHT_FRESHNESS, + "platform_diversity": settings.SCORING_WEIGHT_PLATFORM_DIVERSITY, + }, + "total": ( + settings.SCORING_WEIGHT_TESTS + + settings.SCORING_WEIGHT_DETECTION_RULES + + settings.SCORING_WEIGHT_D3FEND + + settings.SCORING_WEIGHT_FRESHNESS + + settings.SCORING_WEIGHT_PLATFORM_DIVERSITY + ), + } diff --git a/backend/app/services/operational_metrics_service.py b/backend/app/services/operational_metrics_service.py new file mode 100644 index 0000000..2b81f93 --- /dev/null +++ b/backend/app/services/operational_metrics_service.py @@ -0,0 +1,468 @@ +"""Operational metrics service — MTTD, MTTR, Detection Efficacy, and more. + +Calculates security operations KPIs from test data and audit logs. +""" + +from datetime import datetime, timedelta +from typing import Optional + +from sqlalchemy import func, case, and_, or_, extract +from sqlalchemy.orm import Session + +from app.models.test import Test +from app.models.technique import Technique +from app.models.test_detection_result import TestDetectionResult +from app.models.audit import AuditLog +from app.models.enums import TestState, TestResult + + +def _safe_stats(values: list[float]) -> dict: + """Compute mean, median, min, max from a list of floats.""" + if not values: + return None + sorted_vals = sorted(values) + n = len(sorted_vals) + return { + "mean_hours": round(sum(sorted_vals) / n, 1), + "median_hours": round(sorted_vals[n // 2], 1), + "min_hours": round(sorted_vals[0], 1), + "max_hours": round(sorted_vals[-1], 1), + "sample_size": n, + } + + +# ── MTTD (Mean Time to Detect) ─────────────────────────────────────── + + +def calculate_mttd(db: Session) -> Optional[dict]: + """Calculate Mean Time to Detect. + + For each validated test: time between entering red_executing and + entering blue_evaluating (extracted from audit_log timestamps). + """ + # Get validated tests that have both timestamps available + # Using audit log entries for state transitions + tests = ( + db.query(Test) + .filter(Test.state == TestState.validated) + .all() + ) + + detection_times = [] + for test in tests: + # Find the red_executing and blue_evaluating transition timestamps + red_start = ( + db.query(AuditLog.timestamp) + .filter( + AuditLog.entity_type == "test", + AuditLog.entity_id == str(test.id), + AuditLog.action.in_(["test_start_execution", "start_execution"]), + ) + .order_by(AuditLog.timestamp.asc()) + .first() + ) + + blue_start = ( + db.query(AuditLog.timestamp) + .filter( + AuditLog.entity_type == "test", + AuditLog.entity_id == str(test.id), + AuditLog.action.in_(["test_submit_red", "submit_red"]), + ) + .order_by(AuditLog.timestamp.asc()) + .first() + ) + + if red_start and blue_start and blue_start[0] > red_start[0]: + hours = (blue_start[0] - red_start[0]).total_seconds() / 3600 + detection_times.append(hours) + + return _safe_stats(detection_times) + + +# ── MTTR (Mean Time to Respond/Remediate) ───────────────────────────── + + +def calculate_mttr(db: Session) -> Optional[dict]: + """Calculate Mean Time to Respond. + + For tests with remediation_status = completed: time between + detection_result being set and remediation_status = completed. + """ + # Tests with completed remediation + tests = ( + db.query(Test) + .filter( + Test.remediation_status == "completed", + Test.blue_validated_at.isnot(None), + ) + .all() + ) + + response_times = [] + for test in tests: + # Find when remediation was completed from audit log + remediation_complete = ( + db.query(AuditLog.timestamp) + .filter( + AuditLog.entity_type == "test", + AuditLog.entity_id == str(test.id), + AuditLog.action.ilike("%remediation%"), + ) + .order_by(AuditLog.timestamp.desc()) + .first() + ) + + detection_time = test.blue_validated_at + if remediation_complete and detection_time: + hours = (remediation_complete[0] - detection_time).total_seconds() / 3600 + if hours > 0: + response_times.append(hours) + + return _safe_stats(response_times) + + +# ── Detection Efficacy ─────────────────────────────────────────────── + + +def calculate_detection_efficacy(db: Session) -> dict: + """Calculate detection efficacy: detected / total validated tests.""" + validated_tests = ( + db.query(Test) + .filter(Test.state == TestState.validated) + .all() + ) + + total = len(validated_tests) + if total == 0: + return { + "percentage": 0, + "detected": 0, + "partially": 0, + "not_detected": 0, + "total": 0, + } + + detected = len([t for t in validated_tests if t.detection_result == TestResult.detected]) + partially = len([t for t in validated_tests if t.detection_result == TestResult.partially_detected]) + not_detected = len([t for t in validated_tests if t.detection_result == TestResult.not_detected]) + + percentage = round((detected / total) * 100, 1) if total > 0 else 0 + + return { + "percentage": percentage, + "detected": detected, + "partially": partially, + "not_detected": not_detected, + "total": total, + } + + +# ── Alert Fidelity ────────────────────────────────────────────────── + + +def calculate_alert_fidelity(db: Session) -> dict: + """Calculate alert fidelity: ratio of triggered detection rules.""" + total_evaluated = ( + db.query(func.count(TestDetectionResult.id)) + .filter(TestDetectionResult.triggered.isnot(None)) + .scalar() + ) or 0 + + triggered = ( + db.query(func.count(TestDetectionResult.id)) + .filter(TestDetectionResult.triggered == True) + .scalar() + ) or 0 + + not_triggered = total_evaluated - triggered + + return { + "percentage": round((triggered / total_evaluated) * 100, 1) if total_evaluated > 0 else 0, + "triggered": triggered, + "not_triggered": not_triggered, + "total_evaluated": total_evaluated, + } + + +# ── Coverage Velocity ──────────────────────────────────────────────── + + +def calculate_coverage_velocity(db: Session) -> dict: + """Calculate techniques validated per week.""" + # Count techniques that changed to validated/partial in the last 12 weeks + twelve_weeks_ago = datetime.utcnow() - timedelta(weeks=12) + + weekly_counts = ( + db.query( + func.date_trunc("week", Technique.last_review_date).label("week"), + func.count(Technique.id).label("count"), + ) + .filter( + Technique.last_review_date >= twelve_weeks_ago, + Technique.last_review_date.isnot(None), + ) + .group_by(func.date_trunc("week", Technique.last_review_date)) + .order_by("week") + .all() + ) + + if weekly_counts: + counts = [row.count for row in weekly_counts] + avg_per_week = round(sum(counts) / len(counts), 1) + # Trend: compare last 4 weeks vs previous 4 weeks + recent = counts[-4:] if len(counts) >= 4 else counts + earlier = counts[-8:-4] if len(counts) >= 8 else counts[:len(counts) // 2] if counts else [] + + recent_avg = sum(recent) / len(recent) if recent else 0 + earlier_avg = sum(earlier) / len(earlier) if earlier else 0 + + if recent_avg > earlier_avg * 1.1: + trend = "improving" + elif recent_avg < earlier_avg * 0.9: + trend = "declining" + else: + trend = "stable" + else: + avg_per_week = 0 + trend = "stable" + + return { + "techniques_per_week": avg_per_week, + "trend": trend, + } + + +# ── Validation Throughput ──────────────────────────────────────────── + + +def calculate_validation_throughput(db: Session) -> dict: + """Calculate tests validated/rejected per week.""" + twelve_weeks_ago = datetime.utcnow() - timedelta(weeks=12) + + # Tests validated + validated_weekly = ( + db.query( + func.date_trunc("week", Test.red_validated_at).label("week"), + func.count(Test.id).label("count"), + ) + .filter( + Test.red_validated_at >= twelve_weeks_ago, + Test.state.in_([TestState.validated, TestState.rejected]), + ) + .group_by(func.date_trunc("week", Test.red_validated_at)) + .order_by("week") + .all() + ) + + if validated_weekly: + counts = [row.count for row in validated_weekly] + avg_per_week = round(sum(counts) / len(counts), 1) + recent = counts[-4:] if len(counts) >= 4 else counts + earlier = counts[-8:-4] if len(counts) >= 8 else counts[:len(counts) // 2] if counts else [] + + recent_avg = sum(recent) / len(recent) if recent else 0 + earlier_avg = sum(earlier) / len(earlier) if earlier else 0 + + if recent_avg > earlier_avg * 1.1: + trend = "improving" + elif recent_avg < earlier_avg * 0.9: + trend = "declining" + else: + trend = "stable" + else: + avg_per_week = 0 + trend = "stable" + + return { + "tests_per_week": avg_per_week, + "trend": trend, + } + + +# ── Rejection Rate ────────────────────────────────────────────────── + + +def calculate_rejection_rate(db: Session) -> dict: + """Calculate rejection rate, broken down by red_lead and blue_lead.""" + validated_count = ( + db.query(func.count(Test.id)) + .filter(Test.state == TestState.validated) + .scalar() + ) or 0 + + rejected_count = ( + db.query(func.count(Test.id)) + .filter(Test.state == TestState.rejected) + .scalar() + ) or 0 + + total = validated_count + rejected_count + overall_pct = round((rejected_count / total) * 100, 1) if total > 0 else 0 + + # By red_lead (red_validation_status == "rejected") + red_rejected = ( + db.query(func.count(Test.id)) + .filter(Test.red_validation_status == "rejected") + .scalar() + ) or 0 + red_total = ( + db.query(func.count(Test.id)) + .filter(Test.red_validation_status.in_(["approved", "rejected"])) + .scalar() + ) or 0 + red_pct = round((red_rejected / red_total) * 100, 1) if red_total > 0 else 0 + + # By blue_lead + blue_rejected = ( + db.query(func.count(Test.id)) + .filter(Test.blue_validation_status == "rejected") + .scalar() + ) or 0 + blue_total = ( + db.query(func.count(Test.id)) + .filter(Test.blue_validation_status.in_(["approved", "rejected"])) + .scalar() + ) or 0 + blue_pct = round((blue_rejected / blue_total) * 100, 1) if blue_total > 0 else 0 + + return { + "percentage": overall_pct, + "by_red_lead": red_pct, + "by_blue_lead": blue_pct, + } + + +# ── Aggregated Operational Metrics ─────────────────────────────────── + + +def get_all_operational_metrics(db: Session) -> dict: + """Get all operational metrics in a single response.""" + return { + "mttd": calculate_mttd(db), + "mttr": calculate_mttr(db), + "detection_efficacy": calculate_detection_efficacy(db), + "alert_fidelity": calculate_alert_fidelity(db), + "coverage_velocity": calculate_coverage_velocity(db), + "validation_throughput": calculate_validation_throughput(db), + "rejection_rate": calculate_rejection_rate(db), + } + + +# ── Trend Data ─────────────────────────────────────────────────────── + + +def get_operational_trend(db: Session, period: str = "90d") -> list: + """Get weekly trend data for operational metrics.""" + now = datetime.utcnow() + if period == "30d": + start = now - timedelta(days=30) + elif period == "1y": + start = now - timedelta(days=365) + else: + start = now - timedelta(days=90) + + # Build weekly data points + data_points = [] + current = start + while current < now: + week_end = min(current + timedelta(days=7), now) + + # Detection efficacy for tests validated up to this week + validated_up_to = ( + db.query(Test) + .filter( + Test.state == TestState.validated, + Test.red_validated_at <= week_end, + ) + .all() + ) + + total = len(validated_up_to) + detected = len([t for t in validated_up_to if t.detection_result == TestResult.detected]) + efficacy = round((detected / total) * 100, 1) if total > 0 else 0 + + data_points.append({ + "date": current.strftime("%Y-%m-%d"), + "detection_efficacy": efficacy, + "validated_tests": total, + "detected_tests": detected, + }) + + current = week_end + + return data_points + + +# ── By Team ────────────────────────────────────────────────────────── + + +def get_metrics_by_team(db: Session) -> dict: + """Get metrics broken down by Red vs Blue team.""" + # Red team metrics + red_tests_completed = ( + db.query(func.count(Test.id)) + .filter(Test.state.in_([ + TestState.blue_evaluating, + TestState.in_review, + TestState.validated, + TestState.rejected, + ])) + .scalar() + ) or 0 + + red_avg_time = None + red_times = [] + # Time for red team to complete their phase + tests_with_red = ( + db.query(Test) + .filter(Test.red_validated_at.isnot(None), Test.created_at.isnot(None)) + .all() + ) + for t in tests_with_red: + hours = (t.red_validated_at - t.created_at).total_seconds() / 3600 + if hours > 0: + red_times.append(hours) + if red_times: + red_avg_time = round(sum(red_times) / len(red_times), 1) + + # Blue team metrics + blue_tests_completed = ( + db.query(func.count(Test.id)) + .filter(Test.state.in_([ + TestState.in_review, + TestState.validated, + TestState.rejected, + ])) + .scalar() + ) or 0 + + blue_avg_time = None + blue_times = [] + tests_with_blue = ( + db.query(Test) + .filter( + Test.blue_validated_at.isnot(None), + Test.red_validated_at.isnot(None), + ) + .all() + ) + for t in tests_with_blue: + hours = (t.blue_validated_at - t.red_validated_at).total_seconds() / 3600 + if hours > 0: + blue_times.append(hours) + if blue_times: + blue_avg_time = round(sum(blue_times) / len(blue_times), 1) + + return { + "red_team": { + "tests_completed": red_tests_completed, + "avg_completion_hours": red_avg_time, + "rejection_rate": calculate_rejection_rate(db)["by_red_lead"], + }, + "blue_team": { + "tests_completed": blue_tests_completed, + "avg_completion_hours": blue_avg_time, + "rejection_rate": calculate_rejection_rate(db)["by_blue_lead"], + }, + } diff --git a/backend/app/services/scoring_service.py b/backend/app/services/scoring_service.py new file mode 100644 index 0000000..072eb00 --- /dev/null +++ b/backend/app/services/scoring_service.py @@ -0,0 +1,467 @@ +"""Scoring service — granular 0-100 scoring for techniques, tactics, actors, and org. + +Uses configurable weights from Settings to compute coverage scores with +detailed breakdowns. +""" + +from datetime import datetime, timedelta +from typing import Optional + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.config import settings +from app.models.technique import Technique +from app.models.test import Test +from app.models.detection_rule import DetectionRule +from app.models.test_detection_result import TestDetectionResult +from app.models.defensive_technique import DefensiveTechniqueMapping +from app.models.threat_actor import ThreatActor, ThreatActorTechnique +from app.models.enums import TestState, TestResult + + +# ── Technique-level scoring ────────────────────────────────────────── + + +def calculate_technique_score(technique: Technique, db: Session) -> dict: + """Calculate a 0-100 score for a technique with detailed breakdown. + + Weights (configurable via settings): + - tests_validated: weight from SCORING_WEIGHT_TESTS + - detection_rules: weight from SCORING_WEIGHT_DETECTION_RULES + - d3fend_coverage: weight from SCORING_WEIGHT_D3FEND + - freshness: weight from SCORING_WEIGHT_FRESHNESS + - platform_diversity: weight from SCORING_WEIGHT_PLATFORM_DIVERSITY + """ + w_tests = settings.SCORING_WEIGHT_TESTS + w_detection = settings.SCORING_WEIGHT_DETECTION_RULES + w_d3fend = settings.SCORING_WEIGHT_D3FEND + w_freshness = settings.SCORING_WEIGHT_FRESHNESS + w_diversity = settings.SCORING_WEIGHT_PLATFORM_DIVERSITY + + breakdown = {} + + # ── 1. Tests validated with detection ────────────────────────── + all_tests = ( + db.query(Test) + .filter(Test.technique_id == technique.id) + .all() + ) + validated_tests = [t for t in all_tests if t.state == TestState.validated] + detected_tests = [ + t for t in validated_tests + if t.detection_result == TestResult.detected + ] + + if validated_tests: + test_ratio = len(detected_tests) / len(validated_tests) + test_score = round(test_ratio * w_tests, 1) + else: + test_ratio = 0 + test_score = 0 + + breakdown["tests_validated"] = { + "score": test_score, + "max": w_tests, + "detail": f"{len(detected_tests)}/{len(validated_tests)} tests detected" + if validated_tests + else "No validated tests", + } + + # ── 2. Detection rules coverage ─────────────────────────────── + total_rules = ( + db.query(func.count(DetectionRule.id)) + .filter( + DetectionRule.mitre_technique_id == technique.mitre_id, + DetectionRule.is_active == True, + ) + .scalar() + ) or 0 + + triggered_rules = 0 + if total_rules > 0: + triggered_rules = ( + db.query(func.count(TestDetectionResult.id)) + .join( + DetectionRule, + DetectionRule.id == TestDetectionResult.detection_rule_id, + ) + .filter( + DetectionRule.mitre_technique_id == technique.mitre_id, + TestDetectionResult.triggered == True, + ) + .scalar() + ) or 0 + + detection_ratio = min(triggered_rules / total_rules, 1.0) + detection_score = round(detection_ratio * w_detection, 1) + else: + detection_ratio = 0 + detection_score = 0 + + breakdown["detection_rules"] = { + "score": detection_score, + "max": w_detection, + "detail": f"{triggered_rules}/{total_rules} rules triggered" + if total_rules > 0 + else "No detection rules available", + } + + # ── 3. D3FEND coverage ──────────────────────────────────────── + total_countermeasures = ( + db.query(func.count(DefensiveTechniqueMapping.id)) + .filter(DefensiveTechniqueMapping.attack_technique_id == technique.id) + .scalar() + ) or 0 + + # Consider a countermeasure "verified" if we have validated tests + # with detection for the technique (simplified heuristic) + verified_countermeasures = 0 + if total_countermeasures > 0 and len(detected_tests) > 0: + # Rough heuristic: each detected test validates ~1 countermeasure + verified_countermeasures = min(len(detected_tests), total_countermeasures) + d3fend_ratio = verified_countermeasures / total_countermeasures + d3fend_score = round(d3fend_ratio * w_d3fend, 1) + else: + d3fend_ratio = 0 + d3fend_score = 0 + + breakdown["d3fend_coverage"] = { + "score": d3fend_score, + "max": w_d3fend, + "detail": f"{verified_countermeasures}/{total_countermeasures} countermeasures" + if total_countermeasures > 0 + else "No D3FEND mappings", + } + + # ── 4. Freshness ────────────────────────────────────────────── + # Most recent validated test date + most_recent_test = ( + db.query(func.max(Test.red_validated_at)) + .filter( + Test.technique_id == technique.id, + Test.state == TestState.validated, + ) + .scalar() + ) + + now = datetime.utcnow() + if most_recent_test: + days_ago = (now - most_recent_test).days + if days_ago < 90: + freshness_pct = 1.0 + elif days_ago < 180: + freshness_pct = 0.5 + else: + freshness_pct = 0.0 + freshness_score = round(freshness_pct * w_freshness, 1) + freshness_detail = f"Last test {days_ago} days ago" + else: + freshness_pct = 0 + freshness_score = 0 + freshness_detail = "No validated tests" + + breakdown["freshness"] = { + "score": freshness_score, + "max": w_freshness, + "detail": freshness_detail, + } + + # ── 5. Platform diversity ───────────────────────────────────── + available_platforms = technique.platforms or [] + total_platforms = len(available_platforms) if available_platforms else 3 # default 3 + + tested_platforms = set() + for t in validated_tests: + if t.platform: + tested_platforms.add(t.platform.lower()) + + if total_platforms > 0 and tested_platforms: + diversity_ratio = min(len(tested_platforms) / total_platforms, 1.0) + diversity_score = round(diversity_ratio * w_diversity, 1) + else: + diversity_ratio = 0 + diversity_score = 0 + + breakdown["platform_diversity"] = { + "score": diversity_score, + "max": w_diversity, + "detail": f"{len(tested_platforms)}/{total_platforms} platforms covered" + if tested_platforms + else "No platforms tested", + } + + # ── Total ───────────────────────────────────────────────────── + total = min( + test_score + detection_score + d3fend_score + freshness_score + diversity_score, + 100, + ) + + return { + "total_score": round(total, 1), + "breakdown": breakdown, + } + + +# ── Tactic-level scoring ───────────────────────────────────────────── + + +def calculate_tactic_score(tactic: str, db: Session) -> dict: + """Calculate average score for all techniques in a tactic.""" + techniques = ( + db.query(Technique) + .filter(Technique.tactic.ilike(f"%{tactic}%")) + .all() + ) + + if not techniques: + return { + "tactic": tactic, + "average_score": 0, + "techniques_count": 0, + "techniques_scored": 0, + } + + scores = [] + for tech in techniques: + result = calculate_technique_score(tech, db) + scores.append(result["total_score"]) + + return { + "tactic": tactic, + "average_score": round(sum(scores) / len(scores), 1) if scores else 0, + "techniques_count": len(techniques), + "techniques_scored": len([s for s in scores if s > 0]), + } + + +# ── Threat actor scoring ───────────────────────────────────────────── + + +def calculate_actor_coverage_score(actor_id: str, db: Session) -> dict: + """Calculate coverage score for a specific threat actor's techniques.""" + actor = db.query(ThreatActor).filter(ThreatActor.id == actor_id).first() + if not actor: + return {"total_score": 0, "techniques_count": 0, "techniques_covered": 0} + + # Get all techniques used by this actor + actor_techniques = ( + db.query(ThreatActorTechnique) + .filter(ThreatActorTechnique.threat_actor_id == actor.id) + .all() + ) + + technique_ids = [at.technique_id for at in actor_techniques] + if not technique_ids: + return { + "actor_id": str(actor.id), + "actor_name": actor.name, + "total_score": 0, + "techniques_count": 0, + "techniques_covered": 0, + "techniques_detail": [], + } + + techniques = ( + db.query(Technique) + .filter(Technique.id.in_(technique_ids)) + .all() + ) + + scores = [] + details = [] + for tech in techniques: + result = calculate_technique_score(tech, db) + score = result["total_score"] + scores.append(score) + details.append({ + "mitre_id": tech.mitre_id, + "name": tech.name, + "score": score, + "breakdown": result["breakdown"], + }) + + avg_score = round(sum(scores) / len(scores), 1) if scores else 0 + + return { + "actor_id": str(actor.id), + "actor_name": actor.name, + "total_score": avg_score, + "techniques_count": len(techniques), + "techniques_covered": len([s for s in scores if s > 50]), + "techniques_detail": details, + } + + +# ── Organization-level scoring ──────────────────────────────────────── + + +def calculate_organization_score(db: Session) -> dict: + """Calculate the overall organization security score.""" + # All techniques + all_techniques = db.query(Technique).all() + total_count = len(all_techniques) + + if total_count == 0: + return { + "overall_score": 0, + "total_coverage": 0, + "critical_coverage": 0, + "detection_maturity": 0, + "response_readiness": 0, + "techniques_evaluated": 0, + "techniques_total": 0, + } + + # Calculate scores for all techniques (with caching for performance) + all_scores = [] + evaluated_count = 0 + + for tech in all_techniques: + result = calculate_technique_score(tech, db) + score = result["total_score"] + all_scores.append(score) + if score > 0: + evaluated_count += 1 + + # Total coverage: average of all evaluated techniques + evaluated_scores = [s for s in all_scores if s > 0] + total_coverage = ( + round(sum(evaluated_scores) / len(evaluated_scores), 1) + if evaluated_scores + else 0 + ) + + # Critical coverage: techniques with high-severity templates + # (simplified: techniques that have tests are "critical") + from app.models.test_template import TestTemplate + + critical_mitre_ids = set( + row[0] + for row in db.query(TestTemplate.mitre_technique_id) + .filter(TestTemplate.severity.in_(["high", "critical"])) + .distinct() + .all() + ) + + critical_techniques = [ + t for t in all_techniques if t.mitre_id in critical_mitre_ids + ] + if critical_techniques: + critical_scores = [] + for tech in critical_techniques: + result = calculate_technique_score(tech, db) + critical_scores.append(result["total_score"]) + critical_coverage = round(sum(critical_scores) / len(critical_scores), 1) + else: + critical_coverage = 0 + + # Detection maturity: based on detection rule coverage + total_rules = ( + db.query(func.count(DetectionRule.id)) + .filter(DetectionRule.is_active == True) + .scalar() + ) or 0 + triggered_total = ( + db.query(func.count(TestDetectionResult.id)) + .filter(TestDetectionResult.triggered == True) + .scalar() + ) or 0 + + detection_maturity = ( + round((triggered_total / total_rules) * 100, 1) + if total_rules > 0 + else 0 + ) + detection_maturity = min(detection_maturity, 100) + + # Response readiness: based on remediation completion + remediation_total = ( + db.query(func.count(Test.id)) + .filter(Test.remediation_status.isnot(None)) + .scalar() + ) or 0 + remediation_completed = ( + db.query(func.count(Test.id)) + .filter(Test.remediation_status == "completed") + .scalar() + ) or 0 + + response_readiness = ( + round((remediation_completed / remediation_total) * 100, 1) + if remediation_total > 0 + else 0 + ) + + # Overall score: weighted average of sub-scores + overall = round( + total_coverage * 0.4 + + critical_coverage * 0.25 + + detection_maturity * 0.2 + + response_readiness * 0.15, + 1, + ) + + return { + "overall_score": overall, + "total_coverage": total_coverage, + "critical_coverage": critical_coverage, + "detection_maturity": detection_maturity, + "response_readiness": response_readiness, + "techniques_evaluated": evaluated_count, + "techniques_total": total_count, + } + + +# ── Score history ──────────────────────────────────────────────────── + + +def get_score_history(db: Session, period: str = "90d") -> list: + """Get historical score snapshots. + + Since we don't have a dedicated history table, we approximate by + computing scores based on test dates within time windows. + Returns a list of weekly data points. + """ + from app.models.audit import AuditLog + + now = datetime.utcnow() + if period == "30d": + start = now - timedelta(days=30) + elif period == "1y": + start = now - timedelta(days=365) + else: # 90d default + start = now - timedelta(days=90) + + # Group validated tests by week + weeks = [] + current = start + while current < now: + week_end = min(current + timedelta(days=7), now) + + # Count validated tests up to this week + validated_up_to = ( + db.query(func.count(Test.id)) + .filter( + Test.state == TestState.validated, + Test.red_validated_at <= week_end, + ) + .scalar() + ) or 0 + + total_techniques = ( + db.query(func.count(Technique.id)).scalar() + ) or 1 + + # Simple approximation: coverage percentage as score proxy + score_approx = round((validated_up_to / total_techniques) * 100, 1) + + weeks.append({ + "date": current.strftime("%Y-%m-%d"), + "score": min(score_approx, 100), + "validated_tests": validated_up_to, + }) + + current = week_end + + return weeks diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3ed842f..94ff34a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import LoginPage from "./pages/LoginPage"; import DashboardPage from "./pages/DashboardPage"; import TechniquesPage from "./pages/TechniquesPage"; import MatrixPage from "./pages/MatrixPage"; +import ExecutiveDashboardPage from "./pages/ExecutiveDashboardPage"; import TechniqueDetailPage from "./pages/TechniqueDetailPage"; import TestsPage from "./pages/TestsPage"; import TestCreatePage from "./pages/TestCreatePage"; @@ -37,6 +38,14 @@ export default function App() { } /> } /> } /> + + + + } + /> } /> } /> } /> diff --git a/frontend/src/api/operational-metrics.ts b/frontend/src/api/operational-metrics.ts new file mode 100644 index 0000000..c427848 --- /dev/null +++ b/frontend/src/api/operational-metrics.ts @@ -0,0 +1,101 @@ +import client from "./client"; + +// ── Types ──────────────────────────────────────────────────────────── + +export interface MTTDMetric { + mean_hours: number; + median_hours: number; + min_hours: number; + max_hours: number; + sample_size: number; +} + +export interface MTTRMetric { + mean_hours: number; + median_hours: number; + min_hours: number; + max_hours: number; + sample_size: number; +} + +export interface DetectionEfficacy { + percentage: number; + detected: number; + partially: number; + not_detected: number; + total: number; +} + +export interface AlertFidelity { + percentage: number; + triggered: number; + not_triggered: number; + total_evaluated: number; +} + +export interface CoverageVelocity { + techniques_per_week: number; + trend: "improving" | "declining" | "stable"; +} + +export interface ValidationThroughput { + tests_per_week: number; + trend: "improving" | "declining" | "stable"; +} + +export interface RejectionRate { + percentage: number; + by_red_lead: number; + by_blue_lead: number; +} + +export interface OperationalMetrics { + mttd: MTTDMetric | null; + mttr: MTTRMetric | null; + detection_efficacy: DetectionEfficacy; + alert_fidelity: AlertFidelity; + coverage_velocity: CoverageVelocity; + validation_throughput: ValidationThroughput; + rejection_rate: RejectionRate; +} + +export interface OperationalTrendPoint { + date: string; + detection_efficacy: number; + validated_tests: number; + detected_tests: number; +} + +export interface TeamMetrics { + red_team: { + tests_completed: number; + avg_completion_hours: number | null; + rejection_rate: number; + }; + blue_team: { + tests_completed: number; + avg_completion_hours: number | null; + rejection_rate: number; + }; +} + +// ── API Functions ──────────────────────────────────────────────────── + +export async function getOperationalMetrics(): Promise { + const { data } = await client.get("/metrics/operational"); + return data; +} + +export async function getOperationalTrend( + period: string = "90d", +): Promise { + const { data } = await client.get("/metrics/operational/trend", { + params: { period }, + }); + return data; +} + +export async function getMetricsByTeam(): Promise { + const { data } = await client.get("/metrics/operational/by-team"); + return data; +} diff --git a/frontend/src/api/scores.ts b/frontend/src/api/scores.ts new file mode 100644 index 0000000..70b45ce --- /dev/null +++ b/frontend/src/api/scores.ts @@ -0,0 +1,100 @@ +import client from "./client"; + +// ── Types ──────────────────────────────────────────────────────────── + +export interface ScoreBreakdownItem { + score: number; + max: number; + detail: string; +} + +export interface TechniqueScore { + mitre_id: string; + name: string; + tactic: string | null; + status_global: string | null; + total_score: number; + breakdown: Record; +} + +export interface TacticScore { + tactic: string; + average_score: number; + techniques_count: number; + techniques_scored: number; +} + +export interface ActorCoverageScore { + actor_id: string; + actor_name: string; + total_score: number; + techniques_count: number; + techniques_covered: number; + techniques_detail: Array<{ + mitre_id: string; + name: string; + score: number; + breakdown: Record; + }>; +} + +export interface OrganizationScore { + overall_score: number; + total_coverage: number; + critical_coverage: number; + detection_maturity: number; + response_readiness: number; + techniques_evaluated: number; + techniques_total: number; +} + +export interface ScoreHistoryPoint { + date: string; + score: number; + validated_tests: number; +} + +export interface ScoringConfig { + weights: { + tests: number; + detection_rules: number; + d3fend: number; + freshness: number; + platform_diversity: number; + }; + total: number; +} + +// ── API Functions ──────────────────────────────────────────────────── + +export async function getTechniqueScore(mitreId: string): Promise { + const { data } = await client.get(`/scores/technique/${mitreId}`); + return data; +} + +export async function getTacticScore(tactic: string): Promise { + const { data } = await client.get(`/scores/tactic/${tactic}`); + return data; +} + +export async function getActorCoverageScore(actorId: string): Promise { + const { data } = await client.get(`/scores/threat-actor/${actorId}`); + return data; +} + +export async function getOrganizationScore(): Promise { + const { data } = await client.get("/scores/organization"); + return data; +} + +export async function getScoreHistory(period: string = "90d"): Promise { + const { data } = await client.get("/scores/history", { + params: { period }, + }); + return data; +} + +export async function getScoringConfig(): Promise { + const { data } = await client.get("/scores/config"); + return data; +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 5d0f36b..c69e7e4 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -16,6 +16,7 @@ import { Crosshair, Zap, Grid3X3, + Gauge, } from "lucide-react"; import { useAuth } from "../context/AuthContext"; @@ -40,6 +41,7 @@ const mainLinks: NavItem[] = [ { to: "/test-catalog", label: "Test Catalog", icon: BookOpen }, ], }, + { to: "/executive-dashboard", label: "Executive Dashboard", icon: Gauge }, { to: "/reports", label: "Reports", icon: BarChart3 }, { to: "/threat-actors", label: "Threat Actors", icon: Crosshair }, { to: "/campaigns", label: "Campaigns", icon: Zap }, diff --git a/frontend/src/pages/ExecutiveDashboardPage.tsx b/frontend/src/pages/ExecutiveDashboardPage.tsx new file mode 100644 index 0000000..7b7a3c5 --- /dev/null +++ b/frontend/src/pages/ExecutiveDashboardPage.tsx @@ -0,0 +1,527 @@ +import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; +import { + Loader2, + AlertCircle, + TrendingUp, + TrendingDown, + Minus, + ArrowRight, +} from "lucide-react"; +import { + LineChart, + Line, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Cell, +} from "recharts"; +import { getOrganizationScore, getScoreHistory } from "../api/scores"; +import { + getOperationalMetrics, + getMetricsByTeam, +} from "../api/operational-metrics"; +import { getCoverageByTactic } from "../api/metrics"; +import { getThreatActors } from "../api/threat-actors"; +import { getTechniques, type TechniqueSummary } from "../api/techniques"; + +// ── Score Gauge Component ──────────────────────────────────────────── + +function ScoreGauge({ score, label }: { score: number; label: string }) { + const getColor = (s: number) => { + if (s < 30) return "#ef4444"; + if (s < 50) return "#f97316"; + if (s < 70) return "#eab308"; + return "#22c55e"; + }; + + const color = getColor(score); + const circumference = 2 * Math.PI * 54; + const strokeDasharray = `${(score / 100) * circumference} ${circumference}`; + + return ( +
+
+ + + + +
+ {Math.round(score)} + / 100 +
+
+ {label} +
+ ); +} + +// ── KPI Card Component ────────────────────────────────────────────── + +function KPICard({ + label, + value, + unit, + trend, +}: { + label: string; + value: string | number; + unit?: string; + trend?: "improving" | "declining" | "stable" | null; +}) { + const TrendIcon = + trend === "improving" + ? TrendingUp + : trend === "declining" + ? TrendingDown + : Minus; + + const trendColor = + trend === "improving" + ? "text-green-400" + : trend === "declining" + ? "text-red-400" + : "text-gray-500"; + + return ( +
+

{label}

+
+
+ + {value === null || value === undefined ? "N/A" : value} + + {unit && {unit}} +
+ {trend && ( + + )} +
+
+ ); +} + +// ── Main Component ────────────────────────────────────────────────── + +export default function ExecutiveDashboardPage() { + const navigate = useNavigate(); + + const { data: orgScore, isLoading: loadingScore } = useQuery({ + queryKey: ["org-score"], + queryFn: getOrganizationScore, + }); + + const { data: scoreHistory } = useQuery({ + queryKey: ["score-history", "90d"], + queryFn: () => getScoreHistory("90d"), + }); + + const { data: opMetrics, isLoading: loadingMetrics } = useQuery({ + queryKey: ["operational-metrics"], + queryFn: getOperationalMetrics, + }); + + const { data: teamMetrics } = useQuery({ + queryKey: ["team-metrics"], + queryFn: getMetricsByTeam, + }); + + const { data: tacticCoverage } = useQuery({ + queryKey: ["tactic-coverage"], + queryFn: getCoverageByTactic, + }); + + const { data: threatActors } = useQuery({ + queryKey: ["threat-actors-top"], + queryFn: () => getThreatActors({ limit: 5 }), + }); + + const { data: allTechniques } = useQuery({ + queryKey: ["techniques-exec"], + queryFn: () => getTechniques(), + }); + + const isLoading = loadingScore || loadingMetrics; + + if (isLoading) { + return ( +
+ +
+ ); + } + + // Critical gaps: not_covered or not_evaluated techniques + const criticalGaps: TechniqueSummary[] = (allTechniques || []) + .filter((t) => t.status_global === "not_covered" || t.status_global === "not_evaluated") + .slice(0, 10); + + // Coverage by tactic for bar chart + const tacticData = (tacticCoverage || []).map((tc) => ({ + name: tc.tactic + .split("-") + .map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "), + coverage: tc.total > 0 ? Math.round(((tc.validated + tc.partial) / tc.total) * 100) : 0, + })); + + const getBarColor = (coverage: number) => { + if (coverage < 30) return "#ef4444"; + if (coverage < 50) return "#f97316"; + if (coverage < 70) return "#eab308"; + return "#22c55e"; + }; + + return ( +
+ {/* Header */} +
+

Executive Dashboard

+

+ Organization security posture overview +

+
+ + {/* Section 1: Score Card + Sub-scores */} +
+
+ +
+
+

+ {orgScore?.total_coverage ?? 0} +

+

Coverage

+
+
+

+ {orgScore?.detection_maturity ?? 0} +

+

Detection

+
+
+

+ {orgScore?.critical_coverage ?? 0} +

+

Critical

+
+
+

+ {orgScore?.response_readiness ?? 0} +

+

Response

+
+
+
+ + {/* Section 2: Trend Chart */} +
+

+ Score Trend (90 days) +

+ + + + { + const d = new Date(val); + return `${d.getMonth() + 1}/${d.getDate()}`; + }} + /> + + + + + +
+
+ + {/* Section 3: Top Threat Actors */} +
+

+ Top Threat Actors +

+
+ {(threatActors?.items || []).map((actor) => ( +
navigate(`/threat-actors/${actor.id}`)} + > +
+ {actor.country?.slice(0, 2).toUpperCase() || "??"} +
+
+

+ {actor.name} +

+

+ {actor.target_sectors?.slice(0, 3).join(", ")} +

+
+
+
+
70 + ? "#22c55e" + : actor.coverage_pct > 40 + ? "#eab308" + : "#ef4444", + }} + /> +
+ + {actor.coverage_pct}% + +
+
+ ))} +
+
+ + {/* Section 4: Operational KPIs */} +
+ + + + +
+ + {/* Section 5: Coverage by Tactic */} +
+

+ Coverage by Tactic +

+ + + + `${v}%`} + /> + + [`${value}%`, "Coverage"]} + /> + + {tacticData.map((entry, index) => ( + + ))} + + + +
+ + {/* Section 6: Critical Gaps */} +
+

+ Critical Gaps (Top 10 Uncovered Techniques) +

+
+ + + + + + + + + + + {criticalGaps.map((tech) => ( + navigate(`/techniques/${tech.mitre_id}`)} + > + + + + + + ))} + {criticalGaps.length === 0 && ( + + + + )} + +
MITRE IDNameTacticStatus
+ {tech.mitre_id} + + {tech.name} + + {tech.tactic + ?.split(",")[0] + .trim() + .split("-") + .map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" ")} + + + {tech.status_global?.replace(/_/g, " ")} + +
+ No critical gaps found +
+
+
+ + {/* Section 7: Team Performance */} + {teamMetrics && ( +
+ {/* Red Team */} +
+

+
+ Red Team +

+
+
+

+ {teamMetrics.red_team.tests_completed} +

+

Tests Done

+
+
+

+ {teamMetrics.red_team.avg_completion_hours + ? `${teamMetrics.red_team.avg_completion_hours}h` + : "N/A"} +

+

Avg Time

+
+
+

+ {teamMetrics.red_team.rejection_rate}% +

+

Rejection

+
+
+
+ + {/* Blue Team */} +
+

+
+ Blue Team +

+
+
+

+ {teamMetrics.blue_team.tests_completed} +

+

Tests Done

+
+
+

+ {teamMetrics.blue_team.avg_completion_hours + ? `${teamMetrics.blue_team.avg_completion_hours}h` + : "N/A"} +

+

Avg Time

+
+
+

+ {teamMetrics.blue_team.rejection_rate}% +

+

Rejection

+
+
+
+
+ )} +
+ ); +}