feat(phase-27): add advanced ATT&CK Navigator-style heatmap with layers, filters and export (T-221 to T-223)

This commit is contained in:
2026-02-09 17:16:59 +01:00
parent 57b47c296d
commit a911ddeb52
14 changed files with 2024 additions and 171 deletions

View File

@@ -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 d3fend as d3fend_router
from app.routers import detection_rules as detection_rules_router from app.routers import detection_rules as detection_rules_router
from app.routers import campaigns as campaigns_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.storage import ensure_bucket_exists
from app.jobs.mitre_sync_job import start_scheduler, scheduler 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(d3fend_router.router, prefix="/api/v1")
app.include_router(detection_rules_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(campaigns_router.router, prefix="/api/v1")
app.include_router(heatmap_router.router, prefix="/api/v1")
@app.get("/health") @app.get("/health")

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

View File

@@ -1,20 +1,21 @@
{ {
"name": "app", "name": "aegis-frontend",
"version": "1.0.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "app", "name": "aegis-frontend",
"version": "1.0.0", "version": "0.1.0",
"license": "ISC",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.90.20", "@tanstack/react-query": "^5.90.20",
"@tanstack/react-virtual": "^3.13.18",
"axios": "^1.13.4", "axios": "^1.13.4",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^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": { "devDependencies": {
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
@@ -260,6 +261,15 @@
"@babel/core": "^7.0.0-0" "@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": { "node_modules/@babel/template": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -1455,6 +1465,33 @@
"react": "^18 || ^19" "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": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1500,6 +1537,69 @@
"@babel/types": "^7.28.2" "@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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1643,6 +1743,15 @@
], ],
"license": "CC-BY-4.0" "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": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -1679,9 +1788,129 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "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": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -1719,6 +1954,16 @@
"node": ">=8" "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1851,6 +2096,21 @@
"node": ">=6" "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": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2034,6 +2294,15 @@
"node": ">= 0.4" "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": { "node_modules/jiti": {
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -2048,7 +2317,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/jsesc": { "node_modules/jsesc": {
@@ -2338,6 +2606,24 @@
"url": "https://opencollective.com/parcel" "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": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -2430,6 +2716,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2479,6 +2774,23 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -2506,6 +2818,12 @@
"react": "^19.2.4" "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": { "node_modules/react-refresh": {
"version": "0.18.0", "version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -2554,6 +2872,69 @@
"react-dom": ">=18" "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": { "node_modules/rollup": {
"version": "4.57.1", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
@@ -2652,6 +3033,12 @@
"url": "https://opencollective.com/webpack" "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": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -2714,6 +3101,28 @@
"browserslist": ">= 4.21.0" "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": { "node_modules/vite": {
"version": "7.3.1", "version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",

View File

@@ -10,11 +10,13 @@
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.90.20", "@tanstack/react-query": "^5.90.20",
"@tanstack/react-virtual": "^3.13.18",
"axios": "^1.13.4", "axios": "^1.13.4",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^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": { "devDependencies": {
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",

View File

@@ -2,6 +2,7 @@ import { Routes, Route, Navigate } from "react-router-dom";
import LoginPage from "./pages/LoginPage"; import LoginPage from "./pages/LoginPage";
import DashboardPage from "./pages/DashboardPage"; import DashboardPage from "./pages/DashboardPage";
import TechniquesPage from "./pages/TechniquesPage"; import TechniquesPage from "./pages/TechniquesPage";
import MatrixPage from "./pages/MatrixPage";
import TechniqueDetailPage from "./pages/TechniqueDetailPage"; import TechniqueDetailPage from "./pages/TechniqueDetailPage";
import TestsPage from "./pages/TestsPage"; import TestsPage from "./pages/TestsPage";
import TestCreatePage from "./pages/TestCreatePage"; import TestCreatePage from "./pages/TestCreatePage";
@@ -35,6 +36,7 @@ export default function App() {
> >
<Route path="/dashboard" element={<DashboardPage />} /> <Route path="/dashboard" element={<DashboardPage />} />
<Route path="/techniques" element={<TechniquesPage />} /> <Route path="/techniques" element={<TechniquesPage />} />
<Route path="/matrix" element={<MatrixPage />} />
<Route path="/techniques/:mitreId" element={<TechniqueDetailPage />} /> <Route path="/techniques/:mitreId" element={<TechniqueDetailPage />} />
<Route path="/tests" element={<TestsPage />} /> <Route path="/tests" element={<TestsPage />} />
<Route path="/tests/new" element={<TestCreatePage />} /> <Route path="/tests/new" element={<TestCreatePage />} />

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;
}

View File

@@ -15,6 +15,7 @@ import {
Database, Database,
Crosshair, Crosshair,
Zap, Zap,
Grid3X3,
} from "lucide-react"; } from "lucide-react";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
@@ -28,6 +29,7 @@ interface NavItem {
const mainLinks: NavItem[] = [ const mainLinks: NavItem[] = [
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, { to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ to: "/techniques", label: "ATT&CK Matrix", icon: Shield }, { to: "/techniques", label: "ATT&CK Matrix", icon: Shield },
{ to: "/matrix", label: "Advanced Heatmap", icon: Grid3X3 },
{ {
to: "/tests", to: "/tests",
label: "Tests", label: "Tests",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,197 +1,287 @@
import { useState, useMemo } from "react"; import { useState, useMemo, useCallback } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Loader2, AlertCircle, Filter, X } from "lucide-react"; import { useNavigate } from "react-router-dom";
import { getTechniques, type TechniqueSummary } from "../api/techniques"; import { Loader2, AlertCircle, Download, ZoomIn, ZoomOut } from "lucide-react";
import AttackMatrix from "../components/AttackMatrix"; import {
import type { TechniqueStatus } from "../types/models"; 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 }[] = [ const TACTIC_ORDER = [
{ value: "all", label: "All Statuses", color: "text-gray-400" }, "reconnaissance",
{ value: "validated", label: "Validated", color: "text-green-400" }, "resource-development",
{ value: "partial", label: "Partial", color: "text-yellow-400" }, "initial-access",
{ value: "in_progress", label: "In Progress", color: "text-blue-400" }, "execution",
{ value: "not_covered", label: "Not Covered", color: "text-red-400" }, "persistence",
{ value: "not_evaluated", label: "Not Evaluated", color: "text-gray-400" }, "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() { export default function MatrixPage() {
const [statusFilter, setStatusFilter] = useState<TechniqueStatus | "all">("all"); const navigate = useNavigate();
const [platformFilter, setPlatformFilter] = useState<string>("all");
const [tacticFilter, setTacticFilter] = useState<string>("all");
// 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 { const {
data: techniques, data: layerData,
isLoading, isLoading,
error, error,
} = useQuery({ } = useQuery<HeatmapLayer>({
queryKey: ["techniques"], queryKey,
queryFn: () => getTechniques(), 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 techniques = layerData?.techniques || [];
const availableTactics = useMemo(() => {
if (!techniques) return []; // Handle cell click - navigate to technique detail
const tactics = new Set<string>(); const handleCellClick = useCallback(
for (const tech of techniques) { (techniqueId: string) => {
if (tech.tactic) { navigate(`/techniques/${techniqueId}`);
tech.tactic.split(",").forEach((t) => tactics.add(t.trim().toLowerCase())); },
[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) { // Zoom controls
return ( const zoomIn = () => {
<div className="flex h-64 items-center justify-center"> if (zoom === "compact") setZoom("normal");
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" /> else if (zoom === "normal") setZoom("expanded");
</div> };
);
}
if (error) { const zoomOut = () => {
return ( if (zoom === "expanded") setZoom("normal");
<div className="flex h-64 flex-col items-center justify-center gap-2"> else if (zoom === "normal") setZoom("compact");
<AlertCircle className="h-10 w-10 text-red-400" /> };
<p className="text-red-400">Failed to load techniques</p>
</div>
);
}
return ( return (
<div className="space-y-6"> <div className="space-y-4">
{/* Header */} {/* Header */}
<div> <div>
<h1 className="text-2xl font-bold text-white">ATT&CK Matrix</h1> <h1 className="text-2xl font-bold text-white">ATT&CK Matrix</h1>
<p className="mt-1 text-sm text-gray-400"> <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> </p>
</div> </div>
{/* Filters */} {/* Toolbar: Layer Selector + Filters + Export + Zoom */}
<div className="flex flex-wrap items-center gap-4 rounded-xl border border-gray-800 bg-gray-900 p-4"> <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"> <div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-gray-400" /> {/* Export dropdown */}
<span className="text-sm font-medium text-gray-400">Filters:</span> <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> </div>
{/* Status filter */} {/* Zoom controls */}
<select <div className="flex items-center rounded-lg border border-gray-700 bg-gray-800">
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 <button
onClick={clearFilters} onClick={zoomOut}
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" disabled={zoom === "compact"}
className="px-2 py-1.5 text-gray-400 hover:text-white disabled:opacity-30"
> >
<X className="h-3.5 w-3.5" /> <ZoomOut className="h-3.5 w-3.5" />
Clear
</button> </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>
{/* Filters */}
<HeatmapFiltersComponent
platforms={platforms}
onPlatformsChange={setPlatforms}
selectedTactics={selectedTactics}
onTacticsChange={setSelectedTactics}
minScore={minScore}
onMinScoreChange={setMinScore}
availableTactics={TACTIC_ORDER}
/>
</div>
{/* 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>
)} )}
<div className="ml-auto text-sm text-gray-500"> {/* Loading / Error / Heatmap */}
Showing {filteredTechniques.length} of {techniques?.length || 0} techniques {isLoading ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
</div> </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> </div>
) : (
{/* Matrix */} <AdvancedHeatmap
<AttackMatrix techniques={filteredTechniques} /> techniques={techniques}
onCellClick={handleCellClick}
zoom={zoom}
/>
)}
{/* Legend */} {/* Legend */}
<div className="flex flex-wrap items-center gap-4 rounded-xl border border-gray-800 bg-gray-900 p-4"> <HeatmapLegend layerType={activeLayer} />
<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>
</div> </div>
); );
} }