feat(phase-28): add scoring system, operational metrics and executive dashboard (T-224 to T-226)

This commit is contained in:
2026-02-09 17:24:44 +01:00
parent a911ddeb52
commit 12f33307fd
11 changed files with 1930 additions and 0 deletions

View File

@@ -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"

View File

@@ -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")

View File

@@ -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)

View File

@@ -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
),
}

View File

@@ -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"],
},
}

View File

@@ -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

View File

@@ -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() {
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/techniques" element={<TechniquesPage />} />
<Route path="/matrix" element={<MatrixPage />} />
<Route
path="/executive-dashboard"
element={
<ProtectedRoute roles={["admin", "red_lead", "blue_lead"]}>
<ExecutiveDashboardPage />
</ProtectedRoute>
}
/>
<Route path="/techniques/:mitreId" element={<TechniqueDetailPage />} />
<Route path="/tests" element={<TestsPage />} />
<Route path="/tests/new" element={<TestCreatePage />} />

View File

@@ -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<OperationalMetrics> {
const { data } = await client.get<OperationalMetrics>("/metrics/operational");
return data;
}
export async function getOperationalTrend(
period: string = "90d",
): Promise<OperationalTrendPoint[]> {
const { data } = await client.get<OperationalTrendPoint[]>("/metrics/operational/trend", {
params: { period },
});
return data;
}
export async function getMetricsByTeam(): Promise<TeamMetrics> {
const { data } = await client.get<TeamMetrics>("/metrics/operational/by-team");
return data;
}

100
frontend/src/api/scores.ts Normal file
View File

@@ -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<string, ScoreBreakdownItem>;
}
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<string, ScoreBreakdownItem>;
}>;
}
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<TechniqueScore> {
const { data } = await client.get<TechniqueScore>(`/scores/technique/${mitreId}`);
return data;
}
export async function getTacticScore(tactic: string): Promise<TacticScore> {
const { data } = await client.get<TacticScore>(`/scores/tactic/${tactic}`);
return data;
}
export async function getActorCoverageScore(actorId: string): Promise<ActorCoverageScore> {
const { data } = await client.get<ActorCoverageScore>(`/scores/threat-actor/${actorId}`);
return data;
}
export async function getOrganizationScore(): Promise<OrganizationScore> {
const { data } = await client.get<OrganizationScore>("/scores/organization");
return data;
}
export async function getScoreHistory(period: string = "90d"): Promise<ScoreHistoryPoint[]> {
const { data } = await client.get<ScoreHistoryPoint[]>("/scores/history", {
params: { period },
});
return data;
}
export async function getScoringConfig(): Promise<ScoringConfig> {
const { data } = await client.get<ScoringConfig>("/scores/config");
return data;
}

View File

@@ -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 },

View File

@@ -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 (
<div className="flex flex-col items-center">
<div className="relative h-32 w-32">
<svg className="h-32 w-32 -rotate-90" viewBox="0 0 120 120">
<circle
cx="60"
cy="60"
r="54"
fill="none"
stroke="#1f2937"
strokeWidth="8"
/>
<circle
cx="60"
cy="60"
r="54"
fill="none"
stroke={color}
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={strokeDasharray}
className="transition-all duration-1000"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-3xl font-bold text-white">{Math.round(score)}</span>
<span className="text-[10px] text-gray-500">/ 100</span>
</div>
</div>
<span className="mt-2 text-xs font-medium text-gray-400">{label}</span>
</div>
);
}
// ── 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 (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<p className="text-xs text-gray-500 uppercase tracking-wider">{label}</p>
<div className="mt-2 flex items-end justify-between">
<div>
<span className="text-2xl font-bold text-white">
{value === null || value === undefined ? "N/A" : value}
</span>
{unit && <span className="ml-1 text-sm text-gray-500">{unit}</span>}
</div>
{trend && (
<TrendIcon className={`h-5 w-5 ${trendColor}`} />
)}
</div>
</div>
);
}
// ── 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 (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
</div>
);
}
// 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 (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-white">Executive Dashboard</h1>
<p className="mt-1 text-sm text-gray-400">
Organization security posture overview
</p>
</div>
{/* Section 1: Score Card + Sub-scores */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4">
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6 lg:col-span-1 flex flex-col items-center justify-center">
<ScoreGauge
score={orgScore?.overall_score ?? 0}
label="Overall Score"
/>
<div className="mt-4 grid w-full grid-cols-2 gap-2">
<div className="rounded-lg bg-gray-800 px-3 py-2 text-center">
<p className="text-lg font-bold text-white">
{orgScore?.total_coverage ?? 0}
</p>
<p className="text-[10px] text-gray-500">Coverage</p>
</div>
<div className="rounded-lg bg-gray-800 px-3 py-2 text-center">
<p className="text-lg font-bold text-white">
{orgScore?.detection_maturity ?? 0}
</p>
<p className="text-[10px] text-gray-500">Detection</p>
</div>
<div className="rounded-lg bg-gray-800 px-3 py-2 text-center">
<p className="text-lg font-bold text-white">
{orgScore?.critical_coverage ?? 0}
</p>
<p className="text-[10px] text-gray-500">Critical</p>
</div>
<div className="rounded-lg bg-gray-800 px-3 py-2 text-center">
<p className="text-lg font-bold text-white">
{orgScore?.response_readiness ?? 0}
</p>
<p className="text-[10px] text-gray-500">Response</p>
</div>
</div>
</div>
{/* Section 2: Trend Chart */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4 lg:col-span-3">
<h2 className="mb-3 text-sm font-semibold text-gray-300">
Score Trend (90 days)
</h2>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={scoreHistory || []}>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" />
<XAxis
dataKey="date"
tick={{ fill: "#6b7280", fontSize: 10 }}
tickFormatter={(val) => {
const d = new Date(val);
return `${d.getMonth() + 1}/${d.getDate()}`;
}}
/>
<YAxis
domain={[0, 100]}
tick={{ fill: "#6b7280", fontSize: 10 }}
/>
<Tooltip
contentStyle={{
backgroundColor: "#111827",
border: "1px solid #374151",
borderRadius: "8px",
fontSize: "12px",
}}
labelStyle={{ color: "#9ca3af" }}
/>
<Line
type="monotone"
dataKey="score"
stroke="#06b6d4"
strokeWidth={2}
dot={false}
name="Score"
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
{/* Section 3: Top Threat Actors */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<h2 className="mb-3 text-sm font-semibold text-gray-300">
Top Threat Actors
</h2>
<div className="space-y-2">
{(threatActors?.items || []).map((actor) => (
<div
key={actor.id}
className="flex items-center gap-3 rounded-lg bg-gray-800/50 p-3 cursor-pointer hover:bg-gray-800"
onClick={() => navigate(`/threat-actors/${actor.id}`)}
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-700 text-xs font-bold text-gray-300">
{actor.country?.slice(0, 2).toUpperCase() || "??"}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">
{actor.name}
</p>
<p className="text-[10px] text-gray-500 truncate">
{actor.target_sectors?.slice(0, 3).join(", ")}
</p>
</div>
<div className="flex items-center gap-2">
<div className="w-24 h-2 rounded-full bg-gray-700 overflow-hidden">
<div
className="h-full rounded-full transition-all"
style={{
width: `${actor.coverage_pct}%`,
backgroundColor:
actor.coverage_pct > 70
? "#22c55e"
: actor.coverage_pct > 40
? "#eab308"
: "#ef4444",
}}
/>
</div>
<span className="w-10 text-right text-xs font-medium text-gray-300">
{actor.coverage_pct}%
</span>
</div>
</div>
))}
</div>
</div>
{/* Section 4: Operational KPIs */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<KPICard
label="MTTD"
value={opMetrics?.mttd?.mean_hours ?? "N/A"}
unit={opMetrics?.mttd ? "hrs" : undefined}
/>
<KPICard
label="MTTR"
value={opMetrics?.mttr?.mean_hours ?? "N/A"}
unit={opMetrics?.mttr ? "hrs" : undefined}
/>
<KPICard
label="Detection Efficacy"
value={opMetrics?.detection_efficacy?.percentage ?? 0}
unit="%"
/>
<KPICard
label="Validation Throughput"
value={opMetrics?.validation_throughput?.tests_per_week ?? 0}
unit="/week"
trend={opMetrics?.validation_throughput?.trend}
/>
</div>
{/* Section 5: Coverage by Tactic */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<h2 className="mb-3 text-sm font-semibold text-gray-300">
Coverage by Tactic
</h2>
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={tacticData}
layout="vertical"
margin={{ left: 120 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" />
<XAxis
type="number"
domain={[0, 100]}
tick={{ fill: "#6b7280", fontSize: 10 }}
tickFormatter={(v) => `${v}%`}
/>
<YAxis
type="category"
dataKey="name"
width={120}
tick={{ fill: "#9ca3af", fontSize: 10 }}
/>
<Tooltip
contentStyle={{
backgroundColor: "#111827",
border: "1px solid #374151",
borderRadius: "8px",
fontSize: "12px",
}}
formatter={(value: number) => [`${value}%`, "Coverage"]}
/>
<Bar dataKey="coverage" radius={[0, 4, 4, 0]}>
{tacticData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={getBarColor(entry.coverage)}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
{/* Section 6: Critical Gaps */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<h2 className="mb-3 text-sm font-semibold text-gray-300">
Critical Gaps (Top 10 Uncovered Techniques)
</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-800 text-left text-xs text-gray-500">
<th className="pb-2 pr-4">MITRE ID</th>
<th className="pb-2 pr-4">Name</th>
<th className="pb-2 pr-4">Tactic</th>
<th className="pb-2 pr-4">Status</th>
</tr>
</thead>
<tbody>
{criticalGaps.map((tech) => (
<tr
key={tech.mitre_id}
className="border-b border-gray-800/50 cursor-pointer hover:bg-gray-800/30"
onClick={() => navigate(`/techniques/${tech.mitre_id}`)}
>
<td className="py-2 pr-4 font-mono text-xs text-cyan-400">
{tech.mitre_id}
</td>
<td className="py-2 pr-4 text-gray-300 truncate max-w-[200px]">
{tech.name}
</td>
<td className="py-2 pr-4 text-gray-500 text-xs">
{tech.tactic
?.split(",")[0]
.trim()
.split("-")
.map((w: string) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ")}
</td>
<td className="py-2 pr-4">
<span
className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-medium ${
tech.status_global === "not_covered"
? "bg-red-500/10 text-red-400"
: "bg-gray-500/10 text-gray-400"
}`}
>
{tech.status_global?.replace(/_/g, " ")}
</span>
</td>
</tr>
))}
{criticalGaps.length === 0 && (
<tr>
<td colSpan={4} className="py-4 text-center text-gray-500">
No critical gaps found
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Section 7: Team Performance */}
{teamMetrics && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* Red Team */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-red-400">
<div className="h-2 w-2 rounded-full bg-red-500" />
Red Team
</h2>
<div className="grid grid-cols-3 gap-3">
<div className="rounded-lg bg-gray-800 px-3 py-2 text-center">
<p className="text-lg font-bold text-white">
{teamMetrics.red_team.tests_completed}
</p>
<p className="text-[10px] text-gray-500">Tests Done</p>
</div>
<div className="rounded-lg bg-gray-800 px-3 py-2 text-center">
<p className="text-lg font-bold text-white">
{teamMetrics.red_team.avg_completion_hours
? `${teamMetrics.red_team.avg_completion_hours}h`
: "N/A"}
</p>
<p className="text-[10px] text-gray-500">Avg Time</p>
</div>
<div className="rounded-lg bg-gray-800 px-3 py-2 text-center">
<p className="text-lg font-bold text-white">
{teamMetrics.red_team.rejection_rate}%
</p>
<p className="text-[10px] text-gray-500">Rejection</p>
</div>
</div>
</div>
{/* Blue Team */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-blue-400">
<div className="h-2 w-2 rounded-full bg-blue-500" />
Blue Team
</h2>
<div className="grid grid-cols-3 gap-3">
<div className="rounded-lg bg-gray-800 px-3 py-2 text-center">
<p className="text-lg font-bold text-white">
{teamMetrics.blue_team.tests_completed}
</p>
<p className="text-[10px] text-gray-500">Tests Done</p>
</div>
<div className="rounded-lg bg-gray-800 px-3 py-2 text-center">
<p className="text-lg font-bold text-white">
{teamMetrics.blue_team.avg_completion_hours
? `${teamMetrics.blue_team.avg_completion_hours}h`
: "N/A"}
</p>
<p className="text-[10px] text-gray-500">Avg Time</p>
</div>
<div className="rounded-lg bg-gray-800 px-3 py-2 text-center">
<p className="text-lg font-bold text-white">
{teamMetrics.blue_team.rejection_rate}%
</p>
<p className="text-[10px] text-gray-500">Rejection</p>
</div>
</div>
</div>
</div>
)}
</div>
);
}