From a911ddeb52a4732ec60b145f333f60d1f31df2a7 Mon Sep 17 00:00:00 2001 From: Kitos Date: Mon, 9 Feb 2026 17:16:59 +0100 Subject: [PATCH] feat(phase-27): add advanced ATT&CK Navigator-style heatmap with layers, filters and export (T-221 to T-223) --- backend/app/main.py | 2 + backend/app/routers/heatmap.py | 526 ++++++++++++++++++ frontend/package-lock.json | 425 +++++++++++++- frontend/package.json | 4 +- frontend/src/App.tsx | 2 + frontend/src/api/heatmap.ts | 98 ++++ frontend/src/components/Sidebar.tsx | 2 + .../components/heatmap/AdvancedHeatmap.tsx | 185 ++++++ .../src/components/heatmap/HeatmapCell.tsx | 81 +++ .../src/components/heatmap/HeatmapFilters.tsx | 148 +++++ .../heatmap/HeatmapLayerSelector.tsx | 120 ++++ .../src/components/heatmap/HeatmapLegend.tsx | 79 +++ .../src/components/heatmap/HeatmapTooltip.tsx | 109 ++++ frontend/src/pages/MatrixPage.tsx | 414 ++++++++------ 14 files changed, 2024 insertions(+), 171 deletions(-) create mode 100644 backend/app/routers/heatmap.py create mode 100644 frontend/src/api/heatmap.ts create mode 100644 frontend/src/components/heatmap/AdvancedHeatmap.tsx create mode 100644 frontend/src/components/heatmap/HeatmapCell.tsx create mode 100644 frontend/src/components/heatmap/HeatmapFilters.tsx create mode 100644 frontend/src/components/heatmap/HeatmapLayerSelector.tsx create mode 100644 frontend/src/components/heatmap/HeatmapLegend.tsx create mode 100644 frontend/src/components/heatmap/HeatmapTooltip.tsx diff --git a/backend/app/main.py b/backend/app/main.py index cce989f..14ddadf 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -23,6 +23,7 @@ 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.storage import ensure_bucket_exists from app.jobs.mitre_sync_job import start_scheduler, scheduler @@ -70,6 +71,7 @@ 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.get("/health") diff --git a/backend/app/routers/heatmap.py b/backend/app/routers/heatmap.py new file mode 100644 index 0000000..cb57344 --- /dev/null +++ b/backend/app/routers/heatmap.py @@ -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}", + }, + ) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3f5d00f..b2d380a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index d954998..b6c81ed 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ef97706..3ed842f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ 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 TechniqueDetailPage from "./pages/TechniqueDetailPage"; import TestsPage from "./pages/TestsPage"; import TestCreatePage from "./pages/TestCreatePage"; @@ -35,6 +36,7 @@ export default function App() { > } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/heatmap.ts b/frontend/src/api/heatmap.ts new file mode 100644 index 0000000..f75c885 --- /dev/null +++ b/frontend/src/api/heatmap.ts @@ -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 { + const { data } = await client.get("/heatmap/coverage", { params: filters }); + return data; +} + +/** Fetch the threat actor heatmap layer. */ +export async function getHeatmapThreatActor( + actorId: string, + filters?: HeatmapFilters, +): Promise { + const { data } = await client.get(`/heatmap/threat-actor/${actorId}`, { + params: filters, + }); + return data; +} + +/** Fetch the detection rules heatmap layer. */ +export async function getHeatmapDetectionRules(filters?: HeatmapFilters): Promise { + const { data } = await client.get("/heatmap/detection-rules", { params: filters }); + return data; +} + +/** Fetch the campaign heatmap layer. */ +export async function getHeatmapCampaign( + campaignId: string, + filters?: HeatmapFilters, +): Promise { + const { data } = await client.get(`/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 { + const params: Record = { + layer: layerType, + layer_id: layerId, + ...filters, + }; + const { data } = await client.get("/heatmap/export-navigator", { + params, + responseType: "blob", + }); + return data; +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 8119db8..5d0f36b 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -15,6 +15,7 @@ import { Database, Crosshair, Zap, + Grid3X3, } from "lucide-react"; import { useAuth } from "../context/AuthContext"; @@ -28,6 +29,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", diff --git a/frontend/src/components/heatmap/AdvancedHeatmap.tsx b/frontend/src/components/heatmap/AdvancedHeatmap.tsx new file mode 100644 index 0000000..89075a7 --- /dev/null +++ b/frontend/src/components/heatmap/AdvancedHeatmap.tsx @@ -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(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 ( +
+ {/* Tactic header */} +
+

+ {formatTacticName(tactic)} +

+

+ {techniques.length} techniques +

+
+ + {/* Virtualised list */} +
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const tech = techniques[virtualRow.index]; + return ( +
+ +
+ ); + })} +
+
+
+ ); +} + +export default function AdvancedHeatmap({ + techniques, + onCellClick, + zoom, +}: AdvancedHeatmapProps) { + // Group techniques by tactic + const groupedByTactic = useMemo(() => { + const groups: Record = {}; + + 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 ( +
+

No techniques found for the selected layer

+
+ ); + } + + return ( +
+
+
+ {orderedTactics.map((tactic) => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/src/components/heatmap/HeatmapCell.tsx b/frontend/src/components/heatmap/HeatmapCell.tsx new file mode 100644 index 0000000..7dfa494 --- /dev/null +++ b/frontend/src/components/heatmap/HeatmapCell.tsx @@ -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 ( +
setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + > + + + {showTooltip && technique.enabled && ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/src/components/heatmap/HeatmapFilters.tsx b/frontend/src/components/heatmap/HeatmapFilters.tsx new file mode 100644 index 0000000..ad82339 --- /dev/null +++ b/frontend/src/components/heatmap/HeatmapFilters.tsx @@ -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 ( +
+
+ + Filters: +
+ + {/* Platform checkboxes */} +
+ {PLATFORMS.map((platform) => ( + + ))} +
+ + {/* Tactic multi-select */} +
+ +
+ + {/* Selected tactic pills */} + {selectedTactics.length > 0 && ( +
+ {selectedTactics.map((tactic) => ( + + ))} +
+ )} + + {/* Min score slider */} +
+ Min Score: + onMinScoreChange(parseInt(e.target.value, 10))} + className="h-1 w-20 cursor-pointer accent-cyan-500" + /> + + {minScore} + +
+ + {/* Clear all */} + {hasActiveFilters && ( + + )} +
+ ); +} diff --git a/frontend/src/components/heatmap/HeatmapLayerSelector.tsx b/frontend/src/components/heatmap/HeatmapLayerSelector.tsx new file mode 100644 index 0000000..ff0deb9 --- /dev/null +++ b/frontend/src/components/heatmap/HeatmapLayerSelector.tsx @@ -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 ( +
+ {/* Layer type tabs */} +
+ {LAYERS.map((layer) => ( + + ))} +
+ + {/* Actor dropdown */} + {activeLayer === "threat-actor" && ( + + )} + + {/* Campaign dropdown */} + {activeLayer === "campaign" && ( + + )} +
+ ); +} diff --git a/frontend/src/components/heatmap/HeatmapLegend.tsx b/frontend/src/components/heatmap/HeatmapLegend.tsx new file mode 100644 index 0000000..d91bbab --- /dev/null +++ b/frontend/src/components/heatmap/HeatmapLegend.tsx @@ -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 ( +
+ {legend.label}: + + {/* Gradient bar */} +
+
c.color).join(", ")})`, + }} + /> +
+ + {/* Individual labels */} + {legend.colors.map((item) => ( +
+
+ {item.label} +
+ ))} +
+ ); +} diff --git a/frontend/src/components/heatmap/HeatmapTooltip.tsx b/frontend/src/components/heatmap/HeatmapTooltip.tsx new file mode 100644 index 0000000..e6d0770 --- /dev/null +++ b/frontend/src/components/heatmap/HeatmapTooltip.tsx @@ -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 ( +
+ {/* Header */} +
+

+ {technique.techniqueID} +

+ {technique.tactic && ( +

+ {technique.tactic.replace(/-/g, " ")} +

+ )} +
+ + {/* Status & Score */} +
+
+ Status: + {status.label} +
+
+ Score: + {technique.score}/100 +
+ + {/* Score bar */} +
+
+
+ + {testsCount !== null && ( +
+ Tests: + {testsCount} validated +
+ )} + {detectionRules !== null && ( +
+ Detection Rules: + {detectionRules} available +
+ )} + {totalRules !== null && ( +
+ Rules: + + {evaluatedRules || 0} evaluated / {totalRules} total + +
+ )} + {campaignTests !== null && ( +
+ Campaign Tests: + {campaignTests} +
+ )} + {lastValidated && ( +
+ Last validated: + {lastValidated} +
+ )} +
+ + {/* Comment */} + {technique.comment && ( +

+ {technique.comment} +

+ )} +
+ ); +} diff --git a/frontend/src/pages/MatrixPage.tsx b/frontend/src/pages/MatrixPage.tsx index 6457b4d..aaf1178 100644 --- a/frontend/src/pages/MatrixPage.tsx +++ b/frontend/src/pages/MatrixPage.tsx @@ -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("all"); - const [platformFilter, setPlatformFilter] = useState("all"); - const [tacticFilter, setTacticFilter] = useState("all"); + const navigate = useNavigate(); + // Layer selection state + const [activeLayer, setActiveLayer] = useState("coverage"); + const [selectedActorId, setSelectedActorId] = useState(null); + const [selectedCampaignId, setSelectedCampaignId] = useState(null); + + // Filter state + const [platforms, setPlatforms] = useState([]); + const [selectedTactics, setSelectedTactics] = useState([]); + const [minScore, setMinScore] = useState(0); + + // Zoom + const [zoom, setZoom] = useState("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({ + 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(); - 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 ( -
- -
- ); - } + // Zoom controls + const zoomIn = () => { + if (zoom === "compact") setZoom("normal"); + else if (zoom === "normal") setZoom("expanded"); + }; - if (error) { - return ( -
- -

Failed to load techniques

-
- ); - } + const zoomOut = () => { + if (zoom === "expanded") setZoom("normal"); + else if (zoom === "normal") setZoom("compact"); + }; return ( -
+
{/* Header */}

ATT&CK Matrix

- Interactive MITRE ATT&CK coverage matrix — click any technique for details + Advanced heatmap with multiple layers, filters, and ATT&CK Navigator export

- {/* Filters */} -
-
- - Filters: + {/* Toolbar: Layer Selector + Filters + Export + Zoom */} +
+
+ {/* Layer selector */} + + + {/* Right side: Export + Zoom */} +
+ {/* Export dropdown */} +
+ + {showExportMenu && ( +
+ + +
+ )} +
+ + {/* Zoom controls */} +
+ + + {zoom} + + +
+
- {/* Status filter */} - - - {/* Tactic filter */} - - - {/* Platform filter */} - - - {hasActiveFilters && ( - - )} - -
- Showing {filteredTechniques.length} of {techniques?.length || 0} techniques -
+ {/* Filters */} +
- {/* Matrix */} - + {/* Stats bar */} + {layerData && ( +
+ + Layer: {layerData.name} + + + Techniques:{" "} + + {techniques.filter((t) => t.enabled).length} active + {" "} + / {techniques.length} total + +
+ )} + + {/* Loading / Error / Heatmap */} + {isLoading ? ( +
+ +
+ ) : error ? ( +
+ +

Failed to load heatmap data

+
+ ) : ( + + )} {/* Legend */} -
- Legend: - {STATUS_OPTIONS.filter((s) => s.value !== "all").map((status) => ( -
-
- {status.label} -
- ))} -
+
); }