Compare commits

...

3 Commits

31 changed files with 5470 additions and 171 deletions
@@ -0,0 +1,92 @@
"""add_compliance_tables
Revision ID: b014compliance
Revises: b013campaigns
Create Date: 2026-02-09 20:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "b014compliance"
down_revision: Union[str, None] = "b013campaigns"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ── compliance_frameworks ─────────────────────────────────────
op.create_table(
"compliance_frameworks",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("name", sa.String, unique=True, nullable=False),
sa.Column("version", sa.String, nullable=True),
sa.Column("description", sa.Text, nullable=True),
sa.Column("url", sa.String, nullable=True),
sa.Column("is_active", sa.Boolean, server_default="true"),
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
)
# ── compliance_controls ───────────────────────────────────────
op.create_table(
"compliance_controls",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"framework_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("compliance_frameworks.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("control_id", sa.String, nullable=False),
sa.Column("title", sa.String, nullable=False),
sa.Column("description", sa.Text, nullable=True),
sa.Column("category", sa.String, nullable=True),
)
op.create_index(
"ix_compliance_controls_framework",
"compliance_controls",
["framework_id"],
)
# ── compliance_control_mappings ───────────────────────────────
op.create_table(
"compliance_control_mappings",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"compliance_control_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("compliance_controls.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"technique_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("techniques.id", ondelete="CASCADE"),
nullable=False,
),
)
op.create_index(
"ix_compliance_mappings_control",
"compliance_control_mappings",
["compliance_control_id"],
)
op.create_index(
"ix_compliance_mappings_technique",
"compliance_control_mappings",
["technique_id"],
)
op.create_unique_constraint(
"uq_control_technique",
"compliance_control_mappings",
["compliance_control_id", "technique_id"],
)
def downgrade() -> None:
op.drop_table("compliance_control_mappings")
op.drop_table("compliance_controls")
op.drop_table("compliance_frameworks")
+7
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"
+8
View File
@@ -23,6 +23,10 @@ from app.routers import threat_actors as threat_actors_router
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.routers import compliance as compliance_router
from app.storage import ensure_bucket_exists
from app.jobs.mitre_sync_job import start_scheduler, scheduler
@@ -70,6 +74,10 @@ app.include_router(threat_actors_router.router, prefix="/api/v1")
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.include_router(compliance_router.router, prefix="/api/v1")
@app.get("/health")
+2
View File
@@ -14,6 +14,7 @@ from app.models.defensive_technique import DefensiveTechnique, DefensiveTechniqu
from app.models.test_template_detection_rule import TestTemplateDetectionRule
from app.models.test_detection_result import TestDetectionResult
from app.models.campaign import Campaign, CampaignTest
from app.models.compliance import ComplianceFramework, ComplianceControl, ComplianceControlMapping
from app.models.enums import TechniqueStatus, TestState, TestResult, TeamSide
__all__ = [
@@ -23,5 +24,6 @@ __all__ = [
"DefensiveTechnique", "DefensiveTechniqueMapping",
"TestTemplateDetectionRule", "TestDetectionResult",
"Campaign", "CampaignTest",
"ComplianceFramework", "ComplianceControl", "ComplianceControlMapping",
"TechniqueStatus", "TestState", "TestResult", "TeamSide",
]
+97
View File
@@ -0,0 +1,97 @@
"""Compliance models — frameworks, controls, and technique mappings.
Maps compliance frameworks (NIST 800-53, DORA, NIS2, ISO 27001) to
MITRE ATT&CK techniques, enabling compliance gap analysis.
"""
import uuid
from datetime import datetime
from sqlalchemy import (
Column, String, Text, Boolean, DateTime,
ForeignKey, Index, UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.database import Base
class ComplianceFramework(Base):
"""A compliance framework (e.g. NIST 800-53, ISO 27001)."""
__tablename__ = "compliance_frameworks"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String, unique=True, nullable=False)
version = Column(String, nullable=True)
description = Column(Text, nullable=True)
url = Column(String, nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
controls = relationship(
"ComplianceControl",
back_populates="framework",
cascade="all, delete-orphan",
)
class ComplianceControl(Base):
"""A control within a compliance framework (e.g. AC-2, PR.AC-1)."""
__tablename__ = "compliance_controls"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
framework_id = Column(
UUID(as_uuid=True),
ForeignKey("compliance_frameworks.id", ondelete="CASCADE"),
nullable=False,
)
control_id = Column(String, nullable=False) # e.g. "AC-2"
title = Column(String, nullable=False)
description = Column(Text, nullable=True)
category = Column(String, nullable=True)
# Relationships
framework = relationship("ComplianceFramework", back_populates="controls")
technique_mappings = relationship(
"ComplianceControlMapping",
back_populates="compliance_control",
cascade="all, delete-orphan",
)
__table_args__ = (
Index('ix_compliance_controls_framework', 'framework_id'),
)
class ComplianceControlMapping(Base):
"""Maps a compliance control to a MITRE ATT&CK technique."""
__tablename__ = "compliance_control_mappings"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
compliance_control_id = Column(
UUID(as_uuid=True),
ForeignKey("compliance_controls.id", ondelete="CASCADE"),
nullable=False,
)
technique_id = Column(
UUID(as_uuid=True),
ForeignKey("techniques.id", ondelete="CASCADE"),
nullable=False,
)
# Relationships
compliance_control = relationship(
"ComplianceControl", back_populates="technique_mappings"
)
technique = relationship("Technique")
__table_args__ = (
Index('ix_compliance_mappings_control', 'compliance_control_id'),
Index('ix_compliance_mappings_technique', 'technique_id'),
UniqueConstraint(
'compliance_control_id', 'technique_id',
name='uq_control_technique',
),
)
+380
View File
@@ -0,0 +1,380 @@
"""Compliance endpoints — framework status, reports, and gap analysis.
Provides compliance posture assessment by mapping MITRE ATT&CK technique
coverage to compliance framework controls.
"""
import csv
import io
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session, joinedload
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.compliance import (
ComplianceFramework,
ComplianceControl,
ComplianceControlMapping,
)
from app.models.technique import Technique
from app.models.test_template import TestTemplate
from app.models.threat_actor import ThreatActorTechnique
from app.services.scoring_service import calculate_technique_score
from app.services.compliance_import_service import import_nist_800_53_mappings
router = APIRouter(prefix="/compliance", tags=["compliance"])
# ── Helpers ───────────────────────────────────────────────────────────
def _classify_control(technique_scores: list[float]) -> str:
"""Classify a control status based on its technique scores."""
if not technique_scores:
return "not_evaluated"
all_above_70 = all(s >= 70 for s in technique_scores)
any_above_30 = any(s >= 30 for s in technique_scores)
all_below_30 = all(s < 30 for s in technique_scores)
all_zero = all(s == 0 for s in technique_scores)
if all_zero:
return "not_evaluated"
if all_above_70:
return "covered"
if all_below_30:
return "not_covered"
if any_above_30:
return "partially_covered"
return "not_covered"
def _get_control_status(control: ComplianceControl, db: Session) -> dict:
"""Compute the status and score for a single control."""
mappings = (
db.query(ComplianceControlMapping)
.filter(ComplianceControlMapping.compliance_control_id == control.id)
.all()
)
if not mappings:
return {
"control_id": control.control_id,
"title": control.title,
"category": control.category,
"status": "not_evaluated",
"score": 0,
"techniques_count": 0,
"techniques_covered": 0,
"techniques": [],
}
technique_ids = [m.technique_id for m in mappings]
techniques = (
db.query(Technique)
.filter(Technique.id.in_(technique_ids))
.all()
)
tech_details = []
scores = []
covered_count = 0
for tech in techniques:
result = calculate_technique_score(tech, db)
score = result["total_score"]
scores.append(score)
if score >= 50:
covered_count += 1
tech_details.append({
"mitre_id": tech.mitre_id,
"name": tech.name,
"score": score,
"status": tech.status_global.value if tech.status_global else "not_evaluated",
})
# Sort techniques by score ascending (worst first for priority)
tech_details.sort(key=lambda t: t["score"])
avg_score = round(sum(scores) / len(scores), 1) if scores else 0
status = _classify_control(scores)
return {
"control_id": control.control_id,
"title": control.title,
"category": control.category,
"status": status,
"score": avg_score,
"techniques_count": len(techniques),
"techniques_covered": covered_count,
"techniques": tech_details,
}
# ── GET /compliance/frameworks ────────────────────────────────────────
@router.get("/frameworks")
def list_frameworks(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all available compliance frameworks."""
frameworks = (
db.query(ComplianceFramework)
.filter(ComplianceFramework.is_active == True)
.all()
)
result = []
for fw in frameworks:
control_count = (
db.query(ComplianceControl)
.filter(ComplianceControl.framework_id == fw.id)
.count()
)
result.append({
"id": str(fw.id),
"name": fw.name,
"version": fw.version,
"description": fw.description,
"url": fw.url,
"is_active": fw.is_active,
"controls_count": control_count,
})
return result
# ── GET /compliance/frameworks/{id}/status ────────────────────────────
@router.get("/frameworks/{framework_id}/status")
def framework_status(
framework_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get compliance status for each control in a framework."""
framework = (
db.query(ComplianceFramework)
.filter(ComplianceFramework.id == framework_id)
.first()
)
if not framework:
raise HTTPException(status_code=404, detail="Framework not found")
controls = (
db.query(ComplianceControl)
.filter(ComplianceControl.framework_id == framework.id)
.order_by(ComplianceControl.control_id)
.all()
)
control_statuses = []
summary = {
"total_controls": len(controls),
"covered": 0,
"partially_covered": 0,
"not_covered": 0,
"not_evaluated": 0,
}
for control in controls:
status_data = _get_control_status(control, db)
control_statuses.append(status_data)
status = status_data["status"]
if status in summary:
summary[status] += 1
# Compliance percentage: (covered + partially_covered*0.5) / total * 100
total = summary["total_controls"]
if total > 0:
compliance_pct = round(
(summary["covered"] + summary["partially_covered"] * 0.5) / total * 100,
1,
)
else:
compliance_pct = 0
summary["compliance_percentage"] = compliance_pct
return {
"framework": {"id": str(framework.id), "name": framework.name},
"summary": summary,
"controls": control_statuses,
}
# ── GET /compliance/frameworks/{id}/report ────────────────────────────
@router.get("/frameworks/{framework_id}/report")
def framework_report(
framework_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get the full compliance report (same as status but marked as report)."""
return framework_status(framework_id, db=db, current_user=current_user)
# ── GET /compliance/frameworks/{id}/report/csv ────────────────────────
@router.get("/frameworks/{framework_id}/report/csv")
def framework_report_csv(
framework_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Export compliance report as CSV."""
framework = (
db.query(ComplianceFramework)
.filter(ComplianceFramework.id == framework_id)
.first()
)
if not framework:
raise HTTPException(status_code=404, detail="Framework not found")
controls = (
db.query(ComplianceControl)
.filter(ComplianceControl.framework_id == framework.id)
.order_by(ComplianceControl.control_id)
.all()
)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow([
"control_id",
"title",
"category",
"status",
"score",
"techniques_total",
"techniques_covered",
"technique_ids",
])
for control in controls:
status_data = _get_control_status(control, db)
technique_ids = ",".join(t["mitre_id"] for t in status_data["techniques"])
writer.writerow([
status_data["control_id"],
status_data["title"],
status_data["category"] or "",
status_data["status"],
status_data["score"],
status_data["techniques_count"],
status_data["techniques_covered"],
technique_ids,
])
output.seek(0)
filename = f"compliance_{framework.name.replace(' ', '_')}.csv"
return StreamingResponse(
io.BytesIO(output.getvalue().encode("utf-8")),
media_type="text/csv",
headers={
"Content-Disposition": f"attachment; filename={filename}",
},
)
# ── GET /compliance/frameworks/{id}/gaps ──────────────────────────────
@router.get("/frameworks/{framework_id}/gaps")
def framework_gaps(
framework_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get controls with techniques that are not adequately covered."""
framework = (
db.query(ComplianceFramework)
.filter(ComplianceFramework.id == framework_id)
.first()
)
if not framework:
raise HTTPException(status_code=404, detail="Framework not found")
controls = (
db.query(ComplianceControl)
.filter(ComplianceControl.framework_id == framework.id)
.order_by(ComplianceControl.control_id)
.all()
)
gaps = []
for control in controls:
status_data = _get_control_status(control, db)
if status_data["status"] in ("not_covered", "partially_covered"):
# Find uncovered techniques
uncovered_techniques = []
for tech_info in status_data["techniques"]:
if tech_info["score"] < 70:
# Count available templates
template_count = (
db.query(TestTemplate)
.filter(TestTemplate.mitre_technique_id == tech_info["mitre_id"])
.count()
)
# Count threat actors using this technique
technique = (
db.query(Technique)
.filter(Technique.mitre_id == tech_info["mitre_id"])
.first()
)
actor_count = 0
if technique:
actor_count = (
db.query(ThreatActorTechnique)
.filter(ThreatActorTechnique.technique_id == technique.id)
.count()
)
uncovered_techniques.append({
**tech_info,
"templates_available": template_count,
"threat_actors_using": actor_count,
})
if uncovered_techniques:
gaps.append({
"control_id": status_data["control_id"],
"title": status_data["title"],
"category": status_data["category"],
"status": status_data["status"],
"score": status_data["score"],
"uncovered_techniques": uncovered_techniques,
})
return {
"framework": {"id": str(framework.id), "name": framework.name},
"total_gaps": len(gaps),
"gaps": gaps,
}
# ── POST /compliance/import/nist-800-53 ──────────────────────────────
@router.post("/import/nist-800-53")
def import_nist(
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Import NIST 800-53 Rev 5 mappings (admin only)."""
result = import_nist_800_53_mappings(db)
return result
+526
View File
@@ -0,0 +1,526 @@
"""Heatmap endpoints — ATT&CK Navigator-compatible layer generation.
Provides multiple layer types (coverage, threat actor, detection rules,
campaign) and an export endpoint that produces a JSON file importable
by the official MITRE ATT&CK Navigator.
"""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import func
from sqlalchemy.orm import Session
import io
import json
from app.database import get_db
from app.dependencies.auth import get_current_user
from app.models.user import User
from app.models.technique import Technique
from app.models.test import Test
from app.models.threat_actor import ThreatActor, ThreatActorTechnique
from app.models.detection_rule import DetectionRule
from app.models.campaign import Campaign, CampaignTest
from app.models.defensive_technique import DefensiveTechniqueMapping
from app.models.enums import TechniqueStatus, TestState
router = APIRouter(prefix="/heatmap", tags=["heatmap"])
# ── Constants ─────────────────────────────────────────────────────────
ATTACK_VERSION = "15"
NAVIGATOR_VERSION = "5.0"
LAYER_VERSION = "4.5"
DOMAIN = "enterprise-attack"
# Score mapping for technique status_global
STATUS_SCORE_MAP = {
TechniqueStatus.validated: 100,
TechniqueStatus.partial: 60,
TechniqueStatus.in_progress: 30,
TechniqueStatus.not_covered: 10,
TechniqueStatus.not_evaluated: 0,
TechniqueStatus.review_required: 10,
}
# ── Helpers ───────────────────────────────────────────────────────────
def _score_to_color(score: int) -> str:
"""Map a 0-100 score to a red → yellow → green color hex."""
if score <= 0:
return "#d3d3d3" # gray for not evaluated
if score <= 25:
return "#ff6666" # red
if score <= 50:
return "#ff9933" # orange
if score <= 75:
return "#ffff66" # yellow
return "#66ff66" # green
def _build_layer_skeleton(
name: str,
description: str,
gradient_colors: List[str] | None = None,
) -> dict:
"""Return a base layer dict compatible with ATT&CK Navigator."""
return {
"name": name,
"versions": {
"attack": ATTACK_VERSION,
"navigator": NAVIGATOR_VERSION,
"layer": LAYER_VERSION,
},
"domain": DOMAIN,
"description": description,
"filters": {"platforms": ["windows", "linux", "macos"]},
"gradient": {
"colors": gradient_colors or ["#ff6666", "#ffff66", "#66ff66"],
"minValue": 0,
"maxValue": 100,
},
"techniques": [],
}
def _apply_filters(
query,
model,
platforms: Optional[List[str]] = None,
tactics: Optional[List[str]] = None,
):
"""Apply common platform and tactic filters to a technique query."""
if platforms:
from sqlalchemy import or_, cast, String
from sqlalchemy.dialects.postgresql import JSONB
# Filter techniques that have any of the specified platforms
platform_filters = []
for platform in platforms:
platform_filters.append(
model.platforms.op("@>")(json.dumps([platform]))
)
if platform_filters:
query = query.filter(or_(*platform_filters))
if tactics:
from sqlalchemy import or_
tactic_filters = []
for tactic in tactics:
tactic_filters.append(model.tactic.ilike(f"%{tactic}%"))
query = query.filter(or_(*tactic_filters))
return query
def _format_tactic(tactic_str: str | None) -> str:
"""Normalize tactic string to ATT&CK Navigator format (kebab-case)."""
if not tactic_str:
return ""
# Take first tactic if comma-separated
first = tactic_str.split(",")[0].strip().lower()
return first
def _get_technique_metadata(technique, db: Session) -> list:
"""Build metadata array for a technique."""
# Count validated tests
test_count = (
db.query(func.count(Test.id))
.filter(Test.technique_id == technique.id, Test.state == TestState.validated)
.scalar()
) or 0
# Count detection rules
rule_count = (
db.query(func.count(DetectionRule.id))
.filter(DetectionRule.mitre_technique_id == technique.mitre_id)
.scalar()
) or 0
metadata = [
{"name": "tests_count", "value": str(test_count)},
{"name": "detection_rules", "value": str(rule_count)},
]
if technique.last_review_date:
metadata.append(
{"name": "last_validated", "value": technique.last_review_date.strftime("%Y-%m-%d")}
)
return metadata
# ── GET /heatmap/coverage ─────────────────────────────────────────────
@router.get("/coverage")
def heatmap_coverage(
platforms: Optional[str] = Query(None, description="Comma-separated platforms"),
tactics: Optional[str] = Query(None, description="Comma-separated tactics"),
min_score: int = Query(0, ge=0, le=100),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Coverage layer — score based on status_global of each technique."""
layer = _build_layer_skeleton("Aegis Coverage", "Coverage layer generated by Aegis")
query = db.query(Technique)
platform_list = [p.strip() for p in platforms.split(",")] if platforms else None
tactic_list = [t.strip() for t in tactics.split(",")] if tactics else None
query = _apply_filters(query, Technique, platform_list, tactic_list)
techniques = query.all()
for tech in techniques:
score = STATUS_SCORE_MAP.get(tech.status_global, 0)
if score < min_score:
continue
comment_parts = [f"Status: {tech.status_global.value}"]
metadata = _get_technique_metadata(tech, db)
# Enrich comment with test/rule info
tests_info = next((m for m in metadata if m["name"] == "tests_count"), None)
rules_info = next((m for m in metadata if m["name"] == "detection_rules"), None)
if tests_info:
comment_parts.append(f"{tests_info['value']} tests validated")
if rules_info:
comment_parts.append(f"{rules_info['value']} detection rules")
layer["techniques"].append({
"techniqueID": tech.mitre_id,
"tactic": _format_tactic(tech.tactic),
"color": _score_to_color(score),
"score": score,
"comment": " - ".join(comment_parts),
"enabled": True,
"metadata": metadata,
})
return layer
# ── GET /heatmap/threat-actor/{actor_id} ──────────────────────────────
@router.get("/threat-actor/{actor_id}")
def heatmap_threat_actor(
actor_id: str,
platforms: Optional[str] = Query(None),
tactics: Optional[str] = Query(None),
min_score: int = Query(0, ge=0, le=100),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Threat actor layer — techniques used by an actor with coverage color."""
actor = db.query(ThreatActor).filter(ThreatActor.id == actor_id).first()
if not actor:
raise HTTPException(status_code=404, detail="Threat actor not found")
layer = _build_layer_skeleton(
f"Threat Actor: {actor.name}",
f"Techniques used by {actor.name} with coverage overlay",
gradient_colors=["#808080", "#ff6666", "#66ff66"],
)
# Get actor's technique IDs
actor_technique_rows = (
db.query(ThreatActorTechnique)
.filter(ThreatActorTechnique.threat_actor_id == actor.id)
.all()
)
actor_technique_ids = {row.technique_id for row in actor_technique_rows}
if not actor_technique_ids:
return layer
query = db.query(Technique)
platform_list = [p.strip() for p in platforms.split(",")] if platforms else None
tactic_list = [t.strip() for t in tactics.split(",")] if tactics else None
query = _apply_filters(query, Technique, platform_list, tactic_list)
techniques = query.all()
for tech in techniques:
is_actor_technique = tech.id in actor_technique_ids
score = STATUS_SCORE_MAP.get(tech.status_global, 0) if is_actor_technique else 0
if is_actor_technique and score < min_score:
continue
if is_actor_technique:
metadata = _get_technique_metadata(tech, db)
layer["techniques"].append({
"techniqueID": tech.mitre_id,
"tactic": _format_tactic(tech.tactic),
"color": _score_to_color(score),
"score": score,
"comment": f"Used by {actor.name} - Coverage: {tech.status_global.value}",
"enabled": True,
"metadata": metadata,
})
else:
layer["techniques"].append({
"techniqueID": tech.mitre_id,
"tactic": _format_tactic(tech.tactic),
"color": "",
"score": 0,
"comment": "",
"enabled": False,
"metadata": [],
})
return layer
# ── GET /heatmap/detection-rules ──────────────────────────────────────
@router.get("/detection-rules")
def heatmap_detection_rules(
platforms: Optional[str] = Query(None),
tactics: Optional[str] = Query(None),
min_score: int = Query(0, ge=0, le=100),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Detection rules layer — score based on ratio of rules available vs total."""
layer = _build_layer_skeleton(
"Detection Rules Coverage",
"Coverage of detection rules per technique",
)
query = db.query(Technique)
platform_list = [p.strip() for p in platforms.split(",")] if platforms else None
tactic_list = [t.strip() for t in tactics.split(",")] if tactics else None
query = _apply_filters(query, Technique, platform_list, tactic_list)
techniques = query.all()
# Get rule counts per technique_mitre_id in one query
rule_counts = dict(
db.query(
DetectionRule.mitre_technique_id,
func.count(DetectionRule.id),
)
.filter(DetectionRule.is_active == True)
.group_by(DetectionRule.mitre_technique_id)
.all()
)
# Find the max rule count for normalization
max_rules = max(rule_counts.values()) if rule_counts else 1
from app.models.test_detection_result import TestDetectionResult
# Get evaluated rule counts per technique
evaluated_counts_raw = (
db.query(
DetectionRule.mitre_technique_id,
func.count(TestDetectionResult.id),
)
.join(TestDetectionResult, TestDetectionResult.detection_rule_id == DetectionRule.id)
.filter(TestDetectionResult.triggered.isnot(None))
.group_by(DetectionRule.mitre_technique_id)
.all()
)
evaluated_counts = dict(evaluated_counts_raw)
for tech in techniques:
total_rules = rule_counts.get(tech.mitre_id, 0)
evaluated_rules = evaluated_counts.get(tech.mitre_id, 0)
if total_rules > 0:
# Score based on rule availability (normalized) and evaluation ratio
availability_score = min((total_rules / max_rules) * 50, 50)
evaluation_score = (evaluated_rules / total_rules) * 50 if total_rules > 0 else 0
score = int(min(availability_score + evaluation_score, 100))
else:
score = 0
if score < min_score:
continue
layer["techniques"].append({
"techniqueID": tech.mitre_id,
"tactic": _format_tactic(tech.tactic),
"color": _score_to_color(score),
"score": score,
"comment": f"{total_rules} rules available, {evaluated_rules} evaluated",
"enabled": True,
"metadata": [
{"name": "total_rules", "value": str(total_rules)},
{"name": "evaluated_rules", "value": str(evaluated_rules)},
],
})
return layer
# ── GET /heatmap/campaign/{campaign_id} ───────────────────────────────
@router.get("/campaign/{campaign_id}")
def heatmap_campaign(
campaign_id: str,
platforms: Optional[str] = Query(None),
tactics: Optional[str] = Query(None),
min_score: int = Query(0, ge=0, le=100),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Campaign layer — only techniques in the campaign, colored by test state."""
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
layer = _build_layer_skeleton(
f"Campaign: {campaign.name}",
f"Progress of campaign '{campaign.name}'",
)
# Get campaign tests with their associated techniques
campaign_tests = (
db.query(CampaignTest)
.filter(CampaignTest.campaign_id == campaign.id)
.all()
)
if not campaign_tests:
return layer
# Map test_id -> test for all tests in campaign
test_ids = [ct.test_id for ct in campaign_tests]
tests = db.query(Test).filter(Test.id.in_(test_ids)).all()
test_map = {t.id: t for t in tests}
# Map technique_id -> technique
technique_ids = {t.technique_id for t in tests if t.technique_id}
techniques = db.query(Technique).filter(Technique.id.in_(technique_ids)).all()
tech_map = {t.id: t for t in techniques}
# Score mapping for test states
test_state_score = {
TestState.validated: 100,
TestState.in_review: 70,
TestState.blue_evaluating: 50,
TestState.red_executing: 30,
TestState.draft: 10,
TestState.rejected: 5,
}
# Group by technique (a technique may have multiple tests in a campaign)
tech_scores: dict = {}
for ct in campaign_tests:
test = test_map.get(ct.test_id)
if not test:
continue
tech = tech_map.get(test.technique_id)
if not tech:
continue
state_score = test_state_score.get(test.state, 0)
if tech.mitre_id not in tech_scores:
tech_scores[tech.mitre_id] = {
"technique": tech,
"max_score": state_score,
"tests": [],
}
else:
tech_scores[tech.mitre_id]["max_score"] = max(
tech_scores[tech.mitre_id]["max_score"], state_score
)
tech_scores[tech.mitre_id]["tests"].append(test)
platform_list = [p.strip() for p in platforms.split(",")] if platforms else None
tactic_list = [t.strip() for t in tactics.split(",")] if tactics else None
for mitre_id, info in tech_scores.items():
tech = info["technique"]
score = info["max_score"]
# Apply filters
if platform_list:
tech_platforms = tech.platforms or []
if not any(p in tech_platforms for p in platform_list):
continue
if tactic_list:
tech_tactics = (tech.tactic or "").lower().split(",")
tech_tactics = [t.strip() for t in tech_tactics]
if not any(t in tech_tactics for t in tactic_list):
continue
if score < min_score:
continue
test_states = [t.state.value for t in info["tests"]]
layer["techniques"].append({
"techniqueID": mitre_id,
"tactic": _format_tactic(tech.tactic),
"color": _score_to_color(score),
"score": score,
"comment": f"Campaign tests: {', '.join(test_states)}",
"enabled": True,
"metadata": [
{"name": "campaign_tests", "value": str(len(info["tests"]))},
{"name": "best_state", "value": max(test_states) if test_states else "none"},
],
})
return layer
# ── GET /heatmap/export-navigator ─────────────────────────────────────
@router.get("/export-navigator")
def export_navigator(
layer: str = Query(..., description="Layer type: coverage, threat-actor, detection-rules, campaign"),
layer_id: Optional[str] = Query(None, description="Actor ID or Campaign ID (if applicable)"),
platforms: Optional[str] = Query(None),
tactics: Optional[str] = Query(None),
min_score: int = Query(0, ge=0, le=100),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Export a heatmap layer as a downloadable JSON file for ATT&CK Navigator."""
# Delegate to the appropriate layer endpoint
if layer == "coverage":
data = heatmap_coverage(
platforms=platforms, tactics=tactics, min_score=min_score,
db=db, current_user=current_user,
)
elif layer == "threat-actor":
if not layer_id:
raise HTTPException(status_code=400, detail="layer_id required for threat-actor layer")
data = heatmap_threat_actor(
actor_id=layer_id, platforms=platforms, tactics=tactics,
min_score=min_score, db=db, current_user=current_user,
)
elif layer == "detection-rules":
data = heatmap_detection_rules(
platforms=platforms, tactics=tactics, min_score=min_score,
db=db, current_user=current_user,
)
elif layer == "campaign":
if not layer_id:
raise HTTPException(status_code=400, detail="layer_id required for campaign layer")
data = heatmap_campaign(
campaign_id=layer_id, platforms=platforms, tactics=tactics,
min_score=min_score, db=db, current_user=current_user,
)
else:
raise HTTPException(status_code=400, detail=f"Unknown layer type: {layer}")
# Convert to JSON and return as downloadable file
json_content = json.dumps(data, indent=2, default=str)
buffer = io.BytesIO(json_content.encode("utf-8"))
filename = f"aegis_{layer}_layer.json"
return StreamingResponse(
buffer,
media_type="application/json",
headers={
"Content-Disposition": f"attachment; filename={filename}",
},
)
@@ -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)
+189
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
),
}
@@ -0,0 +1,356 @@
"""Compliance import service — imports NIST 800-53 to ATT&CK mappings.
Downloads and parses the STIX bundle from the Center for Threat-Informed
Defense's attack_to_nist_mapping repository to create ComplianceFramework,
ComplianceControl, and ComplianceControlMapping records.
"""
import logging
import json
import re
from typing import Optional
import requests
from sqlalchemy.orm import Session
from app.models.compliance import (
ComplianceFramework,
ComplianceControl,
ComplianceControlMapping,
)
from app.models.technique import Technique
logger = logging.getLogger(__name__)
# URL for the NIST 800-53 Rev 5 to ATT&CK mapping
# This is the JSON STIX bundle that contains the relationships
NIST_MAPPING_URL = (
"https://raw.githubusercontent.com/center-for-threat-informed-defense/"
"attack_to_nist_mapping/main/data/attack-to-nist-rev5.json"
)
def import_nist_800_53_mappings(db: Session) -> dict:
"""Import NIST 800-53 Rev 5 mappings from MITRE CTI repository.
Steps:
1. Create or get the NIST 800-53 Rev 5 framework
2. Download the STIX bundle JSON
3. Parse controls and relationship objects
4. Create ComplianceControl records
5. Create ComplianceControlMapping records
Returns a summary dict with counts.
"""
# ── 1. Create or get framework ────────────────────────────────
framework = (
db.query(ComplianceFramework)
.filter(ComplianceFramework.name == "NIST 800-53 Rev 5")
.first()
)
if not framework:
framework = ComplianceFramework(
name="NIST 800-53 Rev 5",
version="5",
description="National Institute of Standards and Technology Special Publication 800-53 Revision 5 — Security and Privacy Controls for Information Systems and Organizations",
url="https://csrc.nist.gov/publications/detail/sp/800-53/rev-5/final",
is_active=True,
)
db.add(framework)
db.flush()
logger.info("Created NIST 800-53 Rev 5 framework")
else:
logger.info("NIST 800-53 Rev 5 framework already exists")
# ── 2. Download STIX bundle ───────────────────────────────────
try:
response = requests.get(NIST_MAPPING_URL, timeout=30)
response.raise_for_status()
stix_bundle = response.json()
except requests.RequestException as e:
logger.warning(f"Failed to download STIX bundle: {e}")
# Fallback: create a sample set of well-known NIST controls
return _import_sample_nist_mappings(db, framework)
# ── 3. Parse STIX objects ─────────────────────────────────────
objects = stix_bundle.get("objects", [])
# Build lookup maps
# STIX IDs -> control info
control_map = {} # stix_id -> {control_id, title, category}
technique_map = {} # stix_id -> mitre_technique_id
relationships = [] # (source_ref, target_ref) for "mitigates" relationships
for obj in objects:
obj_type = obj.get("type", "")
if obj_type == "course-of-action":
# This is a NIST control
name = obj.get("name", "")
desc = obj.get("description", "")
stix_id = obj.get("id", "")
# Extract control ID from name (e.g., "AC-2 Account Management")
match = re.match(r"^([A-Z]{2}-\d+(?:\.\d+)?)\s*(.*)", name)
if match:
control_id = match.group(1)
title = match.group(2) or name
else:
control_id = name
title = name
# Extract category from control family
category_match = re.match(r"^([A-Z]{2})", control_id)
category = _get_nist_category(category_match.group(1)) if category_match else None
control_map[stix_id] = {
"control_id": control_id,
"title": title,
"description": desc[:500] if desc else None,
"category": category,
}
elif obj_type == "attack-pattern":
# This is an ATT&CK technique
stix_id = obj.get("id", "")
ext_refs = obj.get("external_references", [])
for ref in ext_refs:
if ref.get("source_name") == "mitre-attack":
technique_map[stix_id] = ref.get("external_id", "")
break
elif obj_type == "relationship":
rel_type = obj.get("relationship_type", "")
if rel_type == "mitigates":
source_ref = obj.get("source_ref", "")
target_ref = obj.get("target_ref", "")
relationships.append((source_ref, target_ref))
# ── 4. Create controls ────────────────────────────────────────
controls_created = 0
controls_existing = 0
control_db_map = {} # control_id -> ComplianceControl
# Load existing controls for this framework
existing_controls = {
c.control_id: c
for c in db.query(ComplianceControl)
.filter(ComplianceControl.framework_id == framework.id)
.all()
}
for stix_id, info in control_map.items():
cid = info["control_id"]
if cid in existing_controls:
control_db_map[stix_id] = existing_controls[cid]
controls_existing += 1
else:
ctrl = ComplianceControl(
framework_id=framework.id,
control_id=cid,
title=info["title"],
description=info["description"],
category=info["category"],
)
db.add(ctrl)
db.flush()
control_db_map[stix_id] = ctrl
controls_created += 1
# ── 5. Create mappings ────────────────────────────────────────
mappings_created = 0
mappings_skipped = 0
# Build technique DB lookup (mitre_id -> Technique)
all_techniques = {t.mitre_id: t for t in db.query(Technique).all()}
# Load existing mappings
existing_mappings = set()
for m in db.query(ComplianceControlMapping).all():
existing_mappings.add((str(m.compliance_control_id), str(m.technique_id)))
for source_ref, target_ref in relationships:
control = control_db_map.get(source_ref)
mitre_id = technique_map.get(target_ref)
if not control or not mitre_id:
mappings_skipped += 1
continue
technique = all_techniques.get(mitre_id)
if not technique:
mappings_skipped += 1
continue
key = (str(control.id), str(technique.id))
if key in existing_mappings:
mappings_skipped += 1
continue
mapping = ComplianceControlMapping(
compliance_control_id=control.id,
technique_id=technique.id,
)
db.add(mapping)
existing_mappings.add(key)
mappings_created += 1
db.commit()
summary = {
"framework": framework.name,
"controls_created": controls_created,
"controls_existing": controls_existing,
"mappings_created": mappings_created,
"mappings_skipped": mappings_skipped,
"total_controls": controls_created + controls_existing,
"total_relationships_found": len(relationships),
}
logger.info(f"NIST 800-53 import complete: {summary}")
return summary
def _import_sample_nist_mappings(db: Session, framework: ComplianceFramework) -> dict:
"""Import a curated sample of NIST 800-53 controls when the download fails.
This ensures the feature works even without network access.
"""
SAMPLE_CONTROLS = [
{"control_id": "AC-2", "title": "Account Management", "category": "Access Control",
"techniques": ["T1078", "T1136", "T1098", "T1087", "T1069"]},
{"control_id": "AC-3", "title": "Access Enforcement", "category": "Access Control",
"techniques": ["T1078", "T1548", "T1134"]},
{"control_id": "AC-4", "title": "Information Flow Enforcement", "category": "Access Control",
"techniques": ["T1048", "T1041", "T1572"]},
{"control_id": "AC-6", "title": "Least Privilege", "category": "Access Control",
"techniques": ["T1078", "T1548", "T1134"]},
{"control_id": "AU-2", "title": "Event Logging", "category": "Audit and Accountability",
"techniques": ["T1562", "T1070"]},
{"control_id": "AU-6", "title": "Audit Record Review", "category": "Audit and Accountability",
"techniques": ["T1562", "T1070", "T1027"]},
{"control_id": "CA-7", "title": "Continuous Monitoring", "category": "Assessment, Authorization, and Monitoring",
"techniques": ["T1059", "T1053"]},
{"control_id": "CM-2", "title": "Baseline Configuration", "category": "Configuration Management",
"techniques": ["T1574", "T1546"]},
{"control_id": "CM-6", "title": "Configuration Settings", "category": "Configuration Management",
"techniques": ["T1574", "T1546", "T1112"]},
{"control_id": "CM-7", "title": "Least Functionality", "category": "Configuration Management",
"techniques": ["T1059", "T1218"]},
{"control_id": "IA-2", "title": "Identification and Authentication", "category": "Identification and Authentication",
"techniques": ["T1078", "T1110"]},
{"control_id": "IA-5", "title": "Authenticator Management", "category": "Identification and Authentication",
"techniques": ["T1078", "T1110", "T1003"]},
{"control_id": "IR-4", "title": "Incident Handling", "category": "Incident Response",
"techniques": ["T1059", "T1547"]},
{"control_id": "RA-5", "title": "Vulnerability Monitoring and Scanning", "category": "Risk Assessment",
"techniques": ["T1190", "T1203"]},
{"control_id": "SC-7", "title": "Boundary Protection", "category": "System and Communications Protection",
"techniques": ["T1048", "T1041", "T1071"]},
{"control_id": "SC-28", "title": "Protection of Information at Rest", "category": "System and Communications Protection",
"techniques": ["T1005", "T1114"]},
{"control_id": "SI-3", "title": "Malicious Code Protection", "category": "System and Information Integrity",
"techniques": ["T1059", "T1204", "T1566"]},
{"control_id": "SI-4", "title": "System Monitoring", "category": "System and Information Integrity",
"techniques": ["T1059", "T1053", "T1547"]},
{"control_id": "SI-7", "title": "Software, Firmware, and Information Integrity", "category": "System and Information Integrity",
"techniques": ["T1195", "T1553"]},
{"control_id": "PM-16", "title": "Threat Awareness Program", "category": "Program Management",
"techniques": ["T1566", "T1204"]},
]
# Build technique lookup
all_techniques = {t.mitre_id: t for t in db.query(Technique).all()}
existing_controls = {
c.control_id: c
for c in db.query(ComplianceControl)
.filter(ComplianceControl.framework_id == framework.id)
.all()
}
existing_mappings = set()
for m in db.query(ComplianceControlMapping).all():
existing_mappings.add((str(m.compliance_control_id), str(m.technique_id)))
controls_created = 0
mappings_created = 0
for sample in SAMPLE_CONTROLS:
# Create or get control
if sample["control_id"] in existing_controls:
control = existing_controls[sample["control_id"]]
else:
control = ComplianceControl(
framework_id=framework.id,
control_id=sample["control_id"],
title=sample["title"],
category=sample["category"],
)
db.add(control)
db.flush()
existing_controls[sample["control_id"]] = control
controls_created += 1
# Create mappings
for mitre_id in sample["techniques"]:
technique = all_techniques.get(mitre_id)
if not technique:
# Try with subtechnique prefix
for key, tech in all_techniques.items():
if key.startswith(mitre_id):
technique = tech
break
if not technique:
continue
key = (str(control.id), str(technique.id))
if key in existing_mappings:
continue
mapping = ComplianceControlMapping(
compliance_control_id=control.id,
technique_id=technique.id,
)
db.add(mapping)
existing_mappings.add(key)
mappings_created += 1
db.commit()
return {
"framework": framework.name,
"controls_created": controls_created,
"controls_existing": len(existing_controls) - controls_created,
"mappings_created": mappings_created,
"mappings_skipped": 0,
"total_controls": len(existing_controls),
"source": "sample_data",
}
def _get_nist_category(family_code: str) -> str:
"""Map NIST 800-53 family code to category name."""
categories = {
"AC": "Access Control",
"AT": "Awareness and Training",
"AU": "Audit and Accountability",
"CA": "Assessment, Authorization, and Monitoring",
"CM": "Configuration Management",
"CP": "Contingency Planning",
"IA": "Identification and Authentication",
"IR": "Incident Response",
"MA": "Maintenance",
"MP": "Media Protection",
"PE": "Physical and Environmental Protection",
"PL": "Planning",
"PM": "Program Management",
"PS": "Personnel Security",
"PT": "Personally Identifiable Information Processing and Transparency",
"RA": "Risk Assessment",
"SA": "System and Services Acquisition",
"SC": "System and Communications Protection",
"SI": "System and Information Integrity",
"SR": "Supply Chain Risk Management",
}
return categories.get(family_code, "Unknown")
@@ -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"],
},
}
+467
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
+417 -8
View File
@@ -1,20 +1,21 @@
{
"name": "app",
"version": "1.0.0",
"name": "aegis-frontend",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "app",
"version": "1.0.0",
"license": "ISC",
"name": "aegis-frontend",
"version": "0.1.0",
"dependencies": {
"@tanstack/react-query": "^5.90.20",
"@tanstack/react-virtual": "^3.13.18",
"axios": "^1.13.4",
"lucide-react": "^0.563.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.0"
"react-router-dom": "^7.13.0",
"recharts": "^2.15.4"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
@@ -260,6 +261,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -1455,6 +1465,33 @@
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.18",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz",
"integrity": "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.18",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz",
"integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1500,6 +1537,69 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1643,6 +1743,15 @@
],
"license": "CC-BY-4.0"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -1679,9 +1788,129 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1700,6 +1929,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -1719,6 +1954,16 @@
"node": ">=8"
}
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1851,6 +2096,21 @@
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2034,6 +2294,15 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -2048,7 +2317,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsesc": {
@@ -2338,6 +2606,24 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -2430,6 +2716,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2479,6 +2774,23 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -2506,6 +2818,12 @@
"react": "^19.2.4"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -2554,6 +2872,69 @@
"react-dom": ">=18"
}
},
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
"license": "MIT",
"dependencies": {
"fast-equals": "^5.0.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts-scale": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
"license": "MIT",
"dependencies": {
"decimal.js-light": "^2.4.1"
}
},
"node_modules/rollup": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
@@ -2652,6 +3033,12 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -2714,6 +3101,28 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
+3 -1
View File
@@ -10,11 +10,13 @@
},
"dependencies": {
"@tanstack/react-query": "^5.90.20",
"@tanstack/react-virtual": "^3.13.18",
"axios": "^1.13.4",
"lucide-react": "^0.563.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.0"
"react-router-dom": "^7.13.0",
"recharts": "^2.15.4"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
+13
View File
@@ -2,6 +2,9 @@ import { Routes, Route, Navigate } from "react-router-dom";
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 CompliancePage from "./pages/CompliancePage";
import TechniqueDetailPage from "./pages/TechniqueDetailPage";
import TestsPage from "./pages/TestsPage";
import TestCreatePage from "./pages/TestCreatePage";
@@ -35,6 +38,15 @@ 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 />} />
@@ -46,6 +58,7 @@ export default function App() {
<Route path="/threat-actors/:actorId" element={<ThreatActorDetailPage />} />
<Route path="/campaigns" element={<CampaignsPage />} />
<Route path="/campaigns/:campaignId" element={<CampaignDetailPage />} />
<Route path="/compliance" element={<CompliancePage />} />
<Route
path="/system"
element={
+116
View File
@@ -0,0 +1,116 @@
import client from "./client";
// ── Types ────────────────────────────────────────────────────────────
export interface ComplianceFrameworkSummary {
id: string;
name: string;
version: string | null;
description: string | null;
url: string | null;
is_active: boolean;
controls_count: number;
}
export interface ComplianceTechniqueInfo {
mitre_id: string;
name: string;
score: number;
status: string;
}
export interface ComplianceControlStatus {
control_id: string;
title: string;
category: string | null;
status: "covered" | "partially_covered" | "not_covered" | "not_evaluated";
score: number;
techniques_count: number;
techniques_covered: number;
techniques: ComplianceTechniqueInfo[];
}
export interface ComplianceSummary {
total_controls: number;
covered: number;
partially_covered: number;
not_covered: number;
not_evaluated: number;
compliance_percentage: number;
}
export interface ComplianceFrameworkStatus {
framework: { id: string; name: string };
summary: ComplianceSummary;
controls: ComplianceControlStatus[];
}
export interface ComplianceGapTechnique extends ComplianceTechniqueInfo {
templates_available: number;
threat_actors_using: number;
}
export interface ComplianceGap {
control_id: string;
title: string;
category: string | null;
status: string;
score: number;
uncovered_techniques: ComplianceGapTechnique[];
}
export interface ComplianceGapsResponse {
framework: { id: string; name: string };
total_gaps: number;
gaps: ComplianceGap[];
}
// ── API Functions ────────────────────────────────────────────────────
/** List all available compliance frameworks. */
export async function getComplianceFrameworks(): Promise<ComplianceFrameworkSummary[]> {
const { data } = await client.get<ComplianceFrameworkSummary[]>("/compliance/frameworks");
return data;
}
/** Get compliance status for a framework. */
export async function getFrameworkStatus(
frameworkId: string,
): Promise<ComplianceFrameworkStatus> {
const { data } = await client.get<ComplianceFrameworkStatus>(
`/compliance/frameworks/${frameworkId}/status`,
);
return data;
}
/** Get compliance gaps for a framework. */
export async function getFrameworkGaps(
frameworkId: string,
): Promise<ComplianceGapsResponse> {
const { data } = await client.get<ComplianceGapsResponse>(
`/compliance/frameworks/${frameworkId}/gaps`,
);
return data;
}
/** Download CSV report for a framework. */
export async function downloadComplianceCSV(frameworkId: string): Promise<void> {
const { data } = await client.get(`/compliance/frameworks/${frameworkId}/report/csv`, {
responseType: "blob",
});
const blob = new Blob([data], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "compliance_report.csv";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/** Import NIST 800-53 mappings (admin). */
export async function importNistMappings(): Promise<Record<string, unknown>> {
const { data } = await client.post("/compliance/import/nist-800-53");
return data;
}
+98
View File
@@ -0,0 +1,98 @@
import client from "./client";
// ── Types ────────────────────────────────────────────────────────────
export interface HeatmapMetadata {
name: string;
value: string;
}
export interface HeatmapTechnique {
techniqueID: string;
tactic: string;
color: string;
score: number;
comment: string;
enabled: boolean;
metadata: HeatmapMetadata[];
}
export interface HeatmapLayer {
name: string;
versions: {
attack: string;
navigator: string;
layer: string;
};
domain: string;
description: string;
filters: {
platforms: string[];
};
gradient: {
colors: string[];
minValue: number;
maxValue: number;
};
techniques: HeatmapTechnique[];
}
export interface HeatmapFilters {
platforms?: string;
tactics?: string;
min_score?: number;
}
// ── API Functions ────────────────────────────────────────────────────
/** Fetch the coverage heatmap layer. */
export async function getHeatmapCoverage(filters?: HeatmapFilters): Promise<HeatmapLayer> {
const { data } = await client.get<HeatmapLayer>("/heatmap/coverage", { params: filters });
return data;
}
/** Fetch the threat actor heatmap layer. */
export async function getHeatmapThreatActor(
actorId: string,
filters?: HeatmapFilters,
): Promise<HeatmapLayer> {
const { data } = await client.get<HeatmapLayer>(`/heatmap/threat-actor/${actorId}`, {
params: filters,
});
return data;
}
/** Fetch the detection rules heatmap layer. */
export async function getHeatmapDetectionRules(filters?: HeatmapFilters): Promise<HeatmapLayer> {
const { data } = await client.get<HeatmapLayer>("/heatmap/detection-rules", { params: filters });
return data;
}
/** Fetch the campaign heatmap layer. */
export async function getHeatmapCampaign(
campaignId: string,
filters?: HeatmapFilters,
): Promise<HeatmapLayer> {
const { data } = await client.get<HeatmapLayer>(`/heatmap/campaign/${campaignId}`, {
params: filters,
});
return data;
}
/** Export a heatmap layer as a Navigator JSON file (returns blob URL). */
export async function exportNavigatorJSON(
layerType: string,
layerId?: string,
filters?: HeatmapFilters,
): Promise<Blob> {
const params: Record<string, string | number | undefined> = {
layer: layerType,
layer_id: layerId,
...filters,
};
const { data } = await client.get("/heatmap/export-navigator", {
params,
responseType: "blob",
});
return data;
}
+101
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
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;
}
+6
View File
@@ -15,6 +15,9 @@ import {
Database,
Crosshair,
Zap,
Grid3X3,
Gauge,
ShieldCheck,
} from "lucide-react";
import { useAuth } from "../context/AuthContext";
@@ -28,6 +31,7 @@ interface NavItem {
const mainLinks: NavItem[] = [
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ to: "/techniques", label: "ATT&CK Matrix", icon: Shield },
{ to: "/matrix", label: "Advanced Heatmap", icon: Grid3X3 },
{
to: "/tests",
label: "Tests",
@@ -38,9 +42,11 @@ 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 },
{ to: "/compliance", label: "Compliance", icon: ShieldCheck },
];
const adminLinks: NavItem[] = [
@@ -0,0 +1,62 @@
interface ComplianceGaugeProps {
percentage: number;
size?: "sm" | "md" | "lg";
}
export default function ComplianceGauge({
percentage,
size = "md",
}: ComplianceGaugeProps) {
const getColor = (p: number) => {
if (p < 30) return "#ef4444";
if (p < 50) return "#f97316";
if (p < 70) return "#eab308";
return "#22c55e";
};
const color = getColor(percentage);
const sizes = {
sm: { outer: 64, radius: 26, stroke: 5, text: "text-lg", label: "text-[8px]" },
md: { outer: 96, radius: 40, stroke: 6, text: "text-2xl", label: "text-[10px]" },
lg: { outer: 128, radius: 54, stroke: 8, text: "text-3xl", label: "text-xs" },
};
const s = sizes[size];
const circumference = 2 * Math.PI * s.radius;
const strokeDasharray = `${(percentage / 100) * circumference} ${circumference}`;
const viewBox = `0 0 ${s.outer + 4} ${s.outer + 4}`;
const center = (s.outer + 4) / 2;
return (
<div className="relative inline-flex items-center justify-center" style={{ width: s.outer + 4, height: s.outer + 4 }}>
<svg className="-rotate-90" viewBox={viewBox} width={s.outer + 4} height={s.outer + 4}>
<circle
cx={center}
cy={center}
r={s.radius}
fill="none"
stroke="#1f2937"
strokeWidth={s.stroke}
/>
<circle
cx={center}
cy={center}
r={s.radius}
fill="none"
stroke={color}
strokeWidth={s.stroke}
strokeLinecap="round"
strokeDasharray={strokeDasharray}
className="transition-all duration-700"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className={`font-bold text-white ${s.text}`}>
{Math.round(percentage)}
</span>
<span className={`text-gray-500 ${s.label}`}>%</span>
</div>
</div>
);
}
@@ -0,0 +1,216 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { ChevronDown, ChevronRight, Search, Filter } from "lucide-react";
import type { ComplianceControlStatus } from "../../api/compliance";
interface ControlsTableProps {
controls: ComplianceControlStatus[];
}
const STATUS_COLORS: Record<string, { bg: string; text: string; dot: string }> = {
covered: { bg: "bg-green-500/10", text: "text-green-400", dot: "bg-green-500" },
partially_covered: { bg: "bg-yellow-500/10", text: "text-yellow-400", dot: "bg-yellow-500" },
not_covered: { bg: "bg-red-500/10", text: "text-red-400", dot: "bg-red-500" },
not_evaluated: { bg: "bg-gray-500/10", text: "text-gray-400", dot: "bg-gray-500" },
};
const STATUS_LABELS: Record<string, string> = {
covered: "Covered",
partially_covered: "Partial",
not_covered: "Not Covered",
not_evaluated: "Not Evaluated",
};
export default function ControlsTable({ controls }: ControlsTableProps) {
const navigate = useNavigate();
const [expandedId, setExpandedId] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<string>("all");
const [categoryFilter, setCategoryFilter] = useState<string>("all");
const [search, setSearch] = useState("");
// Extract unique categories
const categories = [...new Set(controls.map((c) => c.category).filter(Boolean))] as string[];
// Apply filters
const filteredControls = controls.filter((c) => {
if (statusFilter !== "all" && c.status !== statusFilter) return false;
if (categoryFilter !== "all" && c.category !== categoryFilter) return false;
if (search) {
const q = search.toLowerCase();
return (
c.control_id.toLowerCase().includes(q) ||
c.title.toLowerCase().includes(q)
);
}
return true;
});
const toggleExpand = (controlId: string) => {
setExpandedId(expandedId === controlId ? null : controlId);
};
return (
<div>
{/* Filters row */}
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-gray-400" />
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-xs text-gray-200 focus:border-cyan-500 focus:outline-none"
>
<option value="all">All Statuses</option>
<option value="covered">Covered</option>
<option value="partially_covered">Partial</option>
<option value="not_covered">Not Covered</option>
<option value="not_evaluated">Not Evaluated</option>
</select>
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-xs text-gray-200 focus:border-cyan-500 focus:outline-none"
>
<option value="all">All Categories</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat}
</option>
))}
</select>
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-500" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by ID or title..."
className="w-full rounded-lg border border-gray-700 bg-gray-800 py-1.5 pl-8 pr-3 text-xs text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
/>
</div>
<span className="ml-auto text-xs text-gray-500">
{filteredControls.length} of {controls.length} controls
</span>
</div>
{/* Table */}
<div className="overflow-hidden rounded-xl border border-gray-800">
<table className="w-full">
<thead>
<tr className="bg-gray-800/50 text-left text-xs text-gray-500 uppercase tracking-wider">
<th className="w-8 px-3 py-2.5" />
<th className="px-3 py-2.5">Control</th>
<th className="px-3 py-2.5">Title</th>
<th className="px-3 py-2.5 hidden lg:table-cell">Category</th>
<th className="px-3 py-2.5">Status</th>
<th className="px-3 py-2.5">Score</th>
<th className="px-3 py-2.5">Techniques</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800/50">
{filteredControls.map((control) => {
const isExpanded = expandedId === control.control_id;
const statusStyle = STATUS_COLORS[control.status] || STATUS_COLORS.not_evaluated;
return (
<tbody key={control.control_id}>
<tr
className="cursor-pointer transition-colors hover:bg-gray-800/30"
onClick={() => toggleExpand(control.control_id)}
>
<td className="px-3 py-2.5">
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5 text-gray-400" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-gray-400" />
)}
</td>
<td className="px-3 py-2.5 font-mono text-xs font-medium text-cyan-400">
{control.control_id}
</td>
<td className="px-3 py-2.5 text-sm text-gray-200 truncate max-w-[200px]">
{control.title}
</td>
<td className="px-3 py-2.5 text-xs text-gray-500 hidden lg:table-cell">
{control.category}
</td>
<td className="px-3 py-2.5">
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] font-medium ${statusStyle.bg} ${statusStyle.text}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${statusStyle.dot}`} />
{STATUS_LABELS[control.status]}
</span>
</td>
<td className="px-3 py-2.5 text-sm font-medium text-gray-300">
{control.score.toFixed(1)}
</td>
<td className="px-3 py-2.5 text-xs text-gray-400">
{control.techniques_covered}/{control.techniques_count}
</td>
</tr>
{/* Expanded row: technique details */}
{isExpanded && control.techniques.length > 0 && (
<tr>
<td colSpan={7} className="bg-gray-800/20 px-6 py-3">
<div className="space-y-1">
<p className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-gray-500">
Mapped Techniques
</p>
{control.techniques.map((tech) => {
const techStatusColor =
tech.score >= 70
? "text-green-400"
: tech.score >= 30
? "text-yellow-400"
: tech.score > 0
? "text-red-400"
: "text-gray-500";
return (
<div
key={tech.mitre_id}
className="flex items-center justify-between rounded-lg bg-gray-900/50 px-3 py-1.5 cursor-pointer hover:bg-gray-900"
onClick={(e) => {
e.stopPropagation();
navigate(`/techniques/${tech.mitre_id}`);
}}
>
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-cyan-400">
{tech.mitre_id}
</span>
<span className="text-xs text-gray-300">
{tech.name}
</span>
</div>
<div className="flex items-center gap-3">
<span className="text-[10px] text-gray-500">
{tech.status.replace(/_/g, " ")}
</span>
<span className={`text-xs font-medium ${techStatusColor}`}>
{tech.score.toFixed(1)}
</span>
</div>
</div>
);
})}
</div>
</td>
</tr>
)}
</tbody>
);
})}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,185 @@
import { useMemo, useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { HeatmapTechnique } from "../../api/heatmap";
import HeatmapCell from "./HeatmapCell";
// MITRE ATT&CK Enterprise tactics in canonical order
const TACTIC_ORDER = [
"reconnaissance",
"resource-development",
"initial-access",
"execution",
"persistence",
"privilege-escalation",
"defense-evasion",
"credential-access",
"discovery",
"lateral-movement",
"collection",
"command-and-control",
"exfiltration",
"impact",
];
const formatTacticName = (tactic: string): string =>
tactic
.split("-")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
interface AdvancedHeatmapProps {
techniques: HeatmapTechnique[];
onCellClick: (techniqueId: string) => void;
zoom: "compact" | "normal" | "expanded";
}
/** Virtualised tactic column — renders only visible rows. */
function TacticColumn({
tactic,
techniques,
zoom,
onCellClick,
}: {
tactic: string;
techniques: HeatmapTechnique[];
zoom: "compact" | "normal" | "expanded";
onCellClick: (techniqueId: string) => void;
}) {
const parentRef = useRef<HTMLDivElement>(null);
const rowHeight = zoom === "compact" ? 28 : zoom === "normal" ? 40 : 60;
const rowVirtualizer = useVirtualizer({
count: techniques.length,
getScrollElement: () => parentRef.current,
estimateSize: () => rowHeight,
overscan: 10,
});
const columnWidth =
zoom === "compact" ? "w-32" : zoom === "normal" ? "w-44" : "w-56";
return (
<div className={`${columnWidth} flex-shrink-0`}>
{/* Tactic header */}
<div className="mb-2 rounded-lg bg-gray-800 px-2 py-2">
<h3 className="text-center text-xs font-semibold text-cyan-400">
{formatTacticName(tactic)}
</h3>
<p className="mt-0.5 text-center text-[10px] text-gray-500">
{techniques.length} techniques
</p>
</div>
{/* Virtualised list */}
<div
ref={parentRef}
className="overflow-y-auto scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-gray-900"
style={{ maxHeight: "calc(100vh - 320px)" }}
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const tech = techniques[virtualRow.index];
return (
<div
key={tech.techniqueID + tactic}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
padding: "2px 0",
}}
>
<HeatmapCell
technique={tech}
size={zoom}
onClick={onCellClick}
/>
</div>
);
})}
</div>
</div>
</div>
);
}
export default function AdvancedHeatmap({
techniques,
onCellClick,
zoom,
}: AdvancedHeatmapProps) {
// Group techniques by tactic
const groupedByTactic = useMemo(() => {
const groups: Record<string, HeatmapTechnique[]> = {};
for (const tech of techniques) {
// Normalize tactic names
const tacticRaw = tech.tactic || "unknown";
const tacticNormalized = tacticRaw
.trim()
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/_/g, "-");
if (!groups[tacticNormalized]) {
groups[tacticNormalized] = [];
}
groups[tacticNormalized].push(tech);
}
// Sort techniques within each tactic by techniqueID
for (const tactic of Object.keys(groups)) {
groups[tactic].sort((a, b) =>
a.techniqueID.localeCompare(b.techniqueID),
);
}
return groups;
}, [techniques]);
// Get ordered tactics
const orderedTactics = useMemo(() => {
const tacticSet = new Set(Object.keys(groupedByTactic));
const ordered = TACTIC_ORDER.filter((t) => tacticSet.has(t));
const remaining = Array.from(tacticSet).filter(
(t) => !TACTIC_ORDER.includes(t),
);
return [...ordered, ...remaining];
}, [groupedByTactic]);
if (techniques.length === 0) {
return (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-8 text-center">
<p className="text-gray-400">No techniques found for the selected layer</p>
</div>
);
}
return (
<div className="overflow-x-auto rounded-xl border border-gray-800 bg-gray-900">
<div className="min-w-max p-3">
<div className="flex gap-2">
{orderedTactics.map((tactic) => (
<TacticColumn
key={tactic}
tactic={tactic}
techniques={groupedByTactic[tactic] || []}
zoom={zoom}
onCellClick={onCellClick}
/>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,81 @@
import { useState } from "react";
import type { HeatmapTechnique } from "../../api/heatmap";
import HeatmapTooltip from "./HeatmapTooltip";
interface HeatmapCellProps {
technique: HeatmapTechnique;
size: "compact" | "normal" | "expanded";
onClick: (techniqueId: string) => void;
}
export default function HeatmapCell({ technique, size, onClick }: HeatmapCellProps) {
const [showTooltip, setShowTooltip] = useState(false);
const sizeClasses = {
compact: "h-6 text-[9px] px-1",
normal: "h-9 text-[11px] px-1.5",
expanded: "h-14 text-xs px-2",
};
const bgColor = technique.enabled ? technique.color : "transparent";
const isDisabled = !technique.enabled;
// Determine text color based on background brightness
const getTextColor = (hex: string): string => {
if (!hex || hex === "transparent" || hex === "") return "text-gray-600";
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
return brightness > 128 ? "text-gray-900" : "text-white";
};
// Status indicators
const hasTests = technique.metadata.find((m) => m.name === "tests_count");
const testsCount = hasTests ? parseInt(hasTests.value, 10) : 0;
const reviewRequired = technique.comment?.toLowerCase().includes("review");
const isValidated = technique.score >= 100;
return (
<div
className="relative"
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
<button
onClick={() => onClick(technique.techniqueID)}
disabled={isDisabled}
className={`
w-full rounded border transition-all duration-150
${sizeClasses[size]}
${isDisabled
? "cursor-default border-gray-800/30 bg-gray-900/20 opacity-30"
: "cursor-pointer border-gray-700/50 hover:brightness-110 hover:ring-1 hover:ring-cyan-400/40"
}
${reviewRequired && !isDisabled ? "ring-1 ring-amber-400/60" : ""}
flex items-center gap-1 overflow-hidden
`}
style={{
backgroundColor: isDisabled ? undefined : bgColor,
}}
>
<span className={`truncate font-mono font-medium leading-tight ${getTextColor(bgColor)}`}>
{technique.techniqueID}
</span>
{size !== "compact" && !isDisabled && (
<span className="ml-auto flex items-center gap-0.5 flex-shrink-0">
{testsCount === 0 && <span className="text-[8px]" title="No tests">&#x1F534;</span>}
{reviewRequired && <span className="text-[8px]" title="Review required">&#x26A0;&#xFE0F;</span>}
{isValidated && <span className="text-[8px]" title="Validated">&#x2705;</span>}
</span>
)}
</button>
{showTooltip && technique.enabled && (
<div className="absolute left-full top-0 z-50 ml-2">
<HeatmapTooltip technique={technique} />
</div>
)}
</div>
);
}
@@ -0,0 +1,148 @@
import { Filter, X } from "lucide-react";
interface HeatmapFiltersProps {
platforms: string[];
onPlatformsChange: (platforms: string[]) => void;
selectedTactics: string[];
onTacticsChange: (tactics: string[]) => void;
minScore: number;
onMinScoreChange: (score: number) => void;
availableTactics: string[];
}
const PLATFORMS = ["windows", "linux", "macos"];
const formatTacticName = (tactic: string): string =>
tactic
.split("-")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
export default function HeatmapFilters({
platforms,
onPlatformsChange,
selectedTactics,
onTacticsChange,
minScore,
onMinScoreChange,
availableTactics,
}: HeatmapFiltersProps) {
const togglePlatform = (platform: string) => {
if (platforms.includes(platform)) {
onPlatformsChange(platforms.filter((p) => p !== platform));
} else {
onPlatformsChange([...platforms, platform]);
}
};
const toggleTactic = (tactic: string) => {
if (selectedTactics.includes(tactic)) {
onTacticsChange(selectedTactics.filter((t) => t !== tactic));
} else {
onTacticsChange([...selectedTactics, tactic]);
}
};
const hasActiveFilters = platforms.length > 0 || selectedTactics.length > 0 || minScore > 0;
const clearAll = () => {
onPlatformsChange([]);
onTacticsChange([]);
onMinScoreChange(0);
};
return (
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-gray-400" />
<span className="text-xs font-medium text-gray-400">Filters:</span>
</div>
{/* Platform checkboxes */}
<div className="flex items-center gap-2">
{PLATFORMS.map((platform) => (
<label
key={platform}
className="flex cursor-pointer items-center gap-1.5"
>
<input
type="checkbox"
checked={platforms.includes(platform)}
onChange={() => togglePlatform(platform)}
className="h-3.5 w-3.5 rounded border-gray-600 bg-gray-800 text-cyan-500 focus:ring-cyan-500/40"
/>
<span className="text-xs text-gray-300 capitalize">{platform}</span>
</label>
))}
</div>
{/* Tactic multi-select */}
<div className="relative">
<select
value=""
onChange={(e) => {
if (e.target.value) toggleTactic(e.target.value);
}}
className="rounded-lg border border-gray-700 bg-gray-800 px-2 py-1 text-xs text-gray-200 focus:border-cyan-500 focus:outline-none"
>
<option value="">
{selectedTactics.length > 0
? `${selectedTactics.length} Tactics`
: "All Tactics"}
</option>
{availableTactics
.filter((t) => !selectedTactics.includes(t))
.map((tactic) => (
<option key={tactic} value={tactic}>
{formatTacticName(tactic)}
</option>
))}
</select>
</div>
{/* Selected tactic pills */}
{selectedTactics.length > 0 && (
<div className="flex flex-wrap items-center gap-1">
{selectedTactics.map((tactic) => (
<button
key={tactic}
onClick={() => toggleTactic(tactic)}
className="flex items-center gap-1 rounded-full bg-cyan-500/10 px-2 py-0.5 text-[10px] text-cyan-400 hover:bg-cyan-500/20"
>
{formatTacticName(tactic)}
<X className="h-2.5 w-2.5" />
</button>
))}
</div>
)}
{/* Min score slider */}
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400">Min Score:</span>
<input
type="range"
min={0}
max={100}
step={5}
value={minScore}
onChange={(e) => onMinScoreChange(parseInt(e.target.value, 10))}
className="h-1 w-20 cursor-pointer accent-cyan-500"
/>
<span className="w-6 text-right text-xs font-medium text-gray-300">
{minScore}
</span>
</div>
{/* Clear all */}
{hasActiveFilters && (
<button
onClick={clearAll}
className="flex items-center gap-1 rounded-lg border border-gray-700 bg-gray-800 px-2 py-1 text-xs text-gray-400 hover:border-red-500/50 hover:text-red-400"
>
<X className="h-3 w-3" />
Clear
</button>
)}
</div>
);
}
@@ -0,0 +1,120 @@
import { useState, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { Shield, User, Search, ClipboardList } from "lucide-react";
import { getThreatActors, type ThreatActorSummary } from "../../api/threat-actors";
import { listCampaigns, type CampaignSummary } from "../../api/campaigns";
export type LayerType = "coverage" | "threat-actor" | "detection-rules" | "campaign";
interface HeatmapLayerSelectorProps {
activeLayer: LayerType;
onLayerChange: (layer: LayerType) => void;
selectedActorId: string | null;
onActorChange: (actorId: string | null) => void;
selectedCampaignId: string | null;
onCampaignChange: (campaignId: string | null) => void;
}
const LAYERS: {
id: LayerType;
label: string;
icon: React.FC<{ className?: string }>;
}[] = [
{ id: "coverage", label: "Coverage", icon: Shield },
{ id: "threat-actor", label: "Threat Actor", icon: User },
{ id: "detection-rules", label: "Detection Rules", icon: Search },
{ id: "campaign", label: "Campaign", icon: ClipboardList },
];
export default function HeatmapLayerSelector({
activeLayer,
onLayerChange,
selectedActorId,
onActorChange,
selectedCampaignId,
onCampaignChange,
}: HeatmapLayerSelectorProps) {
// Fetch actors for dropdown
const { data: actorsData } = useQuery({
queryKey: ["threat-actors-selector"],
queryFn: () => getThreatActors({ limit: 200 }),
enabled: activeLayer === "threat-actor",
});
// Fetch campaigns for dropdown
const { data: campaignsData } = useQuery({
queryKey: ["campaigns-selector"],
queryFn: () => listCampaigns({ limit: 200 }),
enabled: activeLayer === "campaign",
});
const actors: ThreatActorSummary[] = actorsData?.items || [];
const campaigns: CampaignSummary[] = campaignsData?.items || [];
// Auto-select first actor/campaign if none selected
useEffect(() => {
if (activeLayer === "threat-actor" && !selectedActorId && actors.length > 0) {
onActorChange(actors[0].id);
}
}, [activeLayer, actors, selectedActorId, onActorChange]);
useEffect(() => {
if (activeLayer === "campaign" && !selectedCampaignId && campaigns.length > 0) {
onCampaignChange(campaigns[0].id);
}
}, [activeLayer, campaigns, selectedCampaignId, onCampaignChange]);
return (
<div className="flex flex-wrap items-center gap-3">
{/* Layer type tabs */}
<div className="flex rounded-lg border border-gray-700 bg-gray-900 p-0.5">
{LAYERS.map((layer) => (
<button
key={layer.id}
onClick={() => onLayerChange(layer.id)}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
activeLayer === layer.id
? "bg-cyan-500/20 text-cyan-400"
: "text-gray-400 hover:bg-gray-800 hover:text-gray-200"
}`}
>
<layer.icon className="h-3.5 w-3.5" />
{layer.label}
</button>
))}
</div>
{/* Actor dropdown */}
{activeLayer === "threat-actor" && (
<select
value={selectedActorId || ""}
onChange={(e) => onActorChange(e.target.value || null)}
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
>
<option value="">Select Threat Actor...</option>
{actors.map((actor) => (
<option key={actor.id} value={actor.id}>
{actor.name} {actor.country ? `(${actor.country})` : ""}
</option>
))}
</select>
)}
{/* Campaign dropdown */}
{activeLayer === "campaign" && (
<select
value={selectedCampaignId || ""}
onChange={(e) => onCampaignChange(e.target.value || null)}
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
>
<option value="">Select Campaign...</option>
{campaigns.map((campaign) => (
<option key={campaign.id} value={campaign.id}>
{campaign.name} ({campaign.status})
</option>
))}
</select>
)}
</div>
);
}
@@ -0,0 +1,79 @@
interface HeatmapLegendProps {
layerType: "coverage" | "threat-actor" | "detection-rules" | "campaign";
}
const LEGENDS: Record<
string,
{ label: string; colors: { color: string; label: string }[] }
> = {
coverage: {
label: "Coverage Status",
colors: [
{ color: "#d3d3d3", label: "Not Evaluated (0)" },
{ color: "#ff6666", label: "Not Covered (10)" },
{ color: "#ff9933", label: "In Progress (30)" },
{ color: "#ffff66", label: "Partial (60)" },
{ color: "#66ff66", label: "Validated (100)" },
],
},
"threat-actor": {
label: "Threat Actor Coverage",
colors: [
{ color: "#d3d3d3", label: "Not Used by Actor" },
{ color: "#ff6666", label: "Not Covered (10)" },
{ color: "#ff9933", label: "In Progress (30)" },
{ color: "#ffff66", label: "Partial (60)" },
{ color: "#66ff66", label: "Covered (100)" },
],
},
"detection-rules": {
label: "Detection Rules Coverage",
colors: [
{ color: "#d3d3d3", label: "No Rules (0)" },
{ color: "#ff6666", label: "Few Rules (<25)" },
{ color: "#ff9933", label: "Some Rules (25-50)" },
{ color: "#ffff66", label: "Good Coverage (50-75)" },
{ color: "#66ff66", label: "Full Coverage (75-100)" },
],
},
campaign: {
label: "Campaign Progress",
colors: [
{ color: "#ff6666", label: "Draft / Rejected" },
{ color: "#ff9933", label: "Red Executing (30)" },
{ color: "#ffff66", label: "Blue Evaluating (50)" },
{ color: "#66ff66", label: "Validated (100)" },
],
},
};
export default function HeatmapLegend({ layerType }: HeatmapLegendProps) {
const legend = LEGENDS[layerType] || LEGENDS.coverage;
return (
<div className="flex flex-wrap items-center gap-4 rounded-xl border border-gray-800 bg-gray-900 p-4">
<span className="text-sm font-medium text-gray-400">{legend.label}:</span>
{/* Gradient bar */}
<div className="flex items-center gap-1">
<div
className="h-3 w-40 rounded"
style={{
background: `linear-gradient(to right, ${legend.colors.map((c) => c.color).join(", ")})`,
}}
/>
</div>
{/* Individual labels */}
{legend.colors.map((item) => (
<div key={item.label} className="flex items-center gap-1.5">
<div
className="h-3 w-3 rounded border border-gray-700"
style={{ backgroundColor: item.color }}
/>
<span className="text-xs text-gray-400">{item.label}</span>
</div>
))}
</div>
);
}
@@ -0,0 +1,109 @@
import type { HeatmapTechnique } from "../../api/heatmap";
interface HeatmapTooltipProps {
technique: HeatmapTechnique;
}
export default function HeatmapTooltip({ technique }: HeatmapTooltipProps) {
const getMeta = (name: string): string | null => {
const item = technique.metadata.find((m) => m.name === name);
return item?.value ?? null;
};
const testsCount = getMeta("tests_count");
const detectionRules = getMeta("detection_rules");
const totalRules = getMeta("total_rules");
const evaluatedRules = getMeta("evaluated_rules");
const lastValidated = getMeta("last_validated");
const campaignTests = getMeta("campaign_tests");
// Determine status label from score
const getStatusLabel = (score: number): { label: string; color: string } => {
if (score >= 100) return { label: "Validated", color: "text-green-400" };
if (score >= 60) return { label: "Partial", color: "text-yellow-400" };
if (score >= 30) return { label: "In Progress", color: "text-blue-400" };
if (score > 0) return { label: "Not Covered", color: "text-red-400" };
return { label: "Not Evaluated", color: "text-gray-400" };
};
const status = getStatusLabel(technique.score);
return (
<div className="w-72 rounded-lg border border-gray-700 bg-gray-900 p-3 shadow-xl">
{/* Header */}
<div className="mb-2 border-b border-gray-800 pb-2">
<p className="font-mono text-sm font-bold text-white">
{technique.techniqueID}
</p>
{technique.tactic && (
<p className="mt-0.5 text-[10px] uppercase tracking-wider text-gray-500">
{technique.tactic.replace(/-/g, " ")}
</p>
)}
</div>
{/* Status & Score */}
<div className="space-y-1.5 text-xs">
<div className="flex items-center justify-between">
<span className="text-gray-400">Status:</span>
<span className={`font-medium ${status.color}`}>{status.label}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-400">Score:</span>
<span className="font-medium text-white">{technique.score}/100</span>
</div>
{/* Score bar */}
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-800">
<div
className="h-full rounded-full transition-all"
style={{
width: `${technique.score}%`,
backgroundColor: technique.color || "#666",
}}
/>
</div>
{testsCount !== null && (
<div className="flex items-center justify-between">
<span className="text-gray-400">Tests:</span>
<span className="text-gray-200">{testsCount} validated</span>
</div>
)}
{detectionRules !== null && (
<div className="flex items-center justify-between">
<span className="text-gray-400">Detection Rules:</span>
<span className="text-gray-200">{detectionRules} available</span>
</div>
)}
{totalRules !== null && (
<div className="flex items-center justify-between">
<span className="text-gray-400">Rules:</span>
<span className="text-gray-200">
{evaluatedRules || 0} evaluated / {totalRules} total
</span>
</div>
)}
{campaignTests !== null && (
<div className="flex items-center justify-between">
<span className="text-gray-400">Campaign Tests:</span>
<span className="text-gray-200">{campaignTests}</span>
</div>
)}
{lastValidated && (
<div className="flex items-center justify-between">
<span className="text-gray-400">Last validated:</span>
<span className="text-gray-200">{lastValidated}</span>
</div>
)}
</div>
{/* Comment */}
{technique.comment && (
<p className="mt-2 border-t border-gray-800 pt-2 text-[10px] leading-relaxed text-gray-500">
{technique.comment}
</p>
)}
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Loader2, AlertCircle, Download, FileText } from "lucide-react";
import {
getComplianceFrameworks,
getFrameworkStatus,
downloadComplianceCSV,
type ComplianceFrameworkSummary,
} from "../api/compliance";
import ComplianceGauge from "../components/compliance/ComplianceGauge";
import ControlsTable from "../components/compliance/ControlsTable";
export default function CompliancePage() {
const [selectedFrameworkId, setSelectedFrameworkId] = useState<string | null>(null);
// Fetch available frameworks
const {
data: frameworks,
isLoading: loadingFrameworks,
} = useQuery({
queryKey: ["compliance-frameworks"],
queryFn: getComplianceFrameworks,
});
// Auto-select first framework
const activeFrameworkId = selectedFrameworkId || frameworks?.[0]?.id || null;
// Fetch framework status
const {
data: frameworkStatus,
isLoading: loadingStatus,
} = useQuery({
queryKey: ["compliance-status", activeFrameworkId],
queryFn: () => getFrameworkStatus(activeFrameworkId!),
enabled: !!activeFrameworkId,
});
const isLoading = loadingFrameworks || loadingStatus;
const summary = frameworkStatus?.summary;
const controls = frameworkStatus?.controls || [];
const handleExportCSV = async () => {
if (activeFrameworkId) {
await downloadComplianceCSV(activeFrameworkId);
}
};
const handleExportJSON = async () => {
if (!frameworkStatus) return;
const json = JSON.stringify(frameworkStatus, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `compliance_${frameworkStatus.framework.name.replace(/\s+/g, "_")}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
if (isLoading && !frameworkStatus) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Compliance</h1>
<p className="mt-1 text-sm text-gray-400">
Map ATT&CK coverage to compliance framework controls
</p>
</div>
<div className="flex items-center gap-2">
{/* Framework selector */}
<select
value={activeFrameworkId || ""}
onChange={(e) => setSelectedFrameworkId(e.target.value)}
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
>
{(frameworks || []).map((fw) => (
<option key={fw.id} value={fw.id}>
{fw.name}
</option>
))}
</select>
{/* Export buttons */}
<button
onClick={handleExportCSV}
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-xs font-medium text-gray-300 hover:border-cyan-500/50 hover:text-white"
>
<Download className="h-3.5 w-3.5" />
Export CSV
</button>
<button
onClick={handleExportJSON}
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-xs font-medium text-gray-300 hover:border-cyan-500/50 hover:text-white"
>
<FileText className="h-3.5 w-3.5" />
Export JSON
</button>
</div>
</div>
{/* Summary cards */}
{summary && (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-5">
{/* Gauge */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4 flex flex-col items-center justify-center">
<ComplianceGauge percentage={summary.compliance_percentage} size="md" />
<p className="mt-2 text-xs text-gray-500">Overall Compliance</p>
</div>
{/* Covered */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<p className="text-xs text-gray-500 uppercase tracking-wider">Covered</p>
<p className="mt-2 text-3xl font-bold text-green-400">{summary.covered}</p>
<div className="mt-2 h-1.5 w-full rounded-full bg-gray-800">
<div
className="h-full rounded-full bg-green-500"
style={{ width: `${summary.total_controls > 0 ? (summary.covered / summary.total_controls) * 100 : 0}%` }}
/>
</div>
</div>
{/* Partial */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<p className="text-xs text-gray-500 uppercase tracking-wider">Partial</p>
<p className="mt-2 text-3xl font-bold text-yellow-400">
{summary.partially_covered}
</p>
<div className="mt-2 h-1.5 w-full rounded-full bg-gray-800">
<div
className="h-full rounded-full bg-yellow-500"
style={{ width: `${summary.total_controls > 0 ? (summary.partially_covered / summary.total_controls) * 100 : 0}%` }}
/>
</div>
</div>
{/* Not Covered */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<p className="text-xs text-gray-500 uppercase tracking-wider">Not Covered</p>
<p className="mt-2 text-3xl font-bold text-red-400">{summary.not_covered}</p>
<div className="mt-2 h-1.5 w-full rounded-full bg-gray-800">
<div
className="h-full rounded-full bg-red-500"
style={{ width: `${summary.total_controls > 0 ? (summary.not_covered / summary.total_controls) * 100 : 0}%` }}
/>
</div>
</div>
{/* Not Evaluated */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<p className="text-xs text-gray-500 uppercase tracking-wider">Not Evaluated</p>
<p className="mt-2 text-3xl font-bold text-gray-400">{summary.not_evaluated}</p>
<div className="mt-2 h-1.5 w-full rounded-full bg-gray-800">
<div
className="h-full rounded-full bg-gray-500"
style={{ width: `${summary.total_controls > 0 ? (summary.not_evaluated / summary.total_controls) * 100 : 0}%` }}
/>
</div>
</div>
</div>
)}
{/* Controls table */}
{controls.length > 0 ? (
<ControlsTable controls={controls} />
) : (
!isLoading && (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-8 text-center">
<AlertCircle className="mx-auto h-10 w-10 text-gray-600" />
<p className="mt-3 text-gray-400">
No compliance data available. Import a compliance framework from the System page.
</p>
</div>
)
)}
</div>
);
}
@@ -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>
);
}
+252 -162
View File
@@ -1,197 +1,287 @@
import { useState, useMemo } from "react";
import { useState, useMemo, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { Loader2, AlertCircle, Filter, X } from "lucide-react";
import { getTechniques, type TechniqueSummary } from "../api/techniques";
import AttackMatrix from "../components/AttackMatrix";
import type { TechniqueStatus } from "../types/models";
import { useNavigate } from "react-router-dom";
import { Loader2, AlertCircle, Download, ZoomIn, ZoomOut } from "lucide-react";
import {
getHeatmapCoverage,
getHeatmapThreatActor,
getHeatmapDetectionRules,
getHeatmapCampaign,
exportNavigatorJSON,
type HeatmapLayer,
type HeatmapFilters as HeatmapFilterParams,
} from "../api/heatmap";
import AdvancedHeatmap from "../components/heatmap/AdvancedHeatmap";
import HeatmapLayerSelector, {
type LayerType,
} from "../components/heatmap/HeatmapLayerSelector";
import HeatmapFiltersComponent from "../components/heatmap/HeatmapFilters";
import HeatmapLegend from "../components/heatmap/HeatmapLegend";
const STATUS_OPTIONS: { value: TechniqueStatus | "all"; label: string; color: string }[] = [
{ value: "all", label: "All Statuses", color: "text-gray-400" },
{ value: "validated", label: "Validated", color: "text-green-400" },
{ value: "partial", label: "Partial", color: "text-yellow-400" },
{ value: "in_progress", label: "In Progress", color: "text-blue-400" },
{ value: "not_covered", label: "Not Covered", color: "text-red-400" },
{ value: "not_evaluated", label: "Not Evaluated", color: "text-gray-400" },
const TACTIC_ORDER = [
"reconnaissance",
"resource-development",
"initial-access",
"execution",
"persistence",
"privilege-escalation",
"defense-evasion",
"credential-access",
"discovery",
"lateral-movement",
"collection",
"command-and-control",
"exfiltration",
"impact",
];
const PLATFORM_OPTIONS = ["all", "windows", "linux", "macos", "cloud", "network"] as const;
type ZoomLevel = "compact" | "normal" | "expanded";
export default function MatrixPage() {
const [statusFilter, setStatusFilter] = useState<TechniqueStatus | "all">("all");
const [platformFilter, setPlatformFilter] = useState<string>("all");
const [tacticFilter, setTacticFilter] = useState<string>("all");
const navigate = useNavigate();
// Layer selection state
const [activeLayer, setActiveLayer] = useState<LayerType>("coverage");
const [selectedActorId, setSelectedActorId] = useState<string | null>(null);
const [selectedCampaignId, setSelectedCampaignId] = useState<string | null>(null);
// Filter state
const [platforms, setPlatforms] = useState<string[]>([]);
const [selectedTactics, setSelectedTactics] = useState<string[]>([]);
const [minScore, setMinScore] = useState(0);
// Zoom
const [zoom, setZoom] = useState<ZoomLevel>("normal");
// Export dropdown
const [showExportMenu, setShowExportMenu] = useState(false);
// Build filter params
const filterParams: HeatmapFilterParams = useMemo(
() => ({
platforms: platforms.length > 0 ? platforms.join(",") : undefined,
tactics: selectedTactics.length > 0 ? selectedTactics.join(",") : undefined,
min_score: minScore > 0 ? minScore : undefined,
}),
[platforms, selectedTactics, minScore],
);
// Build query key based on active layer + selection
const queryKey = useMemo(() => {
const base = ["heatmap", activeLayer, filterParams];
if (activeLayer === "threat-actor") return [...base, selectedActorId];
if (activeLayer === "campaign") return [...base, selectedCampaignId];
return base;
}, [activeLayer, filterParams, selectedActorId, selectedCampaignId]);
// Fetch the active layer data
const {
data: techniques,
data: layerData,
isLoading,
error,
} = useQuery({
queryKey: ["techniques"],
queryFn: () => getTechniques(),
} = useQuery<HeatmapLayer>({
queryKey,
queryFn: () => {
switch (activeLayer) {
case "coverage":
return getHeatmapCoverage(filterParams);
case "threat-actor":
if (!selectedActorId) return Promise.resolve({ name: "", versions: { attack: "", navigator: "", layer: "" }, domain: "", description: "", filters: { platforms: [] }, gradient: { colors: [], minValue: 0, maxValue: 0 }, techniques: [] } as HeatmapLayer);
return getHeatmapThreatActor(selectedActorId, filterParams);
case "detection-rules":
return getHeatmapDetectionRules(filterParams);
case "campaign":
if (!selectedCampaignId) return Promise.resolve({ name: "", versions: { attack: "", navigator: "", layer: "" }, domain: "", description: "", filters: { platforms: [] }, gradient: { colors: [], minValue: 0, maxValue: 0 }, techniques: [] } as HeatmapLayer);
return getHeatmapCampaign(selectedCampaignId, filterParams);
default:
return getHeatmapCoverage(filterParams);
}
},
enabled:
activeLayer === "coverage" ||
activeLayer === "detection-rules" ||
(activeLayer === "threat-actor" && !!selectedActorId) ||
(activeLayer === "campaign" && !!selectedCampaignId),
});
// Extract unique tactics from techniques
const availableTactics = useMemo(() => {
if (!techniques) return [];
const tactics = new Set<string>();
for (const tech of techniques) {
if (tech.tactic) {
tech.tactic.split(",").forEach((t) => tactics.add(t.trim().toLowerCase()));
const techniques = layerData?.techniques || [];
// Handle cell click - navigate to technique detail
const handleCellClick = useCallback(
(techniqueId: string) => {
navigate(`/techniques/${techniqueId}`);
},
[navigate],
);
// Handle export
const handleExport = async (type: "download" | "url") => {
setShowExportMenu(false);
const layerId =
activeLayer === "threat-actor"
? selectedActorId ?? undefined
: activeLayer === "campaign"
? selectedCampaignId ?? undefined
: undefined;
if (type === "download") {
try {
const blob = await exportNavigatorJSON(activeLayer, layerId, filterParams);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `aegis_${activeLayer}_layer.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch {
console.error("Failed to export Navigator JSON");
}
} else {
// Copy Navigator URL
const navigatorUrl = `https://mitre-attack.github.io/attack-navigator/#layerURL=${encodeURIComponent(
window.location.origin + `/api/v1/heatmap/export-navigator?layer=${activeLayer}${layerId ? `&layer_id=${layerId}` : ""}`
)}`;
navigator.clipboard.writeText(navigatorUrl);
}
return Array.from(tactics).sort();
}, [techniques]);
// Apply filters
const filteredTechniques = useMemo(() => {
if (!techniques) return [];
return techniques.filter((tech: TechniqueSummary) => {
// Status filter
if (statusFilter !== "all" && tech.status_global !== statusFilter) {
return false;
}
// Tactic filter
if (tacticFilter !== "all") {
const techTactics = tech.tactic?.split(",").map((t) => t.trim().toLowerCase()) || [];
if (!techTactics.includes(tacticFilter)) {
return false;
}
}
// Platform filter is handled client-side since we don't have platform in summary
// For now we show all - platform filtering would need the full technique data
return true;
});
}, [techniques, statusFilter, tacticFilter]);
const hasActiveFilters = statusFilter !== "all" || tacticFilter !== "all" || platformFilter !== "all";
const clearFilters = () => {
setStatusFilter("all");
setPlatformFilter("all");
setTacticFilter("all");
};
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
</div>
);
}
// Zoom controls
const zoomIn = () => {
if (zoom === "compact") setZoom("normal");
else if (zoom === "normal") setZoom("expanded");
};
if (error) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-2">
<AlertCircle className="h-10 w-10 text-red-400" />
<p className="text-red-400">Failed to load techniques</p>
</div>
);
}
const zoomOut = () => {
if (zoom === "expanded") setZoom("normal");
else if (zoom === "normal") setZoom("compact");
};
return (
<div className="space-y-6">
<div className="space-y-4">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-white">ATT&CK Matrix</h1>
<p className="mt-1 text-sm text-gray-400">
Interactive MITRE ATT&CK coverage matrix click any technique for details
Advanced heatmap with multiple layers, filters, and ATT&CK Navigator export
</p>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-4 rounded-xl border border-gray-800 bg-gray-900 p-4">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-gray-400" />
<span className="text-sm font-medium text-gray-400">Filters:</span>
{/* Toolbar: Layer Selector + Filters + Export + Zoom */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-4">
{/* Layer selector */}
<HeatmapLayerSelector
activeLayer={activeLayer}
onLayerChange={setActiveLayer}
selectedActorId={selectedActorId}
onActorChange={setSelectedActorId}
selectedCampaignId={selectedCampaignId}
onCampaignChange={setSelectedCampaignId}
/>
{/* Right side: Export + Zoom */}
<div className="flex items-center gap-2">
{/* Export dropdown */}
<div className="relative">
<button
onClick={() => setShowExportMenu(!showExportMenu)}
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-xs font-medium text-gray-300 hover:border-cyan-500/50 hover:text-white"
>
<Download className="h-3.5 w-3.5" />
Export
</button>
{showExportMenu && (
<div className="absolute right-0 top-full z-30 mt-1 w-52 rounded-lg border border-gray-700 bg-gray-900 py-1 shadow-xl">
<button
onClick={() => handleExport("download")}
className="w-full px-3 py-2 text-left text-xs text-gray-300 hover:bg-gray-800"
>
Export Navigator JSON
</button>
<button
onClick={() => handleExport("url")}
className="w-full px-3 py-2 text-left text-xs text-gray-300 hover:bg-gray-800"
>
Copy Navigator URL
</button>
</div>
)}
</div>
{/* Zoom controls */}
<div className="flex items-center rounded-lg border border-gray-700 bg-gray-800">
<button
onClick={zoomOut}
disabled={zoom === "compact"}
className="px-2 py-1.5 text-gray-400 hover:text-white disabled:opacity-30"
>
<ZoomOut className="h-3.5 w-3.5" />
</button>
<span className="border-x border-gray-700 px-2 py-1 text-[10px] font-medium uppercase text-gray-400">
{zoom}
</span>
<button
onClick={zoomIn}
disabled={zoom === "expanded"}
className="px-2 py-1.5 text-gray-400 hover:text-white disabled:opacity-30"
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
{/* Status filter */}
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as TechniqueStatus | "all")}
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{/* Tactic filter */}
<select
value={tacticFilter}
onChange={(e) => setTacticFilter(e.target.value)}
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
>
<option value="all">All Tactics</option>
{availableTactics.map((tactic) => (
<option key={tactic} value={tactic}>
{tactic
.split("-")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ")}
</option>
))}
</select>
{/* Platform filter */}
<select
value={platformFilter}
onChange={(e) => setPlatformFilter(e.target.value)}
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
>
{PLATFORM_OPTIONS.map((platform) => (
<option key={platform} value={platform}>
{platform === "all" ? "All Platforms" : platform.charAt(0).toUpperCase() + platform.slice(1)}
</option>
))}
</select>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="flex items-center gap-1 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-400 hover:border-red-500/50 hover:text-red-400"
>
<X className="h-3.5 w-3.5" />
Clear
</button>
)}
<div className="ml-auto text-sm text-gray-500">
Showing {filteredTechniques.length} of {techniques?.length || 0} techniques
</div>
{/* Filters */}
<HeatmapFiltersComponent
platforms={platforms}
onPlatformsChange={setPlatforms}
selectedTactics={selectedTactics}
onTacticsChange={setSelectedTactics}
minScore={minScore}
onMinScoreChange={setMinScore}
availableTactics={TACTIC_ORDER}
/>
</div>
{/* Matrix */}
<AttackMatrix techniques={filteredTechniques} />
{/* Stats bar */}
{layerData && (
<div className="flex items-center gap-4 text-xs text-gray-500">
<span>
Layer: <span className="text-gray-300">{layerData.name}</span>
</span>
<span>
Techniques:{" "}
<span className="text-gray-300">
{techniques.filter((t) => t.enabled).length} active
</span>{" "}
/ {techniques.length} total
</span>
</div>
)}
{/* Loading / Error / Heatmap */}
{isLoading ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
</div>
) : error ? (
<div className="flex h-64 flex-col items-center justify-center gap-2">
<AlertCircle className="h-10 w-10 text-red-400" />
<p className="text-red-400">Failed to load heatmap data</p>
</div>
) : (
<AdvancedHeatmap
techniques={techniques}
onCellClick={handleCellClick}
zoom={zoom}
/>
)}
{/* Legend */}
<div className="flex flex-wrap items-center gap-4 rounded-xl border border-gray-800 bg-gray-900 p-4">
<span className="text-sm font-medium text-gray-400">Legend:</span>
{STATUS_OPTIONS.filter((s) => s.value !== "all").map((status) => (
<div key={status.value} className="flex items-center gap-2">
<div
className={`h-3 w-3 rounded ${
status.value === "validated"
? "bg-green-500"
: status.value === "partial"
? "bg-yellow-500"
: status.value === "in_progress"
? "bg-blue-500"
: status.value === "not_covered"
? "bg-red-500"
: "bg-gray-600"
}`}
/>
<span className="text-xs text-gray-400">{status.label}</span>
</div>
))}
</div>
<HeatmapLegend layerType={activeLayer} />
</div>
);
}