feat(phase-23): add Threat Actor profiles with MITRE CTI import, API, heatmap and gap analysis (T-208 to T-212)

This commit is contained in:
2026-02-09 16:27:38 +01:00
parent f4c8cbf768
commit 2fc0e2cafd
12 changed files with 1798 additions and 2 deletions

View File

@@ -0,0 +1,72 @@
"""add_threat_actors_tables
Revision ID: b010threatactors
Revises: b009detectionrules
Create Date: 2026-02-09 15:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB
# revision identifiers, used by Alembic.
revision: str = 'b010threatactors'
down_revision: Union[str, Sequence[str], None] = 'b009detectionrules'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create threat_actors and threat_actor_techniques tables."""
# threat_actors
op.create_table(
'threat_actors',
sa.Column('id', UUID(as_uuid=True), primary_key=True),
sa.Column('mitre_id', sa.String(), unique=True, nullable=True),
sa.Column('name', sa.String(), nullable=False),
sa.Column('aliases', JSONB(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('country', sa.String(), nullable=True),
sa.Column('target_sectors', JSONB(), nullable=True),
sa.Column('target_regions', JSONB(), nullable=True),
sa.Column('motivation', sa.String(), nullable=True),
sa.Column('sophistication', sa.String(), nullable=True),
sa.Column('first_seen', sa.String(), nullable=True),
sa.Column('last_seen', sa.String(), nullable=True),
sa.Column('references', JSONB(), nullable=True),
sa.Column('mitre_url', sa.String(), nullable=True),
sa.Column('is_active', sa.Boolean(), server_default='true'),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
)
op.create_index('ix_threat_actors_country', 'threat_actors', ['country'])
op.create_index('ix_threat_actors_motivation', 'threat_actors', ['motivation'])
# threat_actor_techniques (junction table)
op.create_table(
'threat_actor_techniques',
sa.Column('id', UUID(as_uuid=True), primary_key=True),
sa.Column('threat_actor_id', UUID(as_uuid=True),
sa.ForeignKey('threat_actors.id', ondelete='CASCADE'), nullable=False),
sa.Column('technique_id', UUID(as_uuid=True),
sa.ForeignKey('techniques.id', ondelete='CASCADE'), nullable=False),
sa.Column('usage_description', sa.Text(), nullable=True),
sa.Column('first_seen_using', sa.String(), nullable=True),
)
op.create_index('ix_threat_actor_techniques_actor', 'threat_actor_techniques', ['threat_actor_id'])
op.create_index('ix_threat_actor_techniques_technique', 'threat_actor_techniques', ['technique_id'])
op.create_unique_constraint('uq_actor_technique', 'threat_actor_techniques',
['threat_actor_id', 'technique_id'])
def downgrade() -> None:
"""Drop threat_actor_techniques and threat_actors tables."""
op.drop_constraint('uq_actor_technique', 'threat_actor_techniques', type_='unique')
op.drop_index('ix_threat_actor_techniques_technique', table_name='threat_actor_techniques')
op.drop_index('ix_threat_actor_techniques_actor', table_name='threat_actor_techniques')
op.drop_table('threat_actor_techniques')
op.drop_index('ix_threat_actors_motivation', table_name='threat_actors')
op.drop_index('ix_threat_actors_country', table_name='threat_actors')
op.drop_table('threat_actors')

View File

@@ -19,6 +19,7 @@ from app.routers import audit as audit_router
from app.routers import notifications as notifications_router
from app.routers import reports as reports_router
from app.routers import data_sources as data_sources_router
from app.routers import threat_actors as threat_actors_router
from app.storage import ensure_bucket_exists
from app.jobs.mitre_sync_job import start_scheduler, scheduler
@@ -62,6 +63,7 @@ app.include_router(audit_router.router, prefix="/api/v1")
app.include_router(notifications_router.router, prefix="/api/v1")
app.include_router(reports_router.router, prefix="/api/v1")
app.include_router(data_sources_router.router, prefix="/api/v1")
app.include_router(threat_actors_router.router, prefix="/api/v1")
@app.get("/health")

View File

@@ -9,11 +9,12 @@ from app.models.audit import AuditLog
from app.models.notification import Notification
from app.models.data_source import DataSource
from app.models.detection_rule import DetectionRule
from app.models.threat_actor import ThreatActor, ThreatActorTechnique
from app.models.enums import TechniqueStatus, TestState, TestResult, TeamSide
__all__ = [
"User", "Technique", "Test", "TestTemplate", "Evidence",
"IntelItem", "AuditLog", "Notification", "DataSource",
"DetectionRule",
"DetectionRule", "ThreatActor", "ThreatActorTechnique",
"TechniqueStatus", "TestState", "TestResult", "TeamSide",
]

View File

@@ -0,0 +1,92 @@
"""ThreatActor and ThreatActorTechnique models.
Stores profiles of APT groups and their associated MITRE ATT&CK
techniques, imported from MITRE CTI (STIX 2.0).
"""
import uuid
from datetime import datetime
from sqlalchemy import (
Column, String, Text, Boolean, DateTime,
ForeignKey, Index, UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from app.database import Base
class ThreatActor(Base):
"""
Threat actor / APT group profile.
Imported from MITRE CTI ``intrusion-set`` STIX objects.
"""
__tablename__ = "threat_actors"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
mitre_id = Column(String, unique=True, nullable=True) # e.g. "G0016" (APT29)
name = Column(String, nullable=False)
aliases = Column(JSONB, nullable=True, default=[]) # ["Cozy Bear", "The Dukes", ...]
description = Column(Text, nullable=True)
country = Column(String, nullable=True)
target_sectors = Column(JSONB, nullable=True, default=[]) # ["government", "defense", ...]
target_regions = Column(JSONB, nullable=True, default=[]) # ["north-america", "europe", ...]
motivation = Column(String, nullable=True) # espionage / financial / destruction / ...
sophistication = Column(String, nullable=True) # low / medium / high / advanced
first_seen = Column(String, nullable=True)
last_seen = Column(String, nullable=True)
references = Column(JSONB, nullable=True, default=[]) # [{"url": "...", "description": "..."}]
mitre_url = Column(String, nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
techniques = relationship(
"ThreatActorTechnique",
back_populates="threat_actor",
cascade="all, delete-orphan",
)
__table_args__ = (
Index('ix_threat_actors_country', 'country'),
Index('ix_threat_actors_motivation', 'motivation'),
)
class ThreatActorTechnique(Base):
"""
Association between a threat actor and a MITRE ATT&CK technique.
Stores additional context about how the actor uses the technique
(from the STIX ``relationship`` ``uses`` objects).
"""
__tablename__ = "threat_actor_techniques"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
threat_actor_id = Column(
UUID(as_uuid=True),
ForeignKey("threat_actors.id", ondelete="CASCADE"),
nullable=False,
)
technique_id = Column(
UUID(as_uuid=True),
ForeignKey("techniques.id", ondelete="CASCADE"),
nullable=False,
)
usage_description = Column(Text, nullable=True)
first_seen_using = Column(String, nullable=True)
# Relationships
threat_actor = relationship("ThreatActor", back_populates="techniques")
technique = relationship("Technique")
__table_args__ = (
Index('ix_threat_actor_techniques_actor', 'threat_actor_id'),
Index('ix_threat_actor_techniques_technique', 'technique_id'),
UniqueConstraint(
'threat_actor_id', 'technique_id',
name='uq_actor_technique',
),
)

View File

@@ -39,7 +39,8 @@ def _get_sync_handler(source_name: str):
"gtfobins": ("app.services.lolbas_import_service", "sync_gtfobins"),
"caldera": ("app.services.caldera_import_service", "sync"),
"elastic_rules": ("app.services.elastic_import_service", "sync"),
# d3fend and mitre_cti added in later phases
"mitre_cti": ("app.services.threat_actor_import_service", "sync"),
# d3fend added in later phases
}
if source_name not in handlers:

View File

@@ -0,0 +1,309 @@
"""Threat actor endpoints.
Provides listing, detail, coverage analysis, and gap analysis for
threat actor profiles imported from MITRE CTI.
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, or_
from sqlalchemy.orm import Session, joinedload
from app.database import get_db
from app.dependencies.auth import get_current_user
from app.models.user import User
from app.models.threat_actor import ThreatActor, ThreatActorTechnique
from app.models.technique import Technique
from app.models.test import Test
from app.models.test_template import TestTemplate
from app.models.enums import TechniqueStatus
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/threat-actors", tags=["threat-actors"])
# ---------------------------------------------------------------------------
# GET /threat-actors — Listado con filtros
# ---------------------------------------------------------------------------
@router.get("")
def list_threat_actors(
search: Optional[str] = Query(None),
country: Optional[str] = Query(None),
motivation: Optional[str] = Query(None),
sophistication: Optional[str] = Query(None),
target_sectors: Optional[str] = Query(None),
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List threat actors with optional filters and pagination.
**Requires** authentication (any role).
"""
query = db.query(ThreatActor)
# Filters
if search:
pattern = f"%{search}%"
query = query.filter(
or_(
ThreatActor.name.ilike(pattern),
ThreatActor.description.ilike(pattern),
func.cast(ThreatActor.aliases, func.text()).ilike(pattern),
)
)
if country:
query = query.filter(ThreatActor.country == country)
if motivation:
query = query.filter(ThreatActor.motivation == motivation)
if sophistication:
query = query.filter(ThreatActor.sophistication == sophistication)
if target_sectors:
# JSONB contains check
query = query.filter(
func.cast(ThreatActor.target_sectors, func.text()).ilike(f"%{target_sectors}%")
)
# Total count
total = query.count()
# Paginate
actors = query.order_by(ThreatActor.name).offset(offset).limit(limit).all()
# For each actor, count techniques and calculate basic coverage
results = []
for actor in actors:
tech_count = (
db.query(ThreatActorTechnique)
.filter(ThreatActorTechnique.threat_actor_id == actor.id)
.count()
)
# Quick coverage calculation
covered = (
db.query(ThreatActorTechnique)
.join(Technique, ThreatActorTechnique.technique_id == Technique.id)
.filter(ThreatActorTechnique.threat_actor_id == actor.id)
.filter(Technique.status_global.in_([
TechniqueStatus.validated,
TechniqueStatus.partial,
]))
.count()
)
coverage_pct = round((covered / tech_count * 100), 1) if tech_count > 0 else 0.0
results.append({
"id": str(actor.id),
"mitre_id": actor.mitre_id,
"name": actor.name,
"aliases": actor.aliases or [],
"country": actor.country,
"target_sectors": actor.target_sectors or [],
"target_regions": actor.target_regions or [],
"motivation": actor.motivation,
"sophistication": actor.sophistication,
"mitre_url": actor.mitre_url,
"technique_count": tech_count,
"coverage_pct": coverage_pct,
"is_active": actor.is_active,
})
return {
"total": total,
"offset": offset,
"limit": limit,
"items": results,
}
# ---------------------------------------------------------------------------
# GET /threat-actors/{id} — Detalle
# ---------------------------------------------------------------------------
@router.get("/{actor_id}")
def get_threat_actor(
actor_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get detailed info about a threat actor including techniques.
**Requires** authentication (any role).
"""
actor = db.query(ThreatActor).filter(ThreatActor.id == actor_id).first()
if not actor:
raise HTTPException(status_code=404, detail="Threat actor not found")
# Get associated techniques with their coverage status
actor_techniques = (
db.query(ThreatActorTechnique, Technique)
.join(Technique, ThreatActorTechnique.technique_id == Technique.id)
.filter(ThreatActorTechnique.threat_actor_id == actor.id)
.order_by(Technique.mitre_id)
.all()
)
techniques_list = []
for at, tech in actor_techniques:
techniques_list.append({
"technique_id": str(tech.id),
"mitre_id": tech.mitre_id,
"name": tech.name,
"tactic": tech.tactic,
"status_global": tech.status_global.value if tech.status_global else None,
"usage_description": at.usage_description,
"first_seen_using": at.first_seen_using,
})
return {
"id": str(actor.id),
"mitre_id": actor.mitre_id,
"name": actor.name,
"aliases": actor.aliases or [],
"description": actor.description,
"country": actor.country,
"target_sectors": actor.target_sectors or [],
"target_regions": actor.target_regions or [],
"motivation": actor.motivation,
"sophistication": actor.sophistication,
"first_seen": actor.first_seen,
"last_seen": actor.last_seen,
"references": actor.references or [],
"mitre_url": actor.mitre_url,
"is_active": actor.is_active,
"techniques": techniques_list,
}
# ---------------------------------------------------------------------------
# GET /threat-actors/{id}/coverage — Cobertura
# ---------------------------------------------------------------------------
@router.get("/{actor_id}/coverage")
def get_threat_actor_coverage(
actor_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Calculate coverage percentage against a specific threat actor.
**Requires** authentication (any role).
Returns the percentage of the actor's techniques that have been
validated or partially validated, along with a breakdown.
"""
actor = db.query(ThreatActor).filter(ThreatActor.id == actor_id).first()
if not actor:
raise HTTPException(status_code=404, detail="Threat actor not found")
# Get all techniques for this actor
actor_techniques = (
db.query(Technique)
.join(ThreatActorTechnique, ThreatActorTechnique.technique_id == Technique.id)
.filter(ThreatActorTechnique.threat_actor_id == actor.id)
.all()
)
total = len(actor_techniques)
if total == 0:
return {
"actor_id": str(actor.id),
"actor_name": actor.name,
"total_techniques": 0,
"coverage_pct": 0.0,
"breakdown": {},
}
breakdown = {}
for tech in actor_techniques:
status = tech.status_global.value if tech.status_global else "not_evaluated"
breakdown[status] = breakdown.get(status, 0) + 1
covered = breakdown.get("validated", 0) + breakdown.get("partial", 0)
coverage_pct = round((covered / total * 100), 1)
return {
"actor_id": str(actor.id),
"actor_name": actor.name,
"total_techniques": total,
"covered": covered,
"coverage_pct": coverage_pct,
"breakdown": breakdown,
}
# ---------------------------------------------------------------------------
# GET /threat-actors/{id}/gaps — Gap analysis
# ---------------------------------------------------------------------------
@router.get("/{actor_id}/gaps")
def get_threat_actor_gaps(
actor_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Identify techniques of this actor that are NOT fully validated.
**Requires** authentication (any role).
Returns list of gap techniques with available templates.
"""
actor = db.query(ThreatActor).filter(ThreatActor.id == actor_id).first()
if not actor:
raise HTTPException(status_code=404, detail="Threat actor not found")
# Get techniques NOT validated
gap_techniques = (
db.query(Technique, ThreatActorTechnique)
.join(ThreatActorTechnique, ThreatActorTechnique.technique_id == Technique.id)
.filter(ThreatActorTechnique.threat_actor_id == actor.id)
.filter(Technique.status_global != TechniqueStatus.validated)
.order_by(Technique.mitre_id)
.all()
)
gaps = []
for tech, at in gap_techniques:
# Count available templates for this technique
template_count = (
db.query(TestTemplate)
.filter(TestTemplate.mitre_technique_id == tech.mitre_id)
.filter(TestTemplate.is_active == True)
.count()
)
# Count existing tests
test_count = (
db.query(Test)
.filter(Test.technique_id == tech.id)
.count()
)
gaps.append({
"technique_id": str(tech.id),
"mitre_id": tech.mitre_id,
"name": tech.name,
"tactic": tech.tactic,
"status_global": tech.status_global.value if tech.status_global else None,
"usage_description": at.usage_description,
"available_templates": template_count,
"existing_tests": test_count,
"has_templates": template_count > 0,
})
return {
"actor_id": str(actor.id),
"actor_name": actor.name,
"total_gaps": len(gaps),
"gaps": gaps,
}

View File

@@ -0,0 +1,373 @@
"""Threat Actor import service (MITRE CTI / STIX 2.0).
Downloads the MITRE CTI repository, parses the STIX 2.0 bundle for
``intrusion-set`` objects (APT groups) and ``relationship`` objects
linking them to ``attack-pattern`` (techniques), then creates
:class:`ThreatActor` and :class:`ThreatActorTechnique` records.
STIX 2.0 structure
------------------
The enterprise-attack bundle contains:
- ``intrusion-set`` objects → our ThreatActor rows
- ``attack-pattern`` objects → already in our Technique table
- ``relationship`` objects (type=uses) → connects intrusion-set → attack-pattern
Strategy
--------
1. Download ZIP of ``github.com/mitre/cti``.
2. Load ``enterprise-attack/enterprise-attack.json`` (single STIX bundle).
3. Build lookup maps for intrusion-sets and attack-patterns.
4. Parse relationships to connect actors → techniques.
5. Upsert into database.
Idempotency
-----------
Deduplication by ``mitre_id`` for ThreatActor and by the unique
constraint ``(threat_actor_id, technique_id)`` for ThreatActorTechnique.
"""
import io
import json
import logging
import shutil
import tempfile
import zipfile
from datetime import datetime
from pathlib import Path
import requests as _requests
from sqlalchemy.orm import Session
from app.models.threat_actor import ThreatActor, ThreatActorTechnique
from app.models.technique import Technique
from app.models.data_source import DataSource
from app.services.audit_service import log_action
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
MITRE_CTI_ZIP_URL = (
"https://github.com/mitre/cti"
"/archive/refs/heads/master.zip"
)
_DOWNLOAD_TIMEOUT = 300
_ZIP_ROOT_PREFIX = "cti-master"
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _download_zip(url: str = MITRE_CTI_ZIP_URL) -> bytes:
"""Download the MITRE CTI ZIP and return raw bytes."""
logger.info("Downloading MITRE CTI ZIP from %s", url)
resp = _requests.get(url, timeout=_DOWNLOAD_TIMEOUT, stream=True)
resp.raise_for_status()
content = resp.content
logger.info("Downloaded %.1f MB", len(content) / (1024 * 1024))
return content
def _extract_zip_and_load_bundle(zip_bytes: bytes, dest: str) -> dict:
"""Extract ZIP and load the enterprise-attack STIX bundle."""
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
zf.extractall(dest)
bundle_path = (
Path(dest) / _ZIP_ROOT_PREFIX
/ "enterprise-attack" / "enterprise-attack.json"
)
if not bundle_path.is_file():
raise FileNotFoundError(
f"STIX bundle not found at {bundle_path}"
)
logger.info("Loading STIX bundle from %s", bundle_path)
with open(bundle_path, "r", encoding="utf-8") as fh:
bundle = json.load(fh)
objects = bundle.get("objects", [])
logger.info("Loaded %d STIX objects", len(objects))
return bundle
def _extract_mitre_id(external_references: list) -> str | None:
"""Extract the MITRE ATT&CK ID from external_references."""
if not isinstance(external_references, list):
return None
for ref in external_references:
if isinstance(ref, dict) and ref.get("source_name") == "mitre-attack":
return ref.get("external_id")
return None
def _extract_mitre_url(external_references: list) -> str | None:
"""Extract the MITRE ATT&CK URL from external_references."""
if not isinstance(external_references, list):
return None
for ref in external_references:
if isinstance(ref, dict) and ref.get("source_name") == "mitre-attack":
return ref.get("url")
return None
def _parse_intrusion_sets(objects: list) -> list[dict]:
"""Parse STIX intrusion-set objects into ThreatActor dicts."""
actors = []
for obj in objects:
if obj.get("type") != "intrusion-set":
continue
if obj.get("revoked"):
continue
ext_refs = obj.get("external_references", [])
mitre_id = _extract_mitre_id(ext_refs)
mitre_url = _extract_mitre_url(ext_refs)
name = obj.get("name", "").strip()
if not name:
continue
aliases = obj.get("aliases", [])
if isinstance(aliases, list) and name in aliases:
aliases = [a for a in aliases if a != name]
description = obj.get("description", "")
# Extract references (non-MITRE)
references = []
for ref in ext_refs:
if isinstance(ref, dict) and ref.get("source_name") != "mitre-attack":
references.append({
"source": ref.get("source_name", ""),
"url": ref.get("url", ""),
"description": ref.get("description", ""),
})
actors.append({
"stix_id": obj.get("id"), # e.g. "intrusion-set--abc123"
"mitre_id": mitre_id,
"name": name,
"aliases": aliases if aliases else [],
"description": description,
"mitre_url": mitre_url,
"references": references[:20], # cap to avoid bloat
"first_seen": obj.get("first_seen"),
"last_seen": obj.get("last_seen"),
})
logger.info("Parsed %d intrusion-sets (threat actors)", len(actors))
return actors
def _parse_relationships(objects: list) -> list[dict]:
"""Parse STIX relationship objects (type=uses) linking
intrusion-sets to attack-patterns.
"""
relationships = []
for obj in objects:
if obj.get("type") != "relationship":
continue
if obj.get("relationship_type") != "uses":
continue
if obj.get("revoked"):
continue
source_ref = obj.get("source_ref", "")
target_ref = obj.get("target_ref", "")
# We want intrusion-set → attack-pattern
if not source_ref.startswith("intrusion-set--"):
continue
if not target_ref.startswith("attack-pattern--"):
continue
relationships.append({
"source_ref": source_ref,
"target_ref": target_ref,
"description": obj.get("description", ""),
})
logger.info("Parsed %d uses-relationships (actor→technique)", len(relationships))
return relationships
def _build_attack_pattern_map(objects: list) -> dict[str, str]:
"""Build a map from STIX attack-pattern ID → MITRE technique ID.
e.g. {"attack-pattern--abc123": "T1059.001"}
"""
mapping = {}
for obj in objects:
if obj.get("type") != "attack-pattern":
continue
if obj.get("revoked"):
continue
stix_id = obj.get("id", "")
mitre_id = _extract_mitre_id(obj.get("external_references", []))
if stix_id and mitre_id:
mapping[stix_id] = mitre_id
logger.info("Built attack-pattern map with %d entries", len(mapping))
return mapping
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def sync(db: Session) -> dict:
"""Download and import threat actors from MITRE CTI.
Returns a summary dict.
"""
tmp_dir = tempfile.mkdtemp(prefix="aegis_mitre_cti_")
try:
zip_bytes = _download_zip()
bundle = _extract_zip_and_load_bundle(zip_bytes, tmp_dir)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
logger.info("Cleaned up temp directory %s", tmp_dir)
objects = bundle.get("objects", [])
# Step 1: Parse data
actor_dicts = _parse_intrusion_sets(objects)
relationships = _parse_relationships(objects)
attack_pattern_map = _build_attack_pattern_map(objects)
# Step 2: Build STIX-ID → actor dict map
stix_to_actor = {a["stix_id"]: a for a in actor_dicts}
# Step 3: Load existing actors and techniques from DB
existing_actors = {
row.mitre_id: row
for row in db.query(ThreatActor).all()
if row.mitre_id
}
technique_by_mitre_id = {
row.mitre_id: row
for row in db.query(Technique).all()
}
# Step 4: Upsert threat actors
actors_created = 0
actors_skipped = 0
stix_to_db_actor: dict[str, ThreatActor] = {}
for actor_dict in actor_dicts:
mitre_id = actor_dict["mitre_id"]
stix_id = actor_dict["stix_id"]
if mitre_id and mitre_id in existing_actors:
# Update existing actor
db_actor = existing_actors[mitre_id]
db_actor.name = actor_dict["name"]
db_actor.aliases = actor_dict["aliases"]
db_actor.description = actor_dict["description"]
db_actor.mitre_url = actor_dict["mitre_url"]
db_actor.references = actor_dict["references"]
db_actor.first_seen = actor_dict.get("first_seen")
db_actor.last_seen = actor_dict.get("last_seen")
stix_to_db_actor[stix_id] = db_actor
actors_skipped += 1
else:
# Create new actor
db_actor = ThreatActor(
mitre_id=mitre_id,
name=actor_dict["name"],
aliases=actor_dict["aliases"],
description=actor_dict["description"],
mitre_url=actor_dict["mitre_url"],
references=actor_dict["references"],
first_seen=actor_dict.get("first_seen"),
last_seen=actor_dict.get("last_seen"),
is_active=True,
)
db.add(db_actor)
db.flush() # get the ID
if mitre_id:
existing_actors[mitre_id] = db_actor
stix_to_db_actor[stix_id] = db_actor
actors_created += 1
db.flush()
# Step 5: Upsert actor-technique relationships
# Load existing relationships
existing_rels: set[tuple] = set()
for row in db.query(ThreatActorTechnique).all():
existing_rels.add((str(row.threat_actor_id), str(row.technique_id)))
rels_created = 0
rels_skipped = 0
for rel in relationships:
source_ref = rel["source_ref"]
target_ref = rel["target_ref"]
# Resolve actor
db_actor = stix_to_db_actor.get(source_ref)
if not db_actor:
continue
# Resolve technique
mitre_technique_id = attack_pattern_map.get(target_ref)
if not mitre_technique_id:
continue
db_technique = technique_by_mitre_id.get(mitre_technique_id)
if not db_technique:
continue
rel_key = (str(db_actor.id), str(db_technique.id))
if rel_key in existing_rels:
rels_skipped += 1
continue
actor_technique = ThreatActorTechnique(
threat_actor_id=db_actor.id,
technique_id=db_technique.id,
usage_description=rel["description"][:5000] if rel["description"] else None,
)
db.add(actor_technique)
existing_rels.add(rel_key)
rels_created += 1
db.commit()
summary = {
"actors_created": actors_created,
"actors_updated": actors_skipped,
"relationships_created": rels_created,
"relationships_skipped": rels_skipped,
"total_actors_parsed": len(actor_dicts),
"total_relationships_parsed": len(relationships),
}
# Update DataSource record
ds = db.query(DataSource).filter(DataSource.name == "mitre_cti").first()
if ds:
ds.last_sync_at = datetime.utcnow()
ds.last_sync_status = "success"
ds.last_sync_stats = summary
db.commit()
logger.info("MITRE CTI threat actor import complete — %s", summary)
log_action(
db,
user_id=None,
action="import_threat_actors",
entity_type="threat_actor",
entity_id=None,
details=summary,
)
return summary

View File

@@ -12,6 +12,8 @@ import SystemPage from "./pages/SystemPage";
import UsersPage from "./pages/UsersPage";
import AuditLogPage from "./pages/AuditLogPage";
import DataSourcesPage from "./pages/DataSourcesPage";
import ThreatActorsPage from "./pages/ThreatActorsPage";
import ThreatActorDetailPage from "./pages/ThreatActorDetailPage";
import Layout from "./components/Layout";
import ProtectedRoute from "./components/ProtectedRoute";
@@ -38,6 +40,8 @@ export default function App() {
<Route path="/test-catalog" element={<TestCatalogPage />} />
<Route path="/test-catalog/:templateId/use" element={<TestCatalogPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/threat-actors" element={<ThreatActorsPage />} />
<Route path="/threat-actors/:actorId" element={<ThreatActorDetailPage />} />
<Route
path="/system"
element={

View File

@@ -0,0 +1,123 @@
import client from "./client";
// ── Types ─────────────────────────────────────────────────────────
export interface ThreatActorSummary {
id: string;
mitre_id: string | null;
name: string;
aliases: string[];
country: string | null;
target_sectors: string[];
target_regions: string[];
motivation: string | null;
sophistication: string | null;
mitre_url: string | null;
technique_count: number;
coverage_pct: number;
is_active: boolean;
}
export interface ThreatActorListResponse {
total: number;
offset: number;
limit: number;
items: ThreatActorSummary[];
}
export interface ThreatActorTechnique {
technique_id: string;
mitre_id: string;
name: string;
tactic: string | null;
status_global: string | null;
usage_description: string | null;
first_seen_using: string | null;
}
export interface ThreatActorDetail {
id: string;
mitre_id: string | null;
name: string;
aliases: string[];
description: string | null;
country: string | null;
target_sectors: string[];
target_regions: string[];
motivation: string | null;
sophistication: string | null;
first_seen: string | null;
last_seen: string | null;
references: Array<{ source: string; url: string; description: string }>;
mitre_url: string | null;
is_active: boolean;
techniques: ThreatActorTechnique[];
}
export interface CoverageResponse {
actor_id: string;
actor_name: string;
total_techniques: number;
covered: number;
coverage_pct: number;
breakdown: Record<string, number>;
}
export interface GapItem {
technique_id: string;
mitre_id: string;
name: string;
tactic: string | null;
status_global: string | null;
usage_description: string | null;
available_templates: number;
existing_tests: number;
has_templates: boolean;
}
export interface GapsResponse {
actor_id: string;
actor_name: string;
total_gaps: number;
gaps: GapItem[];
}
// ── API Functions ─────────────────────────────────────────────────
export interface ListThreatActorsParams {
search?: string;
country?: string;
motivation?: string;
sophistication?: string;
target_sectors?: string;
offset?: number;
limit?: number;
}
/** List threat actors with filters. */
export async function getThreatActors(
params?: ListThreatActorsParams
): Promise<ThreatActorListResponse> {
const { data } = await client.get<ThreatActorListResponse>("/threat-actors", {
params,
});
return data;
}
/** Get detailed info about a threat actor. */
export async function getThreatActor(id: string): Promise<ThreatActorDetail> {
const { data } = await client.get<ThreatActorDetail>(`/threat-actors/${id}`);
return data;
}
/** Get coverage analysis for a threat actor. */
export async function getThreatActorCoverage(id: string): Promise<CoverageResponse> {
const { data } = await client.get<CoverageResponse>(`/threat-actors/${id}/coverage`);
return data;
}
/** Get gap analysis for a threat actor. */
export async function getThreatActorGaps(id: string): Promise<GapsResponse> {
const { data } = await client.get<GapsResponse>(`/threat-actors/${id}/gaps`);
return data;
}

View File

@@ -13,6 +13,7 @@ import {
ListChecks,
ClipboardList,
Database,
Crosshair,
} from "lucide-react";
import { useAuth } from "../context/AuthContext";
@@ -37,6 +38,7 @@ const mainLinks: NavItem[] = [
],
},
{ to: "/reports", label: "Reports", icon: BarChart3 },
{ to: "/threat-actors", label: "Threat Actors", icon: Crosshair },
];
const adminLinks: NavItem[] = [

View File

@@ -0,0 +1,571 @@
import { useParams, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Loader2,
AlertCircle,
ArrowLeft,
Globe,
Target,
Shield,
ExternalLink,
BookOpen,
AlertTriangle,
CheckCircle,
XCircle,
Clock,
Crosshair,
FlaskConical,
} from "lucide-react";
import {
getThreatActor,
getThreatActorCoverage,
getThreatActorGaps,
type ThreatActorTechnique,
type GapItem,
} from "../api/threat-actors";
// ── MITRE ATT&CK Tactics in kill chain order ──────────────────────
const TACTICS_ORDER = [
"reconnaissance",
"resource-development",
"initial-access",
"execution",
"persistence",
"privilege-escalation",
"defense-evasion",
"credential-access",
"discovery",
"lateral-movement",
"collection",
"command-and-control",
"exfiltration",
"impact",
];
/** Status → cell colour. */
function statusCellColor(status: string | null) {
switch (status) {
case "validated":
return "bg-green-500/80 border-green-400/40";
case "partial":
return "bg-yellow-500/80 border-yellow-400/40";
case "in_progress":
return "bg-blue-500/60 border-blue-400/40";
case "not_covered":
return "bg-red-500/70 border-red-400/40";
case "not_evaluated":
default:
return "bg-gray-700/50 border-gray-600/40";
}
}
function statusLabel(status: string | null) {
switch (status) {
case "validated":
return "Validated";
case "partial":
return "Partial";
case "in_progress":
return "In Progress";
case "not_covered":
return "Not Covered";
case "review_required":
return "Review Required";
case "not_evaluated":
default:
return "Not Evaluated";
}
}
function statusIcon(status: string | null) {
switch (status) {
case "validated":
return <CheckCircle className="h-3.5 w-3.5 text-green-400" />;
case "partial":
return <AlertTriangle className="h-3.5 w-3.5 text-yellow-400" />;
case "in_progress":
return <Clock className="h-3.5 w-3.5 text-blue-400" />;
case "not_covered":
return <XCircle className="h-3.5 w-3.5 text-red-400" />;
default:
return <Shield className="h-3.5 w-3.5 text-gray-500" />;
}
}
export default function ThreatActorDetailPage() {
const { actorId } = useParams();
const navigate = useNavigate();
// ── Queries ─────────────────────────────────────────────────────
const { data: actor, isLoading, error } = useQuery({
queryKey: ["threat-actor", actorId],
queryFn: () => getThreatActor(actorId!),
enabled: !!actorId,
});
const { data: coverage } = useQuery({
queryKey: ["threat-actor-coverage", actorId],
queryFn: () => getThreatActorCoverage(actorId!),
enabled: !!actorId,
});
const { data: gaps } = useQuery({
queryKey: ["threat-actor-gaps", actorId],
queryFn: () => getThreatActorGaps(actorId!),
enabled: !!actorId,
});
// ── Loading / Error ─────────────────────────────────────────────
if (isLoading) {
return (
<div className="flex items-center justify-center py-24">
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
</div>
);
}
if (error || !actor) {
return (
<div className="rounded-xl border border-red-500/30 bg-red-900/20 p-6 text-center">
<AlertCircle className="mx-auto h-8 w-8 text-red-400" />
<p className="mt-2 text-sm text-red-400">
{error ? (error as Error)?.message : "Threat actor not found"}
</p>
</div>
);
}
// ── Organise techniques by tactic for heatmap ───────────────────
const techniquesByTactic: Record<string, ThreatActorTechnique[]> = {};
for (const tech of actor.techniques) {
const tactic = tech.tactic || "unknown";
// A technique's tactic field may be comma-separated
const tactics = tactic.split(",").map((t) => t.trim().toLowerCase().replace(/\s+/g, "-"));
for (const t of tactics) {
if (!techniquesByTactic[t]) techniquesByTactic[t] = [];
techniquesByTactic[t].push(tech);
}
}
// Sort tactics by kill chain order
const orderedTactics = TACTICS_ORDER.filter((t) => techniquesByTactic[t]);
const unknownTactics = Object.keys(techniquesByTactic).filter(
(t) => !TACTICS_ORDER.includes(t)
);
const allTactics = [...orderedTactics, ...unknownTactics];
return (
<div className="space-y-6">
{/* Back Button */}
<button
onClick={() => navigate("/threat-actors")}
className="flex items-center gap-1.5 text-sm text-gray-400 hover:text-white transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Back to Threat Actors
</button>
{/* ── SECTION 1: Header ──────────────────────────────────────── */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3">
<Crosshair className="h-7 w-7 text-purple-400" />
<h1 className="text-2xl font-bold text-white">{actor.name}</h1>
{actor.mitre_id && (
<span className="rounded-full border border-purple-500/30 bg-purple-900/50 px-2.5 py-0.5 text-xs font-mono text-purple-400">
{actor.mitre_id}
</span>
)}
</div>
{/* Aliases */}
{actor.aliases && actor.aliases.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{actor.aliases.map((alias, i) => (
<span
key={i}
className="rounded-full border border-gray-700 bg-gray-800 px-2 py-0.5 text-xs text-gray-400"
>
{alias}
</span>
))}
</div>
)}
</div>
{/* MITRE link */}
{actor.mitre_url && (
<a
href={actor.mitre_url}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-xs text-gray-400 hover:text-white transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
MITRE ATT&CK
</a>
)}
</div>
{/* Meta badges */}
<div className="mt-4 flex flex-wrap items-center gap-3">
{actor.country && (
<span className="inline-flex items-center gap-1.5 rounded-full border border-gray-700 bg-gray-800 px-3 py-1 text-xs text-gray-300">
<Globe className="h-3.5 w-3.5 text-gray-500" />
{actor.country}
</span>
)}
{actor.motivation && (
<span className="inline-flex items-center gap-1.5 rounded-full border border-purple-500/30 bg-purple-900/50 px-3 py-1 text-xs text-purple-400">
<Target className="h-3.5 w-3.5" />
{actor.motivation}
</span>
)}
{actor.sophistication && (
<span className="inline-flex items-center gap-1.5 rounded-full border border-cyan-500/30 bg-cyan-900/50 px-3 py-1 text-xs text-cyan-400">
{actor.sophistication}
</span>
)}
{actor.first_seen && (
<span className="text-xs text-gray-500">
First seen: {actor.first_seen}
</span>
)}
{actor.last_seen && (
<span className="text-xs text-gray-500">
Last seen: {actor.last_seen}
</span>
)}
</div>
{/* Target sectors */}
{actor.target_sectors && actor.target_sectors.length > 0 && (
<div className="mt-3 flex items-center gap-2">
<Target className="h-4 w-4 text-gray-600 shrink-0" />
<div className="flex flex-wrap gap-1.5">
{actor.target_sectors.map((s, i) => (
<span
key={i}
className="rounded-full border border-gray-700 bg-gray-800 px-2 py-0.5 text-[11px] text-gray-400"
>
{s}
</span>
))}
</div>
</div>
)}
</div>
{/* ── SECTION 2: Description ─────────────────────────────────── */}
{actor.description && (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-500">
Description
</h2>
<p className="text-sm leading-relaxed text-gray-300 whitespace-pre-line">
{actor.description}
</p>
</div>
)}
{/* ── SECTION 3: Coverage Overview ───────────────────────────── */}
{coverage && (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-500">
Coverage Overview
</h2>
<div className="grid gap-4 sm:grid-cols-4">
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-4 text-center">
<p className="text-3xl font-bold text-white">{coverage.total_techniques}</p>
<p className="text-xs text-gray-400">Total Techniques</p>
</div>
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-4 text-center">
<p className="text-3xl font-bold text-green-400">{coverage.covered}</p>
<p className="text-xs text-gray-400">Covered</p>
</div>
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-4 text-center">
<p className="text-3xl font-bold text-red-400">
{coverage.total_techniques - coverage.covered}
</p>
<p className="text-xs text-gray-400">Gaps</p>
</div>
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-4 text-center">
<p className={`text-3xl font-bold ${
coverage.coverage_pct >= 80 ? "text-green-400" :
coverage.coverage_pct >= 50 ? "text-yellow-400" : "text-red-400"
}`}>
{coverage.coverage_pct}%
</p>
<p className="text-xs text-gray-400">Coverage</p>
</div>
</div>
{/* Breakdown bar */}
{coverage.total_techniques > 0 && (
<div className="mt-4">
<div className="flex h-3 overflow-hidden rounded-full bg-gray-800">
{coverage.breakdown.validated && (
<div
className="bg-green-500 transition-all"
style={{
width: `${(coverage.breakdown.validated / coverage.total_techniques) * 100}%`,
}}
title={`Validated: ${coverage.breakdown.validated}`}
/>
)}
{coverage.breakdown.partial && (
<div
className="bg-yellow-500 transition-all"
style={{
width: `${(coverage.breakdown.partial / coverage.total_techniques) * 100}%`,
}}
title={`Partial: ${coverage.breakdown.partial}`}
/>
)}
{coverage.breakdown.in_progress && (
<div
className="bg-blue-500 transition-all"
style={{
width: `${(coverage.breakdown.in_progress / coverage.total_techniques) * 100}%`,
}}
title={`In Progress: ${coverage.breakdown.in_progress}`}
/>
)}
{coverage.breakdown.not_covered && (
<div
className="bg-red-500/70 transition-all"
style={{
width: `${(coverage.breakdown.not_covered / coverage.total_techniques) * 100}%`,
}}
title={`Not Covered: ${coverage.breakdown.not_covered}`}
/>
)}
</div>
<div className="mt-2 flex flex-wrap gap-4 text-xs text-gray-400">
{Object.entries(coverage.breakdown).map(([status, count]) => (
<span key={status} className="flex items-center gap-1.5">
{statusIcon(status)}
{statusLabel(status)}: {count}
</span>
))}
</div>
</div>
)}
</div>
)}
{/* ── SECTION 4: Technique Heatmap ───────────────────────────── */}
{allTactics.length > 0 && (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-500">
Technique Heatmap
</h2>
{/* Legend */}
<div className="mb-4 flex flex-wrap gap-3 text-xs">
<span className="flex items-center gap-1.5">
<span className="h-3 w-3 rounded bg-green-500/80" /> Validated
</span>
<span className="flex items-center gap-1.5">
<span className="h-3 w-3 rounded bg-yellow-500/80" /> Partial
</span>
<span className="flex items-center gap-1.5">
<span className="h-3 w-3 rounded bg-blue-500/60" /> In Progress
</span>
<span className="flex items-center gap-1.5">
<span className="h-3 w-3 rounded bg-red-500/70" /> Not Covered
</span>
<span className="flex items-center gap-1.5">
<span className="h-3 w-3 rounded bg-gray-700/50" /> Not Evaluated
</span>
</div>
{/* Heatmap grid — one column per tactic */}
<div className="overflow-x-auto">
<div className="inline-flex gap-2 min-w-max">
{allTactics.map((tactic) => {
const techs = techniquesByTactic[tactic] || [];
return (
<div key={tactic} className="flex flex-col gap-1" style={{ minWidth: 120 }}>
{/* Tactic header */}
<div className="rounded bg-gray-800 px-2 py-1.5 text-center">
<span className="text-[10px] font-semibold uppercase tracking-wide text-gray-400">
{tactic.replace(/-/g, " ")}
</span>
<span className="ml-1 text-[10px] text-gray-600">({techs.length})</span>
</div>
{/* Technique cells */}
{techs.map((tech) => (
<button
key={tech.technique_id}
onClick={() => navigate(`/techniques/${tech.mitre_id}`)}
className={`rounded border p-1.5 text-left transition-all hover:opacity-80 ${statusCellColor(tech.status_global)}`}
title={`${tech.mitre_id}: ${tech.name} (${statusLabel(tech.status_global)})`}
>
<span className="block truncate text-[10px] font-mono text-white/90">
{tech.mitre_id}
</span>
<span className="block truncate text-[9px] text-white/60">
{tech.name}
</span>
</button>
))}
</div>
);
})}
</div>
</div>
</div>
)}
{/* ── SECTION 5: Gap Analysis ────────────────────────────────── */}
{gaps && gaps.gaps.length > 0 && (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-gray-500">
<AlertTriangle className="h-4 w-4 text-orange-400" />
Coverage Gap Analysis ({gaps.total_gaps} gaps)
</h2>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-800">
<th className="pb-3 pr-4 font-medium text-gray-400">Technique</th>
<th className="pb-3 px-4 font-medium text-gray-400">Tactic</th>
<th className="pb-3 px-4 font-medium text-gray-400">Status</th>
<th className="pb-3 px-4 font-medium text-gray-400">Templates</th>
<th className="pb-3 px-4 font-medium text-gray-400">Tests</th>
</tr>
</thead>
<tbody>
{gaps.gaps.map((gap: GapItem) => (
<tr
key={gap.technique_id}
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors cursor-pointer"
onClick={() => navigate(`/techniques/${gap.mitre_id}`)}
>
<td className="py-2.5 pr-4">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-cyan-400">{gap.mitre_id}</span>
<span className="truncate text-gray-300 text-xs max-w-[200px]">
{gap.name}
</span>
</div>
</td>
<td className="py-2.5 px-4 text-xs text-gray-400">
{gap.tactic || "-"}
</td>
<td className="py-2.5 px-4">
<span className="flex items-center gap-1.5 text-xs">
{statusIcon(gap.status_global)}
{statusLabel(gap.status_global)}
</span>
</td>
<td className="py-2.5 px-4">
{gap.has_templates ? (
<span className="inline-flex items-center gap-1 text-xs text-green-400">
<BookOpen className="h-3.5 w-3.5" />
{gap.available_templates}
</span>
) : (
<span className="text-xs text-gray-600">0</span>
)}
</td>
<td className="py-2.5 px-4">
{gap.existing_tests > 0 ? (
<span className="inline-flex items-center gap-1 text-xs text-blue-400">
<FlaskConical className="h-3.5 w-3.5" />
{gap.existing_tests}
</span>
) : (
<span className="text-xs text-gray-600">0</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* ── SECTION 6: All Techniques List ─────────────────────────── */}
{actor.techniques.length > 0 && (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-500">
All Techniques ({actor.techniques.length})
</h2>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-800">
<th className="pb-3 pr-4 font-medium text-gray-400">ID</th>
<th className="pb-3 px-4 font-medium text-gray-400">Name</th>
<th className="pb-3 px-4 font-medium text-gray-400">Tactic</th>
<th className="pb-3 px-4 font-medium text-gray-400">Status</th>
</tr>
</thead>
<tbody>
{actor.techniques.map((tech: ThreatActorTechnique) => (
<tr
key={tech.technique_id}
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors cursor-pointer"
onClick={() => navigate(`/techniques/${tech.mitre_id}`)}
>
<td className="py-2.5 pr-4 font-mono text-xs text-cyan-400">
{tech.mitre_id}
</td>
<td className="py-2.5 px-4 text-xs text-gray-300 truncate max-w-[250px]">
{tech.name}
</td>
<td className="py-2.5 px-4 text-xs text-gray-400">
{tech.tactic || "-"}
</td>
<td className="py-2.5 px-4">
<span className="flex items-center gap-1.5 text-xs">
{statusIcon(tech.status_global)}
{statusLabel(tech.status_global)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* ── References ─────────────────────────────────────────────── */}
{actor.references && actor.references.length > 0 && (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-500">
References
</h2>
<ul className="space-y-1.5">
{actor.references.map((ref, i) => (
<li key={i} className="text-xs">
{ref.url ? (
<a
href={ref.url}
target="_blank"
rel="noreferrer"
className="text-cyan-400 hover:text-cyan-300 hover:underline"
>
{ref.source || ref.url}
</a>
) : (
<span className="text-gray-400">{ref.source}</span>
)}
{ref.description && (
<span className="ml-2 text-gray-500">{ref.description}</span>
)}
</li>
))}
</ul>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,246 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import {
Loader2,
AlertCircle,
Search,
Users,
Shield,
ChevronLeft,
ChevronRight,
Globe,
Target,
Crosshair,
} from "lucide-react";
import {
getThreatActors,
type ThreatActorSummary,
type ListThreatActorsParams,
} from "../api/threat-actors";
/** Coverage colour based on percentage. */
function coverageColor(pct: number) {
if (pct >= 80) return "text-green-400";
if (pct >= 50) return "text-yellow-400";
if (pct >= 20) return "text-orange-400";
return "text-red-400";
}
function coverageBg(pct: number) {
if (pct >= 80) return "bg-green-500";
if (pct >= 50) return "bg-yellow-500";
if (pct >= 20) return "bg-orange-500";
return "bg-red-500";
}
/** Motivation badge colour. */
function motivationColor(m: string | null) {
switch (m?.toLowerCase()) {
case "espionage":
return "border-purple-500/30 bg-purple-900/50 text-purple-400";
case "financial":
return "border-yellow-500/30 bg-yellow-900/50 text-yellow-400";
case "destruction":
return "border-red-500/30 bg-red-900/50 text-red-400";
case "hacktivism":
return "border-cyan-500/30 bg-cyan-900/50 text-cyan-400";
default:
return "border-gray-600/30 bg-gray-800/50 text-gray-400";
}
}
export default function ThreatActorsPage() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [motivation, setMotivation] = useState("");
const [page, setPage] = useState(0);
const limit = 24;
const params: ListThreatActorsParams = {
offset: page * limit,
limit,
...(search ? { search } : {}),
...(motivation ? { motivation } : {}),
};
const { data, isLoading, error } = useQuery({
queryKey: ["threat-actors", params],
queryFn: () => getThreatActors(params),
});
const totalPages = data ? Math.ceil(data.total / limit) : 0;
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
<Users className="h-7 w-7 text-purple-400" />
Threat Actors
</h1>
<p className="mt-1 text-sm text-gray-400">
APT groups and threat actor profiles from MITRE ATT&CK with coverage analysis
</p>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
<input
type="text"
placeholder="Search actors, aliases..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
className="w-full rounded-lg border border-gray-700 bg-gray-800 py-2 pl-10 pr-4 text-sm text-gray-300 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
/>
</div>
{/* Motivation filter */}
<select
value={motivation}
onChange={(e) => { setMotivation(e.target.value); setPage(0); }}
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none"
>
<option value="">All Motivations</option>
<option value="espionage">Espionage</option>
<option value="financial">Financial</option>
<option value="destruction">Destruction</option>
<option value="hacktivism">Hacktivism</option>
</select>
</div>
{/* Loading */}
{isLoading && (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
</div>
)}
{/* Error */}
{error && (
<div className="rounded-xl border border-red-500/30 bg-red-900/20 p-6 text-center">
<AlertCircle className="mx-auto h-8 w-8 text-red-400" />
<p className="mt-2 text-sm text-red-400">
Failed to load threat actors: {(error as Error)?.message}
</p>
</div>
)}
{/* Grid */}
{data && data.items.length > 0 && (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{data.items.map((actor: ThreatActorSummary) => (
<button
key={actor.id}
onClick={() => navigate(`/threat-actors/${actor.id}`)}
className="group rounded-xl border border-gray-800 bg-gray-900 p-5 text-left transition-all hover:border-purple-500/40 hover:bg-gray-900/80"
>
{/* Name + ID */}
<div className="flex items-start justify-between">
<div className="min-w-0">
<h3 className="truncate text-base font-semibold text-gray-200 group-hover:text-white">
{actor.name}
</h3>
{actor.mitre_id && (
<span className="text-xs font-mono text-purple-400">{actor.mitre_id}</span>
)}
</div>
<Crosshair className="h-5 w-5 shrink-0 text-gray-600 group-hover:text-purple-400 transition-colors" />
</div>
{/* Country + Motivation */}
<div className="mt-3 flex flex-wrap items-center gap-2">
{actor.country && (
<span className="inline-flex items-center gap-1 rounded-full border border-gray-700 bg-gray-800 px-2 py-0.5 text-[11px] text-gray-400">
<Globe className="h-3 w-3" />
{actor.country}
</span>
)}
{actor.motivation && (
<span className={`inline-flex rounded-full border px-2 py-0.5 text-[11px] font-medium ${motivationColor(actor.motivation)}`}>
{actor.motivation}
</span>
)}
</div>
{/* Sectors */}
{actor.target_sectors && actor.target_sectors.length > 0 && (
<div className="mt-2 flex items-center gap-1.5">
<Target className="h-3 w-3 text-gray-600 shrink-0" />
<span className="truncate text-[11px] text-gray-500">
{actor.target_sectors.slice(0, 3).join(", ")}
{actor.target_sectors.length > 3 && ` +${actor.target_sectors.length - 3}`}
</span>
</div>
)}
{/* Stats */}
<div className="mt-4 flex items-center justify-between border-t border-gray-800 pt-3">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<Shield className="h-3.5 w-3.5" />
{actor.technique_count} techniques
</div>
<div className="flex items-center gap-2">
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-gray-800">
<div
className={`h-full rounded-full ${coverageBg(actor.coverage_pct)}`}
style={{ width: `${Math.min(actor.coverage_pct, 100)}%` }}
/>
</div>
<span className={`text-xs font-medium ${coverageColor(actor.coverage_pct)}`}>
{actor.coverage_pct}%
</span>
</div>
</div>
</button>
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">
Showing {page * limit + 1}{Math.min((page + 1) * limit, data.total)} of{" "}
{data.total}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setPage(Math.max(0, page - 1))}
disabled={page === 0}
className="rounded-lg border border-gray-700 bg-gray-800 p-2 text-gray-400 hover:text-white disabled:opacity-40"
>
<ChevronLeft className="h-4 w-4" />
</button>
<span className="text-sm text-gray-400">
Page {page + 1} of {totalPages}
</span>
<button
onClick={() => setPage(Math.min(totalPages - 1, page + 1))}
disabled={page >= totalPages - 1}
className="rounded-lg border border-gray-700 bg-gray-800 p-2 text-gray-400 hover:text-white disabled:opacity-40"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
)}
</>
)}
{/* Empty */}
{data && data.items.length === 0 && (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-12 text-center">
<Users className="mx-auto h-12 w-12 text-gray-600" />
<h3 className="mt-4 text-lg font-medium text-gray-300">No Threat Actors Found</h3>
<p className="mt-1 text-sm text-gray-500">
Import threat actors from MITRE CTI via the Data Sources panel.
</p>
</div>
)}
</div>
);
}